diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1989e0ad..0e3a2ff4 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -51,6 +51,8 @@ You are operating within a constrained context window and strict system prompts. - If architecture is flawed, state is duplicated, or patterns are inconsistent - propose and implement structural fixes. Ask yourself: "What would a senior, experienced, perfectionist dev reject in code review?" Fix all of it. +Codex will review your output once you are done! + ## Task Management diff --git a/.claude/architecture-principles.md b/.claude/architecture-principles.md new file mode 100644 index 00000000..ab0008af --- /dev/null +++ b/.claude/architecture-principles.md @@ -0,0 +1,145 @@ +# Autopilot-Monitor — Architecture Principles + +**Source of truth:** [plans/REFACTOR_AGENT_V2.md](../plans/REFACTOR_AGENT_V2.md) §7. +This file is a git-versioned extract for persistent Claude-session context. On every +architecture-relevant plan change, both locations are updated in sync. + +The principles outlive this plan — they carry the design substance we do not want to +re-derive every time. They are **not nice-to-haves**, they are review criteria: any code +change that violates a principle is either (a) a bug, (b) a reason to change the principle +(with user sign-off), or (c) an explicitly documented exception with rationale. + +--- + +## L.1 Truth Hierarchy (immutable) + +`SignalLog` (input) → `Journal` (decisions) → `Snapshot` (cache) → `Projections` (derivable). +No projection patches backwards into the journal or the signal log. On inconsistency, the +lower layer always wins (e.g. discard the snapshot, do not "repair" the signal log). + +## L.2 Event Sourcing as Foundation + +`SignalLog` is the only input truth. The reducer is pure: +`(oldState, signal) → (newState, transition, effects)`. The backend can re-derive at any +time from `SignalLog`. Replay yields the same `Journal` content. + +## L.3 Immutable State + +All DTOs in the decision core are `sealed` with get-only properties. No in-place mutation. +"Change" = new instance via `With…` method. This is the difference between "purely testable" +and "subtle heisenbugs". + +## L.4 Hypothesis Model instead of Flow Plugins + +Fuzziness is constitutive (White-Glove, enrollment type, device-only). A single reducer with +hypothesis fields (`Unknown/Weak/Strong/Confirmed/Rejected`), organized by scenario in partial +classes. **No** plugin runtime, because clean flow boundaries do not match reality. + +## L.5 Evidence Determinism + +Signal-log determinism is guaranteed: same log → same outcome. Every signal carries an +`Evidence` record with `Kind` (Raw / Derived / Synthetic) and mandatory fields. A signal +without evidence = adapter rejection = bug. + +## L.6 Signal Schema Versioning + +Every `DecisionSignalKind` has a `SchemaVersion`. The reducer dispatches on +`(Kind, SchemaVersion)` — old versions stay replay-capable. A new version or a new kind +without a replay fixture in `tests/fixtures/signal-kinds/{kind}-v{n}.json` = merge block. + +## L.7 Timer Monopoly + +Only the Deadline Scheduler (the EffectRunner role) holds decision-relevant timers. Collectors, +signal adapters and the orchestrator must not. `Task.Delay` or `new Timer(...)` in those +layers with session-state impact = merge block. `IClock` interface + virtual clock for +testability. + +## L.8 Classifier as Post-Hook Kernel Service + +`IClassifier` interface, swappable. The `ClassifierVerdict` flows back into the log as a +synthetic `ClassifierVerdictIssued` signal. `InputHash` (SHA256 over classifier-relevant +snapshot fields) is the anti-loop guard. Classifiers are not baked into the state machine. + +## L.9 Clean-Agent Principle on Refactors + +For structural refactors: build the new project in parallel to the legacy one, do not flag- +route inside the same process. Strict separation rule: no direct reference to legacy +projects. Code is copied, not shared. Legacy runs out, then gets removed. + +## L.10 Generic Telemetry Transport + +One spool for all telemetry types (events, signals, decision transitions). `TelemetryItemId` +is the type-agnostic upload cursor; the type-specific counters (`Event.Sequence`, +`SignalOrdinal`, `StepIndex`) remain storage / UI / reducer semantics. Drain guarantee: +"coordinated + idempotent + resumable" — **not** cross-table atomic, which is not achievable +with three separate tables anyway. + +## L.11 Separation: Agent Lifecycle vs Session State + +`DecisionState` is session-pure. Agent-process flags (crash, admin actions, boot time, +heartbeat status) live separately in `agent-lifecycle.json`, managed by the orchestrator, +never by the reducer. Prevents replay dilution by lifecycle events. + +## L.12 Flush-on-Append Persistence + +No batching of append operations. `FileOptions.WriteThrough` plus an explicit +`FlushAsync(flushToDisk: true)` per append. Every successful `Append` return means: on disk. +Durability > performance in the crash-prone OOBE context. + +## L.13 UI Primacy — End-Users vs Dev Tooling + +Product views (EventTimeline, PhaseTimeline) stay slim and stable. Debug / analysis tools +(Inspector with DecisionGraph, SignalStream, VerifierReport) live on their own subpage behind +an `adminMode` gate. The session-detail page does not get overloaded with dev components. + +## L.14 Forward Compatibility by Default + +Enums via `StringEnumConverter` + `UnknownFallbackEnumConverter` (unknown values → default, +never crash). New schema fields are nullable. The `ReducerVersion` stamp per journal row +allows older backends to read newer rows. Deletions only after a major version bump with +user sign-off. + +## L.15 Test Discipline + +Flow / scenario tests are prioritized over structural tests. Coverage targets per component +(DecisionCore ≥95 %, classifier 100 %, backend functions ≥85 %). A missing fixture for a +scenario = merge block. CI threshold check is a build failure, not a warning. + +## L.16 Fail-Safe Reducer Exceptions + +Exception in the reducer → `DecisionTransition` with `DeadEndReason=reducer_exception`, state +unchanged (fail-safe, never destroy state). Signal counts as processed (no retry loop). After +five consecutive exceptions → the orchestrator emits +`enrollment_failed(reason=reducer_instability)`. + +## L.17 Effect Error Handling by Class + +Three effect classes with their own strategy: + +- **Transient** (retry with exponential backoff 100 ms / 400 ms / 1600 ms): + `EmitEventTimelineEntry`, `PersistSnapshot` +- **Critical** (session abort, e.g. on timer-register failure): `ScheduleDeadline`, + `CancelDeadline` +- **Optional** (best-effort, classifier exception → `Inconclusive` verdict, no abort): + `RunClassifier` + +Every effect failure is logged as a `DecisionTransition`. + +## L.18 Implementation Discipline + +- **R1** Pre-flight plan check before every work step +- **R2** Delegate self-contained sub-tasks **and exploration** to subagents (Anthropic + recommendation: preserve main-context window on large refactors — quality assurance) +- **R3** Real verification (DB round-trip, index dual-write, agent signal emission) — no + "it compiles and runs" +- **R4** The plan is authority — deviations require user sign-off +- **R5** Out-of-scope items land in `tasks/todo.md`, not in memory +- **R6** High test coverage is a prerequisite for future refactorability, not a nice-to-have +- **R7** Code-structure discipline: small files (guideline ≤300 LoC), clear folders, partial + classes by scenario, check in with the user on any uncertainty (placement, naming, scope) + +--- + +**Maintaining these principles:** On every architecture-relevant plan change, the +corresponding L.n points are updated here **and** in [plans/REFACTOR_AGENT_V2.md](../plans/REFACTOR_AGENT_V2.md) +§7 in sync. New principles are discussed in the plan first, then migrated here. diff --git a/.gitignore b/.gitignore index f5611b6b..89bfc779 100644 --- a/.gitignore +++ b/.gitignore @@ -109,16 +109,26 @@ tmpclaude-*-cwd # No individual file ignores needed — the submodule handles everything # Deployment scripts (local only) +scripts/Deployment/build_and_upload_debug_agent_build_dev.ps1 scripts/Deployment/build_and_upload_debug_agent_build.ps1 scripts/Deployment/build_and_upload_release_agent_build.ps1 +# V2 Deployment scripts (tokens + SAS URLs, analog to Legacy) — plan §4 M2 +scripts/Deployment/V2/ + +# M2 — local raw session fixtures before anonymization (plan §4 M2) +tests/fixtures/enrollment-sessions-local/* +!tests/fixtures/enrollment-sessions-local/.gitkeep +!tests/fixtures/enrollment-sessions-local/README.md + .dotnet-home/ .vscode/ TODO.txt tools/orphan-analysis.py -tasks/todo.md +tasks/ # Azure Table cleanup backups (dry-run exports + delete manifests) # Produced by /cleanup-table skill — never commit deletion payloads db-backups/ +plans/REFACTOR_AGENT_V2.md diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln index 1ddc606d..7044c7e5 100644 --- a/AutopilotMonitor.sln +++ b/AutopilotMonitor.sln @@ -25,6 +25,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.SummaryDia EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core.Tests", "src\Agent\AutopilotMonitor.Agent.Core.Tests\AutopilotMonitor.Agent.Core.Tests.csproj", "{89BDAC07-A666-4708-A9D3-A956A89A4E4D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore", "src\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj", "{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2", "src\Agent\AutopilotMonitor.Agent.V2\AutopilotMonitor.Agent.V2.csproj", "{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.Core", "src\Agent\AutopilotMonitor.Agent.V2.Core\AutopilotMonitor.Agent.V2.Core.csproj", "{22CB0E75-793A-4FC9-ADE4-48580BCE6321}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore.Tests", "src\Agent\AutopilotMonitor.DecisionCore.Tests\AutopilotMonitor.DecisionCore.Tests.csproj", "{11B36E5C-B512-4D1E-A76B-E5AD866B033C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.Core.Tests", "src\Agent\AutopilotMonitor.Agent.V2.Core.Tests\AutopilotMonitor.Agent.V2.Core.Tests.csproj", "{95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -119,6 +129,66 @@ Global {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x64.Build.0 = Release|Any CPU {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.ActiveCfg = Release|Any CPU {89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.Build.0 = Release|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x64.ActiveCfg = Debug|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x64.Build.0 = Debug|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x86.ActiveCfg = Debug|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x86.Build.0 = Debug|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|Any CPU.Build.0 = Release|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x64.ActiveCfg = Release|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x64.Build.0 = Release|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.ActiveCfg = Release|Any CPU + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.Build.0 = Release|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.ActiveCfg = Debug|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.Build.0 = Debug|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.ActiveCfg = Debug|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.Build.0 = Debug|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.Build.0 = Release|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.ActiveCfg = Release|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.Build.0 = Release|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.ActiveCfg = Release|Any CPU + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.Build.0 = Release|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.ActiveCfg = Debug|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.Build.0 = Debug|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.ActiveCfg = Debug|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.Build.0 = Debug|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.Build.0 = Release|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.ActiveCfg = Release|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.Build.0 = Release|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.ActiveCfg = Release|Any CPU + {22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.Build.0 = Release|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.ActiveCfg = Debug|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.Build.0 = Debug|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.ActiveCfg = Debug|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.Build.0 = Debug|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.Build.0 = Release|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.ActiveCfg = Release|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.Build.0 = Release|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.ActiveCfg = Release|Any CPU + {11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.Build.0 = Release|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|x64.ActiveCfg = Debug|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|x64.Build.0 = Debug|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|x86.ActiveCfg = Debug|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Debug|x86.Build.0 = Debug|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|Any CPU.Build.0 = Release|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|x64.ActiveCfg = Release|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|x64.Build.0 = Release|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|x86.ActiveCfg = Release|Any CPU + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -129,10 +199,15 @@ Global {096B6098-F141-7114-B090-72D1899D8853} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B} = {096B6098-F141-7114-B090-72D1899D8853} {7252A85F-60EC-4CFA-B3A6-A204241FFBBC} = {096B6098-F141-7114-B090-72D1899D8853} - {B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853} - {89BDAC07-A666-4708-A9D3-A956A89A4E4D} = {096B6098-F141-7114-B090-72D1899D8853} {FE360695-3F2B-1049-3887-35FBB5135923} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {4BA82F07-39A9-4B80-A12D-3D4E4ACE6682} = {FE360695-3F2B-1049-3887-35FBB5135923} {E3E0A5D8-4899-4F41-9D5F-CB858EF9F091} = {FE360695-3F2B-1049-3887-35FBB5135923} + {B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853} + {89BDAC07-A666-4708-A9D3-A956A89A4E4D} = {096B6098-F141-7114-B090-72D1899D8853} + {306C580A-0C25-4B4C-90B9-F15D0E0E0DDF} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C} + {F9DCEFA0-127B-4E3F-BF52-4680DB4CA054} = {096B6098-F141-7114-B090-72D1899D8853} + {22CB0E75-793A-4FC9-ADE4-48580BCE6321} = {096B6098-F141-7114-B090-72D1899D8853} + {11B36E5C-B512-4D1E-A76B-E5AD866B033C} = {096B6098-F141-7114-B090-72D1899D8853} + {95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0} = {096B6098-F141-7114-B090-72D1899D8853} EndGlobalSection EndGlobal diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1 new file mode 100644 index 00000000..062cdd7d --- /dev/null +++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2-dev.ps1 @@ -0,0 +1,278 @@ +<# +.SYNOPSIS + DEV variant of the bootstrap script to deploy and start the Autopilot Monitor agent. + +.DESCRIPTION + Funktionally identical to Install-AutopilotMonitor.ps1 with two differences: + 1. Default $AgentDownloadUrl points to AutopilotMonitor-Agent-dev.zip + 2. Integrity check is read from version-dev.json (NOT version.json) + This enables parallel lab testing of unreleased agent builds via a dedicated + Intune Platform Script assignment, without touching any production artefacts. + + See Install-AutopilotMonitor.ps1 for the full behaviour (guards, SHA-256 + integrity verification, scheduled task registration via --install, etc.). + +.PARAMETER AgentDownloadUrl + URL to download the DEV agent binaries from (ZIP file). Defaults to + AutopilotMonitor-Agent-dev.zip in the same blob container as production. + +.NOTES + - ASCII-only. PowerShell 5.1 / IME AgentExecutor reads scripts without BOM + as ANSI and corrupts multi-byte chars. + - Guards are intentionally identical to Prod bootstrapper so lab behaviour + matches production behaviour on fresh images. + +.CHANGELOG + 2026-04-20 v2.0-pre-dev Forked from Install-AutopilotMonitor-Dev.ps1 v1.1-dev for V2-Agent. + Default URL -> AutopilotMonitor-Agent-V2-dev.zip, + integrity file -> version-v2-dev.json, + agent exe -> AutopilotMonitor.Agent.V2.exe. + Plan: plans/REFACTOR_AGENT_V2.md section 4 M2. + 2026-04-14 v1.1-dev Initial DEV variant forked from Install-AutopilotMonitor.ps1 v1.1. + Default URL -> AutopilotMonitor-Agent-dev.zip, integrity file -> version-dev.json. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2-dev.zip", + + [Parameter(Mandatory = $false)] + [int]$MaxBootstrapWindowHours = 12 +) + +# Script version (bump on meaningful changes; see .CHANGELOG above) +$ScriptVersion = "2.0-pre-dev" + +# Configuration - Everything in ProgramData for easy cleanup +$AgentBasePath = "$env:ProgramData\AutopilotMonitor" +$AgentBinPath = "$AgentBasePath\Agent" +$AgentLogPath = "$AgentBasePath\Logs" +$LogFile = "$AgentLogPath\bootstrap_agent.log" + +# Ensure directories exist +New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null +New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null +New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null + +function Write-Log { + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logMessage = "[$timestamp] $Message" + Write-Output $logMessage + Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue +} + +function Get-FileMd5Base64 { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $md5 = [System.Security.Cryptography.MD5]::Create() + try { + $stream = [System.IO.File]::OpenRead($Path) + try { + $hashBytes = $md5.ComputeHash($stream) + } + finally { + $stream.Dispose() + } + + return [Convert]::ToBase64String($hashBytes) + } + finally { + $md5.Dispose() + } +} + +try { + Write-Log "===== Autopilot Monitor Bootstrap Started (DEV) =====" + Write-Log "Bootstrap script version: v$ScriptVersion" + Write-Log "Agent download URL: $AgentDownloadUrl" + + # -- Pre-flight: Multi-signal guard (identical to production bootstrapper) -- + # Guard 1: Ghost re-installs (registry marker from previous deployment) + # Guard 2: Productive devices (real user profile exists -- WMI + filesystem) + # Guard 3: Productive devices (a real user has logged on before) + # Guard 4: Bootstrap window expired (device uptime > 12h without agent) + # Guard 5: Agent binary already present from a previous run + + # Guard 1 + $deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed + if ($deployed) { + Write-Log "SKIP: Agent was previously deployed at $deployed." + exit 0 + } + + # Guard 2 + $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$' + + $profileNames = @( + try { + Get-CimInstance Win32_UserProfile -ErrorAction Stop | + Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } | + ForEach-Object { Split-Path $_.LocalPath -Leaf } + } catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." } + + (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name + ) | Where-Object { $_ -and $_ -notmatch $excludePattern } | + Select-Object -Unique + + if ($profileNames) { + $names = ($profileNames | Select-Object -First 3) -join ', ' + Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive." + exit 0 + } + + # Guard 3 + $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser + if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') { + Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive." + exit 0 + } + + # Guard 4 + $lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime + $uptimeHours = ((Get-Date) - $lastBoot).TotalHours + Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)" + if ($uptimeHours -gt $MaxBootstrapWindowHours) { + Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h." + exit 0 + } + + # Guard 5 + if (Test-Path $AgentBinPath) { + $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue + if ($existingAgent) { + Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)." + exit 0 + } + } + + Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window" + + # Download and extract agent binaries + $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe" + + if (Test-Path $agentExePath) { + Write-Log "Agent already installed at $agentExePath" + } + else { + Write-Log "Downloading DEV agent from $AgentDownloadUrl..." + + try { + # DEV: Integrity-File ist version-dev.json (im selben Container wie das -dev ZIP) + $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2-dev.json' + $expectedSha256 = $null + + try { + Write-Log "Fetching version-dev.json from $versionJsonUrl for integrity verification..." + $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + if ($versionJsonResponse.sha256) { + $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant() + Write-Log "SHA-256 hash from version-dev.json: $expectedSha256 (version: $($versionJsonResponse.version))" + } else { + Write-Log "version-dev.json has no sha256 field - falling back to legacy MD5 check" + } + } + catch { + Write-Log "WARNING: Could not fetch version-dev.json - falling back to legacy MD5 check: $($_.Exception.Message)" + } + + $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2-dev.zip" + $maxDownloadAttempts = 3 + $downloadAttempt = 0 + $downloadResponse = $null + + do { + $downloadAttempt++ + try { + Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}" + $downloadResponse = Invoke-WebRequest ` + -Uri $AgentDownloadUrl ` + -OutFile $zipPath ` + -UseBasicParsing ` + -TimeoutSec 30 ` + -ErrorAction Stop ` + -PassThru + Write-Log "Downloaded agent to $zipPath" + break + } + catch { + if ($downloadAttempt -ge $maxDownloadAttempts) { + throw + } + + $retryDelaysInSeconds = @(2, 4, 8) + $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1] + Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..." + Start-Sleep -Seconds $retryDelaySeconds + } + } while ($downloadAttempt -lt $maxDownloadAttempts) + + if ($expectedSha256) { + $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Log "Validating SHA-256 hash against version-dev.json" + if ($actualSha256 -ne $expectedSha256) { + throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted." + } + Write-Log "SHA-256 integrity check passed" + } + else { + $expectedMd5Header = $downloadResponse.Headers["Content-MD5"] + $expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() } + if ($expectedMd5 -notmatch '\S') { + Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation" + } + else { + $actualMd5 = Get-FileMd5Base64 -Path $zipPath + Write-Log "Validating Content-MD5 header against downloaded ZIP" + if ($actualMd5 -ne $expectedMd5) { + throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'" + } + Write-Log "MD5 integrity check passed" + } + } + + Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force + Write-Log "Extracted agent to $AgentBinPath" + + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + Write-Log "Cleaned up temporary files" + + if (-not (Test-Path $agentExePath)) { + throw "Agent executable not found after extraction at $agentExePath" + } + + Write-Log "DEV agent installation completed successfully" + } + catch { + Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)" + throw + } + } + + Write-Log "Calling agent install mode (--install)..." + & $agentExePath --install + $installExitCode = $LASTEXITCODE + if ($installExitCode -ne 0) { + throw "Agent install failed with exit code $installExitCode" + } + Write-Log "Agent install mode completed successfully" + Write-Log "===== Bootstrap Completed Successfully (DEV) =====" + + exit 0 +} +catch { + Write-Log "===== Bootstrap FAILED (DEV) =====" + Write-Log "ERROR: $($_.Exception.Message)" + Write-Log "Stack trace: $($_.ScriptStackTrace)" + Write-Log "Please check log file: $LogFile" + + $errMsg = "AutopilotMonitor bootstrap (DEV) failed: $($_.Exception.Message)" + if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' } + [Console]::Error.WriteLine($errMsg) + + exit 1 +} diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 new file mode 100644 index 00000000..4edec32d --- /dev/null +++ b/scripts/Bootstrap/Install-AutopilotMonitor-v2.ps1 @@ -0,0 +1,322 @@ +<# +.SYNOPSIS + Bootstrap script to deploy and start the Autopilot Monitor agent. + +.DESCRIPTION + This script is designed to be deployed via Intune as a PowerShell Script during Autopilot. + It runs VERY EARLY in the enrollment process (first Intune action) and: + 1. Creates a unique session ID for this enrollment + 2. Downloads the monitoring agent binaries + 3. Verifies integrity via SHA-256 hash from version.json + 4. Installs agent binaries (agent uses built-in backend URL) + 5. Registers agent as Scheduled Task (runs on computer startup) + 6. Agent self-destructs when enrollment completes + + INTEGRITY VERIFICATION: + The script downloads version.json from the same blob container as the agent ZIP. + If version.json contains a "sha256" field, the downloaded ZIP is verified against it + using SHA-256. This prevents tampering during download (MITM) and detects corrupted + downloads. If the hash does not match, installation is aborted. + + For backward compatibility: if version.json does not contain a "sha256" field (older + builds), the script falls back to the legacy Content-MD5 header check. + +.PARAMETER AgentDownloadUrl + URL to download the agent binaries from (ZIP file) + +.EXAMPLE + .\Install-AutopilotMonitor.ps1 + (Uses built-in backend URL from the agent) + +.NOTES + - Agent is temporary and auto-removes after enrollment + - Everything in C:\ProgramData\AutopilotMonitor (easy cleanup) + - One Registry key left after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed (prevents ghost re-installs) + - Scheduled Task survives reboots during enrollment + - SHA-256 integrity verification since v1.0.706+ + - IMPORTANT: This script MUST remain pure ASCII (no Unicode/UTF-8 special chars). + PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars. + +.CHANGELOG + 2026-04-20 v2.0-pre Forked from Install-AutopilotMonitor.ps1 v1.1 for V2-Agent. + URL -> AutopilotMonitor-Agent-V2.zip, integrity file -> version-v2.json, + agent exe -> AutopilotMonitor.Agent.V2.exe. Same guards + install flow. + Plan: plans/REFACTOR_AGENT_V2.md section 4 M2. + 2026-04-09 v1.1 Introduced explicit script version and log it on startup + (all entries above were v1.0) + 2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard: + registry deployment marker, WMI/filesystem user profile detection, + lastloggedonUser set, and 12h bootstrap window. + 2026-03-30 Fixed non-ASCII characters (em-dashes, Unicode symbols) that broke + script parsing under PowerShell 5.1 / IME AgentExecutor + 2026-03-29 Hardened integrity check: SHA-256 verification via version.json + 2026-02-16 Extended OS age default threshold + 2026-02-13 Simplified bootstrapper, introduced --install parameter for agent + 2026-02-12 More robust download with integrity check (Content-MD5) + 2026-02-12 Enhanced pre-flight checks, boot time support + 2026-02-12 Added pre-flight check: skip if agent already installed + 2026-02-05 Initial version +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2.zip", + + [Parameter(Mandatory = $false)] + [int]$MaxBootstrapWindowHours = 12 +) + +# Script version (bump on meaningful changes; see .CHANGELOG above) +$ScriptVersion = "2.0-pre" + +# Configuration - Everything in ProgramData for easy cleanup +$AgentBasePath = "$env:ProgramData\AutopilotMonitor" +$AgentBinPath = "$AgentBasePath\Agent" +$AgentLogPath = "$AgentBasePath\Logs" +$LogFile = "$AgentLogPath\bootstrap_agent.log" + +# Ensure directories exist +New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null +New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null +New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null + +function Write-Log { + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logMessage = "[$timestamp] $Message" + Write-Output $logMessage + Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue +} + +function Get-FileMd5Base64 { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $md5 = [System.Security.Cryptography.MD5]::Create() + try { + $stream = [System.IO.File]::OpenRead($Path) + try { + $hashBytes = $md5.ComputeHash($stream) + } + finally { + $stream.Dispose() + } + + return [Convert]::ToBase64String($hashBytes) + } + finally { + $md5.Dispose() + } +} + +try { + Write-Log "===== Autopilot Monitor Bootstrap Started =====" + Write-Log "Bootstrap script version: v$ScriptVersion" + + # -- Pre-flight: Multi-signal guard to prevent installation on non-provisioning devices -- + # Layered approach: each guard catches a different scenario. + # Guard 1: Ghost re-installs (registry marker from previous deployment) + # Guard 2: Productive devices (real user profile exists -- WMI + filesystem) + # Guard 3: Productive devices (a real user has logged on before) + # Guard 4: Bootstrap window expired (device uptime > 12h without agent) + # Guard 5: Agent binary already present from a previous run + # NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment). + # NOTE: explorer.exe is NOT used -- it runs even in early OOBE right after script execution. + + # Guard 1: Agent was already deployed on this device (registry marker survives self-destruct) + $deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed + if ($deployed) { + Write-Log "SKIP: Agent was previously deployed at $deployed." + exit 0 + } + + # Guard 2: No real user profile should exist yet (primary productive-device guard) + # Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability. + $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$' + + $profileNames = @( + # WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService + try { + Get-CimInstance Win32_UserProfile -ErrorAction Stop | + Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } | + ForEach-Object { Split-Path $_.LocalPath -Leaf } + } catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." } + + # Filesystem view -- catches profiles WMI might miss + (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name + ) | Where-Object { $_ -and $_ -notmatch $excludePattern } | + Select-Object -Unique + + if ($profileNames) { + $names = ($profileNames | Select-Object -First 3) -join ', ' + Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive." + exit 0 + } + + # Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet + $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser + if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') { + Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive." + exit 0 + } + + # Guard 4: Bootstrap window check (no key, no user profiles, but device running too long) + # NOT "how long may enrollment take" -- agent handles that internally (6h emergency break). + # This is "how old can the OOBE state be before I no longer trust it for initial install". + # 12h bootstrap window vs 6h agent emergency break = consistent layered approach. + # Sleep/standby does NOT reset uptime -- only real boot/restart does. + $lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime + $uptimeHours = ((Get-Date) - $lastBoot).TotalHours + Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)" + if ($uptimeHours -gt $MaxBootstrapWindowHours) { + Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h." + exit 0 + } + + # Guard 5: Is the agent already installed? (leftover from previous run) + if (Test-Path $AgentBinPath) { + $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue + if ($existingAgent) { + Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)." + exit 0 + } + } + + Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window" + + # Download and extract agent binaries + $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe" + + if (Test-Path $agentExePath) { + Write-Log "Agent already installed at $agentExePath" + } + else { + Write-Log "Downloading agent from $AgentDownloadUrl..." + + try { + # Derive version.json URL from the agent download URL (same blob container) + $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2.json' + $expectedSha256 = $null + + # Download version.json for SHA-256 integrity verification + try { + Write-Log "Fetching version.json from $versionJsonUrl for integrity verification..." + $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + if ($versionJsonResponse.sha256) { + $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant() + Write-Log "SHA-256 hash from version.json: $expectedSha256 (version: $($versionJsonResponse.version))" + } else { + Write-Log "version.json has no sha256 field - falling back to legacy MD5 check (older build)" + } + } + catch { + Write-Log "WARNING: Could not fetch version.json - falling back to legacy MD5 check: $($_.Exception.Message)" + } + + # Download agent ZIP + $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2.zip" + $maxDownloadAttempts = 3 + $downloadAttempt = 0 + $downloadResponse = $null + + do { + $downloadAttempt++ + try { + Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}" + $downloadResponse = Invoke-WebRequest ` + -Uri $AgentDownloadUrl ` + -OutFile $zipPath ` + -UseBasicParsing ` + -TimeoutSec 30 ` + -ErrorAction Stop ` + -PassThru + Write-Log "Downloaded agent to $zipPath" + break + } + catch { + if ($downloadAttempt -ge $maxDownloadAttempts) { + throw + } + + $retryDelaysInSeconds = @(2, 4, 8) + $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1] + Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..." + Start-Sleep -Seconds $retryDelaySeconds + } + } while ($downloadAttempt -lt $maxDownloadAttempts) + + # Integrity check: SHA-256 (preferred) or legacy Content-MD5 (fallback) + if ($expectedSha256) { + $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Log "Validating SHA-256 hash against version.json" + if ($actualSha256 -ne $expectedSha256) { + throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted." + } + Write-Log "SHA-256 integrity check passed" + } + else { + # Legacy fallback: Content-MD5 header check + $expectedMd5Header = $downloadResponse.Headers["Content-MD5"] + $expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() } + if ($expectedMd5 -notmatch '\S') { + Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation" + } + else { + $actualMd5 = Get-FileMd5Base64 -Path $zipPath + Write-Log "Validating Content-MD5 header against downloaded ZIP" + if ($actualMd5 -ne $expectedMd5) { + throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'" + } + Write-Log "MD5 integrity check passed" + } + } + + # Extract to agent bin path + Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force + Write-Log "Extracted agent to $AgentBinPath" + + # Cleanup + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + Write-Log "Cleaned up temporary files" + + # Verify extraction + if (-not (Test-Path $agentExePath)) { + throw "Agent executable not found after extraction at $agentExePath" + } + + Write-Log "Agent installation completed successfully" + } + catch { + Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)" + throw + } + } + + # Let the agent install/deploy itself and manage its own Scheduled Task + Write-Log "Calling agent install mode (--install)..." + & $agentExePath --install + $installExitCode = $LASTEXITCODE + if ($installExitCode -ne 0) { + throw "Agent install failed with exit code $installExitCode" + } + Write-Log "Agent install mode completed successfully" + Write-Log "===== Bootstrap Completed Successfully =====" + + exit 0 +} +catch { + Write-Log "===== Bootstrap FAILED =====" + Write-Log "ERROR: $($_.Exception.Message)" + Write-Log "Stack trace: $($_.ScriptStackTrace)" + Write-Log "Please check log file: $LogFile" + + # some errors are captured by Intune but not fully visible in the UI (truncated, no stack trace), so also write critical info to stderr for better visibility. + $errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)" + if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' } + [Console]::Error.WriteLine($errMsg) + + exit 1 +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj b/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj index 7604f68c..d42fcf33 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj +++ b/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj new file mode 100644 index 00000000..46dad5b2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj @@ -0,0 +1,36 @@ + + + + + net48 + latest + enable + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TempDirectory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TempDirectory.cs new file mode 100644 index 00000000..f681140d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TempDirectory.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness +{ + /// + /// Disposable per-test temp directory. Deleted on even if + /// sub-files are still open (best-effort). + /// + public sealed class TempDirectory : IDisposable + { + public TempDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "apmon-v2-test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public string File(string name) => System.IO.Path.Combine(Path, name); + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch + { + // Best-effort cleanup — test-host may hold a handle on a file we tried to + // write to. Not worth failing a test on cleanup. + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TestSignals.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TestSignals.cs new file mode 100644 index 00000000..ffd201db --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/TestSignals.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness +{ + /// + /// Builder helpers for in tests — keeps call sites compact. + /// + internal static class TestSignals + { + public static DecisionSignal Raw( + long ordinal, + DecisionSignalKind kind = DecisionSignalKind.EspPhaseChanged, + DateTime? occurredAtUtc = null, + long? traceOrdinal = null, + string origin = "UnitTest", + IReadOnlyDictionary? payload = null) + { + var at = occurredAtUtc ?? new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + return new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: traceOrdinal ?? ordinal, + kind: kind, + kindSchemaVersion: 1, + occurredAtUtc: at, + sourceOrigin: origin, + evidence: new Evidence( + kind: EvidenceKind.Raw, + identifier: $"raw-{ordinal}", + summary: $"Raw signal {ordinal}", + rawPointer: null, + derivationInputs: null), + payload: payload); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/VirtualClock.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/VirtualClock.cs new file mode 100644 index 00000000..cfe87a98 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Harness/VirtualClock.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.DecisionCore.Engine; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness +{ + /// + /// Deterministic for V2.Core tests. + /// 1:1-Copy aus DecisionCore.Tests/Harness/VirtualClock.cs — Plan §2.16 + /// Copy-Not-Share-Prinzip zwischen Testprojekten (konsistent zur V2-Isolation). + /// + public sealed class VirtualClock : IClock + { + private DateTime _now; + + public VirtualClock(DateTime initialUtcNow) + { + if (initialUtcNow.Kind != DateTimeKind.Utc && initialUtcNow.Kind != DateTimeKind.Unspecified) + { + throw new ArgumentException("VirtualClock expects UTC/unspecified DateTime.", nameof(initialUtcNow)); + } + _now = DateTime.SpecifyKind(initialUtcNow, DateTimeKind.Utc); + } + + public DateTime UtcNow => _now; + + public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Advance(delay); + return Task.CompletedTask; + } + + public void SetUtcNow(DateTime utcNow) + { + utcNow = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc); + if (utcNow < _now) + { + throw new InvalidOperationException( + $"VirtualClock cannot go backwards (current={_now:O}, requested={utcNow:O})."); + } + _now = utcNow; + } + + public void Advance(TimeSpan delta) + { + if (delta < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(delta), "Advance delta must be non-negative."); + } + _now = _now.Add(delta); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs new file mode 100644 index 00000000..21cdfe01 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/EnrollmentOrchestratorFixture.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration +{ + /// + /// End-to-end integration-test fixture for the V2 Agent runtime. + /// Plan §4.x M4.4.5.g. + /// + /// Drives the real pipeline: DecisionEngine + DecisionStepProcessor + SignalIngress + + /// EffectRunner + Persistence (SignalLog/Journal/Snapshot) + TelemetryTransport + production + /// Classifiers (WhiteGloveSealingClassifier + WhiteGlovePart2CompletionClassifier). + /// + /// + /// System-boundary fakes only: HTTP upload (), + /// wall-clock (), file-system (isolated ). + /// No collectors are wired — tests drive signals directly via + /// / , which mirrors the Collector→Adapter→Ingress + /// path but lets the test control timing. + /// + /// + /// Deadline caveat: the real runs. Effect-scheduled + /// deadlines use wall-clock timers; fixture signals with DeadlineFired-Kind are + /// posted manually (same as the M3 ClassifierAwareReplayHarness) — the real timers + /// would fire in the wall-clock future, long after test shutdown. + /// + /// + internal sealed class EnrollmentOrchestratorFixture : IDisposable + { + public const string SessionId = "session-anon-integration"; + public const string TenantId = "tenant-anon-integration"; + + public TempDirectory Tmp { get; } = new TempDirectory(); + public VirtualClock Clock { get; } + public AgentLogger Logger { get; } + public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader(); + public EnrollmentOrchestrator Orchestrator { get; } + public string StateDir { get; } + public string TransportDir { get; } + + public EnrollmentOrchestratorFixture() + { + Clock = new VirtualClock(new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc)); + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + StateDir = Path.Combine(Tmp.Path, "State"); + TransportDir = Path.Combine(Tmp.Path, "Transport"); + + // Uploader is scripted OK so terminal drains succeed. + Uploader.QueueOk(100); + + var classifiers = new List + { + new WhiteGloveSealingClassifier(), + new WhiteGlovePart2CompletionClassifier(), + }; + + Orchestrator = new EnrollmentOrchestrator( + sessionId: SessionId, + tenantId: TenantId, + stateDirectory: StateDir, + transportDirectory: TransportDir, + clock: Clock, + logger: Logger, + uploader: Uploader, + classifiers: classifiers, + // No collectors — tests drive the Ingress directly. + componentFactory: null, + drainInterval: TimeSpan.FromDays(1), + terminalDrainTimeout: TimeSpan.FromSeconds(2)); + } + + public void Start() => Orchestrator.Start(); + + public void Stop() => Orchestrator.Stop(); + + /// + /// Post one signal via the IngressSink, mirroring the Collector→Adapter path. + /// + public void Post(DecisionSignal signal) + { + if (signal == null) throw new ArgumentNullException(nameof(signal)); + Orchestrator.IngressSink.Post( + kind: signal.Kind, + occurredAtUtc: signal.OccurredAtUtc, + sourceOrigin: signal.SourceOrigin, + evidence: signal.Evidence, + payload: signal.Payload, + kindSchemaVersion: signal.KindSchemaVersion); + } + + /// + /// Load an M3 fixture (e.g. "userdriven-happy-v1.jsonl") and replay every signal + /// in order. After each post, wait until: + /// + /// processor.CurrentState.StepIndex has advanced — ApplyStep fully + /// completed (journal + effects + snapshot + state-forward) + /// Ingress queue is empty — any ClassifierVerdictIssued posted by the + /// EffectRunner has been processed + /// + /// + /// Why not ? That counter is updated + /// inside SignalIngress.ProcessItem BEFORE the EffectRunner runs — so a wait + /// on LastOrdinal can release before any synthetic signal (e.g. a classifier verdict) + /// has even been posted. StepIndex on the other hand is only incremented after + /// DecisionStepProcessor.ApplyStep completes, by which time the EffectRunner has + /// synchronously posted any RunClassifier-triggered synthetic. + /// + /// + public void PostFixture(string fixtureFilename) + { + var signals = FixtureLoader.Load(fixtureFilename); + var ingress = GetIngress(); + + foreach (var sig in signals) + { + int stepIndexBefore = Orchestrator.CurrentState.StepIndex; + Post(sig); + + if (!SpinWait.SpinUntil( + () => Orchestrator.CurrentState.StepIndex > stepIndexBefore + && ingress.ApproximateQueueLength == 0, + 5000)) + { + throw new TimeoutException( + $"Fixture replay stalled after {sig.Kind} (stepIndex before={stepIndexBefore}, " + + $"now={Orchestrator.CurrentState.StepIndex}, stage={Orchestrator.CurrentState.Stage}, " + + $"queueLen={ingress.ApproximateQueueLength}). Fixture: {fixtureFilename}."); + } + } + } + + public AutopilotMonitor.Agent.V2.Core.Orchestration.SignalIngress GetIngress() + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_ingress", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (AutopilotMonitor.Agent.V2.Core.Orchestration.SignalIngress)field!.GetValue(Orchestrator)!; + } + + /// Waits for to match any of . + public bool WaitForStage(int timeoutMs, params SessionStage[] stages) + { + var target = new HashSet(stages); + return SpinWait.SpinUntil( + () => target.Contains(Orchestrator.CurrentState.Stage), + timeoutMs); + } + + public ISignalLogWriter GetSignalLog() + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_signalLog", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (ISignalLogWriter)field!.GetValue(Orchestrator)!; + } + + public IJournalWriter GetJournal() + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_journal", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (IJournalWriter)field!.GetValue(Orchestrator)!; + } + + public TelemetrySpool GetSpool() + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_spool", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (TelemetrySpool)field!.GetValue(Orchestrator)!; + } + + /// Returns the last journal transition (or null if empty). + public AutopilotMonitor.DecisionCore.Engine.DecisionTransition? LastTransition() + { + var all = GetJournal().ReadAll(); + return all.Count == 0 ? null : all[all.Count - 1]; + } + + /// Returns all event-kind items that were enqueued for the backend (post-drain or not). + public IReadOnlyList AllEventItemsInSpool() + { + return GetSpool() + .Peek(int.MaxValue) + .Where(i => i.Kind == TelemetryItemKind.Event) + .ToArray(); + } + + public void Dispose() + { + try { Orchestrator.Dispose(); } + catch { /* best-effort */ } + Tmp.Dispose(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/FixtureLoader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/FixtureLoader.cs new file mode 100644 index 00000000..f3d45456 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/FixtureLoader.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration +{ + /// + /// Load a DecisionSignal JSONL fixture from tests/fixtures/enrollment-sessions/. + /// 1:1-Copy aus DecisionCore.Tests/Harness/FixtureLoader.cs — Plan §2.16 Copy-Not-Share- + /// Prinzip für V2-Isolation. + /// + /// Comment lines starting with '#' and blank lines are ignored. + /// + /// + public static class FixtureLoader + { + public static IReadOnlyList LoadJsonl(string path) + { + var signals = new List(); + foreach (var rawLine in File.ReadAllLines(path)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') continue; + signals.Add(SignalSerializer.Deserialize(line)); + } + return signals; + } + + /// + /// Locate the tests/fixtures/enrollment-sessions directory relative to the test binary. + /// Walks up from looking for the solution file. + /// + public static string FixtureRoot() + { + var dir = AppContext.BaseDirectory; + for (int i = 0; i < 12; i++) + { + if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) + { + return Path.Combine(dir, "tests", "fixtures", "enrollment-sessions"); + } + var parent = Directory.GetParent(dir)?.FullName; + if (parent == null || parent == dir) break; + dir = parent; + } + throw new DirectoryNotFoundException( + "Could not locate repo root (AutopilotMonitor.sln) walking up from " + + AppContext.BaseDirectory); + } + + public static IReadOnlyList Load(string filename) => + LoadJsonl(Path.Combine(FixtureRoot(), filename)); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs new file mode 100644 index 00000000..68890be2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ScenarioTests.cs @@ -0,0 +1,272 @@ +using System.Linq; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration +{ + /// + /// End-to-end runtime tests that drive the V2 Agent orchestrator through the same fixtures + /// the M3 ClassifierAwareReplayHarness uses. Release-Gate M4.4 (Plan §4.x M4.4.5.g). + /// + /// Each test loads a JSONL fixture from tests/fixtures/enrollment-sessions/, replays + /// all signals through , and asserts the final + /// + journal tail against the expected terminal shape. Unlike + /// the M3 harness, this exercises Persistence + EffectRunner + Classifiers + Telemetry in + /// a single live pipeline. + /// + /// + /// Scenario coverage mirrors AutopilotMonitor.DecisionCore.Tests/Scenarios/*: + /// UserDriven happy/hello-timeout, SelfDeploying happy/esp-unknown, WhiteGlove inline/ + /// signal-correlated/anti-loop/false-positive, WhiteGlove Part-2 happy/stuck, Hybrid-Reboot, + /// ESP-Terminal-Failure, Late-AADJ. DevPrep-v2 variants land with M5 (no M3 fixture yet). + /// + /// + public sealed class ScenarioTests + { + private const int DefaultTerminalTimeoutMs = 10_000; + + // ================================================================ 1) UserDriven-v1 Happy + + [Fact] + public void Scenario01_UserDriven_Happy_reaches_Completed() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("userdriven-happy-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed, SessionStage.Failed)); + Assert.Equal(SessionStage.Completed, f.Orchestrator.CurrentState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome); + Assert.Equal("Success", f.Orchestrator.CurrentState.HelloOutcome?.Value); + + f.Stop(); + } + + // ================================================================ 2) UserDriven-v1 HelloTimeout + + [Fact] + public void Scenario02_UserDriven_HelloTimeout_completes_with_timeout_outcome() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("userdriven-hello-timeout-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed)); + Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome); + Assert.Equal("Timeout", f.Orchestrator.CurrentState.HelloOutcome?.Value); + + f.Stop(); + } + + // ================================================================ 3) SelfDeploying-v1 Happy + + [Fact] + public void Scenario03_SelfDeploying_Happy_reaches_Completed() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("selfdeploying-happy-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed)); + Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome); + + f.Stop(); + } + + // ================================================================ 4) SelfDeploying-v1 ESP-Exit-Unknown + + [Fact] + public void Scenario04_SelfDeploying_EspExitUnknown_completes_via_provisioning() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("selfdeploying-esp-exit-unknown-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed)); + Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome); + // DeviceOnlyDeployment hypothesis should have climbed from Unknown → Strong/Confirmed. + Assert.NotEqual(HypothesisLevel.Unknown, f.Orchestrator.CurrentState.DeviceOnlyDeployment.Level); + + f.Stop(); + } + + // ================================================================ 5) WhiteGlove Inline + + [Fact] + public void Scenario05_WhiteGlove_Inline_seals_part1() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("whiteglove-inline-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed)); + Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level); + + f.Stop(); + } + + // ================================================================ 6) WhiteGlove Signal-Correlated + + [Fact] + public void Scenario06_WhiteGlove_SignalCorrelated_seals_after_second_signal() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("whiteglove-signal-correlated-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.WhiteGloveSealed)); + Assert.Equal(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level); + + f.Stop(); + } + + // ================================================================ 7) WhiteGlove Anti-Loop + + [Fact] + public void Scenario07_WhiteGlove_AntiLoop_does_not_double_invoke_classifier() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("whiteglove-antiloop-v1.jsonl"); + + // Anti-Loop: after two classifier-ticks with unchanged inputs, second run is skipped. + // Verify we reached a state where at least one sealing verdict was produced. + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, + SessionStage.WhiteGloveSealed, + SessionStage.Completed, + SessionStage.Failed, + SessionStage.EspDeviceSetup)); + + f.Stop(); + } + + // ================================================================ 8) WhiteGlove False-Positive (Late-AADJ) + + [Fact] + public void Scenario08_WhiteGlove_FalsePositive_LateAADJ_falls_back_to_classic() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("whiteglove-false-positive-v1.jsonl"); + + // Late-AADJ rejects the WG hypothesis; session still completes via the classic path. + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed)); + Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome); + Assert.NotEqual(HypothesisLevel.Confirmed, f.Orchestrator.CurrentState.WhiteGloveSealing.Level); + + f.Stop(); + } + + // ================================================================ 9) WhiteGlove Part-2 Happy + + [Fact] + public void Scenario09_WhiteGlovePart2_Happy_completes_part2() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("whiteglove-part2-happy-v1.jsonl"); + + var reached = f.WaitForStage( + DefaultTerminalTimeoutMs, + SessionStage.WhiteGloveCompletedPart2, + SessionStage.Completed, + SessionStage.Failed); + + Assert.True(reached, + $"Part-2 happy fixture did not reach a terminal stage within {DefaultTerminalTimeoutMs}ms; " + + $"stuck at {f.Orchestrator.CurrentState.Stage} (stepIndex={f.Orchestrator.CurrentState.StepIndex}). " + + $"Last transition: {f.LastTransition()?.Trigger}."); + Assert.Equal(SessionStage.WhiteGloveCompletedPart2, f.Orchestrator.CurrentState.Stage); + Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, f.Orchestrator.CurrentState.Outcome); + + f.Stop(); + } + + // ================================================================ 10) WhiteGlove Part-2 Stuck + + [Fact] + public void Scenario10_WhiteGlovePart2_Stuck_fails_on_safety_deadline() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("whiteglove-part2-stuck-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Failed)); + Assert.Equal(SessionOutcome.EnrollmentFailed, f.Orchestrator.CurrentState.Outcome); + + f.Stop(); + } + + // ================================================================ 11) Hybrid-Reboot + + [Fact] + public void Scenario11_HybridReboot_preserves_stage_and_completes() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("hybrid-reboot-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed)); + Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome); + Assert.NotNull(f.Orchestrator.CurrentState.SystemRebootUtc); + + f.Stop(); + } + + // ================================================================ 12) ESP-Terminal-Failure + + [Fact] + public void Scenario12_EspTerminalFailure_fails_with_reason() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("esp-terminal-failure-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Failed)); + Assert.Equal(SessionOutcome.EnrollmentFailed, f.Orchestrator.CurrentState.Outcome); + + var last = f.LastTransition(); + Assert.NotNull(last); + Assert.Equal("EspTerminalFailure", last!.Trigger); + + f.Stop(); + } + + // ================================================================ 13) Late-AADJ Standalone + + [Fact] + public void Scenario13_LateAADJ_standalone_classic_completion() + { + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("late-aadj-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed)); + Assert.Equal(SessionOutcome.EnrollmentComplete, f.Orchestrator.CurrentState.Outcome); + + f.Stop(); + } + + // ================================================================ Pipeline smoke (Telemetry) + + [Fact] + public void Full_pipeline_enqueues_telemetry_items_for_terminal_events() + { + // Verifies that beyond state-shape, the EffectRunner emitted at least one + // EmitEventTimelineEntry effect (e.g. enrollment_complete) that reached the Spool. + using var f = new EnrollmentOrchestratorFixture(); + f.Start(); + f.PostFixture("userdriven-happy-v1.jsonl"); + + Assert.True(f.WaitForStage(DefaultTerminalTimeoutMs, SessionStage.Completed)); + + var eventItems = f.AllEventItemsInSpool(); + Assert.NotEmpty(eventItems); + // At least one item should be a terminal enrollment_complete event. + Assert.Contains(eventItems, i => i.PayloadJson.Contains("enrollment_complete")); + + f.Stop(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DeadlineSchedulerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DeadlineSchedulerTests.cs new file mode 100644 index 00000000..2e93d917 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DeadlineSchedulerTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + [Collection("SerialThreading")] + public sealed class DeadlineSchedulerTests + { + // 5000ms cushion: 2000ms was racy under full-suite parallelism even after adding + // [Collection("SerialThreading")] — timer callbacks execute on the shared ThreadPool + // and can lag when the pool is saturated by unrelated test classes. Plan §4.x M4.5.c. + private const int DefaultWaitMs = 5000; + + private static ActiveDeadline Deadline(string name, DateTime dueAt) => + new ActiveDeadline(name, dueAt, DecisionSignalKind.DeadlineFired); + + [Fact] + public void Past_due_deadline_fires_immediately_on_ThreadPool() + { + var now = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(now); + using var sched = new DeadlineScheduler(clock); + + using var fired = new ManualResetEventSlim(false); + ActiveDeadline? seen = null; + sched.Fired += (_, args) => { seen = args.Deadline; fired.Set(); }; + + var pastDue = Deadline("hello_safety", now.AddSeconds(-30)); + sched.Schedule(pastDue); + + Assert.True(fired.Wait(DefaultWaitMs), "Past-due deadline did not fire."); + Assert.Equal("hello_safety", seen!.Name); + Assert.False(sched.IsScheduled("hello_safety")); + } + + [Fact] + public void Future_deadline_fires_after_elapsed_time() + { + var now = DateTime.UtcNow; + // Use SystemClock for a real wait so the wall-clock Timer ticks. + var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock(); + using var sched = new DeadlineScheduler(clock); + + using var fired = new ManualResetEventSlim(false); + sched.Fired += (_, _) => fired.Set(); + + var deadline = Deadline("short", DateTime.UtcNow.AddMilliseconds(80)); + sched.Schedule(deadline); + Assert.True(sched.IsScheduled("short")); + + Assert.True(fired.Wait(DefaultWaitMs), "Future deadline did not fire."); + Assert.False(sched.IsScheduled("short")); + } + + [Fact] + public void Cancel_prevents_future_fire() + { + var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock(); + using var sched = new DeadlineScheduler(clock); + + int fireCount = 0; + sched.Fired += (_, _) => Interlocked.Increment(ref fireCount); + + sched.Schedule(Deadline("cancel-me", DateTime.UtcNow.AddMilliseconds(200))); + Assert.True(sched.IsScheduled("cancel-me")); + + sched.Cancel("cancel-me"); + Assert.False(sched.IsScheduled("cancel-me")); + + Thread.Sleep(400); + Assert.Equal(0, fireCount); + } + + [Fact] + public void Cancel_unknown_name_is_noop() + { + using var sched = new DeadlineScheduler(new AutopilotMonitor.DecisionCore.Engine.SystemClock()); + sched.Cancel("never-scheduled"); + sched.Cancel(string.Empty); + // Just assert no exception. + } + + [Fact] + public void Reschedule_same_name_replaces_old_timer() + { + // Plan §2.6 Schedule-semantics: same-name replaces. + var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock(); + using var sched = new DeadlineScheduler(clock); + + var firedNames = new List(); + using var someFired = new ManualResetEventSlim(false); + sched.Fired += (_, args) => + { + lock (firedNames) firedNames.Add(args.Deadline); + someFired.Set(); + }; + + // First: far in the future (won't fire within the test window). + sched.Schedule(Deadline("classifier_tick", DateTime.UtcNow.AddSeconds(30))); + // Replace with past-due. Old timer must be cancelled before new fires. + var replacement = Deadline("classifier_tick", DateTime.UtcNow.AddSeconds(-1)); + sched.Schedule(replacement); + + Assert.True(someFired.Wait(DefaultWaitMs)); + + lock (firedNames) + { + Assert.Single(firedNames); + Assert.Equal("classifier_tick", firedNames[0].Name); + // Replacement's DueAtUtc differs from the original — prove the replacement fired. + Assert.Equal(replacement.DueAtUtc, firedNames[0].DueAtUtc); + } + } + + [Fact] + public void ActiveDeadlines_snapshot_reflects_current_schedule() + { + var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock(); + using var sched = new DeadlineScheduler(clock); + + sched.Schedule(Deadline("a", DateTime.UtcNow.AddSeconds(30))); + sched.Schedule(Deadline("b", DateTime.UtcNow.AddSeconds(30))); + + var snap = sched.ActiveDeadlines; + Assert.Equal(2, snap.Count); + + sched.Cancel("a"); + Assert.Single(sched.ActiveDeadlines); + } + + [Fact] + public void RehydrateFromSnapshot_reschedules_each_deadline_past_due_fires_immediately() + { + // Plan §2.6 Restart-Recovery: past-due → sofort DeadlineFired. + var now = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(now); + using var sched = new DeadlineScheduler(clock); + + int fireCount = 0; + using var allFired = new CountdownEvent(2); + sched.Fired += (_, _) => + { + Interlocked.Increment(ref fireCount); + allFired.Signal(); + }; + + sched.RehydrateFromSnapshot(new[] + { + Deadline("expired_1", now.AddMinutes(-10)), + Deadline("expired_2", now.AddSeconds(-1)), + }); + + Assert.True(allFired.Wait(DefaultWaitMs)); + Assert.Equal(2, fireCount); + Assert.Empty(sched.ActiveDeadlines); + } + + [Fact] + public void Dispose_cancels_all_pending_timers() + { + var clock = new AutopilotMonitor.DecisionCore.Engine.SystemClock(); + var sched = new DeadlineScheduler(clock); + + int fireCount = 0; + sched.Fired += (_, _) => Interlocked.Increment(ref fireCount); + + sched.Schedule(Deadline("a", DateTime.UtcNow.AddMilliseconds(150))); + sched.Schedule(Deadline("b", DateTime.UtcNow.AddMilliseconds(150))); + + sched.Dispose(); + Thread.Sleep(350); + + Assert.Equal(0, fireCount); + } + + [Fact] + public void Schedule_after_Dispose_throws() + { + var sched = new DeadlineScheduler(new AutopilotMonitor.DecisionCore.Engine.SystemClock()); + sched.Dispose(); + + Assert.Throws(() => + sched.Schedule(Deadline("x", DateTime.UtcNow.AddSeconds(1)))); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs new file mode 100644 index 00000000..2bfa84cd --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// M4.6.β — fires the terminal-stage callback exactly + /// once, at the transition from a non-terminal to a terminal . + /// + public sealed class DecisionStepProcessorTerminalStageTests + { + private static DateTime At => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public FakeJournalWriter Journal { get; } = new FakeJournalWriter(); + public FakeEffectRunner Effects { get; } = new FakeEffectRunner(); + public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence(); + public FakeQuarantineSink Quarantine { get; } = new FakeQuarantineSink(); + + public Rig() { Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); } + + public DecisionStepProcessor Build(DecisionState initial, Action? onTerminal) => + new DecisionStepProcessor( + initialState: initial, + journal: Journal, + effectRunner: Effects, + snapshot: Snapshot, + quarantineSink: Quarantine, + logger: Logger, + quarantineThreshold: DecisionStepProcessor.DefaultQuarantineThreshold, + onTerminalStageReached: onTerminal); + + public void Dispose() => Tmp.Dispose(); + } + + private static DecisionSignal Sig(long ordinal, DateTime at) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 1, + occurredAtUtc: at, + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Raw, $"raw-{ordinal}", $"evidence-{ordinal}"), + payload: null); + + private static DecisionStep StepToStage(DecisionState oldState, SessionStage newStage, long signalOrdinal, DateTime at) + { + var builder = new DecisionStateBuilder(oldState) { Stage = newStage, StepIndex = oldState.StepIndex + 1 }; + var newState = builder.Build(); + var transition = new DecisionTransition( + stepIndex: newState.StepIndex, + sessionTraceOrdinal: signalOrdinal, + signalOrdinalRef: signalOrdinal, + occurredAtUtc: at, + trigger: "TestTerminalTrigger", + fromStage: oldState.Stage, + toStage: newStage, + taken: true, + deadEndReason: null, + reducerVersion: "2.0.0.0"); + return new DecisionStep(newState, transition, Array.Empty()); + } + + [Theory] + [InlineData(SessionStage.Completed)] + [InlineData(SessionStage.Failed)] + [InlineData(SessionStage.WhiteGloveSealed)] + [InlineData(SessionStage.WhiteGloveCompletedPart2)] + public void Terminal_stage_transition_fires_callback_with_terminal_state(SessionStage terminal) + { + using var rig = new Rig(); + DecisionState? captured = null; + var callCount = 0; + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), + onTerminal: state => + { + Interlocked.Increment(ref callCount); + captured = state; + }); + + var step = StepToStage(sut.CurrentState, terminal, signalOrdinal: 1, at: At); + sut.ApplyStep(step, Sig(1, At)); + + Assert.Equal(1, callCount); + Assert.NotNull(captured); + Assert.Equal(terminal, captured!.Stage); + } + + [Fact] + public void Terminal_callback_does_not_fire_for_non_terminal_transitions() + { + using var rig = new Rig(); + var callCount = 0; + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), + onTerminal: _ => Interlocked.Increment(ref callCount)); + + var step = StepToStage(sut.CurrentState, SessionStage.AwaitingEspPhaseChange, signalOrdinal: 1, at: At); + sut.ApplyStep(step, Sig(1, At)); + + Assert.Equal(0, callCount); + } + + [Fact] + public void Terminal_callback_fires_at_most_once_per_processor_instance() + { + using var rig = new Rig(); + var callCount = 0; + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), + onTerminal: _ => Interlocked.Increment(ref callCount)); + + // Transition to Completed, then apply a second step that leaves the stage terminal. + var firstStep = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At); + sut.ApplyStep(firstStep, Sig(1, At)); + + // Synthetic second step — stage still terminal (same value). + var secondStep = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 2, at: At.AddSeconds(1)); + sut.ApplyStep(secondStep, Sig(2, At.AddSeconds(1))); + + Assert.Equal(1, callCount); + } + + [Fact] + public void Terminal_callback_suppressed_when_initial_state_is_already_terminal() + { + // Recovery scenario: snapshot loaded a state whose stage is already terminal + // (e.g. crash after terminal transition but before Stop). The processor must not + // re-fire the callback on the first post-recovery step. + using var rig = new Rig(); + var callCount = 0; + var recoveredState = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) + { + Stage = SessionStage.Completed, + }.Build(); + + var sut = rig.Build(recoveredState, + onTerminal: _ => Interlocked.Increment(ref callCount)); + + // No ApplyStep needed — the ctor already observed the terminal state. Even if we + // apply a follow-up terminal-preserving step, callback stays silent. + var step = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At); + sut.ApplyStep(step, Sig(1, At)); + + Assert.Equal(0, callCount); + } + + [Fact] + public void Terminal_callback_null_is_supported() + { + using var rig = new Rig(); + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), onTerminal: null); + var step = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At); + + // Should not throw when callback is null. + sut.ApplyStep(step, Sig(1, At)); + Assert.Equal(SessionStage.Completed, sut.CurrentState.Stage); + } + + [Fact] + public void Terminal_callback_exception_is_swallowed_and_logged() + { + using var rig = new Rig(); + var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), + onTerminal: _ => throw new InvalidOperationException("handler bug")); + + var step = StepToStage(sut.CurrentState, SessionStage.Failed, signalOrdinal: 1, at: At); + // Must not bubble the handler's exception — the step is already committed. + sut.ApplyStep(step, Sig(1, At)); + Assert.Equal(SessionStage.Failed, sut.CurrentState.Stage); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTests.cs new file mode 100644 index 00000000..058e7c96 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTests.cs @@ -0,0 +1,247 @@ +using System; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + public sealed class DecisionStepProcessorTests + { + private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + /// + /// Rig wires the processor against in-memory fakes + a real + /// so we get genuine instances (ctor-validated transition + + /// effects) without duplicating the reducer shape in test builders. + /// + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public FakeJournalWriter Journal { get; } = new FakeJournalWriter(); + public FakeEffectRunner Effects { get; } = new FakeEffectRunner(); + public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence(); + public FakeQuarantineSink Quarantine { get; } = new FakeQuarantineSink(); + public DecisionEngine Engine { get; } = new DecisionEngine(); + public DecisionState InitialState { get; } = DecisionState.CreateInitial("S1", "T1"); + + public Rig() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + } + + public DecisionStepProcessor Build(int threshold = DecisionStepProcessor.DefaultQuarantineThreshold) => + new DecisionStepProcessor( + initialState: InitialState, + journal: Journal, + effectRunner: Effects, + snapshot: Snapshot, + quarantineSink: Quarantine, + logger: Logger, + quarantineThreshold: threshold); + + /// Builds a real DecisionStep by Reducing SessionStarted against the current state. + public (DecisionStep step, DecisionSignal signal) ReduceSessionStarted(long ordinal = 0) + { + var signal = TestSignals.Raw(ordinal, DecisionSignalKind.SessionStarted, At); + var step = Engine.Reduce(InitialState, signal); + return (step, signal); + } + + public void Dispose() => Tmp.Dispose(); + } + + // ========================================================================= Ctor + + [Fact] + public void Ctor_rejects_null_dependencies() + { + using var rig = new Rig(); + Assert.Throws(() => + new DecisionStepProcessor(null!, rig.Journal, rig.Effects, rig.Snapshot, rig.Quarantine, rig.Logger)); + Assert.Throws(() => + new DecisionStepProcessor(rig.InitialState, null!, rig.Effects, rig.Snapshot, rig.Quarantine, rig.Logger)); + Assert.Throws(() => + new DecisionStepProcessor(rig.InitialState, rig.Journal, null!, rig.Snapshot, rig.Quarantine, rig.Logger)); + Assert.Throws(() => + new DecisionStepProcessor(rig.InitialState, rig.Journal, rig.Effects, null!, rig.Quarantine, rig.Logger)); + Assert.Throws(() => + new DecisionStepProcessor(rig.InitialState, rig.Journal, rig.Effects, rig.Snapshot, null!, rig.Logger)); + Assert.Throws(() => + new DecisionStepProcessor(rig.InitialState, rig.Journal, rig.Effects, rig.Snapshot, rig.Quarantine, null!)); + } + + [Fact] + public void Ctor_rejects_non_positive_threshold() + { + using var rig = new Rig(); + Assert.Throws(() => rig.Build(threshold: 0)); + Assert.Throws(() => rig.Build(threshold: -1)); + } + + [Fact] + public void CurrentState_starts_at_initial_state() + { + using var rig = new Rig(); + var sut = rig.Build(); + Assert.Same(rig.InitialState, sut.CurrentState); + } + + // ========================================================================= Happy path + + [Fact] + public void ApplyStep_happy_path_appends_journal_runs_effects_saves_snapshot_forwards_state() + { + using var rig = new Rig(); + var sut = rig.Build(); + var (step, signal) = rig.ReduceSessionStarted(); + + sut.ApplyStep(step, signal); + + Assert.Single(rig.Journal.Appended); + Assert.Same(step.Transition, rig.Journal.Appended[0]); + Assert.Equal(1, rig.Effects.CallCount); + Assert.Same(step.NewState, rig.Effects.Calls[0].State); + Assert.Equal(signal.OccurredAtUtc, rig.Effects.Calls[0].OccurredAtUtc); + Assert.Single(rig.Snapshot.Saved); + Assert.Same(step.NewState, rig.Snapshot.Saved[0]); + Assert.Same(step.NewState, sut.CurrentState); + Assert.Equal(0, sut.ConsecutiveJournalFailureCount); + Assert.Empty(rig.Quarantine.Reasons); + } + + // ========================================================================= Journal failures + + [Fact] + public void ApplyStep_journal_fails_once_does_not_trigger_quarantine() + { + using var rig = new Rig(); + rig.Journal.ScriptThrow(new InvalidOperationException("disk-hiccup")); + var sut = rig.Build(); + var (step, signal) = rig.ReduceSessionStarted(); + + Assert.Throws(() => sut.ApplyStep(step, signal)); + + Assert.Equal(1, sut.ConsecutiveJournalFailureCount); + Assert.Empty(rig.Quarantine.Reasons); + Assert.Equal(0, rig.Effects.CallCount); // no effects run after append fails + Assert.Empty(rig.Snapshot.Saved); + Assert.Same(rig.InitialState, sut.CurrentState); // state NOT advanced + } + + [Fact] + public void ApplyStep_journal_fails_threshold_times_triggers_quarantine_once() + { + using var rig = new Rig(); + rig.Journal.ScriptThrow(new InvalidOperationException("disk-full"), count: 5); + var sut = rig.Build(threshold: 3); + var (step, signal) = rig.ReduceSessionStarted(); + + // Failures 1 + 2 must not quarantine. + Assert.Throws(() => sut.ApplyStep(step, signal)); + Assert.Throws(() => sut.ApplyStep(step, signal)); + Assert.Empty(rig.Quarantine.Reasons); + + // Failure 3 crosses the threshold. + Assert.Throws(() => sut.ApplyStep(step, signal)); + Assert.Single(rig.Quarantine.Reasons); + Assert.Contains("journal append failed", rig.Quarantine.Reasons[0]); + Assert.Contains("3x", rig.Quarantine.Reasons[0]); + + // Failure 4 must not re-trigger quarantine (fire-once). + Assert.Throws(() => sut.ApplyStep(step, signal)); + Assert.Single(rig.Quarantine.Reasons); + + Assert.Same(rig.InitialState, sut.CurrentState); + } + + [Fact] + public void ApplyStep_journal_recovers_after_transient_failure_resets_counter() + { + using var rig = new Rig(); + rig.Journal.ScriptThrow(new InvalidOperationException("blip")).ScriptOk(); + var sut = rig.Build(); + var (step, signal) = rig.ReduceSessionStarted(); + + Assert.Throws(() => sut.ApplyStep(step, signal)); + Assert.Equal(1, sut.ConsecutiveJournalFailureCount); + + sut.ApplyStep(step, signal); // succeeds second time + Assert.Equal(0, sut.ConsecutiveJournalFailureCount); + Assert.Same(step.NewState, sut.CurrentState); + } + + // ========================================================================= Snapshot best-effort + + [Fact] + public void ApplyStep_snapshot_fails_does_not_fail_step() + { + using var rig = new Rig(); + rig.Snapshot.ScriptThrow(new InvalidOperationException("snapshot-disk-full")); + var sut = rig.Build(); + var (step, signal) = rig.ReduceSessionStarted(); + + sut.ApplyStep(step, signal); // does NOT throw + + Assert.Single(rig.Journal.Appended); + Assert.Equal(1, rig.Effects.CallCount); + Assert.Same(step.NewState, sut.CurrentState); // state still advanced + Assert.Empty(rig.Quarantine.Reasons); + } + + // ========================================================================= EffectRunner behavior + + [Fact] + public void ApplyStep_effect_runner_SessionMustAbort_is_logged_but_step_committed() + { + using var rig = new Rig(); + var abortResult = new EffectRunResult( + sessionMustAbort: true, + abortReason: "timer_infrastructure_failure: timer-broken", + failures: Array.Empty(), + classifierInvocations: 0, + classifierSkippedByAntiLoop: 0); + rig.Effects.ScriptResult(abortResult); + + var sut = rig.Build(); + var (step, signal) = rig.ReduceSessionStarted(); + + sut.ApplyStep(step, signal); // state still advances + + Assert.Single(rig.Journal.Appended); + Assert.Same(step.NewState, sut.CurrentState); + Assert.Empty(rig.Quarantine.Reasons); // effect-failures do NOT quarantine + } + + [Fact] + public void ApplyStep_effect_runner_unexpected_throw_logged_but_step_committed() + { + using var rig = new Rig(); + rig.Effects.ScriptThrow(new InvalidOperationException("unexpected")); + var sut = rig.Build(); + var (step, signal) = rig.ReduceSessionStarted(); + + sut.ApplyStep(step, signal); // swallowed + + Assert.Single(rig.Journal.Appended); + Assert.Same(step.NewState, sut.CurrentState); + } + + // ========================================================================= Argument validation + + [Fact] + public void ApplyStep_rejects_null_step_or_signal() + { + using var rig = new Rig(); + var sut = rig.Build(); + var (step, signal) = rig.ReduceSessionStarted(); + + Assert.Throws(() => sut.ApplyStep(null!, signal)); + Assert.Throws(() => sut.ApplyStep(step, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorUploadPathTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorUploadPathTests.cs new file mode 100644 index 00000000..8d2a41da --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorUploadPathTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Codex Finding 1 regression gate for : verifies that + /// every successfully-journaled is also projected onto + /// the telemetry transport for backend upload, and that a throwing emitter does NOT + /// abort the step. + /// + public sealed class DecisionStepProcessorUploadPathTests + { + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public FakeJournalWriter Journal { get; } = new FakeJournalWriter(); + public FakeEffectRunner Effects { get; } = new FakeEffectRunner(); + public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence(); + public FakeQuarantineSink Quarantine { get; } = new FakeQuarantineSink(); + public DecisionEngine Engine { get; } = new DecisionEngine(); + public DecisionState InitialState { get; } = DecisionState.CreateInitial(SessionId, TenantId); + public FakeTelemetryTransport Transport { get; } = new FakeTelemetryTransport(); + + public Rig() { Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); } + + public DecisionStepProcessor Build(TelemetryTransitionEmitter? emitter) + => new DecisionStepProcessor( + initialState: InitialState, + journal: Journal, + effectRunner: Effects, + snapshot: Snapshot, + quarantineSink: Quarantine, + logger: Logger, + transitionEmitter: emitter); + + public (DecisionStep step, DecisionSignal signal) ReduceSessionStarted(long ordinal = 0) + { + var signal = TestSignals.Raw(ordinal, DecisionSignalKind.SessionStarted, At); + var step = Engine.Reduce(InitialState, signal); + return (step, signal); + } + + public void Dispose() => Tmp.Dispose(); + } + + [Fact] + public void ApplyStep_emits_transition_to_transport_after_Journal_append() + { + using var rig = new Rig(); + var emitter = new TelemetryTransitionEmitter(rig.Transport, SessionId, TenantId); + var processor = rig.Build(emitter); + + var (step, signal) = rig.ReduceSessionStarted(); + processor.ApplyStep(step, signal); + + Assert.Single(rig.Journal.Appended); + Assert.Equal(1, rig.Transport.EnqueueCount); + Assert.Equal(TelemetryItemKind.DecisionTransition, rig.Transport.Enqueued[0].Kind); + Assert.Equal($"{TenantId}_{SessionId}", rig.Transport.Enqueued[0].PartitionKey); + } + + [Fact] + public void ApplyStep_without_emitter_keeps_pre_M5_behaviour_unchanged() + { + using var rig = new Rig(); + var processor = rig.Build(emitter: null); + + var (step, signal) = rig.ReduceSessionStarted(); + processor.ApplyStep(step, signal); + + Assert.Single(rig.Journal.Appended); + Assert.Equal(0, rig.Transport.EnqueueCount); + } + + [Fact] + public void Journal_failure_prevents_emit_so_no_ghost_rows_leak_to_backend() + { + // Journal is authoritative. If journal.Append throws, the transition NEVER happened + // locally — and must NEVER be uploaded. Otherwise the backend would see phantom + // transitions that can't be reconciled. + using var rig = new Rig(); + rig.Journal.ScriptThrow(new InvalidOperationException("disk full (test)")); + var emitter = new TelemetryTransitionEmitter(rig.Transport, SessionId, TenantId); + var processor = rig.Build(emitter); + + var (step, signal) = rig.ReduceSessionStarted(); + + Assert.Throws(() => processor.ApplyStep(step, signal)); + Assert.Equal(0, rig.Transport.EnqueueCount); + } + + [Fact] + public void Transport_throw_is_swallowed_and_effects_still_run() + { + // Transport failure must NOT break the local post-journal pipeline: snapshot + + // effects + CurrentState update all happen regardless. + using var rig = new Rig(); + var throwingTransport = new ThrowingTelemetryTransport(); + var emitter = new TelemetryTransitionEmitter(throwingTransport, SessionId, TenantId); + var processor = rig.Build(emitter); + + var (step, signal) = rig.ReduceSessionStarted(); + processor.ApplyStep(step, signal); + + Assert.Equal(1, throwingTransport.EnqueueAttempts); + Assert.Single(rig.Journal.Appended); + Assert.Equal(1, rig.Effects.CallCount); + Assert.Equal(step.NewState, processor.CurrentState); + } + + private sealed class ThrowingTelemetryTransport : ITelemetryTransport + { + public int EnqueueAttempts; + + public long LastUploadedItemId => -1; + + public TelemetryItem Enqueue(TelemetryItemDraft draft) + { + Interlocked.Increment(ref EnqueueAttempts); + throw new InvalidOperationException("spool overflow (test)"); + } + + public Task DrainAllAsync(CancellationToken cancellationToken = default) + => Task.FromResult(DrainResult.Empty()); + + public void Dispose() { } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EffectRunnerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EffectRunnerTests.cs new file mode 100644 index 00000000..cd7cea71 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EffectRunnerTests.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + public sealed class EffectRunnerTests + { + private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private static DecisionState InitialState() => + DecisionState.CreateInitial("S1", "T1"); + + private static ActiveDeadline Deadline(string name, DateTime dueAt) => + new ActiveDeadline(name, dueAt, DecisionSignalKind.DeadlineFired); + + private sealed class Rig + { + public FakeDeadlineScheduler Scheduler { get; } = new FakeDeadlineScheduler(); + public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence(); + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public FakeEventTimelineEmitter Emitter { get; } = new FakeEventTimelineEmitter(); + public Dictionary Classifiers { get; } = new Dictionary(); + public VirtualClock Clock { get; } = new VirtualClock(At); + + public IEffectRunner Build() => + new EffectRunner( + scheduler: Scheduler, + classifiers: new ClassifierRegistry(Classifiers.Values), + ingress: Ingress, + emitter: Emitter, + snapshot: Snapshot, + clock: Clock); + } + + // ----- ScheduleDeadline / CancelDeadline (critical class) ----- + + [Fact] + public async Task ScheduleDeadline_forwards_to_scheduler() + { + var rig = new Rig(); + var sut = rig.Build(); + var d = Deadline("hello_safety", At.AddMinutes(5)); + var effect = new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: d); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.True(result.Success); + Assert.Single(rig.Scheduler.Scheduled); + Assert.Equal("hello_safety", rig.Scheduler.Scheduled[0].Name); + } + + [Fact] + public async Task CancelDeadline_forwards_to_scheduler() + { + var rig = new Rig(); + var sut = rig.Build(); + var effect = new DecisionEffect(DecisionEffectKind.CancelDeadline, cancelDeadlineName: "hello_safety"); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.True(result.Success); + Assert.Single(rig.Scheduler.Cancelled); + Assert.Equal("hello_safety", rig.Scheduler.Cancelled[0]); + } + + [Fact] + public async Task ScheduleDeadline_exception_aborts_session_with_timer_infrastructure_failure() + { + var rig = new Rig(); + rig.Scheduler.ThrowOnSchedule = new InvalidOperationException("timer-broken"); + var sut = rig.Build(); + var effect = new DecisionEffect( + DecisionEffectKind.ScheduleDeadline, + deadline: Deadline("x", At.AddMinutes(1))); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.True(result.SessionMustAbort); + Assert.Contains("timer_infrastructure_failure", result.AbortReason); + Assert.Single(result.Failures); + Assert.Equal(DecisionEffectKind.ScheduleDeadline, result.Failures[0].EffectKind); + Assert.False(result.Failures[0].IsTransient); + } + + [Fact] + public async Task ScheduleDeadline_abort_stops_processing_subsequent_effects() + { + var rig = new Rig(); + rig.Scheduler.ThrowOnSchedule = new InvalidOperationException("timer-broken"); + var sut = rig.Build(); + + var effects = new[] + { + new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: Deadline("x", At.AddMinutes(1))), + new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry, parameters: new Dictionary { ["eventType"] = "won't-fire" }), + new DecisionEffect(DecisionEffectKind.PersistSnapshot), + }; + + var result = await sut.RunAsync(effects, InitialState(), At); + + Assert.True(result.SessionMustAbort); + Assert.Equal(0, rig.Emitter.CallCount); // dispatching stopped + Assert.Empty(rig.Snapshot.Saved); + } + + [Fact] + public async Task ScheduleDeadline_missing_Deadline_payload_aborts() + { + var rig = new Rig(); + var sut = rig.Build(); + var effect = new DecisionEffect(DecisionEffectKind.ScheduleDeadline); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.True(result.SessionMustAbort); + } + + // ----- EmitEventTimelineEntry (transient class) ----- + + [Fact] + public async Task EmitEventTimelineEntry_forwards_parameters_and_state() + { + var rig = new Rig(); + var sut = rig.Build(); + var parameters = new Dictionary { ["eventType"] = "enrollment_complete" }; + var effect = new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry, parameters: parameters); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.True(result.Success); + Assert.Equal(1, rig.Emitter.CallCount); + Assert.Equal("enrollment_complete", rig.Emitter.Calls[0].Parameters!["eventType"]); + Assert.Equal("S1", rig.Emitter.Calls[0].State.SessionId); + } + + [Fact] + public async Task EmitEventTimelineEntry_retries_on_transient_failure_up_to_3_times() + { + var rig = new Rig(); + rig.Emitter.ScriptThrow(new InvalidOperationException("hiccup"), count: 2).ScriptOk(); + var sut = rig.Build(); + var effect = new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.True(result.Success); + Assert.Equal(3, rig.Emitter.CallCount); // 2 fails + 1 success + } + + [Fact] + public async Task EmitEventTimelineEntry_transient_exhaust_records_failure_without_aborting() + { + // Plan §2.7b: transient exhaust → Warning-Log, State unverändert, weiter im Flow. + var rig = new Rig(); + rig.Emitter.ScriptThrow(new InvalidOperationException("persistent-hiccup"), count: 4); + var sut = rig.Build(); + var effect = new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.False(result.SessionMustAbort); + Assert.Single(result.Failures); + Assert.True(result.Failures[0].IsTransient); + Assert.True(result.Failures[0].ExhaustedRetries); + Assert.Equal(4, rig.Emitter.CallCount); // 1 initial + 3 retries + } + + [Fact] + public async Task EmitEventTimelineEntry_failure_does_not_block_subsequent_effects() + { + var rig = new Rig(); + rig.Emitter.ScriptThrow(new InvalidOperationException("bad"), count: 4); + var sut = rig.Build(); + + var effects = new[] + { + new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry), + new DecisionEffect(DecisionEffectKind.PersistSnapshot), + }; + + var result = await sut.RunAsync(effects, InitialState(), At); + + Assert.False(result.SessionMustAbort); + Assert.Single(result.Failures); + Assert.Single(rig.Snapshot.Saved); + } + + // ----- PersistSnapshot (transient class) ----- + + [Fact] + public async Task PersistSnapshot_saves_current_state() + { + var rig = new Rig(); + var sut = rig.Build(); + var effect = new DecisionEffect(DecisionEffectKind.PersistSnapshot); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.True(result.Success); + Assert.Single(rig.Snapshot.Saved); + Assert.Equal("S1", rig.Snapshot.Saved[0].SessionId); + } + + [Fact] + public async Task PersistSnapshot_retries_on_transient_failure() + { + var rig = new Rig(); + rig.Snapshot.ScriptThrow(new IOException_("disk-full"), count: 1).ScriptOk(); + var sut = rig.Build(); + var effect = new DecisionEffect(DecisionEffectKind.PersistSnapshot); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.True(result.Success); + Assert.Equal(2, rig.Snapshot.SaveCallCount); + } + + // ----- RunClassifier (optional class) ----- + + [Fact] + public async Task RunClassifier_happy_invokes_classifier_and_posts_verdict_signal() + { + var rig = new Rig(); + const string Id = WhiteGloveSealingClassifier.ClassifierId; + const string Hash = "hash-abc123"; + rig.Classifiers[Id] = new FakeClassifier( + Id, + _ => FakeClassifier.Verdict(Id, HypothesisLevel.Strong, Hash, score: 85)); + + var sut = rig.Build(); + var effect = new DecisionEffect( + DecisionEffectKind.RunClassifier, + classifierId: Id, + classifierSnapshot: new HashSnapshot(Hash)); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.True(result.Success); + Assert.Equal(1, result.ClassifierInvocations); + Assert.Equal(0, result.ClassifierSkippedByAntiLoop); + Assert.Single(rig.Ingress.Posted); + Assert.Equal(DecisionSignalKind.ClassifierVerdictIssued, rig.Ingress.Posted[0].Kind); + Assert.Equal(At, rig.Ingress.Posted[0].OccurredAtUtc); + var payload = rig.Ingress.Posted[0].Payload!; + Assert.Equal("Strong", payload["level"]); + Assert.Equal(Hash, payload["inputHash"]); + Assert.Equal(EvidenceKind.Synthetic, rig.Ingress.Posted[0].Evidence.Kind); + } + + [Fact] + public async Task RunClassifier_antiloop_skips_when_snapshot_hash_matches_prior_verdict() + { + // Seed state with a hypothesis whose LastClassifierVerdictId equals the snapshot hash. + const string Id = WhiteGloveSealingClassifier.ClassifierId; + const string Hash = "prior-hash"; + + var state = InitialState() + .ToBuilder() + .Apply(b => b.WhiteGloveSealing = new Hypothesis( + level: HypothesisLevel.Strong, + reason: "seed", + score: 80, + lastUpdatedUtc: At, + lastClassifierVerdictId: Hash)) + .Build(); + + var rig = new Rig(); + var invoked = false; + rig.Classifiers[Id] = new FakeClassifier(Id, _ => + { + invoked = true; + return FakeClassifier.Verdict(Id, HypothesisLevel.Strong, Hash); + }); + + var sut = rig.Build(); + var effect = new DecisionEffect( + DecisionEffectKind.RunClassifier, + classifierId: Id, + classifierSnapshot: new HashSnapshot(Hash)); + + var result = await sut.RunAsync(new[] { effect }, state, At); + + Assert.True(result.Success); + Assert.False(invoked); // classifier not called + Assert.Equal(0, result.ClassifierInvocations); + Assert.Equal(1, result.ClassifierSkippedByAntiLoop); + Assert.Empty(rig.Ingress.Posted); + } + + [Fact] + public async Task RunClassifier_exception_posts_Inconclusive_verdict_without_aborting() + { + var rig = new Rig(); + const string Id = WhiteGloveSealingClassifier.ClassifierId; + rig.Classifiers[Id] = new FakeClassifier(Id, _ => throw new InvalidOperationException("bug")); + + var sut = rig.Build(); + var effect = new DecisionEffect( + DecisionEffectKind.RunClassifier, + classifierId: Id, + classifierSnapshot: new HashSnapshot("h1")); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.False(result.SessionMustAbort); + Assert.Single(result.Failures); + Assert.Single(rig.Ingress.Posted); + var payload = rig.Ingress.Posted[0].Payload!; + Assert.Equal("Inconclusive", payload["level"]); + Assert.Contains("exception", payload["reason"]); + } + + [Fact] + public async Task RunClassifier_unknown_id_records_failure_without_aborting() + { + var rig = new Rig(); + var sut = rig.Build(); + var effect = new DecisionEffect( + DecisionEffectKind.RunClassifier, + classifierId: "never-registered", + classifierSnapshot: new HashSnapshot("h")); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.False(result.SessionMustAbort); + Assert.Single(result.Failures); + Assert.Contains("never-registered", result.Failures[0].ErrorReason); + Assert.Empty(rig.Ingress.Posted); + } + + [Fact] + public async Task RunClassifier_missing_snapshot_records_failure() + { + var rig = new Rig(); + const string Id = WhiteGloveSealingClassifier.ClassifierId; + rig.Classifiers[Id] = new FakeClassifier(Id, _ => FakeClassifier.Verdict(Id, HypothesisLevel.Weak, "x")); + var sut = rig.Build(); + var effect = new DecisionEffect(DecisionEffectKind.RunClassifier, classifierId: Id); + + var result = await sut.RunAsync(new[] { effect }, InitialState(), At); + + Assert.Single(result.Failures); + Assert.False(result.SessionMustAbort); + } + + // ----- Batches and edge cases ----- + + [Fact] + public async Task Multiple_effects_in_one_batch_dispatch_in_order() + { + var rig = new Rig(); + const string Id = WhiteGloveSealingClassifier.ClassifierId; + rig.Classifiers[Id] = new FakeClassifier(Id, _ => FakeClassifier.Verdict(Id, HypothesisLevel.Strong, "h")); + var sut = rig.Build(); + + var effects = new[] + { + new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: Deadline("a", At.AddMinutes(1))), + new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry), + new DecisionEffect(DecisionEffectKind.RunClassifier, classifierId: Id, classifierSnapshot: new HashSnapshot("h")), + new DecisionEffect(DecisionEffectKind.PersistSnapshot), + new DecisionEffect(DecisionEffectKind.CancelDeadline, cancelDeadlineName: "other"), + }; + + var result = await sut.RunAsync(effects, InitialState(), At); + + Assert.True(result.Success); + Assert.Single(rig.Scheduler.Scheduled); + Assert.Equal(1, rig.Emitter.CallCount); + Assert.Equal(1, result.ClassifierInvocations); + Assert.Single(rig.Snapshot.Saved); + Assert.Single(rig.Scheduler.Cancelled); + } + + [Fact] + public async Task Empty_effects_list_returns_empty_success() + { + var rig = new Rig(); + var sut = rig.Build(); + + var result = await sut.RunAsync(Array.Empty(), InitialState(), At); + + Assert.True(result.Success); + Assert.Equal(0, result.ClassifierInvocations); + } + } + + // Local exception type so we don't take a dep on System.IO. + internal sealed class IOException_ : Exception + { + public IOException_(string msg) : base(msg) { } + } + + // Tiny builder-extension helper so tests can set builder props inline. + internal static class DecisionStateBuilderTestExt + { + public static DecisionStateBuilder Apply(this DecisionStateBuilder b, Action mutate) + { + mutate(b); + return b; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs new file mode 100644 index 00000000..67b6eac8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; +using Xunit; + +#pragma warning disable xUnit1031 + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + public sealed class EnrollmentOrchestratorEventBridgeTests + { + private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + /// Test host that lets a test manually fire enrollment events. + private sealed class FakeCollectorHost : ICollectorHost + { + private readonly Action _sink; + public string Name { get; } + public int StartCalls { get; private set; } + public int StopCalls { get; private set; } + public int DisposeCalls { get; private set; } + + public FakeCollectorHost(string name, Action sink) + { + Name = name; + _sink = sink; + } + + public void Start() => StartCalls++; + public void Stop() => StopCalls++; + public void Dispose() => DisposeCalls++; + + public void Emit(EnrollmentEvent evt) => _sink(evt); + } + + private sealed class FakeComponentFactory : IComponentFactory + { + public Action? CapturedSink { get; private set; } + public readonly List Hosts = new List(); + public readonly IReadOnlyList HostNames; + + public FakeComponentFactory(params string[] hostNames) + { + HostNames = hostNames.Length == 0 + ? new[] { "HelloTracker", "ShellCoreTracker", "ProvisioningStatusTracker", "StallProbeCollector", "ModernDeploymentTracker" } + : hostNames; + } + + public IReadOnlyCollection? CapturedWhiteGloveSealingPatternIds { get; private set; } + + public ISignalIngressSink? CapturedIngress { get; private set; } + public IClock? CapturedClock { get; private set; } + + public IReadOnlyList CreateCollectorHosts( + string sessionId, + string tenantId, + AgentLogger logger, + Action onEnrollmentEvent, + IReadOnlyCollection whiteGloveSealingPatternIds, + ISignalIngressSink ingress, + IClock clock) + { + CapturedSink = onEnrollmentEvent; + CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds; + CapturedIngress = ingress; + CapturedClock = clock; + foreach (var name in HostNames) + { + Hosts.Add(new FakeCollectorHost(name, onEnrollmentEvent)); + } + return Hosts; + } + } + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public VirtualClock Clock { get; } = new VirtualClock(At); + public AgentLogger Logger { get; } + public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader(); + public List Classifiers { get; } = new List(); + public FakeComponentFactory Factory { get; } + public string StateDir { get; } + public string TransportDir { get; } + + public Rig(FakeComponentFactory? factory = null) + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + StateDir = Path.Combine(Tmp.Path, "State"); + TransportDir = Path.Combine(Tmp.Path, "Transport"); + Factory = factory ?? new FakeComponentFactory(); + } + + public EnrollmentOrchestrator Build() => + new EnrollmentOrchestrator( + sessionId: "S1", + tenantId: "T1", + stateDirectory: StateDir, + transportDirectory: TransportDir, + clock: Clock, + logger: Logger, + uploader: Uploader, + classifiers: Classifiers, + componentFactory: Factory, + drainInterval: TimeSpan.FromDays(1), + terminalDrainTimeout: TimeSpan.FromSeconds(2)); + + public void Dispose() => Tmp.Dispose(); + } + + private static EnrollmentEvent Evt(string eventType) => new EnrollmentEvent + { + EventType = eventType, + Severity = EventSeverity.Info, + Source = "test", + Phase = EnrollmentPhase.Unknown, + Timestamp = At, + }; + + // ========================================================================= Wiring + + [Fact] + public void Start_without_factory_does_not_throw_and_spawns_no_hosts() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + var uploader = new FakeBackendTelemetryUploader(); + var sut = new EnrollmentOrchestrator( + sessionId: "S1", + tenantId: "T1", + stateDirectory: Path.Combine(tmp.Path, "State"), + transportDirectory: Path.Combine(tmp.Path, "Transport"), + clock: new VirtualClock(At), + logger: logger, + uploader: uploader, + classifiers: new List(), + componentFactory: null, + drainInterval: TimeSpan.FromDays(1), + terminalDrainTimeout: TimeSpan.FromSeconds(2)); + + sut.Start(); + sut.Stop(); // no exception + } + + [Fact] + public void Start_with_factory_creates_all_hosts_and_captures_sink() + { + using var rig = new Rig(); + var sut = rig.Build(); + + sut.Start(); + + Assert.NotNull(rig.Factory.CapturedSink); + Assert.Equal(5, rig.Factory.Hosts.Count); + Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StartCalls)); + + sut.Stop(); + } + + [Fact] + public void Emit_from_host_reaches_telemetry_transport() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + var hello = rig.Factory.Hosts.Find(h => h.Name == "HelloTracker")!; + hello.Emit(Evt("hello_completed")); + + var spool = GetSpool(sut); + var pending = spool.Peek(10); + Assert.Single(pending); + Assert.Equal(TelemetryItemKind.Event, pending[0].Kind); + Assert.Contains("hello_completed", pending[0].PayloadJson); + + sut.Stop(); + } + + [Fact] + public void Emit_from_each_of_five_hosts_produces_five_transport_items() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + foreach (var host in rig.Factory.Hosts) + { + host.Emit(Evt($"event_from_{host.Name}")); + } + + var pending = GetSpool(sut).Peek(10); + Assert.Equal(5, pending.Count); + foreach (var host in rig.Factory.Hosts) + { + Assert.Contains(pending, p => p.PayloadJson.Contains($"event_from_{host.Name}")); + } + + sut.Stop(); + } + + [Fact] + public void Modern_deployment_host_emits_event_bridge_only_no_decision_signal() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + var modern = rig.Factory.Hosts.Find(h => h.Name == "ModernDeploymentTracker")!; + modern.Emit(Evt("modern_deployment_log")); + + // Event reached transport (bridge works). + var pending = GetSpool(sut).Peek(10); + Assert.Single(pending); + Assert.Contains("modern_deployment_log", pending[0].PayloadJson); + + // No decision-signal was posted — signal-log is empty (no SignalAdapter wraps + // ModernDeploymentTracker by design, Plan §4.x M4.3-Erkenntnis). + var signalLog = GetSignalLog(sut); + Assert.Empty(signalLog.ReadAll()); + + sut.Stop(); + } + + [Fact] + public void Emitter_exception_does_not_propagate_to_host_thread() + { + // Simulate: TelemetryEventEmitter throws (e.g. transport disposed mid-emit). + // Build a shared sink that throws on demand; wrap into a host that uses it directly. + // Easier path: dispose the transport post-Start so Enqueue throws, then fire an event. + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + // Force a transport-level exception by disposing it mid-run. + GetTransport(sut).Dispose(); + + var hello = rig.Factory.Hosts.Find(h => h.Name == "HelloTracker")!; + + // Must not throw — orchestrator wraps the emitter call in try/catch. + var ex = Record.Exception(() => hello.Emit(Evt("hello_after_dispose"))); + Assert.Null(ex); + + sut.Stop(); + } + + [Fact] + public void Stop_stops_and_disposes_all_hosts_in_creation_order() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + sut.Stop(); + + Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StopCalls)); + Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.DisposeCalls)); + } + + // ========================================================================= Helpers + + private static TelemetrySpool GetSpool(EnrollmentOrchestrator sut) + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_spool", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (TelemetrySpool)field!.GetValue(sut)!; + } + + private static TelemetryUploadOrchestrator GetTransport(EnrollmentOrchestrator sut) + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_transport", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (TelemetryUploadOrchestrator)field!.GetValue(sut)!; + } + + private static AutopilotMonitor.Agent.V2.Core.Persistence.ISignalLogWriter GetSignalLog(EnrollmentOrchestrator sut) + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_signalLog", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (AutopilotMonitor.Agent.V2.Core.Persistence.ISignalLogWriter)field!.GetValue(sut)!; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs new file mode 100644 index 00000000..e7bd180d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorMaxLifetimeTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.DecisionCore.Classifiers; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// M4.6.α — max-lifetime watchdog. When the configured lifetime elapses without a + /// decision-terminal stage, the orchestrator raises + /// with reason and outcome + /// . + /// + [Collection("SerialThreading")] + public sealed class EnrollmentOrchestratorMaxLifetimeTests + { + private static DateTime At => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + + private static EnrollmentOrchestrator Build( + TempDirectory tmp, + TimeSpan? agentMaxLifetime) + { + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + return new EnrollmentOrchestrator( + sessionId: "S1", + tenantId: "T1", + stateDirectory: Path.Combine(tmp.Path, "State"), + transportDirectory: Path.Combine(tmp.Path, "Transport"), + clock: new VirtualClock(At), + logger: logger, + uploader: new FakeBackendTelemetryUploader(), + classifiers: new List(), + componentFactory: null, + drainInterval: TimeSpan.FromDays(1), + terminalDrainTimeout: TimeSpan.FromSeconds(2), + agentMaxLifetime: agentMaxLifetime); + } + + [Fact] + public void Terminated_fires_once_when_max_lifetime_elapses() + { + using var tmp = new TempDirectory(); + var sut = Build(tmp, agentMaxLifetime: TimeSpan.FromMilliseconds(200)); + + using var fired = new ManualResetEventSlim(false); + EnrollmentTerminatedEventArgs? captured = null; + var fireCount = 0; + sut.Terminated += (_, e) => + { + Interlocked.Increment(ref fireCount); + captured = e; + fired.Set(); + }; + + sut.Start(); + Assert.True(fired.Wait(5000), "Terminated event did not fire within 5s."); + + Assert.NotNull(captured); + Assert.Equal(EnrollmentTerminationReason.MaxLifetimeExceeded, captured!.Reason); + Assert.Equal(EnrollmentTerminationOutcome.TimedOut, captured.Outcome); + Assert.NotNull(captured.StageName); + Assert.NotNull(captured.Details); + + // Even if the timer fired multiple times (it shouldn't — it's one-shot), we only + // raise the event once thanks to the Interlocked latch. + Thread.Sleep(300); + Assert.Equal(1, fireCount); + + sut.Stop(); + } + + [Fact] + public void Terminated_not_fired_when_max_lifetime_is_null() + { + using var tmp = new TempDirectory(); + var sut = Build(tmp, agentMaxLifetime: null); + + var fireCount = 0; + sut.Terminated += (_, _) => Interlocked.Increment(ref fireCount); + + sut.Start(); + Thread.Sleep(200); + sut.Stop(); + + Assert.Equal(0, fireCount); + } + + [Fact] + public void Ctor_rejects_non_positive_max_lifetime() + { + using var tmp = new TempDirectory(); + Assert.Throws(() => Build(tmp, agentMaxLifetime: TimeSpan.Zero)); + Assert.Throws(() => Build(tmp, agentMaxLifetime: TimeSpan.FromMilliseconds(-1))); + } + + [Fact] + public void Stop_before_lifetime_elapses_silences_the_watchdog() + { + using var tmp = new TempDirectory(); + var sut = Build(tmp, agentMaxLifetime: TimeSpan.FromSeconds(5)); + + var fireCount = 0; + sut.Terminated += (_, _) => Interlocked.Increment(ref fireCount); + + sut.Start(); + Thread.Sleep(50); + sut.Stop(); + + // Give any racing timer callback a chance — it must not fire after Stop(). + Thread.Sleep(200); + Assert.Equal(0, fireCount); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs new file mode 100644 index 00000000..7985fb3c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +#pragma warning disable xUnit1031 // Task.Wait + SpinWait for deterministic thread coordination + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + public sealed class EnrollmentOrchestratorTests + { + private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public VirtualClock Clock { get; } = new VirtualClock(At); + public AgentLogger Logger { get; } + public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader(); + public List Classifiers { get; } = new List(); + public string StateDir { get; } + public string TransportDir { get; } + + public Rig() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + StateDir = Path.Combine(Tmp.Path, "State"); + TransportDir = Path.Combine(Tmp.Path, "Transport"); + } + + public EnrollmentOrchestrator Build(TimeSpan? drainInterval = null) => + new EnrollmentOrchestrator( + sessionId: "S1", + tenantId: "T1", + stateDirectory: StateDir, + transportDirectory: TransportDir, + clock: Clock, + logger: Logger, + uploader: Uploader, + classifiers: Classifiers, + // Default drain-interval = 1 day → periodic drain won't fire during short tests. + drainInterval: drainInterval ?? TimeSpan.FromDays(1), + terminalDrainTimeout: TimeSpan.FromSeconds(2)); + + public void Dispose() => Tmp.Dispose(); + } + + // ========================================================================= Ctor + + [Fact] + public void Ctor_rejects_empty_required_strings() + { + using var rig = new Rig(); + Assert.Throws(() => new EnrollmentOrchestrator( + "", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers)); + Assert.Throws(() => new EnrollmentOrchestrator( + "S1", "", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers)); + Assert.Throws(() => new EnrollmentOrchestrator( + "S1", "T1", "", rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers)); + Assert.Throws(() => new EnrollmentOrchestrator( + "S1", "T1", rig.StateDir, "", rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers)); + } + + [Fact] + public void Ctor_rejects_null_dependencies() + { + using var rig = new Rig(); + Assert.Throws(() => new EnrollmentOrchestrator( + "S1", "T1", rig.StateDir, rig.TransportDir, null!, rig.Logger, rig.Uploader, rig.Classifiers)); + Assert.Throws(() => new EnrollmentOrchestrator( + "S1", "T1", rig.StateDir, rig.TransportDir, rig.Clock, null!, rig.Uploader, rig.Classifiers)); + Assert.Throws(() => new EnrollmentOrchestrator( + "S1", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, null!, rig.Classifiers)); + Assert.Throws(() => new EnrollmentOrchestrator( + "S1", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, null!)); + } + + [Fact] + public void Observability_before_start_throws() + { + using var rig = new Rig(); + var sut = rig.Build(); + Assert.Throws(() => sut.CurrentState); + Assert.Throws(() => sut.IngressSink); + Assert.Throws(() => sut.EventEmitter); + } + + // ========================================================================= Start + + [Fact] + public void Start_instantiates_pipeline_no_exception() + { + using var rig = new Rig(); + var sut = rig.Build(); + + sut.Start(); + + Assert.NotNull(sut.CurrentState); + Assert.Equal("S1", sut.CurrentState.SessionId); + Assert.Equal("T1", sut.CurrentState.TenantId); + Assert.NotNull(sut.IngressSink); + Assert.NotNull(sut.EventEmitter); + Assert.False(sut.IsQuarantineRequested); + + sut.Stop(); + } + + [Fact] + public void Start_creates_state_and_transport_directories() + { + using var rig = new Rig(); + Assert.False(Directory.Exists(rig.StateDir)); + Assert.False(Directory.Exists(rig.TransportDir)); + + var sut = rig.Build(); + sut.Start(); + + Assert.True(Directory.Exists(rig.StateDir)); + Assert.True(Directory.Exists(rig.TransportDir)); + + sut.Stop(); + } + + [Fact] + public void Start_creates_initial_state_when_no_snapshot() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + Assert.Equal(SessionStage.SessionStarted, sut.CurrentState.Stage); + Assert.Equal(0, sut.CurrentState.StepIndex); + + sut.Stop(); + } + + [Fact] + public void Start_loads_recovered_snapshot_when_file_exists() + { + using var rig = new Rig(); + Directory.CreateDirectory(rig.StateDir); + // Seed a snapshot with a non-initial stage so recovery is detectable. + var persistedState = DecisionState.CreateInitial("S1", "T1") + .ToBuilder() + .WithStage(SessionStage.EspDeviceSetup) + .WithStepIndex(7) + .Build(); + var snapshotPath = Path.Combine(rig.StateDir, "snapshot.json"); + var seed = new SnapshotPersistence(snapshotPath); + seed.Save(persistedState); + + var sut = rig.Build(); + sut.Start(); + + Assert.Equal(SessionStage.EspDeviceSetup, sut.CurrentState.Stage); + Assert.Equal(7, sut.CurrentState.StepIndex); + + sut.Stop(); + } + + [Fact] + public void Start_twice_throws() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + Assert.Throws(() => sut.Start()); + + sut.Stop(); + } + + // ========================================================================= Stop + + [Fact] + public void Stop_before_start_is_noop() + { + using var rig = new Rig(); + var sut = rig.Build(); + + sut.Stop(); // does not throw + } + + [Fact] + public void Stop_is_idempotent() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + sut.Stop(); + sut.Stop(); // no-op on second call + } + + [Fact] + public void Stop_flushes_final_snapshot_with_current_state() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + // Drive a state change — post a SessionStarted synthetic signal so the reducer + // advances StepIndex. Use the IngressSink to mirror the prod path. + sut.IngressSink.Post( + kind: DecisionSignalKind.SessionStarted, + occurredAtUtc: At, + sourceOrigin: "Test", + evidence: new Evidence(EvidenceKind.Synthetic, "test", "test")); + + // Wait until the worker applied it. + Assert.True(SpinWait.SpinUntil(() => sut.CurrentState.StepIndex > 0, 2000)); + var stepIndexBeforeStop = sut.CurrentState.StepIndex; + + sut.Stop(); + + // Snapshot file must exist + contain the final state. + var snapshotPath = Path.Combine(rig.StateDir, "snapshot.json"); + Assert.True(File.Exists(snapshotPath)); + var reloaded = new SnapshotPersistence(snapshotPath).Load(); + Assert.NotNull(reloaded); + Assert.Equal(stepIndexBeforeStop, reloaded!.StepIndex); + } + + // ========================================================================= Dispose + + [Fact] + public void Dispose_stops_orchestrator() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + sut.Dispose(); + + // Second Dispose is safe; Start after Dispose throws ObjectDisposedException. + sut.Dispose(); + Assert.Throws(() => sut.Start()); + } + + // ========================================================================= Deadline bridge + + [Fact] + public void Deadline_fired_posts_DeadlineFired_signal_to_ingress() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + // Schedule a past-due deadline → DeadlineScheduler fires via ThreadPool immediately. + var scheduler = GetScheduler(sut); + var pastDue = new ActiveDeadline( + name: "hello_safety", + dueAtUtc: At.AddMinutes(-1), + firesSignalKind: DecisionSignalKind.DeadlineFired); + scheduler.Schedule(pastDue); + + // Wait for the signal-log to record the synthetic DeadlineFired signal. We read + // via the SignalLogWriter's own thread-safe ReadAll() to avoid racing the worker's + // file-append lock (File.ReadAllLines opens without FileShare.ReadWrite on net48). + var signalLog = GetSignalLog(sut); + Assert.True(SpinWait.SpinUntil( + () => ContainsDeadlineFired(signalLog), + 3000)); + + sut.Stop(); + } + + // ========================================================================= Quarantine + + [Fact] + public void TriggerQuarantine_sets_flag_and_reason() + { + using var rig = new Rig(); + var sut = rig.Build(); + sut.Start(); + + Assert.False(sut.IsQuarantineRequested); + + sut.TriggerQuarantine("test-reason"); + + Assert.True(sut.IsQuarantineRequested); + Assert.Equal("test-reason", sut.QuarantineReason); + + sut.Stop(); + } + + // ========================================================================= Helpers + + private static bool ContainsDeadlineFired(ISignalLogWriter signalLog) + { + foreach (var sig in signalLog.ReadAll()) + { + if (sig.Kind == DecisionSignalKind.DeadlineFired) return true; + } + return false; + } + + private static ISignalLogWriter GetSignalLog(EnrollmentOrchestrator sut) + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_signalLog", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (ISignalLogWriter)field!.GetValue(sut)!; + } + + /// + /// The orchestrator owns its as a private field; + /// tests need it to script past-due deadlines. Reflection access is acceptable for + /// a single integration-ish test; production code doesn't expose it. + /// + private static DeadlineScheduler GetScheduler(EnrollmentOrchestrator sut) + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_scheduler", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (DeadlineScheduler)field!.GetValue(sut)!; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs new file mode 100644 index 00000000..5663a9b2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + public sealed class EnrollmentOrchestratorWhiteGlovePatternTests + { + private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class CapturingFactory : IComponentFactory + { + public IReadOnlyCollection? CapturedPatternIds { get; private set; } + + public IReadOnlyList CreateCollectorHosts( + string sessionId, + string tenantId, + AgentLogger logger, + Action onEnrollmentEvent, + IReadOnlyCollection whiteGloveSealingPatternIds, + ISignalIngressSink ingress, + IClock clock) + { + CapturedPatternIds = whiteGloveSealingPatternIds; + return Array.Empty(); + } + } + + private EnrollmentOrchestrator Build( + TempDirectory tmp, + IComponentFactory? factory = null, + IReadOnlyCollection? whiteGloveSealingPatternIds = null) + { + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + return new EnrollmentOrchestrator( + sessionId: "S1", + tenantId: "T1", + stateDirectory: Path.Combine(tmp.Path, "State"), + transportDirectory: Path.Combine(tmp.Path, "Transport"), + clock: new VirtualClock(At), + logger: logger, + uploader: new FakeBackendTelemetryUploader(), + classifiers: new List(), + componentFactory: factory, + whiteGloveSealingPatternIds: whiteGloveSealingPatternIds, + drainInterval: TimeSpan.FromDays(1), + terminalDrainTimeout: TimeSpan.FromSeconds(2)); + } + + [Fact] + public void Null_pattern_ids_reach_factory_as_empty_collection() + { + using var tmp = new TempDirectory(); + var factory = new CapturingFactory(); + var sut = Build(tmp, factory, whiteGloveSealingPatternIds: null); + + sut.Start(); + + Assert.NotNull(factory.CapturedPatternIds); + Assert.Empty(factory.CapturedPatternIds!); + + sut.Stop(); + } + + [Fact] + public void Configured_pattern_ids_reach_factory_verbatim() + { + using var tmp = new TempDirectory(); + var factory = new CapturingFactory(); + var ids = new[] { "WG_SEAL_1", "WG_SEAL_2", "WG_SEAL_3" }; + var sut = Build(tmp, factory, whiteGloveSealingPatternIds: ids); + + sut.Start(); + + Assert.NotNull(factory.CapturedPatternIds); + Assert.Equal(ids, factory.CapturedPatternIds); + + sut.Stop(); + } + + [Fact] + public void Empty_pattern_ids_collection_forwards_as_empty_not_null() + { + using var tmp = new TempDirectory(); + var factory = new CapturingFactory(); + var sut = Build(tmp, factory, whiteGloveSealingPatternIds: Array.Empty()); + + sut.Start(); + + Assert.NotNull(factory.CapturedPatternIds); + Assert.Empty(factory.CapturedPatternIds!); + + sut.Stop(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeBackPressureObserver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeBackPressureObserver.cs new file mode 100644 index 00000000..ade36697 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeBackPressureObserver.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Orchestration; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + internal sealed class FakeBackPressureObserver : IBackPressureObserver + { + private readonly object _lock = new object(); + private readonly List _calls = new List(); + + public int CallCount + { + get { lock (_lock) return _calls.Count; } + } + + public IReadOnlyList Calls + { + get { lock (_lock) return _calls.ToArray(); } + } + + public Exception? ThrowOnNotify { get; set; } + + public void OnBackPressure(string origin, int channelCapacity, int queueLength, TimeSpan blockDuration) + { + lock (_lock) + { + _calls.Add(new BackPressureCall(origin, channelCapacity, queueLength, blockDuration)); + } + if (ThrowOnNotify != null) throw ThrowOnNotify; + } + + internal sealed class BackPressureCall + { + public BackPressureCall(string origin, int channelCapacity, int queueLength, TimeSpan blockDuration) + { + Origin = origin; + ChannelCapacity = channelCapacity; + QueueLength = queueLength; + BlockDuration = blockDuration; + } + + public string Origin { get; } + public int ChannelCapacity { get; } + public int QueueLength { get; } + public TimeSpan BlockDuration { get; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeClassifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeClassifier.cs new file mode 100644 index 00000000..1af4cb6c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeClassifier.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Test fake — returns a canned verdict or throws on demand. + /// + internal sealed class FakeClassifier : IClassifier + { + public FakeClassifier(string id, Func classifyFn) + { + Id = id; + _classifyFn = classifyFn; + SnapshotType = typeof(object); + } + + private readonly Func _classifyFn; + + public string Id { get; } + public Type SnapshotType { get; } + public int CallCount { get; private set; } + + public ClassifierVerdict Classify(object snapshot) + { + CallCount++; + return _classifyFn(snapshot); + } + + public static ClassifierVerdict Verdict(string id, HypothesisLevel level, string inputHash, int score = 50) => + new ClassifierVerdict( + classifierId: id, + level: level, + score: score, + contributingFactors: Array.Empty(), + reason: "fake", + inputHash: inputHash); + } + + internal sealed class HashSnapshot : IClassifierSnapshot + { + private readonly string _hash; + public HashSnapshot(string hash) => _hash = hash; + public string ComputeInputHash() => _hash; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeDeadlineScheduler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeDeadlineScheduler.cs new file mode 100644 index 00000000..052e5d84 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeDeadlineScheduler.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Test fake — records Schedule/Cancel calls and can be scripted to throw on the next call. + /// Avoids the real 's timer plumbing for tests that don't + /// need it. + /// + internal sealed class FakeDeadlineScheduler : IDeadlineScheduler + { + private readonly List _scheduled = new List(); + private readonly List _cancelled = new List(); + + public Exception? ThrowOnSchedule { get; set; } + public Exception? ThrowOnCancel { get; set; } + + public IReadOnlyList Scheduled => _scheduled; + public IReadOnlyList Cancelled => _cancelled; + + public event EventHandler? Fired; + + public IReadOnlyList ActiveDeadlines => _scheduled; + + public void Schedule(ActiveDeadline deadline) + { + if (ThrowOnSchedule != null) throw ThrowOnSchedule; + _scheduled.Add(deadline); + } + + public void Cancel(string name) + { + if (ThrowOnCancel != null) throw ThrowOnCancel; + _cancelled.Add(name); + } + + public bool IsScheduled(string name) + { + foreach (var d in _scheduled) if (d.Name == name) return true; + return false; + } + + public void RehydrateFromSnapshot(IEnumerable deadlines) + { + foreach (var d in deadlines) Schedule(d); + } + + public void Dispose() { /* no real timer to dispose */ } + + /// Test helper — raises the event with given args. + public void RaiseFired(ActiveDeadline deadline, DateTime firedAtUtc) => + Fired?.Invoke(this, new DeadlineFiredEventArgs(deadline, firedAtUtc)); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeDecisionStepProcessor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeDecisionStepProcessor.cs new file mode 100644 index 00000000..3714328b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeDecisionStepProcessor.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Test fake for . + /// Captures ApplyStep calls, advances to the step's new state, + /// and supports scripted exceptions + a blocking handle for back-pressure tests. + /// + internal sealed class FakeDecisionStepProcessor : IDecisionStepProcessor + { + private readonly object _lock = new object(); + private readonly Queue _script = new Queue(); + private readonly List _calls = new List(); + private DecisionState _currentState; + private int _failedApplyCount; + + public FakeDecisionStepProcessor(string sessionId = "S1", string tenantId = "T1") + { + _currentState = DecisionState.CreateInitial(sessionId, tenantId); + } + + /// Wenn gesetzt, wartet darauf bevor es weiterläuft. + public ManualResetEventSlim? BlockHandle { get; set; } + + public DecisionState CurrentState + { + get { lock (_lock) return _currentState; } + } + + public int ApplyCallCount + { + get { lock (_lock) return _calls.Count + _failedApplyCount; } + } + + public IReadOnlyList Calls + { + get { lock (_lock) return _calls.ToArray(); } + } + + public FakeDecisionStepProcessor ScriptThrow(Exception ex, int count = 1) + { + lock (_lock) + { + for (int i = 0; i < count; i++) _script.Enqueue(ex); + } + return this; + } + + public FakeDecisionStepProcessor ScriptOk(int count = 1) + { + lock (_lock) + { + for (int i = 0; i < count; i++) _script.Enqueue(null); + } + return this; + } + + public void ApplyStep(DecisionStep step, DecisionSignal signal) + { + BlockHandle?.Wait(); + + lock (_lock) + { + if (_script.Count > 0) + { + var ex = _script.Dequeue(); + if (ex != null) + { + _failedApplyCount++; + throw ex; + } + } + _calls.Add(new ApplyCall(step, signal)); + _currentState = step.NewState; + } + } + + internal sealed class ApplyCall + { + public ApplyCall(DecisionStep step, DecisionSignal signal) + { + Step = step; + Signal = signal; + } + + public DecisionStep Step { get; } + public DecisionSignal Signal { get; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeEffectRunner.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeEffectRunner.cs new file mode 100644 index 00000000..957827b6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeEffectRunner.cs @@ -0,0 +1,68 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Scripted for processor-level unit tests. + /// Default behavior returns ; use ScriptResult + /// or ScriptThrow to script per-call responses. + /// + internal sealed class FakeEffectRunner : IEffectRunner + { + private readonly Queue _script = new Queue(); + private readonly List _calls = new List(); + + public IReadOnlyList Calls => _calls; + public int CallCount => _calls.Count; + + public FakeEffectRunner ScriptResult(EffectRunResult result, int count = 1) + { + for (int i = 0; i < count; i++) _script.Enqueue(result); + return this; + } + + public FakeEffectRunner ScriptThrow(Exception ex, int count = 1) + { + for (int i = 0; i < count; i++) _script.Enqueue(ex); + return this; + } + + public Task RunAsync( + IReadOnlyList effects, + DecisionState stateAfterReduce, + DateTime stepOccurredAtUtc, + CancellationToken cancellationToken = default) + { + _calls.Add(new RunCall(effects, stateAfterReduce, stepOccurredAtUtc)); + + if (_script.Count > 0) + { + var next = _script.Dequeue(); + if (next is Exception ex) throw ex; + if (next is EffectRunResult scripted) return Task.FromResult(scripted); + } + return Task.FromResult(EffectRunResult.Empty()); + } + + internal sealed class RunCall + { + public RunCall(IReadOnlyList effects, DecisionState state, DateTime at) + { + Effects = effects; + State = state; + OccurredAtUtc = at; + } + + public IReadOnlyList Effects { get; } + public DecisionState State { get; } + public DateTime OccurredAtUtc { get; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeEventTimelineEmitter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeEventTimelineEmitter.cs new file mode 100644 index 00000000..5080d0b2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeEventTimelineEmitter.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Test fake — records every call and optionally throws a scripted + /// sequence of exceptions (to exercise retry behavior). + /// + internal sealed class FakeEventTimelineEmitter : IEventTimelineEmitter + { + private readonly Queue> _throwScript = new Queue>(); + private readonly List _calls = new List(); + + public IReadOnlyList Calls => _calls; + public int CallCount => _calls.Count; + + /// Next calls throw . + public FakeEventTimelineEmitter ScriptThrow(Exception ex, int count = 1) + { + for (int i = 0; i < count; i++) _throwScript.Enqueue(_ => ex); + return this; + } + + /// Next calls succeed (no-op). + public FakeEventTimelineEmitter ScriptOk(int count = 1) + { + for (int i = 0; i < count; i++) _throwScript.Enqueue(_ => null); + return this; + } + + public void Emit(IReadOnlyDictionary? parameters, DecisionState currentState, DateTime occurredAtUtc) + { + _calls.Add(new EmitCall(parameters, currentState, occurredAtUtc)); + + if (_throwScript.Count > 0) + { + var maybeEx = _throwScript.Dequeue()(_calls.Count); + if (maybeEx != null) throw maybeEx; + } + } + + internal sealed class EmitCall + { + public EmitCall(IReadOnlyDictionary? parameters, DecisionState state, DateTime occurredAtUtc) + { + Parameters = parameters; + State = state; + OccurredAtUtc = occurredAtUtc; + } + + public IReadOnlyDictionary? Parameters { get; } + public DecisionState State { get; } + public DateTime OccurredAtUtc { get; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeJournalWriter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeJournalWriter.cs new file mode 100644 index 00000000..c7a0eee5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeJournalWriter.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.DecisionCore.Engine; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// In-memory fake with scripted exceptions. + /// Mirrors the pattern. + /// + internal sealed class FakeJournalWriter : IJournalWriter + { + private readonly Queue _appendScript = new Queue(); + private readonly List _appended = new List(); + private int _failedAppends; + + public IReadOnlyList Appended => _appended; + public int AppendCallCount => _appended.Count + _failedAppends; + public int LastStepIndex => _appended.Count == 0 ? -1 : _appended[_appended.Count - 1].StepIndex; + + public long LastTraceOrdinal + { + get + { + long max = -1; + foreach (var t in _appended) + { + if (t.SessionTraceOrdinal > max) max = t.SessionTraceOrdinal; + } + return max; + } + } + + public FakeJournalWriter ScriptThrow(Exception ex, int count = 1) + { + for (int i = 0; i < count; i++) _appendScript.Enqueue(ex); + return this; + } + + public FakeJournalWriter ScriptOk(int count = 1) + { + for (int i = 0; i < count; i++) _appendScript.Enqueue(null); + return this; + } + + public void Append(DecisionTransition transition) + { + if (transition == null) throw new ArgumentNullException(nameof(transition)); + + if (_appendScript.Count > 0) + { + var maybeEx = _appendScript.Dequeue(); + if (maybeEx != null) + { + _failedAppends++; + throw maybeEx; + } + } + _appended.Add(transition); + } + + public IReadOnlyList ReadAll() => _appended.ToArray(); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeQuarantineSink.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeQuarantineSink.cs new file mode 100644 index 00000000..c68a5872 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeQuarantineSink.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Orchestration; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// Captures calls for assertions. + internal sealed class FakeQuarantineSink : IQuarantineSink + { + private readonly List _reasons = new List(); + + public IReadOnlyList Reasons => _reasons; + public int CallCount => _reasons.Count; + + public void TriggerQuarantine(string reason) => _reasons.Add(reason); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeSignalIngressSink.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeSignalIngressSink.cs new file mode 100644 index 00000000..b371a6e1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeSignalIngressSink.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Test fake — captures every synthetic signal posted by the EffectRunner. + /// + internal sealed class FakeSignalIngressSink : ISignalIngressSink + { + private readonly List _posted = new List(); + + public IReadOnlyList Posted => _posted; + + public void Post( + DecisionSignalKind kind, + DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + IReadOnlyDictionary? payload = null, + int kindSchemaVersion = 1) + { + _posted.Add(new PostedSignal(kind, occurredAtUtc, sourceOrigin, evidence, payload, kindSchemaVersion)); + } + + internal sealed class PostedSignal + { + public PostedSignal( + DecisionSignalKind kind, + DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + IReadOnlyDictionary? payload, + int kindSchemaVersion) + { + Kind = kind; + OccurredAtUtc = occurredAtUtc; + SourceOrigin = sourceOrigin; + Evidence = evidence; + Payload = payload; + KindSchemaVersion = kindSchemaVersion; + } + + public DecisionSignalKind Kind { get; } + public DateTime OccurredAtUtc { get; } + public string SourceOrigin { get; } + public Evidence Evidence { get; } + public IReadOnlyDictionary? Payload { get; } + public int KindSchemaVersion { get; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeSnapshotPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeSnapshotPersistence.cs new file mode 100644 index 00000000..f63459de --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/FakeSnapshotPersistence.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Test fake — in-memory snapshot persistence with scripted exception sequence. + /// + internal sealed class FakeSnapshotPersistence : ISnapshotPersistence + { + private readonly Queue _saveScript = new Queue(); + private readonly List _saved = new List(); + + public IReadOnlyList Saved => _saved; + public int SaveCallCount => _saved.Count + _failedSaveCalls; + private int _failedSaveCalls; + + public FakeSnapshotPersistence ScriptThrow(Exception ex, int count = 1) + { + for (int i = 0; i < count; i++) _saveScript.Enqueue(ex); + return this; + } + + public FakeSnapshotPersistence ScriptOk(int count = 1) + { + for (int i = 0; i < count; i++) _saveScript.Enqueue(null); + return this; + } + + public void Save(DecisionState state) + { + if (_saveScript.Count > 0) + { + var maybeEx = _saveScript.Dequeue(); + if (maybeEx != null) + { + _failedSaveCalls++; + throw maybeEx; + } + } + _saved.Add(state); + } + + public DecisionState? Load() => + _saved.Count == 0 ? null : _saved[_saved.Count - 1]; + + public void Quarantine(string reason) { /* no-op */ } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs new file mode 100644 index 00000000..b0bf867b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/MtlsHttpClientFactoryTests.cs @@ -0,0 +1,125 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + public sealed class MtlsHttpClientFactoryTests + { + private sealed class FakeCertificateResolver : ICertificateResolver + { + private readonly X509Certificate2? _cert; + public int CallCount { get; private set; } + + public FakeCertificateResolver(X509Certificate2? cert) => _cert = cert; + + public X509Certificate2? FindClientCertificate(AgentLogger logger) + { + CallCount++; + return _cert; + } + } + + /// + /// Creates an in-memory self-signed cert so the factory has something real to attach + /// without touching the Windows cert store. is + /// available on net48 (4.7.2+). + /// + private static X509Certificate2 CreateSelfSignedCert() + { + using (var rsa = RSA.Create(2048)) + { + var req = new CertificateRequest( + "CN=apmon-v2-test", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(1)); + } + } + + [Fact] + public void CreateHandler_attaches_cert_and_enables_decompression() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + var resolver = new FakeCertificateResolver(cert); + + using var handler = MtlsHttpClientFactory.CreateHandler(resolver, logger); + + Assert.Equal(1, resolver.CallCount); + Assert.Equal( + DecompressionMethods.GZip | DecompressionMethods.Deflate, + handler.AutomaticDecompression); + Assert.Single(handler.ClientCertificates); + Assert.Equal(cert.Thumbprint, ((X509Certificate2)handler.ClientCertificates[0]).Thumbprint); + } + + [Fact] + public void Create_returns_client_with_default_timeout() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + var resolver = new FakeCertificateResolver(cert); + + using var client = MtlsHttpClientFactory.Create(resolver, logger); + + Assert.NotNull(client); + Assert.Equal(MtlsHttpClientFactory.DefaultTimeout, client.Timeout); + } + + [Fact] + public void Create_respects_custom_timeout() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + var resolver = new FakeCertificateResolver(cert); + + using var client = MtlsHttpClientFactory.Create(resolver, logger, TimeSpan.FromSeconds(5)); + + Assert.Equal(TimeSpan.FromSeconds(5), client.Timeout); + } + + [Fact] + public void CreateHandler_throws_when_resolver_returns_null() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + var resolver = new FakeCertificateResolver(null); + + var ex = Assert.Throws( + () => MtlsHttpClientFactory.CreateHandler(resolver, logger)); + + Assert.Contains("no MDM client certificate found", ex.Message); + Assert.Equal(1, resolver.CallCount); + } + + [Fact] + public void Create_rejects_null_dependencies() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + using var cert = CreateSelfSignedCert(); + + Assert.Throws( + () => MtlsHttpClientFactory.Create(null!, logger)); + + Assert.Throws( + () => MtlsHttpClientFactory.Create(new FakeCertificateResolver(cert), null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/RecoveryPathTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/RecoveryPathTests.cs new file mode 100644 index 00000000..ac84e132 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/RecoveryPathTests.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Transport; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +#pragma warning disable xUnit1031 + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + public sealed class RecoveryPathTests + { + private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public VirtualClock Clock { get; } = new VirtualClock(At); + public AgentLogger Logger { get; } + public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader(); + public string StateDir { get; } + public string TransportDir { get; } + + public Rig() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + StateDir = Path.Combine(Tmp.Path, "State"); + TransportDir = Path.Combine(Tmp.Path, "Transport"); + } + + public EnrollmentOrchestrator Build() => + new EnrollmentOrchestrator( + sessionId: "S1", + tenantId: "T1", + stateDirectory: StateDir, + transportDirectory: TransportDir, + clock: Clock, + logger: Logger, + uploader: Uploader, + classifiers: new List(), + drainInterval: TimeSpan.FromDays(1), + terminalDrainTimeout: TimeSpan.FromSeconds(2)); + + public void Dispose() => Tmp.Dispose(); + } + + // ================================================================= Sonderfall 1: WG Part-1 Resume + + [Fact] + public void Whiteglove_part1_snapshot_triggers_session_recovered_bridge_on_start() + { + using var rig = new Rig(); + Directory.CreateDirectory(rig.StateDir); + + // Seed a snapshot with stage = WhiteGloveSealed (Part 1 complete pre-reboot). + var sealedState = DecisionState.CreateInitial("S1", "T1") + .ToBuilder() + .WithStage(SessionStage.WhiteGloveSealed) + .WithStepIndex(5) + .Build(); + new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json")).Save(sealedState); + + var sut = rig.Build(); + sut.Start(); + + Assert.True(sut.IsWhiteGlovePart2Resume); + Assert.False(sut.WasStartupQuarantine); + + // After ingress start, the orchestrator posts SessionRecovered — the reducer bridges + // to WhiteGloveAwaitingUserSignIn. Wait for the signal-log to contain the signal. + var signalLog = GetSignalLog(sut); + Assert.True(SpinWait.SpinUntil( + () => signalLog.ReadAll().Any(s => s.Kind == DecisionSignalKind.SessionRecovered), + 3000)); + + // State must have advanced past WhiteGloveSealed to WhiteGloveAwaitingUserSignIn. + Assert.True(SpinWait.SpinUntil( + () => sut.CurrentState.Stage == SessionStage.WhiteGloveAwaitingUserSignIn, + 3000)); + + sut.Stop(); + } + + [Fact] + public void Non_whiteglove_snapshot_does_not_set_part2_resume_flag() + { + using var rig = new Rig(); + Directory.CreateDirectory(rig.StateDir); + + // Seed snapshot with a non-WG-sealed stage. + var midState = DecisionState.CreateInitial("S1", "T1") + .ToBuilder() + .WithStage(SessionStage.EspDeviceSetup) + .WithStepIndex(3) + .Build(); + new SnapshotPersistence(Path.Combine(rig.StateDir, "snapshot.json")).Save(midState); + + var sut = rig.Build(); + sut.Start(); + + Assert.False(sut.IsWhiteGlovePart2Resume); + + // No SessionRecovered should have been posted. + Thread.Sleep(100); + var signalLog = GetSignalLog(sut); + Assert.DoesNotContain(signalLog.ReadAll(), s => s.Kind == DecisionSignalKind.SessionRecovered); + + sut.Stop(); + } + + // ================================================================= Sonderfall 2: Segment-Quarantine + + [Fact] + public void Corrupt_snapshot_file_triggers_quarantine_and_fresh_start() + { + using var rig = new Rig(); + Directory.CreateDirectory(rig.StateDir); + + // Write a snapshot file that claims a valid checksum but has mismatching content. + var snapshotPath = Path.Combine(rig.StateDir, "snapshot.json"); + File.WriteAllText( + snapshotPath, + "{\"Checksum\":\"deadbeef\",\"State\":{\"SessionId\":\"S1\",\"TenantId\":\"T1\"}}", + Encoding.UTF8); + + // Seed a non-empty signal-log too so we can verify it also gets quarantined. + File.WriteAllText( + Path.Combine(rig.StateDir, "signal-log.jsonl"), + "{\"fake\": \"stale signal\"}\n", + Encoding.UTF8); + + var sut = rig.Build(); + sut.Start(); + + Assert.True(sut.WasStartupQuarantine); + Assert.False(sut.IsWhiteGlovePart2Resume); + + // Original snapshot + signal-log were moved aside. + Assert.False(File.Exists(snapshotPath)); + var signalLogPath = Path.Combine(rig.StateDir, "signal-log.jsonl"); + if (File.Exists(signalLogPath)) + { + // Fresh file should be empty (writer reinstantiated). + Assert.True(new FileInfo(signalLogPath).Length == 0); + } + + var quarantineRoot = Path.Combine(rig.StateDir, ".quarantine"); + Assert.True(Directory.Exists(quarantineRoot)); + var buckets = Directory.GetDirectories(quarantineRoot); + Assert.NotEmpty(buckets); + + // Bucket contains reason + moved segment(s). + var bucket = buckets[0]; + Assert.True(File.Exists(Path.Combine(bucket, "reason.txt"))); + + // Fresh initial state. + Assert.Equal(SessionStage.SessionStarted, sut.CurrentState.Stage); + Assert.Equal(0, sut.CurrentState.StepIndex); + + sut.Stop(); + } + + [Fact] + public void Missing_snapshot_does_not_trigger_quarantine() + { + using var rig = new Rig(); + var sut = rig.Build(); + + sut.Start(); + + Assert.False(sut.WasStartupQuarantine); + Assert.Equal(SessionStage.SessionStarted, sut.CurrentState.Stage); + + sut.Stop(); + } + + // ================================================================= Sonderfall 3: Transport-Resume + + [Fact] + public void Transport_resumes_from_persisted_cursor_after_restart() + { + using var rig = new Rig(); + Directory.CreateDirectory(rig.TransportDir); + + // Seed spool with 3 items (ItemIds 0/1/2, zero-based) and mark ItemId 0 as + // already uploaded. Remaining pending: ItemIds 1 + 2. + var spoolDir = rig.TransportDir; + var seedSpool = new TelemetrySpool(spoolDir, rig.Clock); + for (int i = 1; i <= 3; i++) + { + seedSpool.Enqueue(new TelemetryItemDraft( + kind: TelemetryItemKind.Event, + partitionKey: "T1_S1", + rowKey: $"row-{i}", + payloadJson: $"{{\"n\":{i}}}", + isSessionScoped: true, + requiresImmediateFlush: false)); + } + seedSpool.MarkUploaded(0); // pretend ItemId 0 already uploaded → 2 pending + + // Re-open via Orchestrator — uploader is scripted OK so the remaining 2 items drain. + rig.Uploader.QueueOk(10); + var sut = rig.Build(); + sut.Start(); + + // Explicit drain — tests don't wait for the periodic loop. + var drainResult = InvokeDrain(sut); + Assert.True(drainResult.Success); + Assert.Equal(2, drainResult.UploadedItems); + + // Uploader received one batch containing only items 2 + 3 (cursor was 1 on startup). + Assert.Single(rig.Uploader.Received); + Assert.Equal(2, rig.Uploader.Received[0].Count); + + sut.Stop(); + } + + // ================================================================= Helpers + + private static ISignalLogWriter GetSignalLog(EnrollmentOrchestrator sut) + { + var field = typeof(EnrollmentOrchestrator).GetField( + "_signalLog", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + return (ISignalLogWriter)field!.GetValue(sut)!; + } + + private static DrainResult InvokeDrain(EnrollmentOrchestrator sut) + { + var method = typeof(EnrollmentOrchestrator).GetMethod( + "DrainAsync", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var task = (System.Threading.Tasks.Task)method!.Invoke(sut, new object?[] { CancellationToken.None })!; + return task.GetAwaiter().GetResult(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SerialThreadingCollection.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SerialThreadingCollection.cs new file mode 100644 index 00000000..0383a47a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SerialThreadingCollection.cs @@ -0,0 +1,29 @@ +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Serialises threading-sensitive test classes against each other so xUnit does not + /// run them in parallel. Plan §4.x M4.5.c. + /// + /// The affected tests use / + /// with wall-clock timeouts. + /// Under full-suite parallelism the ThreadPool gets contended by unrelated tests and + /// those timeouts expire before the condition is observed — which we see as flaky + /// "Clock_advance_past_throttle_window…", "Null_observer_is_supported…", + /// "Reschedule_same_name_replaces_old_timer" and + /// "RehydrateFromSnapshot_reschedules_each_deadline_past_due_fires_immediately" failures. + /// + /// + /// Classes carrying [Collection("SerialThreading")] are guaranteed by xUnit to + /// execute sequentially against each other (tests within a class are already sequential + /// by default). Cost: ~2 extra seconds of wall-clock on the suite. Benefit: deterministic + /// green runs in CI. + /// + /// + [CollectionDefinition("SerialThreading", DisableParallelization = true)] + public sealed class SerialThreadingCollection + { + // Marker class — xUnit reads only the attribute. Intentionally empty. + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SessionTraceOrdinalProviderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SessionTraceOrdinalProviderTests.cs new file mode 100644 index 00000000..f0593644 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SessionTraceOrdinalProviderTests.cs @@ -0,0 +1,66 @@ +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + public sealed class SessionTraceOrdinalProviderTests + { + [Fact] + public void Default_seed_emits_zero_then_increments() + { + var p = new SessionTraceOrdinalProvider(); + Assert.Equal(-1, p.LastAssigned); + + Assert.Equal(0, p.Next()); + Assert.Equal(1, p.Next()); + Assert.Equal(2, p.Next()); + Assert.Equal(2, p.LastAssigned); + } + + [Fact] + public void Custom_seed_continues_from_there() + { + // Recovery: höchster persistierter Ordinal = 41 → Next() = 42. + var p = new SessionTraceOrdinalProvider(seedLastAssigned: 41); + Assert.Equal(41, p.LastAssigned); + Assert.Equal(42, p.Next()); + Assert.Equal(43, p.Next()); + } + + [Fact] + public async Task Next_is_thread_safe_and_monotonic_under_concurrent_callers() + { + const int Threads = 8; + const int PerThread = 5000; + var p = new SessionTraceOrdinalProvider(); + + var tasks = new Task[Threads]; + for (int t = 0; t < Threads; t++) + { + tasks[t] = Task.Run(() => + { + var buf = new long[PerThread]; + for (int i = 0; i < PerThread; i++) buf[i] = p.Next(); + return buf; + }); + } + + var results = await Task.WhenAll(tasks); + + // Kein Wert doppelt, alle im Bereich [0, Threads*PerThread-1], lückenlos. + var all = new System.Collections.Generic.HashSet(); + foreach (var buf in results) + { + foreach (var v in buf) + { + Assert.True(all.Add(v), $"Duplicate value {v}"); + } + } + + Assert.Equal(Threads * PerThread, all.Count); + Assert.Equal(Threads * PerThread - 1, p.LastAssigned); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalIngressTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalIngressTests.cs new file mode 100644 index 00000000..a32d13c4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalIngressTests.cs @@ -0,0 +1,572 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; + +// xUnit1031: Task.Wait / ManualResetEventSlim.Wait are used here intentionally to drive +// deterministic multi-thread scenarios (producer is blocked inside BlockingCollection.Add by +// design; switching to async/await would not match the semantics under test). +#pragma warning disable xUnit1031 + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + [Collection("SerialThreading")] + public sealed class SignalIngressTests + { + private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private static Evidence RawEvidence(string id = "raw-1") => + new Evidence(EvidenceKind.Raw, id, $"evidence-{id}"); + + /// + /// Konstruktionshelfer — baut eine SignalIngress gegen In-Memory-Writer + Fake-Engine. + /// + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public SignalLogWriter SignalLog { get; } + public SessionTraceOrdinalProvider TraceCounter { get; } = new SessionTraceOrdinalProvider(); + public FakeDecisionStepProcessor Processor { get; } = new FakeDecisionStepProcessor(); + public FakeBackPressureObserver Observer { get; } = new FakeBackPressureObserver(); + public VirtualClock Clock { get; } = new VirtualClock(new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc)); + public DecisionEngine Engine { get; } = new DecisionEngine(); + + public Rig() + { + SignalLog = new SignalLogWriter(Tmp.File("signal-log.jsonl")); + } + + public SignalIngress Build(int channelCapacity = 256, bool wireObserver = true, TimeSpan? throttle = null) => + new SignalIngress( + engine: Engine, + signalLog: SignalLog, + traceCounter: TraceCounter, + processor: Processor, + clock: Clock, + backPressureObserver: wireObserver ? Observer : null, + channelCapacity: channelCapacity, + backPressureThrottle: throttle); + + public void Dispose() => Tmp.Dispose(); + } + + // 5000ms cushion: 2000ms was racy under ThreadPool contention from parallel test classes + // even with [Collection("SerialThreading")] (timers on the same pool can still lag). + // Plan §4.x M4.5.c. + private static bool WaitFor(Func condition, int timeoutMs = 5000) + { + return SpinWait.SpinUntil(condition, timeoutMs); + } + + // ============================================================ Lifecycle + + [Fact] + public void Start_twice_throws() + { + using var rig = new Rig(); + var ing = rig.Build(); + ing.Start(); + Assert.Throws(() => ing.Start()); + ing.Stop(); + } + + [Fact] + public void Stop_is_idempotent() + { + using var rig = new Rig(); + var ing = rig.Build(); + ing.Start(); + ing.Stop(); + ing.Stop(); // does not throw + } + + [Fact] + public void Post_after_Stop_throws() + { + using var rig = new Rig(); + var ing = rig.Build(); + ing.Start(); + ing.Stop(); + + Assert.Throws(() => + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence())); + } + + [Fact] + public void Dispose_stops_worker_and_releases_channel() + { + using var rig = new Rig(); + var ing = rig.Build(); + ing.Start(); + ing.Dispose(); + + Assert.Throws(() => + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence())); + } + + [Fact] + public void Post_before_Start_queues_until_worker_runs() + { + using var rig = new Rig(); + var ing = rig.Build(); + // Post before Start — item sits in the channel. + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence()); + Assert.Equal(0, rig.Processor.ApplyCallCount); + + ing.Start(); + Assert.True(WaitFor(() => rig.Processor.ApplyCallCount == 1)); + ing.Stop(); + } + + // ============================================================ Ordinals + SignalLog + + [Fact] + public void Worker_assigns_monotonic_ordinals_from_zero_on_empty_log() + { + using var rig = new Rig(); + var ing = rig.Build(); + ing.Start(); + + for (int i = 0; i < 5; i++) + { + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence($"id-{i}")); + } + + ing.Stop(); + + var persisted = rig.SignalLog.ReadAll(); + Assert.Equal(5, persisted.Count); + for (int i = 0; i < 5; i++) Assert.Equal(i, persisted[i].SessionSignalOrdinal); + Assert.Equal(4, rig.SignalLog.LastOrdinal); + Assert.Equal(4, ing.LastAssignedSignalOrdinal); + } + + [Fact] + public void Worker_seeds_ordinal_from_existing_SignalLog_on_recovery() + { + using var tmp = new TempDirectory(); + var logPath = tmp.File("signal-log.jsonl"); + + // Pre-populate log with 3 signals, ordinals 0..2. + var pre = new SignalLogWriter(logPath); + for (int i = 0; i < 3; i++) + { + pre.Append(new DecisionSignal( + sessionSignalOrdinal: i, + sessionTraceOrdinal: i, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 1, + occurredAtUtc: At, + sourceOrigin: "Seed", + evidence: RawEvidence($"pre-{i}"))); + } + + var recovered = new SignalLogWriter(logPath); + var ing = new SignalIngress( + engine: new DecisionEngine(), + signalLog: recovered, + traceCounter: new SessionTraceOrdinalProvider(2), + processor: new FakeDecisionStepProcessor(), + clock: new VirtualClock(At)); + ing.Start(); + + ing.Post(DecisionSignalKind.DesktopArrived, At, "Collector", RawEvidence("fresh")); + ing.Stop(); + + Assert.Equal(3, ing.LastAssignedSignalOrdinal); + var all = recovered.ReadAll(); + Assert.Equal(4, all.Count); + Assert.Equal(3, all[3].SessionSignalOrdinal); + Assert.Equal(DecisionSignalKind.DesktopArrived, all[3].Kind); + } + + [Fact] + public void SignalLog_append_happens_before_processor_ApplyStep() + { + // §2.7c / L.1: signal must be on disk before the reducer sees it. + using var rig = new Rig(); + DecisionSignal? observedAtApply = null; + long logOrdinalAtApply = -1; + + rig.Processor.ScriptOk(1); // dummy to exercise the queue + var processorSpy = new FakeDecisionStepProcessor(); + + var ing = new SignalIngress( + engine: rig.Engine, + signalLog: rig.SignalLog, + traceCounter: rig.TraceCounter, + processor: new ApplyInspectingProcessor(rig.SignalLog, (step, signal, logAtApply) => + { + observedAtApply = signal; + logOrdinalAtApply = logAtApply; + }), + clock: rig.Clock, + backPressureObserver: rig.Observer); + ing.Start(); + + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence()); + ing.Stop(); + + Assert.NotNull(observedAtApply); + Assert.Equal(0, observedAtApply!.SessionSignalOrdinal); + Assert.Equal(0, logOrdinalAtApply); // bereits appendet beim ApplyStep-Call + } + + private sealed class ApplyInspectingProcessor : IDecisionStepProcessor + { + private readonly ISignalLogWriter _log; + private readonly Action _inspect; + private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1"); + + public ApplyInspectingProcessor(ISignalLogWriter log, Action inspect) + { + _log = log; + _inspect = inspect; + } + + public DecisionCore.State.DecisionState CurrentState => _state; + + public void ApplyStep(DecisionStep step, DecisionSignal signal) + { + _inspect(step, signal, _log.LastOrdinal); + _state = step.NewState; + } + } + + // ============================================================ Reducer dispatch + + [Fact] + public void ApplyStep_receives_state_at_step_begin_and_advances_for_next_signal() + { + // Reducer dispatches SessionStarted → state with Stage=EnrollmentStarted (or similar). + // Second signal should see updated state from first. + using var rig = new Rig(); + var ing = rig.Build(); + ing.Start(); + + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0")); + ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1")); + ing.Stop(); + + Assert.Equal(2, rig.Processor.ApplyCallCount); + var calls = rig.Processor.Calls; + Assert.Equal(0, calls[0].Signal.SessionSignalOrdinal); + Assert.Equal(1, calls[1].Signal.SessionSignalOrdinal); + + // StepIndex monoton. + Assert.True(calls[1].Step.NewState.StepIndex > calls[0].Step.NewState.StepIndex); + } + + [Fact] + public void ApplyStep_exception_does_not_kill_worker_subsequent_signals_still_processed() + { + using var rig = new Rig(); + rig.Processor.ScriptThrow(new InvalidOperationException("journal-disk-full")); + var ing = rig.Build(); + ing.Start(); + + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("s0")); + // First ApplyStep throws — worker must survive. + Assert.True(WaitFor(() => rig.Processor.ApplyCallCount == 1)); + + ing.Post(DecisionSignalKind.DesktopArrived, At.AddSeconds(1), "Collector", RawEvidence("s1")); + Assert.True(WaitFor(() => rig.Processor.ApplyCallCount == 2)); + ing.Stop(); + + Assert.Equal(2, rig.SignalLog.ReadAll().Count); + } + + [Fact] + public void Post_via_ISignalIngressSink_reaches_reducer_like_any_signal() + { + using var rig = new Rig(); + var ing = rig.Build(); + ISignalIngressSink sink = ing; + ing.Start(); + + sink.Post( + DecisionSignalKind.ClassifierVerdictIssued, + At, + "effectrunner:classifier:whiteglove_sealing", + new Evidence(EvidenceKind.Synthetic, "classifier:whiteglove_sealing:abc", "Strong/85"), + new Dictionary { ["level"] = "Strong" }); + + ing.Stop(); + + Assert.Single(rig.Processor.Calls); + Assert.Equal(DecisionSignalKind.ClassifierVerdictIssued, rig.Processor.Calls[0].Signal.Kind); + Assert.Equal("Strong", rig.Processor.Calls[0].Signal.Payload!["level"]); + } + + [Fact] + public void Post_null_evidence_throws_synchronously_without_enqueuing() + { + using var rig = new Rig(); + var ing = rig.Build(); + ing.Start(); + + Assert.Throws(() => + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", null!)); + + ing.Stop(); + Assert.Empty(rig.SignalLog.ReadAll()); + } + + [Fact] + public void Post_empty_origin_throws_synchronously() + { + using var rig = new Rig(); + var ing = rig.Build(); + ing.Start(); + + Assert.Throws(() => + ing.Post(DecisionSignalKind.SessionStarted, At, "", RawEvidence())); + + ing.Stop(); + } + + // ============================================================ Back-pressure + + [Fact] + public void Fast_path_does_not_notify_back_pressure() + { + using var rig = new Rig(); + var ing = rig.Build(channelCapacity: 32); + ing.Start(); + + for (int i = 0; i < 20; i++) + { + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence($"id-{i}")); + } + ing.Stop(); + + Assert.Equal(20, rig.Processor.ApplyCallCount); + Assert.Equal(0, rig.Observer.CallCount); + } + + /// + /// Posts -1 signals to fill the channel, then starts + /// a producer task that will block on Add. Returns once the producer is highly + /// likely blocked inside Add (task has started + signaled readiness + small sleep). + /// + private static Task SpawnBlockedProducer(SignalIngress ing, string origin, string evidenceId) + { + var producerRunning = new ManualResetEventSlim(false); + var task = Task.Run(() => + { + producerRunning.Set(); // task has been scheduled and is executing + ing.Post(DecisionSignalKind.DesktopArrived, At, origin, new Evidence(EvidenceKind.Raw, evidenceId, $"ev-{evidenceId}")); + }); + Assert.True(producerRunning.Wait(2000), "Producer task did not start within 2s"); + // Small grace period: giving the producer time to reach the blocking Add call. + // After Set() the lambda still has to step into Post → TryAdd fails → Add blocks. + Thread.Sleep(100); + return task; + } + + [Fact] + public void Full_channel_triggers_back_pressure_observer_once() + { + using var rig = new Rig(); + var ing = rig.Build(channelCapacity: 1); + + // Fill channel without running worker. + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("seed")); + Assert.Equal(1, ing.ApproximateQueueLength); + + // Producer that will block on Add(). + var producer = SpawnBlockedProducer(ing, "Collector", "blocked"); + + // Let worker drain; producer's Add returns after one slot frees. + ing.Start(); + Assert.True(producer.Wait(5000)); + ing.Stop(); + + Assert.Equal(1, rig.Observer.CallCount); + var call = rig.Observer.Calls[0]; + Assert.Equal("Collector", call.Origin); + Assert.Equal(1, call.ChannelCapacity); + Assert.True(call.BlockDuration >= TimeSpan.Zero); + } + + [Fact] + public void Back_pressure_is_throttled_within_window_per_origin() + { + using var rig = new Rig(); + var ing = rig.Build(channelCapacity: 1, throttle: TimeSpan.FromMinutes(1)); + + // --- Round 1: trigger one back-pressure event from "A". + ing.Post(DecisionSignalKind.SessionStarted, At, "A", RawEvidence("a1")); + var t1 = SpawnBlockedProducer(ing, "A", "a2"); + ing.Start(); + Assert.True(t1.Wait(5000)); + Assert.True(WaitFor(() => rig.Processor.ApplyCallCount == 2)); + + Assert.Equal(1, rig.Observer.CallCount); + + // --- Round 2 (same origin, no clock advance): observer should NOT fire (throttled). + var blocker = new ManualResetEventSlim(false); + rig.Processor.BlockHandle = blocker; + ing.Post(DecisionSignalKind.SessionStarted, At, "A", RawEvidence("a3")); + // Wait until worker has pulled it and is blocked on the BlockHandle; channel empty. + Assert.True(WaitFor(() => ing.ApproximateQueueLength == 0)); + // Fill channel then spawn blocked producer. + ing.Post(DecisionSignalKind.SessionStarted, At, "A", RawEvidence("a4")); + Assert.True(WaitFor(() => ing.ApproximateQueueLength == 1)); + var t2 = SpawnBlockedProducer(ing, "A", "a5"); + blocker.Set(); + Assert.True(t2.Wait(5000)); + Assert.True(WaitFor(() => rig.Processor.ApplyCallCount == 5)); + + Assert.Equal(1, rig.Observer.CallCount); // STILL 1 — throttle active. + ing.Stop(); + } + + [Fact] + public void Different_origins_do_not_share_back_pressure_throttle() + { + using var rig = new Rig(); + var ing = rig.Build(channelCapacity: 1); + + // Origin "A" triggers BP. + ing.Post(DecisionSignalKind.SessionStarted, At, "A", RawEvidence("a1")); + var t1 = SpawnBlockedProducer(ing, "A", "a2"); + ing.Start(); + Assert.True(t1.Wait(5000)); + Assert.True(WaitFor(() => rig.Processor.ApplyCallCount == 2)); + + // Origin "B" triggers BP in the same throttle window. + var blocker = new ManualResetEventSlim(false); + rig.Processor.BlockHandle = blocker; + ing.Post(DecisionSignalKind.SessionStarted, At, "B", RawEvidence("b1")); + Assert.True(WaitFor(() => ing.ApproximateQueueLength == 0)); + ing.Post(DecisionSignalKind.SessionStarted, At, "B", RawEvidence("b2")); + Assert.True(WaitFor(() => ing.ApproximateQueueLength == 1)); + var t2 = SpawnBlockedProducer(ing, "B", "b3"); + blocker.Set(); + Assert.True(t2.Wait(5000)); + + ing.Stop(); + + Assert.Equal(2, rig.Observer.CallCount); + Assert.Contains(rig.Observer.Calls, c => c.Origin == "A"); + Assert.Contains(rig.Observer.Calls, c => c.Origin == "B"); + } + + [Fact] + public void Clock_advance_past_throttle_window_re_arms_emission() + { + using var rig = new Rig(); + var ing = rig.Build(channelCapacity: 1, throttle: TimeSpan.FromMinutes(1)); + + // Round 1 — BP for "A". + ing.Post(DecisionSignalKind.SessionStarted, At, "A", RawEvidence("a1")); + var t1 = SpawnBlockedProducer(ing, "A", "a2"); + ing.Start(); + Assert.True(t1.Wait(5000)); + Assert.True(WaitFor(() => rig.Processor.ApplyCallCount == 2)); + Assert.Equal(1, rig.Observer.CallCount); + + // Advance past throttle window. + rig.Clock.Advance(TimeSpan.FromMinutes(2)); + + // Round 2 — same origin, should fire again. + var blocker = new ManualResetEventSlim(false); + rig.Processor.BlockHandle = blocker; + ing.Post(DecisionSignalKind.SessionStarted, At, "A", RawEvidence("a3")); + Assert.True(WaitFor(() => ing.ApproximateQueueLength == 0)); + ing.Post(DecisionSignalKind.SessionStarted, At, "A", RawEvidence("a4")); + Assert.True(WaitFor(() => ing.ApproximateQueueLength == 1)); + var t2 = SpawnBlockedProducer(ing, "A", "a5"); + blocker.Set(); + Assert.True(t2.Wait(5000)); + + ing.Stop(); + Assert.Equal(2, rig.Observer.CallCount); + } + + [Fact] + public void Null_observer_is_supported_and_back_pressure_is_silent() + { + using var rig = new Rig(); + var ing = rig.Build(channelCapacity: 1, wireObserver: false); + + ing.Post(DecisionSignalKind.SessionStarted, At, "A", RawEvidence("a1")); + var t1 = SpawnBlockedProducer(ing, "A", "a2"); + ing.Start(); + Assert.True(t1.Wait(5000)); + ing.Stop(); + + // Observer is null — verifying no NRE crashed the producer or worker. + Assert.Equal(2, rig.Processor.ApplyCallCount); + } + + [Fact] + public void Observer_exception_does_not_halt_producer_or_worker() + { + using var rig = new Rig(); + rig.Observer.ThrowOnNotify = new InvalidOperationException("observer bug"); + var ing = rig.Build(channelCapacity: 1); + + ing.Post(DecisionSignalKind.SessionStarted, At, "A", RawEvidence("a1")); + var t1 = SpawnBlockedProducer(ing, "A", "a2"); + ing.Start(); + Assert.True(t1.Wait(5000)); + ing.Stop(); + + Assert.Equal(2, rig.Processor.ApplyCallCount); + Assert.Equal(1, rig.Observer.CallCount); // recorded despite the throw + } + + // ============================================================ Ordering + stress + + [Fact] + public async Task Concurrent_producers_result_in_strictly_monotonic_ordinals() + { + using var rig = new Rig(); + var ing = rig.Build(channelCapacity: 64); + ing.Start(); + + const int Producers = 8; + const int PerProducer = 50; + + var tasks = new Task[Producers]; + for (int p = 0; p < Producers; p++) + { + int idx = p; + tasks[p] = Task.Run(() => + { + for (int i = 0; i < PerProducer; i++) + { + ing.Post(DecisionSignalKind.SessionStarted, At, $"p{idx}", RawEvidence($"p{idx}-{i}")); + } + }); + } + + await Task.WhenAll(tasks); + ing.Stop(); + + var persisted = rig.SignalLog.ReadAll(); + Assert.Equal(Producers * PerProducer, persisted.Count); + + // Strictly increasing ordinals, contiguous from 0. + for (int i = 0; i < persisted.Count; i++) + { + Assert.Equal(i, persisted[i].SessionSignalOrdinal); + } + // Trace ordinals must be strictly increasing (not necessarily = signal ordinal). + var traces = persisted.Select(s => s.SessionTraceOrdinal).ToArray(); + for (int i = 1; i < traces.Length; i++) + { + Assert.True(traces[i] > traces[i - 1]); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalIngressUploadPathTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalIngressUploadPathTests.cs new file mode 100644 index 00000000..5f0ed1e5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/SignalIngressUploadPathTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Signals; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; + +#pragma warning disable xUnit1031 + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + /// + /// Codex Finding 1 regression gate: without the emitter hook in + /// , no signal ever reached the telemetry transport and the + /// backend /api/sessions/{id}/signals endpoint saw zero traffic. These tests + /// verify that every accepted signal also lands on the transport, and that a throwing + /// transport does NOT take down the ingress worker. + /// + [Collection("SerialThreading")] + public sealed class SignalIngressUploadPathTests + { + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private static Evidence RawEvidence(string id = "raw-1") => + new Evidence(EvidenceKind.Raw, id, $"evidence-{id}"); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public SignalLogWriter SignalLog { get; } + public SessionTraceOrdinalProvider TraceCounter { get; } = new SessionTraceOrdinalProvider(); + public FakeDecisionStepProcessor Processor { get; } = new FakeDecisionStepProcessor(); + public VirtualClock Clock { get; } = new VirtualClock(At); + public DecisionEngine Engine { get; } = new DecisionEngine(); + public FakeTelemetryTransport Transport { get; } = new FakeTelemetryTransport(); + + public Rig() { SignalLog = new SignalLogWriter(Tmp.File("signal-log.jsonl")); } + + public SignalIngress Build(TelemetrySignalEmitter? emitter) + => new SignalIngress( + engine: Engine, + signalLog: SignalLog, + traceCounter: TraceCounter, + processor: Processor, + clock: Clock, + backPressureObserver: null, + signalEmitter: emitter); + + public void Dispose() => Tmp.Dispose(); + } + + private static bool WaitFor(Func cond, int timeoutMs = 5000) + => SpinWait.SpinUntil(cond, timeoutMs); + + [Fact] + public void Post_emits_to_transport_after_SignalLog_append_succeeds() + { + using var rig = new Rig(); + var emitter = new TelemetrySignalEmitter(rig.Transport, SessionId, TenantId); + var ing = rig.Build(emitter); + ing.Start(); + + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence("e-1")); + + Assert.True(WaitFor(() => rig.Transport.EnqueueCount == 1)); + Assert.Equal(TelemetryItemKind.Signal, rig.Transport.Enqueued[0].Kind); + Assert.Equal($"{TenantId}_{SessionId}", rig.Transport.Enqueued[0].PartitionKey); + + ing.Stop(); + } + + [Fact] + public void Post_emits_one_transport_item_per_signal() + { + using var rig = new Rig(); + var emitter = new TelemetrySignalEmitter(rig.Transport, SessionId, TenantId); + var ing = rig.Build(emitter); + ing.Start(); + + for (int i = 0; i < 5; i++) + { + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence($"e-{i}")); + } + + Assert.True(WaitFor(() => rig.Transport.EnqueueCount == 5)); + // RowKeys are D19(ordinal) — SignalIngress assigns ordinals 0..4 against an empty log. + for (int i = 0; i < 5; i++) + { + Assert.Equal(((long)i).ToString("D19"), rig.Transport.Enqueued[i].RowKey); + } + + ing.Stop(); + } + + [Fact] + public void Post_without_emitter_keeps_pre_M5_behaviour_unchanged() + { + // emitter=null (the default) means the agent runs with SignalLog-only persistence. + // Regression guard so the optional-param wiring never becomes required. + using var rig = new Rig(); + var ing = rig.Build(emitter: null); + ing.Start(); + + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence()); + + Assert.True(WaitFor(() => rig.Processor.ApplyCallCount == 1)); + Assert.Equal(0, rig.Transport.EnqueueCount); + + ing.Stop(); + } + + [Fact] + public void Transport_throw_is_swallowed_and_ingress_keeps_reducing() + { + using var rig = new Rig(); + var throwingTransport = new ThrowingTelemetryTransport(); + var emitter = new TelemetrySignalEmitter(throwingTransport, SessionId, TenantId); + var ing = rig.Build(emitter); + ing.Start(); + + // Even with the transport failing on every Enqueue, the reducer must still see the + // signal — local SignalLog is authoritative per §2.7c. + ing.Post(DecisionSignalKind.SessionStarted, At, "Collector", RawEvidence()); + + Assert.True(WaitFor(() => rig.Processor.ApplyCallCount == 1)); + Assert.Equal(1, throwingTransport.EnqueueAttempts); + + ing.Stop(); + } + + private sealed class ThrowingTelemetryTransport : ITelemetryTransport + { + public int EnqueueAttempts; + + public long LastUploadedItemId => -1; + + public TelemetryItem Enqueue(TelemetryItemDraft draft) + { + Interlocked.Increment(ref EnqueueAttempts); + throw new InvalidOperationException("spool overflow (test)"); + } + + public System.Threading.Tasks.Task DrainAllAsync(CancellationToken cancellationToken = default) + => System.Threading.Tasks.Task.FromResult(DrainResult.Empty()); + + public void Dispose() { } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Persistence/JournalWriterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Persistence/JournalWriterTests.cs new file mode 100644 index 00000000..e3ecfba8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Persistence/JournalWriterTests.cs @@ -0,0 +1,181 @@ +using System; +using System.IO; +using System.Text; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Persistence +{ + public sealed class JournalWriterTests + { + private static DecisionTransition Step(int index, long signalRef = 0, SessionStage from = SessionStage.SessionStarted, SessionStage to = SessionStage.AwaitingEspPhaseChange, long? traceOrdinal = null) + { + return new DecisionTransition( + stepIndex: index, + sessionTraceOrdinal: traceOrdinal ?? index, + signalOrdinalRef: signalRef, + occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc).AddSeconds(index), + trigger: "TestTrigger", + fromStage: from, + toStage: to, + taken: true, + deadEndReason: null, + reducerVersion: "2.0.0.0"); + } + + [Fact] + public void Append_then_ReadAll_roundtrips_all_fields_including_guards() + { + using var tmp = new TempDirectory(); + var path = tmp.File("journal.jsonl"); + var w = new JournalWriter(path); + + var step = new DecisionTransition( + stepIndex: 0, + sessionTraceOrdinal: 5, + signalOrdinalRef: 3, + occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc), + trigger: "EspPhaseChanged", + fromStage: SessionStage.SessionStarted, + toStage: SessionStage.AwaitingEspPhaseChange, + taken: true, + deadEndReason: null, + reducerVersion: "2.0.0.0", + guards: new[] + { + new GuardReport("phase-is-accountsetup", passed: true, reason: null), + new GuardReport("enrollment-not-terminal", passed: true, reason: null), + }, + emittedEventSequences: new long[] { 42, 43 }); + + w.Append(step); + + var read = w.ReadAll(); + Assert.Single(read); + Assert.Equal(0, read[0].StepIndex); + Assert.Equal(5, read[0].SessionTraceOrdinal); + Assert.Equal(3, read[0].SignalOrdinalRef); + Assert.Equal(2, read[0].Guards.Count); + Assert.True(read[0].Guards[0].Passed); + Assert.Equal("phase-is-accountsetup", read[0].Guards[0].GuardId); + Assert.Equal(2, read[0].EmittedEventSequences.Count); + Assert.Equal(42, read[0].EmittedEventSequences[0]); + } + + [Fact] + public void Append_stores_DeadEnd_with_reason() + { + using var tmp = new TempDirectory(); + var w = new JournalWriter(tmp.File("journal.jsonl")); + + var deadEnd = new DecisionTransition( + stepIndex: 0, + sessionTraceOrdinal: 7, + signalOrdinalRef: 4, + occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc), + trigger: "HelloResolved", + fromStage: SessionStage.AwaitingEspPhaseChange, + toStage: SessionStage.AwaitingEspPhaseChange, + taken: false, + deadEndReason: "hybrid_reboot_gate_blocking", + reducerVersion: "2.0.0.0", + guards: new[] { new GuardReport("hybrid-gate", passed: false, reason: "reboot pending") }); + + w.Append(deadEnd); + + var back = w.ReadAll(); + Assert.False(back[0].Taken); + Assert.Equal("hybrid_reboot_gate_blocking", back[0].DeadEndReason); + Assert.False(back[0].Guards[0].Passed); + } + + [Fact] + public void Append_rejects_non_monotonic_stepIndex() + { + using var tmp = new TempDirectory(); + var w = new JournalWriter(tmp.File("journal.jsonl")); + + w.Append(Step(5)); + + Assert.Throws(() => w.Append(Step(5))); + Assert.Throws(() => w.Append(Step(3))); + } + + [Fact] + public void New_writer_on_existing_journal_recovers_LastStepIndex() + { + using var tmp = new TempDirectory(); + var path = tmp.File("journal.jsonl"); + + var w1 = new JournalWriter(path); + w1.Append(Step(0)); + w1.Append(Step(1)); + + var w2 = new JournalWriter(path); + Assert.Equal(1, w2.LastStepIndex); + w2.Append(Step(2)); + Assert.Equal(3, w2.ReadAll().Count); + } + + [Fact] + public void ReadAll_stops_at_corrupt_tail_line() + { + using var tmp = new TempDirectory(); + var path = tmp.File("journal.jsonl"); + var w = new JournalWriter(path); + + w.Append(Step(0)); + w.Append(Step(1)); + File.AppendAllText(path, "{\"StepIndex\":2,\"Trigger\":\"Esp", Encoding.UTF8); + + var reread = new JournalWriter(path); + Assert.Equal(1, reread.LastStepIndex); + Assert.Equal(2, reread.ReadAll().Count); + } + + // ============================================================ LastTraceOrdinal (recovery-seed for SessionTraceOrdinalProvider) + + [Fact] + public void LastTraceOrdinal_empty_journal_is_minus_one() + { + using var tmp = new TempDirectory(); + var w = new JournalWriter(tmp.File("journal.jsonl")); + + Assert.Equal(-1, w.LastTraceOrdinal); + } + + [Fact] + public void LastTraceOrdinal_tracks_max_across_appends_not_last_one() + { + // Recovery invariant: the seed for SessionTraceOrdinalProvider must be the true maximum + // across all persisted transitions, even if later steps happen to carry a smaller trace + // (e.g. because they were synthesised from an earlier classifier tick). + using var tmp = new TempDirectory(); + var w = new JournalWriter(tmp.File("journal.jsonl")); + + w.Append(Step(index: 0, traceOrdinal: 5)); + w.Append(Step(index: 1, traceOrdinal: 99)); + w.Append(Step(index: 2, traceOrdinal: 60)); // smaller than prior max — must NOT lower + + Assert.Equal(99, w.LastTraceOrdinal); + } + + [Fact] + public void LastTraceOrdinal_survives_restart_by_rescanning_persisted_journal() + { + using var tmp = new TempDirectory(); + var path = tmp.File("journal.jsonl"); + + var w1 = new JournalWriter(path); + w1.Append(Step(index: 0, traceOrdinal: 3)); + w1.Append(Step(index: 1, traceOrdinal: 77)); + w1.Append(Step(index: 2, traceOrdinal: 12)); + + var w2 = new JournalWriter(path); + Assert.Equal(77, w2.LastTraceOrdinal); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Persistence/SignalLogWriterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Persistence/SignalLogWriterTests.cs new file mode 100644 index 00000000..9a67f2b6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Persistence/SignalLogWriterTests.cs @@ -0,0 +1,175 @@ +using System; +using System.IO; +using System.Text; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Persistence +{ + public sealed class SignalLogWriterTests + { + [Fact] + public void Append_then_ReadAll_roundtrips_all_fields() + { + using var tmp = new TempDirectory(); + var log = tmp.File("signal-log.jsonl"); + + var writer = new SignalLogWriter(log); + var s0 = TestSignals.Raw(0); + var s1 = TestSignals.Raw(1, DecisionSignalKind.DesktopArrived); + + writer.Append(s0); + writer.Append(s1); + + Assert.Equal(1, writer.LastOrdinal); + + var readBack = writer.ReadAll(); + Assert.Equal(2, readBack.Count); + Assert.Equal(0, readBack[0].SessionSignalOrdinal); + Assert.Equal(DecisionSignalKind.EspPhaseChanged, readBack[0].Kind); + Assert.Equal(1, readBack[1].SessionSignalOrdinal); + Assert.Equal(DecisionSignalKind.DesktopArrived, readBack[1].Kind); + } + + [Fact] + public void Append_is_flushed_to_disk_immediately_plan_L12() + { + // Plan §2.7 L.12 — every successful Append returns only after flush-to-disk. + // We verify the file's raw bytes are present before Append returns by opening + // the file for shared read while the writer still exists. + using var tmp = new TempDirectory(); + var log = tmp.File("signal-log.jsonl"); + + var writer = new SignalLogWriter(log); + writer.Append(TestSignals.Raw(0)); + + // Concurrent reader opening with FileShare.ReadWrite gets the bytes now. + var bytesOnDisk = File.ReadAllBytes(log); + Assert.True(bytesOnDisk.Length > 0); + var text = Encoding.UTF8.GetString(bytesOnDisk); + Assert.Contains("\"SessionSignalOrdinal\":0", text); + } + + [Fact] + public void Append_rejects_non_monotonic_ordinal() + { + using var tmp = new TempDirectory(); + var writer = new SignalLogWriter(tmp.File("log.jsonl")); + + writer.Append(TestSignals.Raw(5)); + + Assert.Throws(() => writer.Append(TestSignals.Raw(5))); + Assert.Throws(() => writer.Append(TestSignals.Raw(3))); + } + + [Fact] + public void New_writer_on_existing_log_recovers_LastOrdinal() + { + using var tmp = new TempDirectory(); + var log = tmp.File("signal-log.jsonl"); + + var w1 = new SignalLogWriter(log); + w1.Append(TestSignals.Raw(0)); + w1.Append(TestSignals.Raw(1)); + w1.Append(TestSignals.Raw(2)); + + // Simulate agent restart — fresh writer on same file. + var w2 = new SignalLogWriter(log); + Assert.Equal(2, w2.LastOrdinal); + + // Continuation must maintain monotonicity. + w2.Append(TestSignals.Raw(3)); + Assert.Equal(3, w2.LastOrdinal); + Assert.Equal(4, w2.ReadAll().Count); + } + + [Fact] + public void ReadAll_stops_at_corrupt_tail_line_simulating_crash_mid_append() + { + // Plan §2.7 Sonderfall 3 — crash ohne Terminal-Flush; letzte Zeile kann halb- + // geschrieben sein. Recovery rule: bis zur letzten parsbaren Zeile lesen. + using var tmp = new TempDirectory(); + var log = tmp.File("signal-log.jsonl"); + + var writer = new SignalLogWriter(log); + writer.Append(TestSignals.Raw(0)); + writer.Append(TestSignals.Raw(1)); + + // Append a half-written line manually — simulates a kill-9 mid-write. + File.AppendAllText(log, "{\"SessionSignalOrdinal\":2,\"Kind\":\"Esp", Encoding.UTF8); + + var reread = new SignalLogWriter(log); + Assert.Equal(1, reread.LastOrdinal); // did not pick up the half-line + Assert.Equal(2, reread.ReadAll().Count); + } + + [Fact] + public void ReadAll_on_missing_file_returns_empty() + { + using var tmp = new TempDirectory(); + var writer = new SignalLogWriter(tmp.File("does-not-exist.jsonl")); + Assert.Equal(-1, writer.LastOrdinal); + Assert.Empty(writer.ReadAll()); + } + + [Fact] + public void Constructor_creates_missing_parent_directory() + { + using var tmp = new TempDirectory(); + var nested = Path.Combine(tmp.Path, "State", "sub", "signal-log.jsonl"); + + var writer = new SignalLogWriter(nested); + writer.Append(TestSignals.Raw(0)); + + Assert.True(File.Exists(nested)); + } + + // ============================================================ LastTraceOrdinal (recovery-seed for SessionTraceOrdinalProvider) + + [Fact] + public void LastTraceOrdinal_empty_log_is_minus_one() + { + using var tmp = new TempDirectory(); + var writer = new SignalLogWriter(tmp.File("signal-log.jsonl")); + + Assert.Equal(-1, writer.LastTraceOrdinal); + } + + [Fact] + public void LastTraceOrdinal_tracks_max_across_appends_not_last_one() + { + // Trace ordinals may arrive out of step with SessionSignalOrdinal if multiple producers + // compete for the provider (e.g. a classifier verdict stamped with trace=42 interleaved + // with a raw signal at trace=40 — the raw keeps a lower signal-ordinal but the max trace + // has already advanced). + using var tmp = new TempDirectory(); + var writer = new SignalLogWriter(tmp.File("signal-log.jsonl")); + + writer.Append(TestSignals.Raw(ordinal: 0, traceOrdinal: 10)); + writer.Append(TestSignals.Raw(ordinal: 1, traceOrdinal: 42)); + writer.Append(TestSignals.Raw(ordinal: 2, traceOrdinal: 25)); // lower than prior max — must NOT lower max + + Assert.Equal(42, writer.LastTraceOrdinal); + } + + [Fact] + public void LastTraceOrdinal_survives_restart_by_rescanning_persisted_log() + { + // Core of the recovery seed: a fresh writer on an existing log must reconstruct the + // max trace ordinal from disk — otherwise SessionTraceOrdinalProvider would start at + // -1 and the first post-recovery signal would duplicate a previously-persisted trace. + using var tmp = new TempDirectory(); + var log = tmp.File("signal-log.jsonl"); + + var w1 = new SignalLogWriter(log); + w1.Append(TestSignals.Raw(ordinal: 0, traceOrdinal: 7)); + w1.Append(TestSignals.Raw(ordinal: 1, traceOrdinal: 99)); + w1.Append(TestSignals.Raw(ordinal: 2, traceOrdinal: 50)); + + var w2 = new SignalLogWriter(log); + Assert.Equal(99, w2.LastTraceOrdinal); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Persistence/SnapshotPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Persistence/SnapshotPersistenceTests.cs new file mode 100644 index 00000000..6c260070 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Persistence/SnapshotPersistenceTests.cs @@ -0,0 +1,155 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Persistence +{ + public sealed class SnapshotPersistenceTests + { + private static DecisionState BuildState(string sessionId = "S1", int stepIndex = 7) + { + var builder = DecisionState.CreateInitial(sessionId, "tenant-1") + .ToBuilder() + .WithStage(SessionStage.AwaitingHello) + .WithLastAppliedSignalOrdinal(10) + .WithStepIndex(stepIndex); + + builder.HelloResolvedUtc = new SignalFact( + new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc), 5); + + return builder.Build(); + } + + [Fact] + public void Save_then_Load_roundtrips_state() + { + using var tmp = new TempDirectory(); + var path = tmp.File("snapshot.json"); + var sut = new SnapshotPersistence(path); + + var original = BuildState(); + sut.Save(original); + + var loaded = sut.Load(); + Assert.NotNull(loaded); + Assert.Equal(original.SessionId, loaded!.SessionId); + Assert.Equal(original.TenantId, loaded.TenantId); + Assert.Equal(SessionStage.AwaitingHello, loaded.Stage); + Assert.Equal(10, loaded.LastAppliedSignalOrdinal); + Assert.Equal(7, loaded.StepIndex); + Assert.NotNull(loaded.HelloResolvedUtc); + Assert.Equal(5, loaded.HelloResolvedUtc!.SourceSignalOrdinal); + } + + [Fact] + public void Load_returns_null_when_file_missing() + { + using var tmp = new TempDirectory(); + var sut = new SnapshotPersistence(tmp.File("does-not-exist.json")); + Assert.Null(sut.Load()); + } + + [Fact] + public void Load_returns_null_when_checksum_does_not_match() + { + using var tmp = new TempDirectory(); + var path = tmp.File("snapshot.json"); + var sut = new SnapshotPersistence(path); + sut.Save(BuildState()); + + // Tamper with the inner state JSON without recomputing the checksum. + var content = File.ReadAllText(path, Encoding.UTF8); + var tampered = content.Replace("tenant-1", "tenant-X"); + File.WriteAllText(path, tampered, Encoding.UTF8); + + Assert.Null(sut.Load()); + } + + [Fact] + public void Load_returns_null_when_envelope_is_malformed() + { + using var tmp = new TempDirectory(); + var path = tmp.File("snapshot.json"); + File.WriteAllText(path, "this is not json at all", Encoding.UTF8); + + var sut = new SnapshotPersistence(path); + Assert.Null(sut.Load()); + } + + [Fact] + public void Quarantine_moves_file_into_timestamped_bucket_with_reason() + { + var fixedUtc = new DateTime(2026, 4, 20, 14, 30, 45, 123, DateTimeKind.Utc); + using var tmp = new TempDirectory(); + var path = tmp.File("snapshot.json"); + var sut = new SnapshotPersistence(path, () => fixedUtc); + + sut.Save(BuildState()); + Assert.True(File.Exists(path)); + + sut.Quarantine("checksum-mismatch"); + + Assert.False(File.Exists(path)); + + var quarantineDir = Path.Combine(tmp.Path, ".quarantine"); + Assert.True(Directory.Exists(quarantineDir)); + var bucket = Directory.EnumerateDirectories(quarantineDir).Single(); + Assert.Contains("20260420T143045123Z", bucket); + + Assert.True(File.Exists(Path.Combine(bucket, "snapshot.json"))); + Assert.Equal("checksum-mismatch", File.ReadAllText(Path.Combine(bucket, "reason.txt"))); + } + + [Fact] + public void Save_is_atomic_via_tempfile_no_half_written_snapshot_visible() + { + // Plan §2.7 — Save() tempfile+rename pattern. After Save, the on-disk file is + // either the new snapshot or absent; never a partial byte-sequence of the new one. + using var tmp = new TempDirectory(); + var path = tmp.File("snapshot.json"); + var sut = new SnapshotPersistence(path); + + sut.Save(BuildState("S1")); + var firstBytes = File.ReadAllBytes(path).Length; + Assert.True(firstBytes > 0); + + sut.Save(BuildState("S2")); + + // Old .tmp file must be gone (rename consumed it). + Assert.False(File.Exists(path + ".tmp")); + + // Loaded snapshot reflects the latest save — no partial overwrite. + var loaded = sut.Load(); + Assert.Equal("S2", loaded!.SessionId); + } + + [Fact] + public void Roundtrip_preserves_active_deadlines_list() + { + using var tmp = new TempDirectory(); + var sut = new SnapshotPersistence(tmp.File("snapshot.json")); + + var stateWithDeadlines = DecisionState.CreateInitial("S1", "T1") + .ToBuilder() + .AddDeadline(new ActiveDeadline( + name: "hello_safety", + dueAtUtc: new DateTime(2026, 4, 20, 10, 30, 0, DateTimeKind.Utc), + firesSignalKind: DecisionSignalKind.DeadlineFired)) + .Build(); + + sut.Save(stateWithDeadlines); + + var loaded = sut.Load()!; + Assert.Single(loaded.Deadlines); + Assert.Equal("hello_safety", loaded.Deadlines[0].Name); + Assert.Equal(DecisionSignalKind.DeadlineFired, loaded.Deadlines[0].FiresSignalKind); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/AdminActionOutcomeMappingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/AdminActionOutcomeMappingTests.cs new file mode 100644 index 00000000..9c35de1d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/AdminActionOutcomeMappingTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Program +{ + /// + /// Codex Finding 2 regression gate: verifies that the terminate_session ServerAction's + /// adminOutcome param is mapped onto + /// instead of being silently hard-coded to . + /// Portal Mark-Succeeded traffic is directly exercised here. + /// + public sealed class AdminActionOutcomeMappingTests + { + [Fact] + public void adminOutcome_Succeeded_maps_to_Succeeded() + { + var result = global::AutopilotMonitor.Agent.V2.Program.MapAdminOutcome( + new Dictionary { ["adminOutcome"] = "Succeeded" }); + Assert.Equal(EnrollmentTerminationOutcome.Succeeded, result); + } + + [Theory] + [InlineData("succeeded")] + [InlineData("SUCCEEDED")] + [InlineData("SuCcEeDeD")] + public void adminOutcome_Succeeded_is_case_insensitive(string value) + { + var result = global::AutopilotMonitor.Agent.V2.Program.MapAdminOutcome( + new Dictionary { ["adminOutcome"] = value }); + Assert.Equal(EnrollmentTerminationOutcome.Succeeded, result); + } + + [Fact] + public void adminOutcome_Failed_maps_to_Failed() + { + var result = global::AutopilotMonitor.Agent.V2.Program.MapAdminOutcome( + new Dictionary { ["adminOutcome"] = "Failed" }); + Assert.Equal(EnrollmentTerminationOutcome.Failed, result); + } + + [Theory] + [InlineData("TimedOut")] + [InlineData("weird-future-value")] + [InlineData("")] + public void Non_Succeeded_adminOutcome_defaults_to_Failed(string value) + { + // Failure-safe default: any value that isn't an explicit "Succeeded" (case-insensitive) + // maps to Failed. This means a future outcome value the agent doesn't know yet won't + // accidentally be treated as success. + var result = global::AutopilotMonitor.Agent.V2.Program.MapAdminOutcome( + new Dictionary { ["adminOutcome"] = value }); + Assert.Equal(EnrollmentTerminationOutcome.Failed, result); + } + + [Fact] + public void Missing_adminOutcome_param_maps_to_Failed() + { + // A kill-signal-driven terminate_session sets origin=kill_signal but no adminOutcome — + // that's an expected Failed path (hard termination). + var result = global::AutopilotMonitor.Agent.V2.Program.MapAdminOutcome( + new Dictionary { ["origin"] = "kill_signal" }); + Assert.Equal(EnrollmentTerminationOutcome.Failed, result); + } + + [Fact] + public void Null_params_dictionary_maps_to_Failed() + { + var result = global::AutopilotMonitor.Agent.V2.Program.MapAdminOutcome(null); + Assert.Equal(EnrollmentTerminationOutcome.Failed, result); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs new file mode 100644 index 00000000..3a0d2daa --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/ProgramGuardsTests.cs @@ -0,0 +1,244 @@ +using System; +using System.IO; +using AutopilotMonitor.Agent.V2; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using Newtonsoft.Json; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Program +{ + /// + /// Tests for the M4.6.α startup guards in Program.Guards.cs. These helpers are + /// internal static; the V2 exe exposes its internals to this test assembly via + /// [InternalsVisibleTo]. + /// + public sealed class ProgramGuardsTests + { + private static DateTime ValidUtc => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + + private static AgentLogger NewLogger(string path) + => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info); + + // ================================================================= DetectPreviousExit + + [Fact] + public void DetectPreviousExit_reports_first_run_when_nothing_is_present() + { + using var tmp = new TempDirectory(); + var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, Path.Combine(tmp.Path, "logs")); + Assert.Equal("first_run", summary.ExitType); + Assert.Null(summary.CrashExceptionType); + Assert.Null(summary.LastBootUtc); + } + + [Fact] + public void DetectPreviousExit_reports_clean_when_marker_exists_and_deletes_it() + { + using var tmp = new TempDirectory(); + var markerPath = Path.Combine(tmp.Path, "clean-exit.marker"); + File.WriteAllText(markerPath, ValidUtc.ToString("O")); + + var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, Path.Combine(tmp.Path, "logs")); + + Assert.Equal("clean", summary.ExitType); + Assert.False(File.Exists(markerPath)); + } + + [Fact] + public void DetectPreviousExit_reports_exception_crash_and_extracts_exception_type() + { + using var tmp = new TempDirectory(); + var logDir = Path.Combine(tmp.Path, "logs"); + Directory.CreateDirectory(logDir); + var crashPath = Path.Combine(logDir, "crash_20260421_100000.log"); + File.WriteAllText(crashPath, "[2026-04-21T10:00:00Z] FATAL: InvalidOperationException: simulated crash"); + + var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, logDir); + + Assert.Equal("exception_crash", summary.ExitType); + Assert.Equal("InvalidOperationException", summary.CrashExceptionType); + Assert.False(File.Exists(crashPath)); + } + + [Fact] + public void DetectPreviousExit_reports_hard_kill_when_session_exists_without_marker() + { + using var tmp = new TempDirectory(); + new SessionIdPersistence(tmp.Path).GetOrCreate(); + + var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, Path.Combine(tmp.Path, "logs")); + + Assert.True(summary.ExitType == "hard_kill" || summary.ExitType == "reboot_kill", + $"Expected hard_kill or reboot_kill, got {summary.ExitType}."); + } + + // ================================================================= Emergency break + + [Fact] + public void CheckSessionAgeEmergencyBreak_returns_false_when_session_age_within_limit() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + var persistence = new SessionIdPersistence(tmp.Path); + persistence.GetOrCreate(); + persistence.SaveSessionCreatedAt(DateTime.UtcNow.AddHours(-1)); + + var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak( + dataDirectory: tmp.Path, + stateDirectory: Path.Combine(tmp.Path, "State"), + absoluteMaxSessionHours: 48, + selfDestructOnComplete: false, + cleanupServiceFactory: null, + logger: logger, + consoleMode: false); + + Assert.False(tripped); + } + + [Fact] + public void CheckSessionAgeEmergencyBreak_trips_when_session_exceeds_limit() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + var persistence = new SessionIdPersistence(tmp.Path); + persistence.GetOrCreate(); + persistence.SaveSessionCreatedAt(DateTime.UtcNow.AddHours(-100)); + + var stateDir = Path.Combine(tmp.Path, "State"); + + var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak( + dataDirectory: tmp.Path, + stateDirectory: stateDir, + absoluteMaxSessionHours: 48, + selfDestructOnComplete: false, + cleanupServiceFactory: null, + logger: logger, + consoleMode: false); + + Assert.True(tripped); + // Marker must have been written so the next restart exits cleanly. + Assert.True(File.Exists(Path.Combine(stateDir, "enrollment-complete.marker"))); + // Session must have been cleared. + Assert.False(persistence.SessionExists()); + } + + [Fact] + public void CheckSessionAgeEmergencyBreak_skips_whiteglove_resume_sessions() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + var persistence = new SessionIdPersistence(tmp.Path); + persistence.GetOrCreate(); + persistence.SaveSessionCreatedAt(DateTime.UtcNow.AddHours(-999)); + File.WriteAllText(Path.Combine(tmp.Path, "whiteglove.complete"), "1"); + + var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak( + dataDirectory: tmp.Path, + stateDirectory: Path.Combine(tmp.Path, "State"), + absoluteMaxSessionHours: 48, + selfDestructOnComplete: false, + cleanupServiceFactory: null, + logger: logger, + consoleMode: false); + + Assert.False(tripped); + } + + [Fact] + public void CheckSessionAgeEmergencyBreak_initialises_missing_session_created_on_first_miss() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + + // Session exists but session.created does not — simulates older persistence on upgrade. + File.WriteAllText(Path.Combine(tmp.Path, "session.id"), Guid.NewGuid().ToString()); + + var tripped = AutopilotMonitor.Agent.V2.Program.CheckSessionAgeEmergencyBreak( + dataDirectory: tmp.Path, + stateDirectory: Path.Combine(tmp.Path, "State"), + absoluteMaxSessionHours: 48, + selfDestructOnComplete: false, + cleanupServiceFactory: null, + logger: logger, + consoleMode: false); + + Assert.False(tripped); + Assert.True(File.Exists(Path.Combine(tmp.Path, "session.created"))); + } + + // ================================================================= Bootstrap config IO + + [Fact] + public void TryReadBootstrapConfig_returns_null_when_missing() + { + using var tmp = new TempDirectory(); + Assert.Null(AutopilotMonitor.Agent.V2.Program.TryReadBootstrapConfig(tmp.Path, NewLogger(tmp.Path))); + } + + [Fact] + public void TryReadBootstrapConfig_reads_tenant_and_token() + { + using var tmp = new TempDirectory(); + var cfg = new BootstrapConfigFile { BootstrapToken = "tok-1", TenantId = "t-1" }; + File.WriteAllText(Path.Combine(tmp.Path, "bootstrap-config.json"), JsonConvert.SerializeObject(cfg)); + + var read = AutopilotMonitor.Agent.V2.Program.TryReadBootstrapConfig(tmp.Path, NewLogger(tmp.Path)); + + Assert.NotNull(read); + Assert.Equal("tok-1", read!.BootstrapToken); + Assert.Equal("t-1", read.TenantId); + } + + [Fact] + public void TryReadBootstrapConfig_returns_null_on_corrupt_json() + { + using var tmp = new TempDirectory(); + File.WriteAllText(Path.Combine(tmp.Path, "bootstrap-config.json"), "{ bogus"); + Assert.Null(AutopilotMonitor.Agent.V2.Program.TryReadBootstrapConfig(tmp.Path, NewLogger(tmp.Path))); + } + + [Fact] + public void TryReadAwaitEnrollmentConfig_round_trips() + { + using var tmp = new TempDirectory(); + var cfg = new AwaitEnrollmentConfigFile { TimeoutMinutes = 120 }; + File.WriteAllText(Path.Combine(tmp.Path, "await-enrollment.json"), JsonConvert.SerializeObject(cfg)); + + var read = AutopilotMonitor.Agent.V2.Program.TryReadAwaitEnrollmentConfig(tmp.Path, NewLogger(tmp.Path)); + + Assert.NotNull(read); + Assert.Equal(120, read!.TimeoutMinutes); + } + + [Fact] + public void DeleteAwaitEnrollmentConfig_removes_file() + { + using var tmp = new TempDirectory(); + var path = Path.Combine(tmp.Path, "await-enrollment.json"); + File.WriteAllText(path, "{}"); + + AutopilotMonitor.Agent.V2.Program.DeleteAwaitEnrollmentConfig(tmp.Path, NewLogger(tmp.Path)); + Assert.False(File.Exists(path)); + } + + // ================================================================= Crash log writer + + [Fact] + public void WriteCrashLog_writes_crash_file_with_fatal_prefix() + { + using var tmp = new TempDirectory(); + var logDir = Path.Combine(tmp.Path, "logs"); + + AutopilotMonitor.Agent.V2.Program.WriteCrashLog(logDir, new InvalidOperationException("boom")); + + var files = Directory.GetFiles(logDir, "crash_*.log"); + Assert.Single(files); + var content = File.ReadAllText(files[0]); + Assert.Contains("FATAL:", content); + Assert.Contains("InvalidOperationException", content); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs new file mode 100644 index 00000000..0d037a73 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/AgentAnalyzerManagerTests.cs @@ -0,0 +1,190 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers; +using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime +{ + /// + /// M4.6.δ — AgentAnalyzerManager gating + lifecycle behaviour. The three real analyzers + /// are out-of-scope here (covered by their own tests in Agent.Core.Tests + V2 port-parity + /// sharing the same implementation files) — we verify the manager's own contract. + /// + public sealed class AgentAnalyzerManagerTests + { + private static AgentConfiguration Config() => + new AgentConfiguration { SessionId = "S1", TenantId = "T1" }; + + private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info); + + [Fact] + public void Initialize_respects_toggle_all_off_registers_no_analyzers() + { + using var tmp = new TempDirectory(); + var sut = new AgentAnalyzerManager( + Config(), NewLogger(tmp.Path), _ => { }, + new AnalyzerConfiguration + { + EnableLocalAdminAnalyzer = false, + EnableSoftwareInventoryAnalyzer = false, + EnableIntegrityBypassAnalyzer = false, + }); + + sut.Initialize(); + Assert.Empty(sut.Analyzers); + } + + [Fact] + public void Initialize_registers_only_enabled_analyzers() + { + using var tmp = new TempDirectory(); + var sut = new AgentAnalyzerManager( + Config(), NewLogger(tmp.Path), _ => { }, + new AnalyzerConfiguration + { + EnableLocalAdminAnalyzer = true, + EnableSoftwareInventoryAnalyzer = false, + EnableIntegrityBypassAnalyzer = true, + }); + + sut.Initialize(); + var names = sut.Analyzers.Select(a => a.Name).ToList(); + Assert.Contains("LocalAdminAnalyzer", names); + Assert.DoesNotContain("SoftwareInventoryAnalyzer", names); + Assert.Contains("IntegrityBypassAnalyzer", names); + Assert.Equal(2, sut.Analyzers.Count); + } + + [Fact] + public void Initialize_tolerates_null_analyzer_config() + { + using var tmp = new TempDirectory(); + var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), _ => { }, analyzerConfig: null); + + sut.Initialize(); + // Default AnalyzerConfiguration enables LocalAdmin + IntegrityBypass, disables SoftwareInventory. + var names = sut.Analyzers.Select(a => a.Name).ToList(); + Assert.Contains("LocalAdminAnalyzer", names); + Assert.Contains("IntegrityBypassAnalyzer", names); + Assert.DoesNotContain("SoftwareInventoryAnalyzer", names); + } + + [Fact] + public void Initialize_is_idempotent_across_repeated_calls() + { + using var tmp = new TempDirectory(); + var sut = new AgentAnalyzerManager( + Config(), NewLogger(tmp.Path), _ => { }, + new AnalyzerConfiguration { EnableLocalAdminAnalyzer = true }); + + sut.Initialize(); + var first = sut.Analyzers.Count; + sut.Initialize(); + Assert.Equal(first, sut.Analyzers.Count); + } + + [Fact] + public void Ctor_rejects_null_required_dependencies() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + Assert.Throws(() => + new AgentAnalyzerManager(null!, logger, _ => { }, null)); + Assert.Throws(() => + new AgentAnalyzerManager(Config(), null!, _ => { }, null)); + Assert.Throws(() => + new AgentAnalyzerManager(Config(), logger, null!, null)); + } + + // ---------------------------------------------------------------- Fake-analyzer lifecycle + +#pragma warning disable CS0649 // ThrowOnStartup is settable by tests; keep the symmetric pair. + private sealed class FakeAnalyzer : IAgentAnalyzer + { + public string Name => "FakeAnalyzer"; + public int StartupCalls; + public int ShutdownCalls; + public Exception? ThrowOnStartup; + public Exception? ThrowOnShutdown; + + public void AnalyzeAtStartup() + { + Interlocked.Increment(ref StartupCalls); + if (ThrowOnStartup != null) throw ThrowOnStartup; + } + + public void AnalyzeAtShutdown() + { + Interlocked.Increment(ref ShutdownCalls); + if (ThrowOnShutdown != null) throw ThrowOnShutdown; + } + } +#pragma warning restore CS0649 + + /// + /// Test-only harness: injects a fake analyzer via reflection so we do not have to run + /// the real ones (which touch WMI / registry / file system). + /// + private static void InjectAnalyzer(AgentAnalyzerManager sut, IAgentAnalyzer fake) + { + var field = typeof(AgentAnalyzerManager).GetField("_analyzers", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var list = (System.Collections.Generic.List)field!.GetValue(sut)!; + list.Clear(); + list.Add(fake); + // Mark as initialised so the manager does not reset the list on RunStartup/RunShutdown. + var initField = typeof(AgentAnalyzerManager).GetField("_initialised", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + initField!.SetValue(sut, true); + } + + [Fact] + public void RunShutdown_invokes_AnalyzeAtShutdown_on_every_analyzer() + { + using var tmp = new TempDirectory(); + var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), _ => { }, new AnalyzerConfiguration()); + var fake = new FakeAnalyzer(); + InjectAnalyzer(sut, fake); + + sut.RunShutdown(); + Assert.Equal(1, fake.ShutdownCalls); + } + + [Fact] + public void RunShutdown_swallows_analyzer_exceptions_and_continues() + { + using var tmp = new TempDirectory(); + var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), _ => { }, new AnalyzerConfiguration()); + var fake = new FakeAnalyzer { ThrowOnShutdown = new InvalidOperationException("boom") }; + InjectAnalyzer(sut, fake); + + // Must not throw. + var ex = Record.Exception(() => sut.RunShutdown()); + Assert.Null(ex); + Assert.Equal(1, fake.ShutdownCalls); + } + + [Fact] + public void RunShutdown_is_safe_on_empty_analyzer_list() + { + using var tmp = new TempDirectory(); + var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), _ => { }, + new AnalyzerConfiguration + { + EnableLocalAdminAnalyzer = false, + EnableSoftwareInventoryAnalyzer = false, + EnableIntegrityBypassAnalyzer = false, + }); + sut.Initialize(); + var ex = Record.Exception(() => sut.RunShutdown()); + Assert.Null(ex); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs new file mode 100644 index 00000000..a6722f01 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Runtime/StartupEnvironmentProbesTests.cs @@ -0,0 +1,182 @@ +#nullable enable +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime +{ + /// + /// M4.6.γ — event-shape tests for the Legacy-parity startup probes. We cover the pure event + /// builders directly; the end-to-end probe runner itself needs live HTTP/UDP/tzutil (no value + /// in unit testing — excercised via the V2-agent test-VM smoke run deferred to Follow-Up). + /// + public sealed class StartupEnvironmentProbesTests + { + private static AgentConfiguration Config(bool geoEnabled = true, bool tzAutoSet = false, string? ntpServer = null) => + new AgentConfiguration + { + SessionId = "S1", + TenantId = "T1", + EnableGeoLocation = geoEnabled, + EnableTimezoneAutoSet = tzAutoSet, + NtpServer = ntpServer, + }; + + // ================================================================= Geo success + + [Fact] + public void BuildGeoEvent_returns_info_device_location_event_with_all_fields() + { + var cfg = Config(); + var loc = new GeoLocationResult + { + Country = "DE", + Region = "BW", + City = "Stuttgart", + Loc = "48.7,9.1", + Timezone = "Europe/Berlin", + Source = "ipinfo.io", + }; + + var evt = StartupEnvironmentProbes.BuildGeoEvent(cfg, loc); + + Assert.Equal("device_location", evt.EventType); + Assert.Equal(EventSeverity.Info, evt.Severity); + Assert.Equal("Network", evt.Source); + Assert.Equal("S1", evt.SessionId); + Assert.Equal("T1", evt.TenantId); + Assert.True(evt.ImmediateUpload); + Assert.Contains("Stuttgart", evt.Message); + Assert.Equal("DE", evt.Data["country"]); + Assert.Equal("ipinfo.io", evt.Data["source"]); + } + + // ================================================================= Geo failure + + [Fact] + public void BuildGeoFailureEvent_returns_warning_agent_trace_with_provider_errors() + { + var cfg = Config(); + var attempt = new GeoLocationAttemptResult + { + PrimaryError = "connection refused", + PrimaryRetryError = "DNS failure", + FallbackError = "timeout", + }; + + var evt = StartupEnvironmentProbes.BuildGeoFailureEvent(cfg, attempt); + + Assert.Equal("agent_trace", evt.EventType); + Assert.Equal(EventSeverity.Warning, evt.Severity); + Assert.Equal("geo_location_failed", evt.Data["decision"]); + Assert.Equal("connection refused", evt.Data["primaryError"]); + Assert.Equal("DNS failure", evt.Data["primaryRetryError"]); + Assert.Equal("timeout", evt.Data["fallbackError"]); + } + + [Fact] + public void BuildGeoFailureEvent_handles_null_attempt_gracefully() + { + var evt = StartupEnvironmentProbes.BuildGeoFailureEvent(Config(), attempt: null); + + Assert.Equal("agent_trace", evt.EventType); + Assert.Equal("unknown", evt.Data["primaryError"]); + Assert.Equal("unknown", evt.Data["primaryRetryError"]); + Assert.Equal("unknown", evt.Data["fallbackError"]); + } + + // ================================================================= Timezone + + [Fact] + public void BuildTimezoneEvent_info_on_success() + { + var tz = new TimezoneSetResult + { + Success = true, + IanaTimezone = "Europe/Berlin", + WindowsTimezoneId = "W. Europe Standard Time", + PreviousTimezone = "UTC", + }; + + var evt = StartupEnvironmentProbes.BuildTimezoneEvent(Config(), tz); + + Assert.Equal("timezone_auto_set", evt.EventType); + Assert.Equal(EventSeverity.Info, evt.Severity); + Assert.Contains("W. Europe Standard Time", evt.Message); + Assert.Equal(true, evt.Data["success"]); + } + + [Fact] + public void BuildTimezoneEvent_warning_on_failure() + { + var tz = new TimezoneSetResult + { + Success = false, + IanaTimezone = "Europe/Berlin", + Error = "tzutil failed", + }; + + var evt = StartupEnvironmentProbes.BuildTimezoneEvent(Config(), tz); + + Assert.Equal(EventSeverity.Warning, evt.Severity); + Assert.Contains("tzutil failed", evt.Message); + Assert.Equal("tzutil failed", evt.Data["error"]); + } + + // ================================================================= NTP + + [Fact] + public void BuildNtpEvent_info_for_small_offset() + { + var r = new NtpCheckResult + { + Success = true, + OffsetSeconds = 2.3, + NtpTime = new System.DateTime(2026, 4, 21, 10, 0, 0, System.DateTimeKind.Utc), + LocalTime = new System.DateTime(2026, 4, 21, 10, 0, 2, System.DateTimeKind.Utc), + }; + + var evt = StartupEnvironmentProbes.BuildNtpEvent(Config(), "time.windows.com", r); + + Assert.Equal("ntp_time_check", evt.EventType); + Assert.Equal(EventSeverity.Info, evt.Severity); + Assert.Equal("time.windows.com", evt.Data["ntpServer"]); + Assert.Equal(2.3, evt.Data["offsetSeconds"]); + } + + [Fact] + public void BuildNtpEvent_warning_for_large_offset() + { + var r = new NtpCheckResult + { + Success = true, + OffsetSeconds = 120.0, + NtpTime = System.DateTime.UtcNow, + LocalTime = System.DateTime.UtcNow, + }; + + var evt = StartupEnvironmentProbes.BuildNtpEvent(Config(), "time.windows.com", r); + + Assert.Equal(EventSeverity.Warning, evt.Severity); + } + + [Fact] + public void BuildNtpEvent_warning_on_failure_with_error_detail() + { + var r = new NtpCheckResult + { + Success = false, + Error = "DNS resolution failed", + }; + + var evt = StartupEnvironmentProbes.BuildNtpEvent(Config(), "time.windows.com", r); + + Assert.Equal(EventSeverity.Warning, evt.Severity); + Assert.Contains("DNS resolution failed", evt.Message); + Assert.Equal("DNS resolution failed", evt.Data["error"]); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs new file mode 100644 index 00000000..f677276d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionIdPersistenceTests.cs @@ -0,0 +1,214 @@ +using System; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Security +{ + public sealed class SessionIdPersistenceTests + { + [Fact] + public void GetOrCreate_without_existing_file_creates_a_new_guid() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + + var id = sut.GetOrCreate(); + + Assert.True(Guid.TryParse(id, out _)); + Assert.True(File.Exists(Path.Combine(tmp.Path, "session.id"))); + Assert.Equal(id, File.ReadAllText(Path.Combine(tmp.Path, "session.id"))); + } + + [Fact] + public void GetOrCreate_returns_same_id_on_repeated_calls() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + + var first = sut.GetOrCreate(); + var second = sut.GetOrCreate(); + var third = sut.GetOrCreate(); + + Assert.Equal(first, second); + Assert.Equal(first, third); + } + + [Fact] + public void GetOrCreate_new_instance_resumes_persisted_session() + { + using var tmp = new TempDirectory(); + var first = new SessionIdPersistence(tmp.Path).GetOrCreate(); + var resumed = new SessionIdPersistence(tmp.Path).GetOrCreate(); + + Assert.Equal(first, resumed); + } + + [Fact] + public void GetOrCreate_regenerates_when_file_content_is_corrupt() + { + using var tmp = new TempDirectory(); + File.WriteAllText(Path.Combine(tmp.Path, "session.id"), "not-a-guid"); + + var id = new SessionIdPersistence(tmp.Path).GetOrCreate(); + + Assert.True(Guid.TryParse(id, out _)); + Assert.NotEqual("not-a-guid", id); + } + + [Fact] + public void Delete_clears_persisted_session_so_next_call_regenerates() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + var first = sut.GetOrCreate(); + + sut.Delete(); + Assert.False(File.Exists(Path.Combine(tmp.Path, "session.id"))); + + var second = sut.GetOrCreate(); + Assert.NotEqual(first, second); + } + + [Fact] + public void Ctor_creates_data_directory_when_missing() + { + using var tmp = new TempDirectory(); + var nested = Path.Combine(tmp.Path, "nested", "state"); + Assert.False(Directory.Exists(nested)); + + var sut = new SessionIdPersistence(nested); + var id = sut.GetOrCreate(); + + Assert.True(Directory.Exists(nested)); + Assert.True(Guid.TryParse(id, out _)); + } + + [Fact] + public void Ctor_rejects_null_or_empty_data_directory() + { + Assert.Throws(() => new SessionIdPersistence(null!)); + Assert.Throws(() => new SessionIdPersistence("")); + Assert.Throws(() => new SessionIdPersistence(" ")); + } + + [Fact] + public void GetOrCreate_with_logger_emits_resume_debug_on_second_call() + { + using var tmp = new TempDirectory(); + var logDir = Path.Combine(tmp.Path, "logs"); + var logger = new AgentLogger(logDir, AgentLogLevel.Debug); + + var first = new SessionIdPersistence(tmp.Path).GetOrCreate(logger); + var second = new SessionIdPersistence(tmp.Path).GetOrCreate(logger); + + Assert.Equal(first, second); + } + + // --------------------------------------------------------------- M4.6.α additions + + [Fact] + public void GetOrCreate_also_writes_session_created_timestamp() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + + sut.GetOrCreate(); + + var createdPath = Path.Combine(tmp.Path, "session.created"); + Assert.True(File.Exists(createdPath)); + var parsed = sut.LoadSessionCreatedAt(); + Assert.NotNull(parsed); + Assert.Equal(DateTimeKind.Utc, parsed!.Value.Kind); + } + + [Fact] + public void LoadSessionCreatedAt_returns_null_when_file_missing() + { + using var tmp = new TempDirectory(); + Assert.Null(new SessionIdPersistence(tmp.Path).LoadSessionCreatedAt()); + } + + [Fact] + public void LoadSessionCreatedAt_returns_null_when_file_is_corrupt() + { + using var tmp = new TempDirectory(); + File.WriteAllText(Path.Combine(tmp.Path, "session.created"), "not-a-date"); + Assert.Null(new SessionIdPersistence(tmp.Path).LoadSessionCreatedAt()); + } + + [Fact] + public void GetOrCreate_initialises_missing_session_created_on_recovery() + { + using var tmp = new TempDirectory(); + + // Simulate a session.id written by an older persistence without a companion timestamp. + var existingId = Guid.NewGuid().ToString(); + File.WriteAllText(Path.Combine(tmp.Path, "session.id"), existingId); + + var sut = new SessionIdPersistence(tmp.Path); + var resumed = sut.GetOrCreate(); + + Assert.Equal(existingId, resumed); + Assert.True(File.Exists(Path.Combine(tmp.Path, "session.created"))); + Assert.NotNull(sut.LoadSessionCreatedAt()); + } + + [Fact] + public void SaveSessionCreatedAt_persists_utc_and_roundtrips() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + + var now = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + sut.SaveSessionCreatedAt(now); + + var loaded = sut.LoadSessionCreatedAt(); + Assert.Equal(now, loaded); + } + + [Fact] + public void SessionExists_reflects_session_id_presence() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + + Assert.False(sut.SessionExists()); + + sut.GetOrCreate(); + Assert.True(sut.SessionExists()); + + sut.Delete(); + Assert.False(sut.SessionExists()); + } + + [Fact] + public void IsWhiteGloveResume_returns_true_when_marker_present() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + + Assert.False(sut.IsWhiteGloveResume()); + + File.WriteAllText(Path.Combine(tmp.Path, "whiteglove.complete"), "1"); + Assert.True(sut.IsWhiteGloveResume()); + } + + [Fact] + public void Delete_clears_session_id_created_timestamp_and_whiteglove_marker() + { + using var tmp = new TempDirectory(); + var sut = new SessionIdPersistence(tmp.Path); + sut.GetOrCreate(); + File.WriteAllText(Path.Combine(tmp.Path, "whiteglove.complete"), "1"); + + sut.Delete(); + + Assert.False(File.Exists(Path.Combine(tmp.Path, "session.id"))); + Assert.False(File.Exists(Path.Combine(tmp.Path, "session.created"))); + Assert.False(File.Exists(Path.Combine(tmp.Path, "whiteglove.complete"))); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs new file mode 100644 index 00000000..256afed7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/AadJoinWatcherAdapterTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class AadJoinWatcherAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public AadJoinWatcher Watcher { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Watcher = new AadJoinWatcher(Logger); + } + + public void Dispose() + { + Watcher.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void TriggerFromTest_emits_AadUserJoinedLate_with_domain_not_full_email() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerFromTest("alice@contoso.com", "abcd1234"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.AadUserJoinedLate, posted.Kind); + Assert.Equal("AadJoinWatcher", posted.SourceOrigin); + Assert.Equal(EvidenceKind.Derived, posted.Evidence.Kind); + Assert.Equal("contoso.com", posted.Payload!["userDomain"]); + Assert.Equal("true", posted.Payload["hasThumbprint"]); + + // PII guard: full email MUST NOT appear in payload. + Assert.DoesNotContain("alice@", string.Concat(posted.Payload.Values)); + } + + [Fact] + public void Part2Mode_emits_UserAadSignInComplete_kind() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock, part2Mode: true); + + adapter.TriggerFromTest("bob@tailspin.com", "x"); + + var posted = f.Ingress.Posted.Single(); + Assert.Equal(DecisionSignalKind.UserAadSignInComplete, posted.Kind); + Assert.Contains("Post-reboot", posted.Evidence.Summary); + } + + [Fact] + public void Empty_thumbprint_is_reflected_in_payload() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerFromTest("user@example.org", ""); + + Assert.Equal("false", f.Ingress.Posted[0].Payload!["hasThumbprint"]); + } + + [Fact] + public void Malformed_email_falls_back_to_unknown_domain() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerFromTest("no-at-sign", "x"); + + Assert.Equal("unknown", f.Ingress.Posted[0].Payload!["userDomain"]); + } + + [Fact] + public void Duplicate_trigger_is_deduplicated() + { + using var f = new Fixture(); + using var adapter = new AadJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + adapter.TriggerFromTest("alice@contoso.com", "x"); + adapter.TriggerFromTest("alice@contoso.com", "x"); + + Assert.Single(f.Ingress.Posted); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws(() => new AadJoinWatcherAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws(() => new AadJoinWatcherAdapter(f.Watcher, null!, f.Clock)); + Assert.Throws(() => new AadJoinWatcherAdapter(f.Watcher, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/DesktopArrivalDetectorAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/DesktopArrivalDetectorAdapterTests.cs new file mode 100644 index 00000000..e5db2c74 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/DesktopArrivalDetectorAdapterTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class DesktopArrivalDetectorAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public DesktopArrivalDetector Detector { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Detector = new DesktopArrivalDetector(Logger); + } + + public void Dispose() + { + Detector.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void TriggerFromTest_emits_DesktopArrived_signal_with_derived_evidence() + { + using var f = new Fixture(); + using var adapter = new DesktopArrivalDetectorAdapter(f.Detector, f.Ingress, f.Clock); + + adapter.TriggerFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.DesktopArrived, posted.Kind); + Assert.Equal(Fixed, posted.OccurredAtUtc); + Assert.Equal("DesktopArrivalDetector", posted.SourceOrigin); + Assert.Equal(EvidenceKind.Derived, posted.Evidence.Kind); + Assert.Equal("desktop-arrival-detector-v1", posted.Evidence.Identifier); + } + + [Fact] + public void Part2Mode_emits_DesktopArrivedPart2_kind() + { + using var f = new Fixture(); + using var adapter = new DesktopArrivalDetectorAdapter(f.Detector, f.Ingress, f.Clock, part2Mode: true); + + adapter.TriggerFromTest(); + + var posted = f.Ingress.Posted.Single(); + Assert.Equal(DecisionSignalKind.DesktopArrivedPart2, posted.Kind); + Assert.Contains("Part 2", posted.Evidence.Summary); + } + + [Fact] + public void Duplicate_trigger_is_deduplicated() + { + using var f = new Fixture(); + using var adapter = new DesktopArrivalDetectorAdapter(f.Detector, f.Ingress, f.Clock); + + adapter.TriggerFromTest(); + adapter.TriggerFromTest(); + adapter.TriggerFromTest(); + + Assert.Single(f.Ingress.Posted); + } + + [Fact] + public void Dispose_unsubscribes_from_detector_event() + { + using var f = new Fixture(); + var adapter = new DesktopArrivalDetectorAdapter(f.Detector, f.Ingress, f.Clock); + adapter.Dispose(); + + // After Dispose, the adapter is still functional via TriggerFromTest (test hook + // doesn't go through the event), but the actual event is no longer subscribed. + // We can't observe unsubscription directly without reflection; assert via Disposal-OK. + // No exception during Dispose is the contract. + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws( + () => new DesktopArrivalDetectorAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws( + () => new DesktopArrivalDetectorAdapter(f.Detector, null!, f.Clock)); + Assert.Throws( + () => new DesktopArrivalDetectorAdapter(f.Detector, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterHelloOutcomeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterHelloOutcomeTests.cs new file mode 100644 index 00000000..3ca04e40 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterHelloOutcomeTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + /// + /// Plan §4.x M4.4.4 — the adapter's ReadHelloOutcome now forwards from + /// (which itself delegates to the inner + /// HelloTracker) instead of hardcoded "unknown". Verified via an internal + /// test hook that exercises the full event-handler emit path without driving the inner + /// HelloTracker's live event sources. + /// + public sealed class EspAndHelloTrackerAdapterHelloOutcomeTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public EspAndHelloTracker Coordinator { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Coordinator = new EspAndHelloTracker( + sessionId: "S1", + tenantId: "T1", + onEventCollected: _ => { }, + logger: Logger); + } + + public void Dispose() + { + Coordinator.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void No_inner_HelloTracker_yet_started_falls_back_to_unknown() + { + // Without Coordinator.Start(), the inner HelloTracker is null → HelloOutcome is null + // → adapter falls back to "unknown". + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + + adapter.TriggerHelloFromCoordinatorPropertyForTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.HelloResolved, posted.Kind); + Assert.Equal("unknown", posted.Payload![SignalPayloadKeys.HelloOutcome]); + } + + [Fact] + public void Coordinator_HelloOutcome_set_via_ForceMarkHelloCompleted_is_forwarded_to_signal() + { + // Start() initializes the inner HelloTracker; ForceMarkHelloCompleted sets + // HelloOutcome without firing the HelloCompleted event (used as a safety timeout + // path in production). The adapter then reads the forwarded property — M4.4.4 fix. + using var f = new Fixture(); + f.Coordinator.Start(); + try + { + f.Coordinator.ForceMarkHelloCompleted("completed"); + + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + adapter.TriggerHelloFromCoordinatorPropertyForTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal("completed", posted.Payload![SignalPayloadKeys.HelloOutcome]); + Assert.Contains("completed", posted.Evidence.Summary); + } + finally + { + // Tear down inner tracker timers / event-log watchers. + f.Coordinator.Dispose(); + } + } + + [Fact] + public void Custom_outcome_strings_round_trip_through_the_signal_payload() + { + // Any non-null HelloOutcome value should flow through — the adapter doesn't enforce + // a specific enum, it just forwards. Verifies the M4.4.4 path doesn't insert a + // mapping layer (signal payload carries the raw coordinator value). + using var f = new Fixture(); + f.Coordinator.Start(); + try + { + f.Coordinator.ForceMarkHelloCompleted("timeout"); + + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock, part2Mode: true); + adapter.TriggerHelloFromCoordinatorPropertyForTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.HelloResolvedPart2, posted.Kind); + Assert.Equal("timeout", posted.Payload![SignalPayloadKeys.HelloOutcome]); + } + finally + { + f.Coordinator.Dispose(); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs new file mode 100644 index 00000000..faa445c3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs @@ -0,0 +1,162 @@ +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class EspAndHelloTrackerAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public EspAndHelloTracker Coordinator { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Coordinator = new EspAndHelloTracker( + sessionId: "S1", + tenantId: "T1", + onEventCollected: _ => { }, + logger: Logger); + } + + public void Dispose() + { + Coordinator.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void HelloEvent_emits_HelloResolved_with_outcome_fallback_unknown() + { + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + + adapter.TriggerHelloFromTest(null); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.HelloResolved, posted.Kind); + Assert.Equal("EspAndHelloTracker", posted.SourceOrigin); + Assert.Equal("unknown", posted.Payload![SignalPayloadKeys.HelloOutcome]); + } + + [Fact] + public void Part2Mode_Hello_emits_HelloResolvedPart2() + { + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock, part2Mode: true); + + adapter.TriggerHelloFromTest("completed"); + + Assert.Equal(DecisionSignalKind.HelloResolvedPart2, f.Ingress.Posted[0].Kind); + Assert.Equal("completed", f.Ingress.Posted[0].Payload![SignalPayloadKeys.HelloOutcome]); + } + + [Fact] + public void FinalizingEvent_emits_EspPhaseChanged_with_FinalizingSetup_payload() + { + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + + adapter.TriggerFinalizingFromTest("62404"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind); + Assert.Equal(EnrollmentPhase.FinalizingSetup.ToString(), posted.Payload![SignalPayloadKeys.EspPhase]); + Assert.Equal("62404", posted.Payload["reason"]); + } + + [Fact] + public void WhiteGloveEvent_emits_WhiteGloveShellCoreSuccess() + { + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + + adapter.TriggerWhiteGloveFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.WhiteGloveShellCoreSuccess, posted.Kind); + } + + [Fact] + public void EspFailureEvent_emits_EspTerminalFailure_with_merged_subSource_annotation() + { + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest("CoordinatorMergedFailure"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspTerminalFailure, posted.Kind); + Assert.Equal("CoordinatorMergedFailure", posted.Payload!["failureType"]); + Assert.Contains("merged", posted.Evidence.DerivationInputs!["subSource"]); + } + + [Fact] + public void DeviceSetupCompleteEvent_emits_DeviceSetupProvisioningComplete_with_snapshot_fallback() + { + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + + adapter.TriggerDeviceSetupCompleteFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.DeviceSetupProvisioningComplete, posted.Kind); + Assert.Equal("unknown", posted.Payload!["deviceSetupResolved"]); + } + + [Fact] + public void All_five_signal_kinds_can_fire_in_one_session_each_fire_once() + { + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + + adapter.TriggerFinalizingFromTest("r1"); + adapter.TriggerFinalizingFromTest("r2"); // dedup + + adapter.TriggerWhiteGloveFromTest(); + adapter.TriggerWhiteGloveFromTest(); // dedup + + adapter.TriggerHelloFromTest("completed"); + adapter.TriggerHelloFromTest("timeout"); // dedup + + adapter.TriggerEspFailureFromTest("X"); + adapter.TriggerEspFailureFromTest("Y"); // dedup + + adapter.TriggerDeviceSetupCompleteFromTest(); + adapter.TriggerDeviceSetupCompleteFromTest(); // dedup + + Assert.Equal(5, f.Ingress.Posted.Count); + var kinds = f.Ingress.Posted.Select(p => p.Kind).ToList(); + Assert.Contains(DecisionSignalKind.EspPhaseChanged, kinds); + Assert.Contains(DecisionSignalKind.WhiteGloveShellCoreSuccess, kinds); + Assert.Contains(DecisionSignalKind.HelloResolved, kinds); + Assert.Contains(DecisionSignalKind.EspTerminalFailure, kinds); + Assert.Contains(DecisionSignalKind.DeviceSetupProvisioningComplete, kinds); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws(() => new EspAndHelloTrackerAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws(() => new EspAndHelloTrackerAdapter(f.Coordinator, null!, f.Clock)); + Assert.Throws(() => new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/HelloTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/HelloTrackerAdapterTests.cs new file mode 100644 index 00000000..f08e5f67 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/HelloTrackerAdapterTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +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.SignalAdapters +{ + public sealed class HelloTrackerAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public HelloTracker Tracker { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Tracker = new HelloTracker( + sessionId: "S1", + tenantId: "T1", + onEventCollected: _ => { }, + logger: Logger); + } + + public void Dispose() + { + Tracker.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void TriggerFromTest_emits_HelloResolved_with_outcome_in_payload() + { + using var f = new Fixture(); + using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFromTest("completed"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.HelloResolved, posted.Kind); + Assert.Equal(Fixed, posted.OccurredAtUtc); + Assert.Equal("HelloTracker", posted.SourceOrigin); + Assert.Equal("completed", posted.Payload![SignalPayloadKeys.HelloOutcome]); + Assert.Contains("completed", posted.Evidence.Summary); + } + + [Fact] + public void Part2Mode_emits_HelloResolvedPart2_kind() + { + using var f = new Fixture(); + using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock, part2Mode: true); + + adapter.TriggerFromTest("completed"); + + var posted = f.Ingress.Posted.Single(); + Assert.Equal(DecisionSignalKind.HelloResolvedPart2, posted.Kind); + } + + [Theory] + [InlineData("completed")] + [InlineData("skipped")] + [InlineData("timeout")] + [InlineData("not_configured")] + [InlineData("wizard_not_started")] + public void Outcome_propagates_verbatim_to_payload(string outcome) + { + using var f = new Fixture(); + using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFromTest(outcome); + + Assert.Equal(outcome, f.Ingress.Posted[0].Payload![SignalPayloadKeys.HelloOutcome]); + } + + [Fact] + public void Null_or_empty_outcome_falls_back_to_unknown() + { + using var f = new Fixture(); + using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFromTest(null!); + + Assert.Equal("unknown", f.Ingress.Posted[0].Payload![SignalPayloadKeys.HelloOutcome]); + } + + [Fact] + public void Duplicate_trigger_is_deduplicated() + { + using var f = new Fixture(); + using var adapter = new HelloTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFromTest("completed"); + adapter.TriggerFromTest("timeout"); // even with different outcome, we're fire-once + + Assert.Single(f.Ingress.Posted); + Assert.Equal("completed", f.Ingress.Posted[0].Payload![SignalPayloadKeys.HelloOutcome]); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws(() => new HelloTrackerAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws(() => new HelloTrackerAdapter(f.Tracker, null!, f.Clock)); + Assert.Throws(() => new HelloTrackerAdapter(f.Tracker, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSealingPatternTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSealingPatternTests.cs new file mode 100644 index 00000000..aeeb73aa --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterSealingPatternTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + /// + /// Plan §4.x M4.4.4 — the OnPatternMatched hook on + /// and the WhiteGloveSealingPatternDetected emission it enables on + /// . + /// + public sealed class ImeLogTrackerAdapterSealingPatternTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public ImeLogTracker Tracker { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Tracker = new ImeLogTracker( + logFolder: Tmp.Path, + patterns: new List(), + logger: Logger); + } + + public void Dispose() + { + Tracker.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void Pattern_match_in_sealing_set_emits_WhiteGloveSealingPatternDetected() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter( + f.Tracker, f.Ingress, f.Clock, + whiteGloveSealingPatternIds: new[] { "IME-WG-SEAL-1", "IME-WG-SEAL-2" }); + + adapter.TriggerPatternMatchedFromTest("IME-WG-SEAL-1"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.WhiteGloveSealingPatternDetected, posted.Kind); + Assert.Equal("ImeLogTracker", posted.SourceOrigin); + Assert.Equal("IME-WG-SEAL-1", posted.Payload![SignalPayloadKeys.ImePatternId]); + Assert.Equal(EvidenceKind.Derived, posted.Evidence.Kind); + } + + [Fact] + public void Pattern_match_NOT_in_sealing_set_does_not_emit() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter( + f.Tracker, f.Ingress, f.Clock, + whiteGloveSealingPatternIds: new[] { "IME-WG-SEAL-1" }); + + adapter.TriggerPatternMatchedFromTest("IME-SOMETHING-ELSE"); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void Empty_sealing_pattern_set_disables_emission_entirely() + { + // Default ctor — whiteGloveSealingPatternIds null → no emissions (backwards-compat + // with pre-M4.4.4 M3 behavior). + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerPatternMatchedFromTest("IME-WG-SEAL-1"); + adapter.TriggerPatternMatchedFromTest("IME-ANYTHING"); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void Sealing_pattern_emission_is_fire_once_per_session() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter( + f.Tracker, f.Ingress, f.Clock, + whiteGloveSealingPatternIds: new[] { "IME-WG-SEAL-1", "IME-WG-SEAL-2" }); + + adapter.TriggerPatternMatchedFromTest("IME-WG-SEAL-1"); + adapter.TriggerPatternMatchedFromTest("IME-WG-SEAL-1"); // same ID, already posted + adapter.TriggerPatternMatchedFromTest("IME-WG-SEAL-2"); // different ID, still dedup'd (session-scoped) + + Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.WhiteGloveSealingPatternDetected, f.Ingress.Posted[0].Kind); + } + + [Fact] + public void Empty_or_null_patternId_does_not_emit() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter( + f.Tracker, f.Ingress, f.Clock, + whiteGloveSealingPatternIds: new[] { "IME-WG-SEAL-1" }); + + adapter.TriggerPatternMatchedFromTest(string.Empty); + adapter.TriggerPatternMatchedFromTest(null!); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void Tracker_OnPatternMatched_hook_fires_for_subscribers() + { + // Directly verify the hook surface on ImeLogTracker — adapter subscribes via this + // Action. We simulate the tracker's HandlePatternMatch entry by invoking the + // property the same way HandlePatternMatch does. + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter( + f.Tracker, f.Ingress, f.Clock, + whiteGloveSealingPatternIds: new[] { "ANY" }); + + // Invoke the hook as if a pattern matched. + f.Tracker.OnPatternMatched?.Invoke("ANY"); + + Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.WhiteGloveSealingPatternDetected, f.Ingress.Posted[0].Kind); + } + + [Fact] + public void Previously_wired_OnPatternMatched_is_chain_preserved_and_restored_on_Dispose() + { + using var f = new Fixture(); + var chainInvocations = new List(); + f.Tracker.OnPatternMatched = id => chainInvocations.Add("prev:" + id); + + using (var adapter = new ImeLogTrackerAdapter( + f.Tracker, f.Ingress, f.Clock, + whiteGloveSealingPatternIds: new[] { "WG" })) + { + // Invoking the now-adapter-owned handler should still call the previous one. + f.Tracker.OnPatternMatched?.Invoke("WG"); + Assert.Contains("prev:WG", chainInvocations); + Assert.Single(f.Ingress.Posted); + } + + // After Dispose the previous handler must be restored (no dead callback). + chainInvocations.Clear(); + f.Tracker.OnPatternMatched?.Invoke("WG-AFTER-DISPOSE"); + Assert.Equal(new[] { "prev:WG-AFTER-DISPOSE" }, chainInvocations); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs new file mode 100644 index 00000000..0ffeb88c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs @@ -0,0 +1,232 @@ +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.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class ImeLogTrackerAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public ImeLogTracker Tracker { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Tracker = new ImeLogTracker( + logFolder: Tmp.Path, + patterns: new List(), + logger: Logger); + } + + public void Dispose() + { + Tracker.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void EspPhaseChanged_first_phase_emits_signal_with_phase_in_payload() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind); + Assert.Equal("ImeLogTracker", posted.SourceOrigin); + Assert.Equal("DeviceSetup", posted.Payload![SignalPayloadKeys.EspPhase]); + } + + [Fact] + public void EspPhaseChanged_same_phase_repeated_is_deduped_idempotency() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + + Assert.Single(f.Ingress.Posted); + } + + [Fact] + public void EspPhaseChanged_distinct_phases_emit_separate_signals() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + adapter.TriggerEspPhaseFromTest("AccountSetup"); + adapter.TriggerEspPhaseFromTest("AccountSetup"); // dedup + adapter.TriggerEspPhaseFromTest("FinalizingSetup"); + + Assert.Equal(3, f.Ingress.Posted.Count); + Assert.Equal("DeviceSetup", f.Ingress.Posted[0].Payload![SignalPayloadKeys.EspPhase]); + Assert.Equal("AccountSetup", f.Ingress.Posted[1].Payload![SignalPayloadKeys.EspPhase]); + Assert.Equal("FinalizingSetup", f.Ingress.Posted[2].Payload![SignalPayloadKeys.EspPhase]); + } + + [Fact] + public void EspPhaseChanged_null_or_empty_phase_is_skipped() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest(null!); + adapter.TriggerEspPhaseFromTest(""); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void UserSessionCompleted_emits_ImeUserSessionCompleted_fire_once() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerUserSessionCompletedFromTest(); + adapter.TriggerUserSessionCompletedFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.ImeUserSessionCompleted, posted.Kind); + } + + [Theory] + [InlineData(AppInstallationState.Installed, DecisionSignalKind.AppInstallCompleted)] + [InlineData(AppInstallationState.Skipped, DecisionSignalKind.AppInstallCompleted)] + [InlineData(AppInstallationState.Postponed, DecisionSignalKind.AppInstallCompleted)] + [InlineData(AppInstallationState.Error, DecisionSignalKind.AppInstallFailed)] + public void AppStateChange_to_terminal_state_emits_correct_signal_kind( + AppInstallationState newState, DecisionSignalKind expectedKind) + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + var app = new AppPackageState($"app-{newState}", listPos: 0); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, newState); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(expectedKind, posted.Kind); + Assert.Equal($"app-{newState}", posted.Payload!["appId"]); + Assert.Equal(newState.ToString(), posted.Payload["newState"]); + } + + [Fact] + public void AppStateChange_to_non_terminal_state_does_not_emit() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + var app = new AppPackageState("app-1", 0); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.InProgress); + adapter.TriggerAppStateFromTest(app, AppInstallationState.InProgress, AppInstallationState.Downloading); + adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installing); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void AppStateChange_terminal_per_app_is_fire_once() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + var app = new AppPackageState("app-1", 0); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Installed); + // Even if the tracker re-fires (shouldn't happen, but defend) — still one post. + adapter.TriggerAppStateFromTest(app, AppInstallationState.Installed, AppInstallationState.Error); + + Assert.Single(f.Ingress.Posted); + } + + [Fact] + public void AppStateChange_different_apps_emit_independently() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerAppStateFromTest(new AppPackageState("a", 0), AppInstallationState.Installing, AppInstallationState.Installed); + adapter.TriggerAppStateFromTest(new AppPackageState("b", 1), AppInstallationState.Installing, AppInstallationState.Error); + adapter.TriggerAppStateFromTest(new AppPackageState("c", 2), AppInstallationState.Installing, AppInstallationState.Skipped); + + Assert.Equal(3, f.Ingress.Posted.Count); + Assert.Contains(f.Ingress.Posted, p => p.Payload!["appId"] == "a" && p.Kind == DecisionSignalKind.AppInstallCompleted); + Assert.Contains(f.Ingress.Posted, p => p.Payload!["appId"] == "b" && p.Kind == DecisionSignalKind.AppInstallFailed); + Assert.Contains(f.Ingress.Posted, p => p.Payload!["appId"] == "c" && p.Kind == DecisionSignalKind.AppInstallCompleted); + } + + [Fact] + public void AppStateChange_null_app_is_ignored() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerAppStateFromTest(null!, AppInstallationState.Installing, AppInstallationState.Installed); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void Adapter_preserves_prior_Action_handlers_chain_invoke() + { + using var f = new Fixture(); + int priorEspCalls = 0; + int priorUserCalls = 0; + f.Tracker.OnEspPhaseChanged = _ => priorEspCalls++; + f.Tracker.OnUserSessionCompleted = () => priorUserCalls++; + + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + // Call through the tracker's Action (simulating what the tracker would do). + f.Tracker.OnEspPhaseChanged("DeviceSetup"); + f.Tracker.OnUserSessionCompleted(); + + Assert.Equal(1, priorEspCalls); + Assert.Equal(1, priorUserCalls); + Assert.Equal(2, f.Ingress.Posted.Count); + } + + [Fact] + public void Dispose_restores_prior_Action_handlers() + { + using var f = new Fixture(); + int priorCalls = 0; + Action priorAction = _ => priorCalls++; + f.Tracker.OnEspPhaseChanged = priorAction; + + var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + Assert.NotSame(priorAction, f.Tracker.OnEspPhaseChanged); + + adapter.Dispose(); + Assert.Same(priorAction, f.Tracker.OnEspPhaseChanged); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws(() => new ImeLogTrackerAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws(() => new ImeLogTrackerAdapter(f.Tracker, null!, f.Clock)); + Assert.Throws(() => new ImeLogTrackerAdapter(f.Tracker, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ProvisioningStatusTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ProvisioningStatusTrackerAdapterTests.cs new file mode 100644 index 00000000..2ea60500 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ProvisioningStatusTrackerAdapterTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class ProvisioningStatusTrackerAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public ProvisioningStatusTracker Tracker { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Tracker = new ProvisioningStatusTracker( + sessionId: "S1", + tenantId: "T1", + onEventCollected: _ => { }, + logger: Logger); + } + + public void Dispose() + { + Tracker.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void DeviceSetupComplete_maps_to_DeviceSetupProvisioningComplete_signal() + { + using var f = new Fixture(); + using var adapter = new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerDeviceSetupCompleteFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.DeviceSetupProvisioningComplete, posted.Kind); + Assert.Equal(Fixed, posted.OccurredAtUtc); + Assert.Equal("ProvisioningStatusTracker", posted.SourceOrigin); + // Snapshot without real registry data → "unknown" + Assert.Equal("unknown", posted.Payload!["deviceSetupResolved"]); + } + + [Fact] + public void EspFailure_maps_to_EspTerminalFailure_with_distinct_sourceOrigin_from_ShellCore() + { + using var f = new Fixture(); + using var adapter = new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest("DevicePreparation_Failed"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspTerminalFailure, posted.Kind); + // SourceOrigin distinguishes ShellCore vs. ProvisioningStatus — important for + // multi-source failure attribution in the journal. + Assert.Equal("ProvisioningStatusTracker", posted.SourceOrigin); + Assert.Equal("DevicePreparation_Failed", posted.Payload!["failureType"]); + } + + [Fact] + public void EspFailure_empty_type_falls_back_to_unknown() + { + using var f = new Fixture(); + using var adapter = new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest(""); + + Assert.Equal("unknown", f.Ingress.Posted[0].Payload!["failureType"]); + } + + [Fact] + public void Each_signal_kind_is_deduplicated_independently() + { + using var f = new Fixture(); + using var adapter = new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerDeviceSetupCompleteFromTest(); + adapter.TriggerDeviceSetupCompleteFromTest(); // dedup + adapter.TriggerEspFailureFromTest("t1"); + adapter.TriggerEspFailureFromTest("t2"); // dedup + + Assert.Equal(2, f.Ingress.Posted.Count); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.DeviceSetupProvisioningComplete); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.EspTerminalFailure); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws( + () => new ProvisioningStatusTrackerAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws( + () => new ProvisioningStatusTrackerAdapter(f.Tracker, null!, f.Clock)); + Assert.Throws( + () => new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ShellCoreTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ShellCoreTrackerAdapterTests.cs new file mode 100644 index 00000000..03ddab71 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ShellCoreTrackerAdapterTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class ShellCoreTrackerAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public ShellCoreTracker Tracker { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Tracker = new ShellCoreTracker( + sessionId: "S1", + tenantId: "T1", + onEventCollected: _ => { }, + logger: Logger, + helloTracker: null); + } + + public void Dispose() + { + Tracker.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void FinalizingPhase_maps_to_EspPhaseChanged_with_Finalizing_payload() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFinalizingFromTest("shell-core-62404"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind); + Assert.Equal(Fixed, posted.OccurredAtUtc); + Assert.Equal("ShellCoreTracker", posted.SourceOrigin); + Assert.Equal( + EnrollmentPhase.FinalizingSetup.ToString(), + posted.Payload![SignalPayloadKeys.EspPhase]); + Assert.Equal("shell-core-62404", posted.Payload["reason"]); + } + + [Fact] + public void WhiteGloveCompleted_maps_to_WhiteGloveShellCoreSuccess() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerWhiteGloveCompletedFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.WhiteGloveShellCoreSuccess, posted.Kind); + Assert.Equal("ShellCoreTracker", posted.SourceOrigin); + Assert.Equal(EvidenceKind.Derived, posted.Evidence.Kind); + Assert.Contains("sealing success", posted.Evidence.Summary); + } + + [Fact] + public void EspFailure_maps_to_EspTerminalFailure_with_failureType_in_payload() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest("AccountSetup_Timeout"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspTerminalFailure, posted.Kind); + Assert.Equal("AccountSetup_Timeout", posted.Payload!["failureType"]); + Assert.Equal("AccountSetup_Timeout", posted.Evidence.DerivationInputs!["failureType"]); + } + + [Fact] + public void EspFailure_empty_type_falls_back_to_unknown() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest(""); + + Assert.Equal("unknown", f.Ingress.Posted[0].Payload!["failureType"]); + } + + [Fact] + public void Each_signal_kind_is_deduplicated_independently() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFinalizingFromTest("r1"); + adapter.TriggerFinalizingFromTest("r2"); // dedup + adapter.TriggerWhiteGloveCompletedFromTest(); + adapter.TriggerWhiteGloveCompletedFromTest(); // dedup + adapter.TriggerEspFailureFromTest("t1"); + adapter.TriggerEspFailureFromTest("t2"); // dedup + + Assert.Equal(3, f.Ingress.Posted.Count); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.EspPhaseChanged); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.WhiteGloveShellCoreSuccess); + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.EspTerminalFailure); + } + + [Fact] + public void All_three_events_can_fire_in_one_session() + { + using var f = new Fixture(); + using var adapter = new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerFinalizingFromTest("phase-enter"); + adapter.TriggerWhiteGloveCompletedFromTest(); + adapter.TriggerEspFailureFromTest("Timeout"); + + Assert.Equal(3, f.Ingress.Posted.Count); + Assert.Equal(DecisionSignalKind.EspPhaseChanged, f.Ingress.Posted[0].Kind); + Assert.Equal(DecisionSignalKind.WhiteGloveShellCoreSuccess, f.Ingress.Posted[1].Kind); + Assert.Equal(DecisionSignalKind.EspTerminalFailure, f.Ingress.Posted[2].Kind); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws(() => new ShellCoreTrackerAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws(() => new ShellCoreTrackerAdapter(f.Tracker, null!, f.Clock)); + Assert.Throws(() => new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/StallProbeCollectorAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/StallProbeCollectorAdapterTests.cs new file mode 100644 index 00000000..f3eb58dd --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/StallProbeCollectorAdapterTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class StallProbeCollectorAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public StallProbeCollector Collector { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Collector = new StallProbeCollector( + sessionId: "S1", + tenantId: "T1", + onEventCollected: _ => { }, + logger: Logger, + thresholdsMinutes: new[] { 2, 5 }, + traceIndices: new[] { 2, 3, 4 }, + sources: new[] { "provisioning", "event-logs" }, + sessionStalledAfterProbeIndex: 4); + } + + public void Dispose() + { + // StallProbeCollector does not implement IDisposable in this copy. + Tmp.Dispose(); + } + } + + [Fact] + public void EspFailure_maps_to_EspTerminalFailure_with_stallprobe_origin() + { + using var f = new Fixture(); + using var adapter = new StallProbeCollectorAdapter(f.Collector, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest("AppWorkload_ErrorTerminal"); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspTerminalFailure, posted.Kind); + Assert.Equal("StallProbeCollector", posted.SourceOrigin); + Assert.Equal("AppWorkload_ErrorTerminal", posted.Payload!["failureType"]); + Assert.Equal("stall-probe", posted.Payload["detector"]); + Assert.Equal("AppWorkload_ErrorTerminal", posted.Evidence.DerivationInputs!["terminalReason"]); + } + + [Fact] + public void Empty_terminalReason_falls_back_to_unknown() + { + using var f = new Fixture(); + using var adapter = new StallProbeCollectorAdapter(f.Collector, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest(""); + + Assert.Equal("unknown", f.Ingress.Posted[0].Payload!["failureType"]); + } + + [Fact] + public void Duplicate_trigger_is_deduplicated_fire_once() + { + using var f = new Fixture(); + using var adapter = new StallProbeCollectorAdapter(f.Collector, f.Ingress, f.Clock); + + adapter.TriggerEspFailureFromTest("reason1"); + adapter.TriggerEspFailureFromTest("reason2"); + + Assert.Single(f.Ingress.Posted); + Assert.Equal("reason1", f.Ingress.Posted[0].Payload!["failureType"]); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws(() => new StallProbeCollectorAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws(() => new StallProbeCollectorAdapter(f.Collector, null!, f.Clock)); + Assert.Throws(() => new StallProbeCollectorAdapter(f.Collector, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/BackPressureEventObserverTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/BackPressureEventObserverTests.cs new file mode 100644 index 00000000..983be266 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/BackPressureEventObserverTests.cs @@ -0,0 +1,104 @@ +using System; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events +{ + public sealed class BackPressureEventObserverTests + { + private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public FakeTelemetryTransport Transport { get; } = new FakeTelemetryTransport(); + public VirtualClock Clock { get; } = new VirtualClock(At); + public EventSequenceCounter Counter { get; } + public TelemetryEventEmitter Inner { get; } + public BackPressureEventObserver Sut { get; } + + public Rig() + { + Counter = new EventSequenceCounter(new EventSequencePersistence(Tmp.File("seq.json"))); + Inner = new TelemetryEventEmitter(Transport, Counter, "S1", "T1"); + Sut = new BackPressureEventObserver(Inner, Clock); + } + + public void Dispose() => Tmp.Dispose(); + } + + [Fact] + public void OnBackPressure_emits_ingress_backpressure_event_with_warning_severity() + { + using var r = new Rig(); + r.Sut.OnBackPressure(origin: "ImeLogTracker", channelCapacity: 256, queueLength: 256, blockDuration: TimeSpan.FromMilliseconds(137)); + + Assert.Equal(1, r.Transport.EnqueueCount); + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + Assert.Equal("ingress_backpressure", (string?)parsed["EventType"]); + Assert.Equal("Warning", (string?)parsed["SeverityString"]); + Assert.Equal("signal_ingress", (string?)parsed["Source"]); + } + + [Fact] + public void Data_payload_carries_origin_capacity_queueLength_and_blockDurationMs() + { + using var r = new Rig(); + r.Sut.OnBackPressure("Origin-A", 256, 250, TimeSpan.FromMilliseconds(42)); + + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + var data = (JObject)parsed["Data"]!; + Assert.Equal("Origin-A", (string?)data["origin"]); + Assert.Equal(256, (int)data["channelCapacity"]!); + Assert.Equal(250, (int)data["queueLength"]!); + Assert.Equal(42, (long)data["blockDurationMs"]!); + } + + [Fact] + public void Timestamp_uses_injected_clock_not_wallclock() + { + using var r = new Rig(); + r.Clock.SetUtcNow(new DateTime(2030, 2, 28, 23, 59, 59, 500, DateTimeKind.Utc)); + + r.Sut.OnBackPressure("O", 1, 1, TimeSpan.FromMilliseconds(1)); + + Assert.Equal("20300228235959500_0000000001", r.Transport.Enqueued[0].RowKey); + } + + [Fact] + public void Phase_is_Unknown_for_backpressure_traces() + { + using var r = new Rig(); + r.Sut.OnBackPressure("O", 1, 1, TimeSpan.Zero); + + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + Assert.Equal((int)EnrollmentPhase.Unknown, (int)parsed["PhaseNumber"]!); + } + + [Fact] + public void Each_call_consumes_one_Sequence() + { + using var r = new Rig(); + r.Sut.OnBackPressure("A", 1, 1, TimeSpan.Zero); + r.Sut.OnBackPressure("B", 1, 1, TimeSpan.Zero); + r.Sut.OnBackPressure("A", 1, 1, TimeSpan.Zero); + + Assert.Equal(3, r.Counter.LastAssigned); + Assert.Equal(3, r.Transport.EnqueueCount); + } + + [Fact] + public void Constructor_rejects_null_dependencies() + { + using var tmp = new TempDirectory(); + var counter = new EventSequenceCounter(new EventSequencePersistence(tmp.File("seq.json"))); + var inner = new TelemetryEventEmitter(new FakeTelemetryTransport(), counter, "S1", "T1"); + + Assert.Throws(() => new BackPressureEventObserver(null!, new VirtualClock(At))); + Assert.Throws(() => new BackPressureEventObserver(inner, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventSequenceCounterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventSequenceCounterTests.cs new file mode 100644 index 00000000..0f8da827 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventSequenceCounterTests.cs @@ -0,0 +1,85 @@ +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events +{ + public sealed class EventSequenceCounterTests + { + [Fact] + public void First_Next_returns_one_on_empty_persistence() + { + using var tmp = new TempDirectory(); + var p = new EventSequencePersistence(tmp.File("seq.json")); + var c = new EventSequenceCounter(p); + + Assert.Equal(0, c.LastAssigned); + Assert.Equal(1, c.Next()); + Assert.Equal(2, c.Next()); + Assert.Equal(3, c.Next()); + Assert.Equal(3, c.LastAssigned); + } + + [Fact] + public void Counter_seeds_from_persistence_on_recovery() + { + using var tmp = new TempDirectory(); + var path = tmp.File("seq.json"); + new EventSequencePersistence(path).Save(99); + + var c = new EventSequenceCounter(new EventSequencePersistence(path)); + Assert.Equal(99, c.LastAssigned); + Assert.Equal(100, c.Next()); + } + + [Fact] + public void Next_persists_every_increment() + { + using var tmp = new TempDirectory(); + var path = tmp.File("seq.json"); + var p = new EventSequencePersistence(path); + var c = new EventSequenceCounter(p); + + c.Next(); + c.Next(); + c.Next(); + + Assert.Equal(3, new EventSequencePersistence(path).Load()); + } + + [Fact] + public async Task Next_is_thread_safe_under_concurrent_callers() + { + using var tmp = new TempDirectory(); + var p = new EventSequencePersistence(tmp.File("seq.json")); + var c = new EventSequenceCounter(p); + + // Every Next() persists to disk — stay below the level where parallel xUnit tests + // contend on the Windows atomic-rename path. Thread-safety is exercised by the + // parallel callers; the uniqueness assertion below catches any race. + const int Threads = 4; + const int PerThread = 50; + var tasks = new Task[Threads]; + for (int t = 0; t < Threads; t++) + { + tasks[t] = Task.Run(() => + { + var buf = new long[PerThread]; + for (int i = 0; i < PerThread; i++) buf[i] = c.Next(); + return buf; + }); + } + + var results = await Task.WhenAll(tasks); + + var all = new System.Collections.Generic.HashSet(); + foreach (var buf in results) + foreach (var v in buf) + Assert.True(all.Add(v)); + + Assert.Equal(Threads * PerThread, all.Count); + Assert.Equal(Threads * PerThread, c.LastAssigned); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventSequencePersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventSequencePersistenceTests.cs new file mode 100644 index 00000000..f3b3192c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventSequencePersistenceTests.cs @@ -0,0 +1,70 @@ +using System.IO; +using System.Text; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events +{ + public sealed class EventSequencePersistenceTests + { + [Fact] + public void Load_on_missing_file_returns_zero() + { + using var tmp = new TempDirectory(); + var p = new EventSequencePersistence(tmp.File("missing.json")); + Assert.Equal(0, p.Load()); + } + + [Fact] + public void Save_then_Load_roundtrips_value() + { + using var tmp = new TempDirectory(); + var path = tmp.File("seq.json"); + var p1 = new EventSequencePersistence(path); + p1.Save(42); + + var p2 = new EventSequencePersistence(path); + Assert.Equal(42, p2.Load()); + } + + [Fact] + public void Save_overwrites_previous_value_atomically() + { + using var tmp = new TempDirectory(); + var path = tmp.File("seq.json"); + var p = new EventSequencePersistence(path); + + p.Save(10); + p.Save(20); + p.Save(30); + + Assert.Equal(30, p.Load()); + Assert.False(File.Exists(path + ".tmp"), "Temp file must not linger after rename."); + } + + [Fact] + public void Load_on_corrupt_json_returns_zero() + { + using var tmp = new TempDirectory(); + var path = tmp.File("seq.json"); + File.WriteAllText(path, "{not valid json", Encoding.UTF8); + + var p = new EventSequencePersistence(path); + Assert.Equal(0, p.Load()); + } + + [Fact] + public void Constructor_creates_missing_parent_directory() + { + using var tmp = new TempDirectory(); + var nested = Path.Combine(tmp.Path, "Telemetry", "sub", "seq.json"); + + var p = new EventSequencePersistence(nested); + p.Save(7); + + Assert.True(File.Exists(nested)); + Assert.Equal(7, p.Load()); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterTests.cs new file mode 100644 index 00000000..1bb64357 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/EventTimelineEmitterTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events +{ + public sealed class EventTimelineEmitterTests + { + private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public FakeTelemetryTransport Transport { get; } = new FakeTelemetryTransport(); + public EventSequenceCounter Counter { get; } + public TelemetryEventEmitter Inner { get; } + public EventTimelineEmitter Sut { get; } + + public Rig() + { + Counter = new EventSequenceCounter(new EventSequencePersistence(Tmp.File("seq.json"))); + Inner = new TelemetryEventEmitter(Transport, Counter, "S1", "T1"); + Sut = new EventTimelineEmitter(Inner); + } + + public void Dispose() => Tmp.Dispose(); + } + + private static DecisionState State() => DecisionState.CreateInitial("S1", "T1"); + + [Fact] + public void Emit_builds_EnrollmentEvent_with_eventType_and_routes_to_transport() + { + using var r = new Rig(); + var parameters = new Dictionary { ["eventType"] = "enrollment_complete" }; + + r.Sut.Emit(parameters, State(), At); + + Assert.Equal(1, r.Transport.EnqueueCount); + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + Assert.Equal("enrollment_complete", (string?)parsed["EventType"]); + Assert.Equal("S1", (string?)parsed["SessionId"]); + Assert.Equal("T1", (string?)parsed["TenantId"]); + Assert.Equal(1, (long)parsed["Sequence"]!); + } + + [Fact] + public void Emit_uses_occurredAtUtc_not_wallclock_for_Timestamp() + { + using var r = new Rig(); + var deterministicTime = new DateTime(2030, 1, 15, 12, 34, 56, 789, DateTimeKind.Utc); + var parameters = new Dictionary { ["eventType"] = "enrollment_complete" }; + + r.Sut.Emit(parameters, State(), deterministicTime); + + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + var ts = parsed["Timestamp"]!.ToObject(); + Assert.Equal(deterministicTime.ToUniversalTime(), ts.ToUniversalTime()); + + // RowKey format {yyyyMMddHHmmssfff}_{Sequence:D10} + Assert.Equal("20300115123456789_0000000001", r.Transport.Enqueued[0].RowKey); + } + + [Fact] + public void EventType_with_failed_suffix_is_Error_severity() + { + using var r = new Rig(); + r.Sut.Emit( + new Dictionary { ["eventType"] = "enrollment_failed", ["reason"] = "esp_terminal" }, + State(), + At); + + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + Assert.Equal("Error", (string?)parsed["SeverityString"]); + } + + [Fact] + public void EventType_without_special_suffix_defaults_to_Info() + { + using var r = new Rig(); + r.Sut.Emit( + new Dictionary { ["eventType"] = "whiteglove_complete" }, + State(), + At); + + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + Assert.Equal("Info", (string?)parsed["SeverityString"]); + } + + [Fact] + public void Reason_parameter_is_appended_to_message_and_kept_in_Data() + { + using var r = new Rig(); + r.Sut.Emit( + new Dictionary + { + ["eventType"] = "enrollment_failed", + ["reason"] = "hello_timeout", + ["wgConfidence"] = "None", + }, + State(), + At); + + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + Assert.Equal("enrollment_failed: hello_timeout", (string?)parsed["Message"]); + var data = (JObject)parsed["Data"]!; + Assert.Equal("hello_timeout", (string?)data["reason"]); + Assert.Equal("None", (string?)data["wgConfidence"]); + // eventType must NOT be duplicated inside Data. + Assert.Null(data["eventType"]); + } + + [Fact] + public void Phase_stays_Unknown_for_terminal_events() + { + // feedback_phase_strategy: terminal events do not declare a phase. + using var r = new Rig(); + r.Sut.Emit( + new Dictionary { ["eventType"] = "enrollment_complete" }, + State(), + At); + + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + Assert.Equal((int)EnrollmentPhase.Unknown, (int)parsed["PhaseNumber"]!); + Assert.Equal("Unknown", (string?)parsed["PhaseName"]); + } + + [Fact] + public void ImmediateUpload_is_forced_true_for_reducer_terminal_events() + { + using var r = new Rig(); + r.Sut.Emit( + new Dictionary { ["eventType"] = "whiteglove_complete" }, + State(), + At); + + Assert.True(r.Transport.Enqueued[0].RequiresImmediateFlush); + } + + [Fact] + public void Emit_rejects_missing_or_empty_eventType() + { + using var r = new Rig(); + + Assert.Throws(() => r.Sut.Emit(null, State(), At)); + Assert.Throws(() => + r.Sut.Emit(new Dictionary(), State(), At)); + Assert.Throws(() => + r.Sut.Emit(new Dictionary { ["eventType"] = string.Empty }, State(), At)); + + Assert.Equal(0, r.Transport.EnqueueCount); + } + + [Fact] + public void Emit_rejects_null_state() + { + using var r = new Rig(); + Assert.Throws(() => + r.Sut.Emit(new Dictionary { ["eventType"] = "x" }, null!, At)); + } + + [Fact] + public void Source_is_decision_engine() + { + using var r = new Rig(); + r.Sut.Emit( + new Dictionary { ["eventType"] = "enrollment_complete" }, + State(), + At); + + var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson); + Assert.Equal("decision_engine", (string?)parsed["Source"]); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/FakeTelemetryTransport.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/FakeTelemetryTransport.cs new file mode 100644 index 00000000..5edfd1bf --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/FakeTelemetryTransport.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events +{ + /// + /// In-memory ITelemetryTransport that captures every Enqueue call. Builds a fully-populated + /// (assigned TelemetryItemId, SessionTraceOrdinal when session-scoped), + /// so callers can verify field propagation without disk-backed plumbing. + /// + internal sealed class FakeTelemetryTransport : ITelemetryTransport + { + private readonly object _lock = new object(); + private readonly List _enqueued = new List(); + private long _nextItemId = 0; + + public IReadOnlyList Enqueued + { + get { lock (_lock) return _enqueued.ToArray(); } + } + + public int EnqueueCount + { + get { lock (_lock) return _enqueued.Count; } + } + + public long LastUploadedItemId => -1; + + public TelemetryItem Enqueue(TelemetryItemDraft draft) + { + if (draft == null) throw new ArgumentNullException(nameof(draft)); + + lock (_lock) + { + var id = _nextItemId++; + long? traceOrdinal = draft.IsSessionScoped ? id : (long?)null; + var item = new TelemetryItem( + kind: draft.Kind, + partitionKey: draft.PartitionKey, + rowKey: draft.RowKey, + telemetryItemId: id, + sessionTraceOrdinal: traceOrdinal, + payloadJson: draft.PayloadJson, + requiresImmediateFlush: draft.RequiresImmediateFlush, + enqueuedAtUtc: DateTime.UtcNow); + _enqueued.Add(item); + return item; + } + } + + public Task DrainAllAsync(CancellationToken cancellationToken = default) => + Task.FromResult(DrainResult.Empty()); + + public void Dispose() { } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/TelemetryEventEmitterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/TelemetryEventEmitterTests.cs new file mode 100644 index 00000000..a55827a5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Events/TelemetryEventEmitterTests.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events +{ + public sealed class TelemetryEventEmitterTests + { + private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private static TelemetryEventEmitter Build( + FakeTelemetryTransport transport, + out EventSequenceCounter counter, + string sessionId = "sess-1", + string tenantId = "tenant-1") + { + var tmp = new TempDirectory(); + counter = new EventSequenceCounter(new EventSequencePersistence(tmp.File("seq.json"))); + return new TelemetryEventEmitter(transport, counter, sessionId, tenantId); + } + + private static EnrollmentEvent NewTestEvent(string eventType = "enrollment_complete") => + new EnrollmentEvent + { + EventType = eventType, + Severity = EventSeverity.Info, + Source = "test", + Timestamp = At, + Phase = EnrollmentPhase.Unknown, + Message = "test message", + }; + + [Fact] + public void Emit_assigns_Sequence_RowKey_and_routes_through_transport() + { + var transport = new FakeTelemetryTransport(); + var emitter = Build(transport, out _); + + var evt = NewTestEvent(); + var item = emitter.Emit(evt); + + Assert.Equal(1, evt.Sequence); + Assert.Equal("20260420100000000_0000000001", evt.RowKey); + Assert.Equal("sess-1", evt.SessionId); + Assert.Equal("tenant-1", evt.TenantId); + + Assert.Equal(TelemetryItemKind.Event, item.Kind); + Assert.Equal("tenant-1_sess-1", item.PartitionKey); + Assert.Equal(evt.RowKey, item.RowKey); + Assert.Equal(1, transport.EnqueueCount); + } + + [Fact] + public void Emit_Sequences_are_monotonic_across_calls() + { + var transport = new FakeTelemetryTransport(); + var emitter = Build(transport, out _); + + for (int i = 0; i < 5; i++) + { + emitter.Emit(NewTestEvent($"evt-{i}")); + } + + Assert.Equal(5, transport.EnqueueCount); + for (int i = 0; i < 5; i++) + { + var parsed = JObject.Parse(transport.Enqueued[i].PayloadJson); + Assert.Equal(i + 1, (long)parsed["Sequence"]!); + } + } + + [Fact] + public void PayloadJson_matches_legacy_wire_format_fields() + { + // Newtonsoft JsonConvert.SerializeObject — PascalCase from property names, STJ attributes + // ignored. Backend ingestor uses PropertyNameCaseInsensitive so PascalCase is accepted. + var transport = new FakeTelemetryTransport(); + var emitter = Build(transport, out _); + + emitter.Emit(NewTestEvent("whiteglove_complete")); + + var parsed = JObject.Parse(transport.Enqueued[0].PayloadJson); + Assert.Equal("whiteglove_complete", (string?)parsed["EventType"]); + Assert.Equal("sess-1", (string?)parsed["SessionId"]); + Assert.Equal("tenant-1", (string?)parsed["TenantId"]); + Assert.Equal(1, (long)parsed["Sequence"]!); + Assert.False(string.IsNullOrEmpty((string?)parsed["EventId"])); + Assert.False(string.IsNullOrEmpty((string?)parsed["RowKey"])); + } + + [Fact] + public void Emit_defaults_SessionId_TenantId_when_caller_leaves_empty() + { + var transport = new FakeTelemetryTransport(); + var emitter = Build(transport, out _, sessionId: "S-CTOR", tenantId: "T-CTOR"); + + var evt = NewTestEvent(); + evt.SessionId = null; + evt.TenantId = null; + emitter.Emit(evt); + + Assert.Equal("S-CTOR", evt.SessionId); + Assert.Equal("T-CTOR", evt.TenantId); + Assert.Equal("T-CTOR_S-CTOR", transport.Enqueued[0].PartitionKey); + } + + [Fact] + public void Emit_preserves_caller_SessionId_if_set() + { + var transport = new FakeTelemetryTransport(); + var emitter = Build(transport, out _, sessionId: "S-CTOR", tenantId: "T-CTOR"); + + var evt = NewTestEvent(); + evt.SessionId = "S-EXPLICIT"; + evt.TenantId = "T-EXPLICIT"; + emitter.Emit(evt); + + Assert.Equal("S-EXPLICIT", evt.SessionId); + Assert.Equal("T-EXPLICIT", evt.TenantId); + // PartitionKey is determined by the ctor values (session-scoping). Caller-explicit + // overrides only the payload fields — the transport bucket stays stable. + Assert.Equal("T-CTOR_S-CTOR", transport.Enqueued[0].PartitionKey); + } + + [Fact] + public void RequiresImmediateFlush_forwards_ImmediateUpload_flag() + { + var transport = new FakeTelemetryTransport(); + var emitter = Build(transport, out _); + + var immediate = NewTestEvent(); + immediate.ImmediateUpload = true; + emitter.Emit(immediate); + + var deferred = NewTestEvent(); + deferred.ImmediateUpload = false; + emitter.Emit(deferred); + + Assert.True(transport.Enqueued[0].RequiresImmediateFlush); + Assert.False(transport.Enqueued[1].RequiresImmediateFlush); + } + + [Fact] + public void Emit_rejects_null_event_and_empty_EventType() + { + var transport = new FakeTelemetryTransport(); + var emitter = Build(transport, out _); + + Assert.Throws(() => emitter.Emit(null!)); + + var evt = NewTestEvent(); + evt.EventType = string.Empty; + Assert.Throws(() => emitter.Emit(evt)); + Assert.Equal(0, transport.EnqueueCount); + } + + [Fact] + public void Data_dict_is_serialized_into_payload() + { + var transport = new FakeTelemetryTransport(); + var emitter = Build(transport, out _); + + var evt = NewTestEvent(); + evt.Data = new Dictionary { ["reason"] = "test-reason", ["wgConfidence"] = "Strong" }; + emitter.Emit(evt); + + var parsed = JObject.Parse(transport.Enqueued[0].PayloadJson); + var data = (JObject)parsed["Data"]!; + Assert.Equal("test-reason", (string?)data["reason"]); + Assert.Equal("Strong", (string?)data["wgConfidence"]); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Signals/TelemetrySignalEmitterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Signals/TelemetrySignalEmitterTests.cs new file mode 100644 index 00000000..52b6194d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Signals/TelemetrySignalEmitterTests.cs @@ -0,0 +1,113 @@ +using System; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Signals; +using AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Signals; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Signals +{ + /// + /// Pure emitter tests for (Codex Finding 1 fix). + /// Verifies the agent-side wire format and transport projection; the cross-layer + /// round-trip with the backend parser lives in TelemetrySignalRoundTripTests. + /// + public sealed class TelemetrySignalEmitterTests + { + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private static DecisionSignal Signal(long ordinal, DecisionSignalKind kind = DecisionSignalKind.EspPhaseChanged) + => new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal + 100, + kind: kind, + kindSchemaVersion: 1, + occurredAtUtc: At, + sourceOrigin: "EspAndHelloTrackerAdapter", + evidence: new Evidence(EvidenceKind.Raw, $"raw-{ordinal}", $"evidence-{ordinal}")); + + [Fact] + public void Emit_routes_signal_through_transport_with_correct_keys() + { + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetrySignalEmitter(transport, SessionId, TenantId); + + emitter.Emit(Signal(42)); + + Assert.Equal(1, transport.EnqueueCount); + var item = transport.Enqueued[0]; + Assert.Equal(TelemetryItemKind.Signal, item.Kind); + Assert.Equal($"{TenantId}_{SessionId}", item.PartitionKey); + Assert.Equal("0000000000000000042", item.RowKey); // D19 padding — matches TableSignalRepository.BuildRowKey + Assert.False(item.RequiresImmediateFlush); + } + + [Fact] + public void Emit_serializes_Kind_as_string_enum_name_for_backend_parser_compatibility() + { + // Backend TelemetryPayloadParser.ParseSignal reads `(string?)root["Kind"]`. Default + // Newtonsoft serializes enums as ints, which would silently fail the cast. The emitter + // must configure StringEnumConverter. + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetrySignalEmitter(transport, SessionId, TenantId); + + emitter.Emit(Signal(1, DecisionSignalKind.EspTerminalFailure)); + + var parsed = JObject.Parse(transport.Enqueued[0].PayloadJson); + Assert.Equal("EspTerminalFailure", (string?)parsed["Kind"]); + } + + [Fact] + public void Emit_emits_PascalCase_fields_the_backend_parser_reads() + { + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetrySignalEmitter(transport, SessionId, TenantId); + + emitter.Emit(Signal(7)); + + var parsed = JObject.Parse(transport.Enqueued[0].PayloadJson); + Assert.Equal(7L, (long?)parsed["SessionSignalOrdinal"]); + Assert.Equal(107L, (long?)parsed["SessionTraceOrdinal"]); + Assert.Equal("EspPhaseChanged", (string?)parsed["Kind"]); + Assert.Equal(1, (int?)parsed["KindSchemaVersion"]); + Assert.Equal("EspAndHelloTrackerAdapter", (string?)parsed["SourceOrigin"]); + Assert.NotNull(parsed["OccurredAtUtc"]); + } + + [Fact] + public void Emit_RowKey_padding_survives_long_MaxValue() + { + // 19 digits covers long.MaxValue. Guard the invariant — lex ordering matches numeric + // ordinal order only with full padding. + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetrySignalEmitter(transport, SessionId, TenantId); + + emitter.Emit(Signal(long.MaxValue)); + + Assert.Equal("9223372036854775807", transport.Enqueued[0].RowKey); + } + + [Fact] + public void Emit_rejects_null_signal() + { + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetrySignalEmitter(transport, SessionId, TenantId); + + Assert.Throws(() => emitter.Emit(null!)); + Assert.Equal(0, transport.EnqueueCount); + } + + [Fact] + public void Ctor_rejects_null_or_empty_identifiers() + { + var transport = new FakeTelemetryTransport(); + Assert.Throws(() => new TelemetrySignalEmitter(transport, "", TenantId)); + Assert.Throws(() => new TelemetrySignalEmitter(transport, SessionId, "")); + Assert.Throws(() => new TelemetrySignalEmitter(null!, SessionId, TenantId)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Transitions/TelemetryTransitionEmitterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Transitions/TelemetryTransitionEmitterTests.cs new file mode 100644 index 00000000..f6a9499f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Telemetry/Transitions/TelemetryTransitionEmitterTests.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions; +using AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Transitions +{ + /// + /// Pure emitter tests for (Codex Finding 1 fix). + /// Cross-layer round-trip with the backend parser lives in + /// TelemetryTransitionRoundTripTests. + /// + public sealed class TelemetryTransitionEmitterTests + { + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private static DecisionTransition Taken(int stepIndex, SessionStage to = SessionStage.EspDeviceSetup) + => new DecisionTransition( + stepIndex: stepIndex, + sessionTraceOrdinal: stepIndex + 500, + signalOrdinalRef: stepIndex + 1, + occurredAtUtc: At, + trigger: "EspExiting", + fromStage: SessionStage.AwaitingEspPhaseChange, + toStage: to, + taken: true, + deadEndReason: null, + reducerVersion: "1.0.0"); + + [Fact] + public void Emit_routes_transition_through_transport_with_correct_keys() + { + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetryTransitionEmitter(transport, SessionId, TenantId); + + emitter.Emit(Taken(7)); + + Assert.Equal(1, transport.EnqueueCount); + var item = transport.Enqueued[0]; + Assert.Equal(TelemetryItemKind.DecisionTransition, item.Kind); + Assert.Equal($"{TenantId}_{SessionId}", item.PartitionKey); + Assert.Equal("0000000007", item.RowKey); // D10 padding — matches TableDecisionTransitionRepository.BuildRowKey + Assert.False(item.RequiresImmediateFlush); + } + + [Fact] + public void Emit_serializes_FromStage_and_ToStage_as_string_enum_names() + { + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetryTransitionEmitter(transport, SessionId, TenantId); + + emitter.Emit(Taken(1, to: SessionStage.WhiteGloveSealed)); + + var parsed = JObject.Parse(transport.Enqueued[0].PayloadJson); + Assert.Equal("AwaitingEspPhaseChange", (string?)parsed["FromStage"]); + Assert.Equal("WhiteGloveSealed", (string?)parsed["ToStage"]); + } + + [Fact] + public void Emit_emits_PascalCase_fields_the_backend_parser_reads() + { + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetryTransitionEmitter(transport, SessionId, TenantId); + + emitter.Emit(Taken(3)); + + var parsed = JObject.Parse(transport.Enqueued[0].PayloadJson); + Assert.Equal(3, (int?)parsed["StepIndex"]); + Assert.Equal(503L, (long?)parsed["SessionTraceOrdinal"]); + Assert.Equal(4L, (long?)parsed["SignalOrdinalRef"]); + Assert.Equal("EspExiting", (string?)parsed["Trigger"]); + Assert.Equal(true, (bool?)parsed["Taken"]); + Assert.Equal("1.0.0", (string?)parsed["ReducerVersion"]); + Assert.NotNull(parsed["OccurredAtUtc"]); + } + + [Fact] + public void Emit_dead_end_preserves_DeadEndReason_and_Taken_false() + { + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetryTransitionEmitter(transport, SessionId, TenantId); + + var blocked = new DecisionTransition( + stepIndex: 5, + sessionTraceOrdinal: 505, + signalOrdinalRef: 6, + occurredAtUtc: At, + trigger: "EspExiting", + fromStage: SessionStage.EspDeviceSetup, + toStage: SessionStage.EspDeviceSetup, + taken: false, + deadEndReason: "hybrid_reboot_gate_blocking", + reducerVersion: "1.0.0"); + + emitter.Emit(blocked); + + var parsed = JObject.Parse(transport.Enqueued[0].PayloadJson); + Assert.Equal(false, (bool?)parsed["Taken"]); + Assert.Equal("hybrid_reboot_gate_blocking", (string?)parsed["DeadEndReason"]); + } + + [Fact] + public void Emit_with_classifier_verdict_emits_nested_object_parser_can_read() + { + // Backend ParseTransition reads ClassifierVerdict.ClassifierId and ClassifierVerdict.Level + // (also accepts "HypothesisLevel" as fallback). HypothesisLevel enum must serialize as + // a string — StringEnumConverter handles it. + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetryTransitionEmitter(transport, SessionId, TenantId); + + var verdict = new ClassifierVerdict( + classifierId: "whiteglove-sealing", + level: HypothesisLevel.Strong, + score: 80, + contributingFactors: new List { "PatternMatched" }, + reason: "test", + inputHash: "abc123"); + + var withVerdict = new DecisionTransition( + stepIndex: 9, + sessionTraceOrdinal: 509, + signalOrdinalRef: 10, + occurredAtUtc: At, + trigger: "EspExiting", + fromStage: SessionStage.EspDeviceSetup, + toStage: SessionStage.WhiteGloveSealed, + taken: true, + deadEndReason: null, + reducerVersion: "1.0.0", + classifierVerdict: verdict); + + emitter.Emit(withVerdict); + + var parsed = JObject.Parse(transport.Enqueued[0].PayloadJson); + var verdictJson = (JObject)parsed["ClassifierVerdict"]!; + Assert.Equal("whiteglove-sealing", (string?)verdictJson["ClassifierId"]); + Assert.Equal("Strong", (string?)verdictJson["Level"]); + } + + [Fact] + public void Emit_rejects_null_transition() + { + var transport = new FakeTelemetryTransport(); + var emitter = new TelemetryTransitionEmitter(transport, SessionId, TenantId); + + Assert.Throws(() => emitter.Emit(null!)); + Assert.Equal(0, transport.EnqueueCount); + } + + [Fact] + public void Ctor_rejects_null_or_empty_identifiers() + { + var transport = new FakeTelemetryTransport(); + Assert.Throws(() => new TelemetryTransitionEmitter(transport, "", TenantId)); + Assert.Throws(() => new TelemetryTransitionEmitter(transport, SessionId, "")); + Assert.Throws(() => new TelemetryTransitionEmitter(null!, SessionId, TenantId)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs new file mode 100644 index 00000000..e65c5168 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs @@ -0,0 +1,221 @@ +#nullable enable +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Termination; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination +{ + public sealed class EnrollmentTerminationHandlerTests + { + private static DateTime StartUtc => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + private static DateTime EndUtc => StartUtc.AddMinutes(10); + + /// + /// CleanupService is virtual for testability. ExecuteSelfDestruct is a best-effort + /// fire-and-forget in production — in tests we just count invocations. + /// + private sealed class RecordingCleanupService : CleanupService + { + public int Invocations; + public RecordingCleanupService(AgentConfiguration config, AgentLogger logger) : base(config, logger) { } + public override void ExecuteSelfDestruct() => Interlocked.Increment(ref Invocations); + } + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public string StateDir { get; } + public AgentLogger Logger { get; } + public DecisionState State { get; set; } = DecisionState.CreateInitial("S1", "T1"); + public AppPackageStateList Packages { get; } + public RecordingCleanupService CleanupService { get; } + public int DiagnosticsUploads { get; private set; } + public bool? LastDiagnosticsSucceededFlag { get; private set; } + public string? LastDiagnosticsSuffix { get; private set; } + public DiagnosticsUploadResult? DiagnosticsResult { get; set; } + public int ShutdownSignalled; + + public Rig() + { + StateDir = Path.Combine(Tmp.Path, "State"); + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Packages = new AppPackageStateList(Logger); + CleanupService = new RecordingCleanupService(BuildConfig(), Logger); + } + + public AgentConfiguration BuildConfig( + bool selfDestruct = true, + bool showDialog = false, + bool diagEnabled = false, + string diagMode = "Off") + { + return new AgentConfiguration + { + SessionId = "S1", + TenantId = "T1", + ApiBaseUrl = "http://localhost", + SelfDestructOnComplete = selfDestruct, + ShowEnrollmentSummary = showDialog, + DiagnosticsUploadEnabled = diagEnabled, + DiagnosticsUploadMode = diagMode, + EnrollmentSummaryTimeoutSeconds = 60, + }; + } + + public EnrollmentTerminationHandler Build(AgentConfiguration? config = null) + { + config ??= BuildConfig(); + return new EnrollmentTerminationHandler( + configuration: config, + logger: Logger, + stateDirectory: StateDir, + agentStartTimeUtc: StartUtc, + currentStateAccessor: () => State, + packageStatesAccessor: () => Packages, + cleanupServiceFactory: () => CleanupService, + uploadDiagnosticsAsync: (succeeded, suffix) => + { + DiagnosticsUploads++; + LastDiagnosticsSucceededFlag = succeeded; + LastDiagnosticsSuffix = suffix; + return Task.FromResult(DiagnosticsResult ?? new DiagnosticsUploadResult { BlobName = "blob" }); + }, + signalShutdown: () => Interlocked.Increment(ref ShutdownSignalled)); + } + + public void Dispose() => Tmp.Dispose(); + } + + private static EnrollmentTerminatedEventArgs Args( + EnrollmentTerminationReason reason, + EnrollmentTerminationOutcome outcome, + SessionStage stage, + DateTime? at = null) => + new EnrollmentTerminatedEventArgs( + reason, outcome, stage.ToString(), at ?? EndUtc); + + [Fact] + public void Handle_writes_final_status_json_in_state_directory() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + var statusPath = Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName); + Assert.True(File.Exists(statusPath), "final-status.json should be written even when ShowEnrollmentSummary=false"); + } + + [Fact] + public void Handle_writes_enrollment_complete_marker_on_non_whiteglove_terminal() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.True(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker"))); + } + + [Fact] + public void Handle_skips_marker_and_cleanup_on_whiteglove_part1_exit() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed)); + + Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker"))); + Assert.Equal(0, rig.CleanupService.Invocations); + } + + [Fact] + public void Handle_runs_cleanup_on_success_when_self_destruct_enabled() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + + rig.Build().Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Equal(1, rig.CleanupService.Invocations); + } + + [Fact] + public void Handle_skips_cleanup_when_self_destruct_disabled() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed)); + + Assert.Equal(0, rig.CleanupService.Invocations); + } + + [Fact] + public void Handle_signals_shutdown_exactly_once_even_on_error() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build(); + + var sut = rig.Build(); + sut.Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed)); + + // Idempotent — second Handle is a no-op. + sut.Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed)); + + Assert.Equal(1, rig.ShutdownSignalled); + } + + [Theory] + [InlineData("Off", EnrollmentTerminationOutcome.Succeeded, false, 0)] + [InlineData("Always", EnrollmentTerminationOutcome.Succeeded, true, 1)] + [InlineData("Always", EnrollmentTerminationOutcome.Failed, true, 1)] + [InlineData("OnFailure", EnrollmentTerminationOutcome.Succeeded, true, 0)] + [InlineData("OnFailure", EnrollmentTerminationOutcome.Failed, true, 1)] + public void Handle_diagnostics_upload_respects_mode_and_outcome( + string mode, EnrollmentTerminationOutcome outcome, bool enabled, int expected) + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build(); + var cfg = rig.BuildConfig(diagEnabled: enabled, diagMode: mode, selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, outcome, + outcome == EnrollmentTerminationOutcome.Succeeded ? SessionStage.Completed : SessionStage.Failed)); + + Assert.Equal(expected, rig.DiagnosticsUploads); + } + + [Fact] + public void Handle_diagnostics_upload_suffix_reflects_success_vs_failure() + { + using var rig = new Rig(); + rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build(); + var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false); + + rig.Build(cfg).Handle(sender: null!, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed)); + + Assert.Equal("failure", rig.LastDiagnosticsSuffix); + Assert.Equal(false, rig.LastDiagnosticsSucceededFlag); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs new file mode 100644 index 00000000..ebad4d30 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs @@ -0,0 +1,207 @@ +#nullable enable +using System; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Termination; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using Newtonsoft.Json; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination +{ + public sealed class FinalStatusBuilderTests + { + private static DateTime StartUtc => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + private static DateTime EndUtc => StartUtc.AddMinutes(45); + + private static DecisionState StateWith(SessionStage stage, string? helloOutcome = null) + { + var initial = DecisionState.CreateInitial("S1", "T1"); + var builder = new DecisionStateBuilder(initial) { Stage = stage }; + if (helloOutcome != null) + builder.HelloOutcome = new SignalFact(helloOutcome, sourceSignalOrdinal: 1); + return builder.Build(); + } + + private static EnrollmentTerminatedEventArgs Args( + EnrollmentTerminationReason reason, + EnrollmentTerminationOutcome outcome, + SessionStage stage) => + new EnrollmentTerminatedEventArgs( + reason: reason, + outcome: outcome, + stageName: stage.ToString(), + terminatedAtUtc: EndUtc); + + [Theory] + [InlineData(SessionStage.Completed, EnrollmentTerminationOutcome.Succeeded, "succeeded")] + [InlineData(SessionStage.Failed, EnrollmentTerminationOutcome.Failed, "failed")] + [InlineData(SessionStage.WhiteGloveSealed, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part1")] + [InlineData(SessionStage.WhiteGloveCompletedPart2, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part2")] + public void Build_maps_outcome_from_terminal_stage(SessionStage stage, EnrollmentTerminationOutcome outcome, string expected) + { + var state = StateWith(stage); + var args = Args(EnrollmentTerminationReason.DecisionTerminalStage, outcome, stage); + + var status = FinalStatusBuilder.Build(state, args, packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Equal(expected, status.Outcome); + } + + [Fact] + public void Build_reports_agent_uptime_in_seconds() + { + var state = StateWith(SessionStage.Completed); + var args = Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed); + + var status = FinalStatusBuilder.Build(state, args, packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Equal((EndUtc - StartUtc).TotalSeconds, status.AgentUptimeSeconds); + } + + [Fact] + public void Build_records_completion_source_from_reason() + { + var state = StateWith(SessionStage.Completed); + var args = Args(EnrollmentTerminationReason.MaxLifetimeExceeded, EnrollmentTerminationOutcome.TimedOut, SessionStage.Completed); + + var status = FinalStatusBuilder.Build(state, args, packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Equal(nameof(EnrollmentTerminationReason.MaxLifetimeExceeded), status.CompletionSource); + } + + [Fact] + public void Build_reports_hello_outcome_from_fact_or_unknown() + { + var state1 = StateWith(SessionStage.Completed, helloOutcome: "completed"); + var status1 = FinalStatusBuilder.Build(state1, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + Assert.Equal("completed", status1.HelloOutcome); + + var state2 = StateWith(SessionStage.Completed, helloOutcome: null); + var status2 = FinalStatusBuilder.Build(state2, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + Assert.Equal("unknown", status2.HelloOutcome); + } + + [Fact] + public void Build_signalsSeen_from_decision_state_facts() + { + var initial = DecisionState.CreateInitial("S1", "T1"); + var state = new DecisionStateBuilder(initial) + { + Stage = SessionStage.Completed, + HelloResolvedUtc = new SignalFact(StartUtc.AddMinutes(5), 1), + DesktopArrivedUtc = new SignalFact(StartUtc.AddMinutes(10), 2), + AadJoinedWithUser = new SignalFact(true, 3), + ImeMatchedPatternId = new SignalFact("esp_done", 4), + }.Build(); + + var status = FinalStatusBuilder.Build(state, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Contains("hello_resolved", status.SignalsSeen); + Assert.Contains("desktop_arrived", status.SignalsSeen); + Assert.Contains("aad_user_joined", status.SignalsSeen); + Assert.Contains("ime_pattern:esp_done", status.SignalsSeen); + } + + [Fact] + public void Build_with_null_package_states_produces_empty_app_summary() + { + var state = StateWith(SessionStage.Completed); + var status = FinalStatusBuilder.Build(state, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + + Assert.Equal(0, status.AppSummary.TotalApps); + Assert.Empty(status.AppSummary.AppsByPhase); + Assert.Empty(status.PackageStatesByPhase); + } + + [Fact] + public void Build_counts_apps_by_installation_state_and_targeted_phase() + { + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + var list = new AppPackageStateList(logger); + + var okDevice = new AppPackageState("app-1", 0); // default installationState = Unknown + okDevice.UpdateState(AppInstallationState.Installed); + TestHelpers.SetTargeted(okDevice, AppTargeted.Device); + list.Add(okDevice); + + var errDevice = new AppPackageState("app-2", 1); + errDevice.UpdateState(AppInstallationState.Error); + TestHelpers.SetTargeted(errDevice, AppTargeted.Device); + list.Add(errDevice); + + var errUser = new AppPackageState("app-3", 2); + errUser.UpdateState(AppInstallationState.Error); + TestHelpers.SetTargeted(errUser, AppTargeted.User); + list.Add(errUser); + + var inProgress = new AppPackageState("app-4", 3); + inProgress.UpdateState(AppInstallationState.InProgress); + TestHelpers.SetTargeted(inProgress, AppTargeted.Device); + list.Add(inProgress); + + var state = StateWith(SessionStage.Completed); + var status = FinalStatusBuilder.Build(state, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: list, agentStartTimeUtc: StartUtc); + + Assert.Equal(4, status.AppSummary.TotalApps); + Assert.Equal(3, status.AppSummary.CompletedApps); // Installed + 2 Errors count as completed (terminal) + Assert.Equal(2, status.AppSummary.ErrorCount); + Assert.Equal(1, status.AppSummary.DeviceErrors); + Assert.Equal(1, status.AppSummary.UserErrors); + Assert.Equal(3, status.AppSummary.AppsByPhase["Device"]); + Assert.Equal(1, status.AppSummary.AppsByPhase["User"]); + + Assert.True(status.PackageStatesByPhase.ContainsKey("Device")); + Assert.True(status.PackageStatesByPhase.ContainsKey("User")); + Assert.Equal(3, status.PackageStatesByPhase["Device"].Count); + Assert.Single(status.PackageStatesByPhase["User"]); + } + + [Fact] + public void Build_serializes_to_json_with_expected_property_names() + { + var state = StateWith(SessionStage.Completed); + var status = FinalStatusBuilder.Build(state, + Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed), + packageStates: null, agentStartTimeUtc: StartUtc); + + var json = JsonConvert.SerializeObject(status); + + // Dialog-wire contract: all fields lower-camelCase (matches SummaryDialog's FinalStatus model). + Assert.Contains("\"timestamp\":", json); + Assert.Contains("\"outcome\":", json); + Assert.Contains("\"completionSource\":", json); + Assert.Contains("\"helloOutcome\":", json); + Assert.Contains("\"enrollmentType\":", json); + Assert.Contains("\"agentUptimeSeconds\":", json); + Assert.Contains("\"signalsSeen\":", json); + Assert.Contains("\"appSummary\":", json); + Assert.Contains("\"packageStatesByPhase\":", json); + } + } + + /// Mini helper for reflection-based field setters — AppPackageState.Targeted is a private-set property. + internal static class TestHelpers + { + public static void SetTargeted(AppPackageState pkg, AppTargeted targeted) + { + var prop = typeof(AppPackageState).GetProperty(nameof(AppPackageState.Targeted))!; + prop.SetValue(pkg, targeted); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderControlSignalsTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderControlSignalsTests.cs new file mode 100644 index 00000000..c1635174 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderControlSignalsTests.cs @@ -0,0 +1,154 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport +{ + /// + /// M4.6.ε — BackendTelemetryUploader parses backend-to-agent control signals out of the + /// 2xx response body ( / / + /// / / + /// ). Malformed bodies degrade cleanly to a plain + /// . + /// + public sealed class BackendTelemetryUploaderControlSignalsTests + { + private const string BaseUrl = "https://backend.test"; + private static readonly DateTime At = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + + private static TelemetryItem NewEventItem(long id) => new TelemetryItem( + kind: TelemetryItemKind.Event, + partitionKey: "T1_S1", + rowKey: id.ToString("D10"), + telemetryItemId: id, + sessionTraceOrdinal: id, + payloadJson: "{\"EventType\":\"test\"}", + requiresImmediateFlush: false, + enqueuedAtUtc: At); + + private static (BackendTelemetryUploader sut, RecordingHttpMessageHandler handler) Build() + { + var handler = new RecordingHttpMessageHandler(); + var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; + var sut = new BackendTelemetryUploader( + httpClient: http, + baseUrl: BaseUrl, + tenantId: "T1", + manufacturer: "m", + model: "md", + serialNumber: "sn", + bootstrapToken: null, + agentVersion: "2.0.0"); + return (sut, handler); + } + + private static async System.Threading.Tasks.Task UploadWithBody(string body) + { + var (sut, handler) = Build(); + handler.QueueStatus(HttpStatusCode.OK, body); + return await sut.UploadBatchAsync(new List { NewEventItem(1) }, CancellationToken.None); + } + + [Fact] + public async System.Threading.Tasks.Task Empty_body_returns_plain_ok() + { + var result = await UploadWithBody(string.Empty); + Assert.True(result.Success); + Assert.False(result.DeviceBlocked); + Assert.False(result.DeviceKillSignal); + Assert.Null(result.AdminAction); + Assert.Null(result.Actions); + } + + [Fact] + public async System.Threading.Tasks.Task Malformed_json_body_returns_plain_ok() + { + var result = await UploadWithBody("{ not-json"); + Assert.True(result.Success); + Assert.False(result.DeviceBlocked); + Assert.False(result.DeviceKillSignal); + } + + [Fact] + public async System.Threading.Tasks.Task DeviceBlocked_true_is_parsed_with_unblock_timestamp() + { + var result = await UploadWithBody("{\"deviceBlocked\":true,\"unblockAt\":\"2026-04-21T12:00:00Z\"}"); + + Assert.True(result.Success); + Assert.True(result.DeviceBlocked); + Assert.NotNull(result.UnblockAt); + Assert.Equal(new DateTime(2026, 4, 21, 12, 0, 0, DateTimeKind.Utc), result.UnblockAt); + } + + [Fact] + public async System.Threading.Tasks.Task DeviceBlocked_accepts_PascalCase_field_names() + { + // Legacy IngestEventsResponse serialises PascalCase by default — V2 response parser + // must accept both styles so M5 backend doesn't force a specific casing. + var result = await UploadWithBody("{\"DeviceBlocked\":true}"); + + Assert.True(result.DeviceBlocked); + Assert.Null(result.UnblockAt); + } + + [Fact] + public async System.Threading.Tasks.Task DeviceKillSignal_true_is_parsed() + { + var result = await UploadWithBody("{\"deviceKillSignal\":true}"); + + Assert.True(result.Success); + Assert.True(result.DeviceKillSignal); + } + + [Fact] + public async System.Threading.Tasks.Task AdminAction_string_is_parsed() + { + var result = await UploadWithBody("{\"adminAction\":\"failed\"}"); + + Assert.Equal("failed", result.AdminAction); + } + + [Fact] + public async System.Threading.Tasks.Task Actions_array_is_parsed_into_ServerAction_list() + { + var body = "{\"actions\":[{\"type\":\"rotate_config\",\"reason\":\"manual refresh\"}," + + "{\"type\":\"request_diagnostics\",\"reason\":\"support ticket\",\"ruleId\":\"R-42\"}]}"; + + var result = await UploadWithBody(body); + + Assert.NotNull(result.Actions); + Assert.Equal(2, result.Actions!.Count); + Assert.Equal("rotate_config", result.Actions[0].Type); + Assert.Equal("request_diagnostics", result.Actions[1].Type); + Assert.Equal("R-42", result.Actions[1].RuleId); + } + + [Fact] + public async System.Threading.Tasks.Task Non_2xx_response_does_not_parse_control_signals() + { + var (sut, handler) = Build(); + handler.QueueStatus(HttpStatusCode.InternalServerError, "{\"deviceKillSignal\":true}"); + + var result = await sut.UploadBatchAsync(new List { NewEventItem(1) }, CancellationToken.None); + + Assert.False(result.Success); + Assert.True(result.IsTransient); + Assert.False(result.DeviceKillSignal); + } + + [Fact] + public async System.Threading.Tasks.Task Body_with_only_unknown_fields_returns_plain_ok() + { + var result = await UploadWithBody("{\"unrelated\":\"value\",\"count\":7}"); + Assert.True(result.Success); + Assert.False(result.DeviceBlocked); + Assert.False(result.DeviceKillSignal); + Assert.Null(result.Actions); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderTests.cs new file mode 100644 index 00000000..0baf96bc --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/BackendTelemetryUploaderTests.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport +{ + public sealed class BackendTelemetryUploaderTests + { + private const string BaseUrl = "https://backend.test"; + private const string ExpectedEndpoint = "https://backend.test/api/agent/telemetry"; + private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private static TelemetryItem NewEventItem(long id, string sessionId = "S1", string tenantId = "T1") => + new TelemetryItem( + kind: TelemetryItemKind.Event, + partitionKey: $"{tenantId}_{sessionId}", + rowKey: $"20260420100000000_{id:D10}", + telemetryItemId: id, + sessionTraceOrdinal: id, + payloadJson: $"{{\"EventType\":\"test\",\"Sequence\":{id}}}", + requiresImmediateFlush: false, + enqueuedAtUtc: At); + + private static TelemetryItem NewSignalItem(long id) => + new TelemetryItem( + kind: TelemetryItemKind.Signal, + partitionKey: "T1_S1", + rowKey: id.ToString("D10"), + telemetryItemId: id, + sessionTraceOrdinal: id, + payloadJson: "{\"Kind\":\"SessionStarted\"}", + requiresImmediateFlush: false, + enqueuedAtUtc: At); + + private sealed class Rig : IDisposable + { + public RecordingHttpMessageHandler Handler { get; } = new RecordingHttpMessageHandler(); + public HttpClient Http { get; } + public BackendTelemetryUploader Sut { get; } + + public Rig( + string tenantId = "T1", + string? manufacturer = "Dell", + string? model = "Latitude", + string? serial = "ABC123", + string? bootstrap = null, + string? version = "11.0.0") + { + Http = new HttpClient(Handler) { Timeout = TimeSpan.FromSeconds(30) }; + Sut = new BackendTelemetryUploader( + httpClient: Http, + baseUrl: BaseUrl, + tenantId: tenantId, + manufacturer: manufacturer, + model: model, + serialNumber: serial, + bootstrapToken: bootstrap, + agentVersion: version); + } + + public void Dispose() + { + Http.Dispose(); + Handler.Dispose(); + } + } + + // ============================================================ Request shape + + [Fact] + public async Task UploadBatchAsync_posts_to_api_telemetry_batch_with_expected_headers() + { + using var rig = new Rig(); + rig.Handler.QueueStatus(HttpStatusCode.OK); + + var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.True(result.Success); + Assert.Single(rig.Handler.Captured); + var req = rig.Handler.Captured[0]; + Assert.Equal(HttpMethod.Post, req.Method); + Assert.Equal(ExpectedEndpoint, req.RequestUri!.ToString()); + + Assert.True(req.TryGetHeader("X-Tenant-Id", out var tid)); + Assert.Equal("T1", tid); + Assert.True(req.TryGetHeader("X-Device-Manufacturer", out var mf)); + Assert.Equal("Dell", mf); + Assert.True(req.TryGetHeader("X-Device-Model", out var mo)); + Assert.Equal("Latitude", mo); + Assert.True(req.TryGetHeader("X-Device-SerialNumber", out var sn)); + Assert.Equal("ABC123", sn); + Assert.True(req.TryGetHeader("X-Agent-Version", out var ver)); + Assert.Equal("11.0.0", ver); + + Assert.True(req.TryGetHeader("Content-Type", out var ct)); + Assert.StartsWith("application/json", ct); + + Assert.True(req.TryGetHeader("Content-Encoding", out var ce)); + Assert.Equal("gzip", ce); + + Assert.False(req.Headers.ContainsKey("X-Bootstrap-Token")); + } + + // ============================================================ Compression (bandwidth) + + [Fact] + public async Task Body_is_gzip_compressed_on_the_wire() + { + using var rig = new Rig(); + rig.Handler.QueueStatus(HttpStatusCode.OK); + + // Moderately-sized batch so compression does real work (20 items). + var batch = new List(); + for (long i = 1; i <= 20; i++) batch.Add(NewEventItem(i)); + + await rig.Sut.UploadBatchAsync(batch, CancellationToken.None); + + var req = rig.Handler.Captured[0]; + + // Content-Encoding header advertises gzip to the backend. + Assert.True(req.TryGetHeader("Content-Encoding", out var ce)); + Assert.Equal("gzip", ce); + + // Wire bytes begin with the gzip magic number (1F 8B). + Assert.NotNull(req.RawBody); + Assert.True(req.RawBody!.Length >= 2); + Assert.Equal(0x1F, req.RawBody[0]); + Assert.Equal(0x8B, req.RawBody[1]); + + // Decompressed payload is still the expected JSON array (RecordingHandler + // transparently decompresses into Body for assertions). + var parsed = JArray.Parse(req.Body!); + Assert.Equal(20, parsed.Count); + + // Compression is actually smaller than plain JSON for a batch of this size. + var plainSize = System.Text.Encoding.UTF8.GetByteCount(req.Body!); + Assert.True( + req.RawBody.Length < plainSize, + $"gzip body ({req.RawBody.Length} B) should be smaller than plain JSON ({plainSize} B)."); + } + + [Fact] + public async Task Bootstrap_token_is_attached_only_when_provided() + { + using var rig = new Rig(bootstrap: "bs-abc"); + rig.Handler.QueueStatus(HttpStatusCode.OK); + + await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + var req = rig.Handler.Captured[0]; + Assert.True(req.TryGetHeader("X-Bootstrap-Token", out var tok)); + Assert.Equal("bs-abc", tok); + } + + [Fact] + public async Task Hardware_headers_are_omitted_when_values_are_null() + { + using var rig = new Rig(manufacturer: null, model: null, serial: null); + rig.Handler.QueueStatus(HttpStatusCode.OK); + + await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + var req = rig.Handler.Captured[0]; + Assert.False(req.Headers.ContainsKey("X-Device-Manufacturer")); + Assert.False(req.Headers.ContainsKey("X-Device-Model")); + Assert.False(req.Headers.ContainsKey("X-Device-SerialNumber")); + } + + [Fact] + public async Task Body_is_JSON_array_of_TelemetryItems_with_Kind_as_string() + { + using var rig = new Rig(); + rig.Handler.QueueStatus(HttpStatusCode.OK); + + var batch = new[] { NewEventItem(1), NewSignalItem(2) }; + await rig.Sut.UploadBatchAsync(batch, CancellationToken.None); + + var body = rig.Handler.Captured[0].Body!; + var parsed = JArray.Parse(body); + Assert.Equal(2, parsed.Count); + Assert.Equal("Event", (string?)parsed[0]["Kind"]); + Assert.Equal(1, (long)parsed[0]["TelemetryItemId"]!); + Assert.Equal("Signal", (string?)parsed[1]["Kind"]); + Assert.Equal(2, (long)parsed[1]["TelemetryItemId"]!); + Assert.Equal("T1_S1", (string?)parsed[0]["PartitionKey"]); + } + + [Fact] + public async Task Empty_batch_short_circuits_without_hitting_the_network() + { + using var rig = new Rig(); + var result = await rig.Sut.UploadBatchAsync(Array.Empty(), CancellationToken.None); + + Assert.True(result.Success); + Assert.Empty(rig.Handler.Captured); + } + + // ============================================================ Status mapping + + [Theory] + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.Accepted)] + [InlineData(HttpStatusCode.NoContent)] + public async Task Success_2xx_maps_to_Ok(HttpStatusCode status) + { + using var rig = new Rig(); + rig.Handler.QueueStatus(status); + + var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.True(result.Success); + Assert.Null(result.ErrorReason); + } + + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + public async Task Auth_failure_401_403_maps_to_Permanent_unauthorized(HttpStatusCode status) + { + using var rig = new Rig(); + rig.Handler.QueueStatus(status, body: "device not whitelisted"); + + var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.False(result.Success); + Assert.False(result.IsTransient); + Assert.Contains("unauthorized", result.ErrorReason); + Assert.Contains(((int)status).ToString(), result.ErrorReason); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] // 400 + [InlineData(HttpStatusCode.NotFound)] // 404 + [InlineData(HttpStatusCode.Conflict)] // 409 + [InlineData(HttpStatusCode.UnsupportedMediaType)] // 415 + public async Task Other_4xx_maps_to_Permanent(HttpStatusCode status) + { + using var rig = new Rig(); + rig.Handler.QueueStatus(status); + + var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.False(result.Success); + Assert.False(result.IsTransient); + Assert.Contains(((int)status).ToString(), result.ErrorReason); + } + + [Theory] + [InlineData(HttpStatusCode.RequestTimeout)] // 408 + [InlineData((HttpStatusCode)429)] // TooManyRequests + public async Task Rate_limit_and_408_map_to_Transient(HttpStatusCode status) + { + using var rig = new Rig(); + rig.Handler.QueueStatus(status); + + var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.False(result.Success); + Assert.True(result.IsTransient); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] // 500 + [InlineData(HttpStatusCode.BadGateway)] // 502 + [InlineData(HttpStatusCode.ServiceUnavailable)] // 503 + [InlineData(HttpStatusCode.GatewayTimeout)] // 504 + public async Task Server_5xx_maps_to_Transient(HttpStatusCode status) + { + using var rig = new Rig(); + rig.Handler.QueueStatus(status); + + var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.False(result.Success); + Assert.True(result.IsTransient); + } + + // ============================================================ Exception handling + + [Fact] + public async Task HttpRequestException_network_error_maps_to_Transient() + { + using var rig = new Rig(); + rig.Handler.QueueThrow(new HttpRequestException("dns failure")); + + var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.False(result.Success); + Assert.True(result.IsTransient); + Assert.Contains("network", result.ErrorReason); + } + + [Fact] + public async Task HttpClient_timeout_maps_to_Transient() + { + // HttpClient surfaces its own internal timeout as TaskCanceledException with + // CancellationToken.IsCancellationRequested == false. + using var rig = new Rig(); + rig.Handler.QueueThrow(new TaskCanceledException("The operation was canceled.")); + + var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None); + + Assert.False(result.Success); + Assert.True(result.IsTransient); + Assert.Contains("timeout", result.ErrorReason); + } + + [Fact] + public async Task Caller_cancellation_propagates_as_OperationCanceledException() + { + using var rig = new Rig(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => + rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, cts.Token)); + } + + // ============================================================ Misuse + + [Fact] + public void Constructor_rejects_null_or_empty_required_args() + { + var http = new HttpClient(); + Assert.Throws(() => + new BackendTelemetryUploader(null!, BaseUrl, "T1")); + Assert.Throws(() => + new BackendTelemetryUploader(http, "", "T1")); + Assert.Throws(() => + new BackendTelemetryUploader(http, BaseUrl, "")); + } + + [Fact] + public async Task UploadBatchAsync_rejects_null_items() + { + using var rig = new Rig(); + await Assert.ThrowsAsync(() => + rig.Sut.UploadBatchAsync(null!, CancellationToken.None)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/FakeBackendTelemetryUploader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/FakeBackendTelemetryUploader.cs new file mode 100644 index 00000000..8940f364 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/FakeBackendTelemetryUploader.cs @@ -0,0 +1,95 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport +{ + /// + /// Scripted test double for . Feed it a sequence of + /// s and it returns them in order. Tracks every batch it was called with. + /// + internal sealed class FakeBackendTelemetryUploader : IBackendTelemetryUploader + { + private readonly Queue, UploadResult>> _script = + new Queue, UploadResult>>(); + + private readonly ConcurrentQueue> _received = + new ConcurrentQueue>(); + + public int CallCount { get; private set; } + + public IReadOnlyList> Received + { + get + { + var list = new List>(); + foreach (var b in _received) list.Add(b); + return list; + } + } + + public FakeBackendTelemetryUploader QueueOk(int times = 1) + { + for (int i = 0; i < times; i++) _script.Enqueue(_ => UploadResult.Ok()); + return this; + } + + public FakeBackendTelemetryUploader QueueTransient(string reason = "fake-transient") + { + _script.Enqueue(_ => UploadResult.Transient(reason)); + return this; + } + + public FakeBackendTelemetryUploader QueuePermanent(string reason = "fake-permanent") + { + _script.Enqueue(_ => UploadResult.Permanent(reason)); + return this; + } + + public FakeBackendTelemetryUploader QueueThrow(Exception ex) + { + _script.Enqueue(_ => throw ex); + return this; + } + + /// + /// Queues a 2xx success carrying backend-to-agent control signals (DeviceBlocked / + /// DeviceKillSignal / AdminAction / Actions). M4.6.ε. + /// + public FakeBackendTelemetryUploader QueueOkWithSignals( + bool deviceBlocked = false, + DateTime? unblockAt = null, + bool deviceKillSignal = false, + string? reason_AdminAction = null, + IReadOnlyList? actions = null) + { + _script.Enqueue(_ => UploadResult.OkWithSignals( + deviceBlocked: deviceBlocked, + unblockAt: unblockAt, + deviceKillSignal: deviceKillSignal, + adminAction: reason_AdminAction, + actions: actions)); + return this; + } + + public Task UploadBatchAsync( + IReadOnlyList items, + CancellationToken cancellationToken) + { + CallCount++; + _received.Enqueue(items); + + if (_script.Count == 0) + { + return Task.FromResult(UploadResult.Transient("fake-no-script")); + } + + var next = _script.Dequeue(); + return Task.FromResult(next(items)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/RecordingHttpMessageHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/RecordingHttpMessageHandler.cs new file mode 100644 index 00000000..6fdba0a5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/RecordingHttpMessageHandler.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport +{ + /// + /// Test that captures every outgoing request (including the + /// body string, because the default handler disposes content after SendAsync returns) + /// and returns responses from a scripted queue. + /// + internal sealed class RecordingHttpMessageHandler : HttpMessageHandler + { + private readonly Queue> _script = + new Queue>(); + private readonly List _captured = new List(); + + public IReadOnlyList Captured => _captured; + + public RecordingHttpMessageHandler QueueStatus(HttpStatusCode status, string? body = null) + { + _script.Enqueue(_ => + { + var resp = new HttpResponseMessage(status); + if (body != null) resp.Content = new StringContent(body, Encoding.UTF8, "application/json"); + return resp; + }); + return this; + } + + public RecordingHttpMessageHandler QueueThrow(Exception ex) + { + _script.Enqueue(_ => throw ex); + return this; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + // Real HttpClient throws TaskCanceledException when the caller's token is already + // signaled, before touching the network. Mirror that here so cancellation tests + // don't silently succeed with a default 200. + cancellationToken.ThrowIfCancellationRequested(); + + string? body = null; + byte[]? rawBody = null; + if (request.Content != null) + { + rawBody = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + var isGzipped = System.Linq.Enumerable.Contains( + request.Content.Headers.ContentEncoding, "gzip", StringComparer.OrdinalIgnoreCase); + if (isGzipped && rawBody.Length > 0) + { + using var input = new MemoryStream(rawBody); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + body = Encoding.UTF8.GetString(output.ToArray()); + } + else + { + body = Encoding.UTF8.GetString(rawBody); + } + } + + _captured.Add(new CapturedRequest(request, body, rawBody)); + + if (_script.Count == 0) + { + return new HttpResponseMessage(HttpStatusCode.OK); + } + + return _script.Dequeue()(request); + } + + internal sealed class CapturedRequest + { + public CapturedRequest(HttpRequestMessage request, string? body, byte[]? rawBody = null) + { + Method = request.Method; + RequestUri = request.RequestUri; + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var h in request.Headers) + { + Headers[h.Key] = System.Linq.Enumerable.ToArray(h.Value); + } + if (request.Content != null) + { + foreach (var h in request.Content.Headers) + { + Headers[h.Key] = System.Linq.Enumerable.ToArray(h.Value); + } + } + Body = body; + RawBody = rawBody; + } + + public HttpMethod Method { get; } + public Uri? RequestUri { get; } + public Dictionary Headers { get; } + public string? Body { get; } + public byte[]? RawBody { get; } + + public bool TryGetHeader(string name, out string value) + { + if (Headers.TryGetValue(name, out var values) && values.Length > 0) + { + value = values[0]; + return true; + } + value = string.Empty; + return false; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetrySpoolTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetrySpoolTests.cs new file mode 100644 index 00000000..d321309a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetrySpoolTests.cs @@ -0,0 +1,190 @@ +using System; +using System.IO; +using System.Text; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport +{ + public sealed class TelemetrySpoolTests + { + private static TelemetryItemDraft EventDraft(string rowKey = "row-1") => + new TelemetryItemDraft( + kind: TelemetryItemKind.Event, + partitionKey: "tenant_session", + rowKey: rowKey, + payloadJson: "{\"EventType\":\"agent_started\"}", + isSessionScoped: true); + + private static TelemetryItemDraft SignalDraft(string rowKey = "0000000000") => + new TelemetryItemDraft( + kind: TelemetryItemKind.Signal, + partitionKey: "tenant_session", + rowKey: rowKey, + payloadJson: "{\"Kind\":\"SessionStarted\"}", + isSessionScoped: true); + + private static VirtualClock Clock() => new VirtualClock(new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc)); + + [Fact] + public void Enqueue_assigns_monotonic_TelemetryItemId_starting_at_zero() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + + var a = spool.Enqueue(EventDraft("a")); + var b = spool.Enqueue(SignalDraft("0000000001")); + var c = spool.Enqueue(EventDraft("c")); + + Assert.Equal(0, a.TelemetryItemId); + Assert.Equal(1, b.TelemetryItemId); + Assert.Equal(2, c.TelemetryItemId); + Assert.Equal(2, spool.LastAssignedItemId); + } + + [Fact] + public void Enqueue_session_scoped_sets_SessionTraceOrdinal_equal_to_TelemetryItemId() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + + var item = spool.Enqueue(EventDraft()); + Assert.Equal(item.TelemetryItemId, item.SessionTraceOrdinal); + } + + [Fact] + public void Enqueue_non_session_scoped_has_null_SessionTraceOrdinal() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + + var draft = new TelemetryItemDraft( + kind: TelemetryItemKind.Event, + partitionKey: "tenant_agent-global", + rowKey: "rk", + payloadJson: "{}", + isSessionScoped: false); + + var item = spool.Enqueue(draft); + Assert.Null(item.SessionTraceOrdinal); + } + + [Fact] + public void Enqueue_is_flushed_to_disk_immediately_plan_L12() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + spool.Enqueue(EventDraft("row-flush")); + + var spoolFile = Path.Combine(tmp.Path, "spool.jsonl"); + var bytes = File.ReadAllBytes(spoolFile); + Assert.True(bytes.Length > 0); + var text = Encoding.UTF8.GetString(bytes); + Assert.Contains("row-flush", text); + } + + [Fact] + public void Peek_returns_pending_items_after_cursor_in_order() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + for (int i = 0; i < 5; i++) spool.Enqueue(EventDraft($"r{i}")); + + var peek = spool.Peek(10); + Assert.Equal(5, peek.Count); + for (int i = 0; i < 5; i++) Assert.Equal(i, peek[i].TelemetryItemId); + } + + [Fact] + public void Peek_respects_max_argument() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + for (int i = 0; i < 10; i++) spool.Enqueue(EventDraft($"r{i}")); + + var peek = spool.Peek(3); + Assert.Equal(3, peek.Count); + Assert.Equal(0, peek[0].TelemetryItemId); + Assert.Equal(2, peek[2].TelemetryItemId); + } + + [Fact] + public void Peek_after_MarkUploaded_skips_already_uploaded_items() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + for (int i = 0; i < 5; i++) spool.Enqueue(EventDraft($"r{i}")); + + spool.MarkUploaded(2); + var remaining = spool.Peek(10); + Assert.Equal(2, remaining.Count); + Assert.Equal(3, remaining[0].TelemetryItemId); + Assert.Equal(4, remaining[1].TelemetryItemId); + } + + [Fact] + public void MarkUploaded_regression_is_ignored_idempotent() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + for (int i = 0; i < 3; i++) spool.Enqueue(EventDraft($"r{i}")); + + spool.MarkUploaded(2); + spool.MarkUploaded(0); // regression — must be no-op + spool.MarkUploaded(-5); + Assert.Equal(2, spool.LastUploadedItemId); + } + + [Fact] + public void MarkUploaded_beyond_last_assigned_throws() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + spool.Enqueue(EventDraft("only")); + Assert.Throws(() => spool.MarkUploaded(99)); + } + + [Fact] + public void New_spool_on_existing_dir_recovers_counters() + { + // Simulates agent restart — §2.7 counter-recovery rules 1 + 3. + 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 s2 = new TelemetrySpool(tmp.Path, Clock()); + Assert.Equal(2, s2.LastAssignedItemId); + Assert.Equal(1, s2.LastUploadedItemId); + + var pending = s2.Peek(10); + Assert.Single(pending); + Assert.Equal(2, pending[0].TelemetryItemId); + + var d = s2.Enqueue(EventDraft("d")); + Assert.Equal(3, d.TelemetryItemId); + } + + [Fact] + public void Scan_skips_corrupt_tail_line_simulating_crash() + { + using var tmp = new TempDirectory(); + var s1 = new TelemetrySpool(tmp.Path, Clock()); + s1.Enqueue(EventDraft("a")); + + File.AppendAllText( + Path.Combine(tmp.Path, "spool.jsonl"), + "{\"Kind\":\"Event\",\"PartitionKey\":\"corrupt-no-", + Encoding.UTF8); + + var s2 = new TelemetrySpool(tmp.Path, Clock()); + Assert.Equal(0, s2.LastAssignedItemId); + var next = s2.Enqueue(EventDraft("next")); + Assert.Equal(1, next.TelemetryItemId); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetryUploadOrchestratorServerResponseTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetryUploadOrchestratorServerResponseTests.cs new file mode 100644 index 00000000..7953b397 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetryUploadOrchestratorServerResponseTests.cs @@ -0,0 +1,155 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport +{ + /// + /// M4.6.ε — hook plus the + /// internal DeviceBlocked pause logic. + /// + public sealed class TelemetryUploadOrchestratorServerResponseTests + { + private static TelemetryItemDraft Draft(long traceOrdinal = 1) => + new TelemetryItemDraft( + kind: TelemetryItemKind.Event, + partitionKey: "T1_S1", + rowKey: traceOrdinal.ToString("D10"), + payloadJson: "{}", + isSessionScoped: true, + requiresImmediateFlush: false); + + private sealed class Rig : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public VirtualClock Clock { get; } = new VirtualClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc)); + public TelemetrySpool Spool { get; } + public FakeBackendTelemetryUploader Uploader { get; } = new FakeBackendTelemetryUploader(); + public TelemetryUploadOrchestrator Sut { get; } + + public Rig() + { + Spool = new TelemetrySpool(Tmp.Path, Clock); + Sut = new TelemetryUploadOrchestrator(Spool, Uploader, Clock, batchSize: 10, retryBackoffs: new[] { TimeSpan.Zero }); + } + + public void Dispose() { Sut.Dispose(); Tmp.Dispose(); } + } + + [Fact] + public async Task Plain_ok_does_not_raise_ServerResponseReceived() + { + using var rig = new Rig(); + rig.Sut.Enqueue(Draft()); + rig.Uploader.QueueOk(); + + var fired = 0; + rig.Sut.ServerResponseReceived += (_, _) => Interlocked.Increment(ref fired); + + await rig.Sut.DrainAllAsync(); + Assert.Equal(0, fired); + } + + [Fact] + public async Task Ok_with_kill_signal_raises_ServerResponseReceived_once() + { + using var rig = new Rig(); + rig.Sut.Enqueue(Draft()); + rig.Uploader.QueueOkWithSignals(deviceKillSignal: true); + + UploadResult? captured = null; + var fired = 0; + rig.Sut.ServerResponseReceived += (_, r) => { Interlocked.Increment(ref fired); captured = r; }; + + await rig.Sut.DrainAllAsync(); + Assert.Equal(1, fired); + Assert.NotNull(captured); + Assert.True(captured!.DeviceKillSignal); + } + + [Fact] + public async Task Ok_with_admin_action_raises_event_with_field() + { + using var rig = new Rig(); + rig.Sut.Enqueue(Draft()); + rig.Uploader.QueueOkWithSignals(reason_AdminAction: "failed"); + + string? capturedAction = null; + rig.Sut.ServerResponseReceived += (_, r) => capturedAction = r.AdminAction; + + await rig.Sut.DrainAllAsync(); + Assert.Equal("failed", capturedAction); + } + + [Fact] + public async Task Ok_with_actions_list_raises_event_carrying_actions() + { + using var rig = new Rig(); + rig.Sut.Enqueue(Draft()); + var actions = new List + { + new ServerAction { Type = ServerActionTypes.RotateConfig, Reason = "periodic" }, + }; + rig.Uploader.QueueOkWithSignals(actions: actions); + + IReadOnlyList? captured = null; + rig.Sut.ServerResponseReceived += (_, r) => captured = r.Actions; + + await rig.Sut.DrainAllAsync(); + Assert.NotNull(captured); + Assert.Single(captured!); + Assert.Equal(ServerActionTypes.RotateConfig, captured[0].Type); + } + + [Fact] + public async Task DeviceBlocked_pauses_drain_until_unblock_at() + { + using var rig = new Rig(); + rig.Sut.Enqueue(Draft(traceOrdinal: 1)); + rig.Uploader.QueueOkWithSignals( + deviceBlocked: true, + unblockAt: rig.Clock.UtcNow.AddMinutes(10)); + + await rig.Sut.DrainAllAsync(); + Assert.True(rig.Sut.IsPaused); + + // New item after block arrives — drain must short-circuit without calling the uploader. + // Pause is an expected state, not an upload failure: FailedBatches stays 0, but + // LastErrorReason carries the "device blocked" hint for observability. + rig.Sut.Enqueue(Draft(traceOrdinal: 2)); + var callsBefore = rig.Uploader.CallCount; + var drain = await rig.Sut.DrainAllAsync(); + Assert.Equal(callsBefore, rig.Uploader.CallCount); + Assert.Equal(0, drain.UploadedItems); + Assert.Contains("device blocked", drain.LastErrorReason ?? string.Empty); + + // Advance clock past UnblockAt — pause lifts, next drain uploads the queued item. + rig.Uploader.QueueOk(); + rig.Clock.Advance(TimeSpan.FromMinutes(11)); + Assert.False(rig.Sut.IsPaused); + var drain2 = await rig.Sut.DrainAllAsync(); + Assert.Equal(1, drain2.UploadedItems); + } + + [Fact] + public async Task Handler_exception_does_not_break_drain_cursor() + { + using var rig = new Rig(); + rig.Sut.Enqueue(Draft()); + rig.Uploader.QueueOkWithSignals(deviceKillSignal: true); + + rig.Sut.ServerResponseReceived += (_, _) => throw new InvalidOperationException("handler bug"); + + var drain = await rig.Sut.DrainAllAsync(); + // Upload still succeeded — the cursor moves, drain returns success. + Assert.Equal(1, drain.UploadedItems); + Assert.Equal(0, drain.FailedBatches); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetryUploadOrchestratorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetryUploadOrchestratorTests.cs new file mode 100644 index 00000000..12aacf12 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/TelemetryUploadOrchestratorTests.cs @@ -0,0 +1,288 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport +{ + public sealed class TelemetryUploadOrchestratorTests + { + private static TelemetryItemDraft Draft(string rowKey) => + new TelemetryItemDraft( + kind: TelemetryItemKind.Event, + partitionKey: "tenant_session", + rowKey: rowKey, + payloadJson: $"{{\"rk\":\"{rowKey}\"}}", + isSessionScoped: true); + + private static VirtualClock Clock() => new VirtualClock(new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc)); + + [Fact] + public async Task Drain_happy_path_uploads_all_pending_and_advances_cursor() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + for (int i = 0; i < 3; i++) spool.Enqueue(Draft($"r{i}")); + + var uploader = new FakeBackendTelemetryUploader().QueueOk(1); + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock(), batchSize: 100); + + var result = await sut.DrainAllAsync(); + + Assert.Equal(3, result.UploadedItems); + Assert.Equal(0, result.FailedBatches); + Assert.True(result.Success); + Assert.Equal(2, sut.LastUploadedItemId); + Assert.Single(uploader.Received); + } + + [Fact] + public async Task Drain_empty_spool_is_noop() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + var uploader = new FakeBackendTelemetryUploader(); + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock()); + + var result = await sut.DrainAllAsync(); + + Assert.Equal(0, result.UploadedItems); + Assert.Equal(0, result.FailedBatches); + Assert.Equal(0, uploader.CallCount); + } + + [Fact] + public async Task Drain_batches_according_to_batchSize() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + for (int i = 0; i < 10; i++) spool.Enqueue(Draft($"r{i}")); + + var uploader = new FakeBackendTelemetryUploader().QueueOk(4); + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock(), batchSize: 3); + + var result = await sut.DrainAllAsync(); + + // 10 items / batch 3 = 4 batches (3 + 3 + 3 + 1) + Assert.Equal(10, result.UploadedItems); + Assert.Equal(4, uploader.CallCount); + Assert.Equal(9, sut.LastUploadedItemId); + + // Verify batch sizes and order + var received = uploader.Received; + Assert.Equal(3, received[0].Count); + Assert.Equal(3, received[1].Count); + Assert.Equal(3, received[2].Count); + Assert.Single(received[3]); + Assert.Equal(0, received[0][0].TelemetryItemId); + Assert.Equal(9, received[3][0].TelemetryItemId); + } + + [Fact] + public async Task Retry_transient_failures_up_to_3_attempts_then_succeed() + { + // Plan L.17 — Transient-Retry 100/400/1600ms. VirtualClock.Delay instant. + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + spool.Enqueue(Draft("only")); + + var uploader = new FakeBackendTelemetryUploader() + .QueueTransient("first") + .QueueTransient("second") + .QueueOk(); + + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock()); + var result = await sut.DrainAllAsync(); + + Assert.True(result.Success); + Assert.Equal(1, result.UploadedItems); + Assert.Equal(3, uploader.CallCount); + Assert.Equal(0, sut.LastUploadedItemId); + } + + [Fact] + public async Task Retry_gives_up_after_all_transient_backoffs_exhausted() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + spool.Enqueue(Draft("only")); + + var uploader = new FakeBackendTelemetryUploader() + .QueueTransient() + .QueueTransient() + .QueueTransient() + .QueueTransient("final-transient"); + + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock()); + var result = await sut.DrainAllAsync(); + + Assert.False(result.Success); + Assert.Equal(0, result.UploadedItems); + Assert.Equal(1, result.FailedBatches); + Assert.Equal(4, uploader.CallCount); // 1 initial + 3 retries + Assert.Contains("final-transient", result.LastErrorReason); + Assert.Equal(-1, sut.LastUploadedItemId); // cursor not advanced + } + + [Fact] + public async Task Permanent_failure_aborts_without_retrying() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + spool.Enqueue(Draft("only")); + + var uploader = new FakeBackendTelemetryUploader().QueuePermanent("bad-data"); + + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock()); + var result = await sut.DrainAllAsync(); + + Assert.False(result.Success); + Assert.Equal(1, uploader.CallCount); // no retries for permanent + Assert.Equal(-1, sut.LastUploadedItemId); + } + + [Fact] + public async Task Exception_in_uploader_is_treated_as_transient_and_retried() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + spool.Enqueue(Draft("only")); + + var uploader = new FakeBackendTelemetryUploader() + .QueueThrow(new InvalidOperationException("boom")) + .QueueOk(); + + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock()); + var result = await sut.DrainAllAsync(); + + Assert.True(result.Success); + Assert.Equal(2, uploader.CallCount); + } + + [Fact] + public async Task Drain_resumes_on_second_call_after_transient_failure() + { + // Plan §2.7a resume-fähig. Failed batch stays in spool; next drain picks it up again. + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + for (int i = 0; i < 3; i++) spool.Enqueue(Draft($"r{i}")); + + // First drain: transient exhaust. + var uploader = new FakeBackendTelemetryUploader() + .QueueTransient().QueueTransient().QueueTransient().QueueTransient(); + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock()); + var first = await sut.DrainAllAsync(); + Assert.False(first.Success); + Assert.Equal(-1, sut.LastUploadedItemId); + + // Second drain: uploader is healthy now — must re-upload same items. + uploader.QueueOk(); + var second = await sut.DrainAllAsync(); + Assert.True(second.Success); + Assert.Equal(3, second.UploadedItems); + Assert.Equal(2, sut.LastUploadedItemId); + } + + [Fact] + public async Task Drain_after_restart_continues_from_persisted_cursor() + { + // Plan §2.7a — LastUploadedItemId survives process restart. + using var tmp = new TempDirectory(); + + // First run: enqueue 5 items; drain with batchSize=3 + 1 OK; uploader exhausts + // mid-drain, cursor advances to 2, items 3-4 stay pending. + var spool1 = new TelemetrySpool(tmp.Path, Clock()); + for (int i = 0; i < 5; i++) spool1.Enqueue(Draft($"r{i}")); + var uploader1 = new FakeBackendTelemetryUploader().QueueOk(1); + using (var sut1 = new TelemetryUploadOrchestrator(spool1, uploader1, Clock(), batchSize: 3)) + { + await sut1.DrainAllAsync(); + Assert.Equal(2, sut1.LastUploadedItemId); + } + + // Restart: fresh spool + orchestrator on same dir recovers cursor=2. + var spool2 = new TelemetrySpool(tmp.Path, Clock()); + Assert.Equal(4, spool2.LastAssignedItemId); + Assert.Equal(2, spool2.LastUploadedItemId); + + spool2.Enqueue(Draft("r5")); + + var uploader2 = new FakeBackendTelemetryUploader().QueueOk(10); + using var sut2 = new TelemetryUploadOrchestrator(spool2, uploader2, Clock(), batchSize: 10); + + var result = await sut2.DrainAllAsync(); + + Assert.Equal(3, result.UploadedItems); // items 3, 4, 5 + Assert.Equal(5, sut2.LastUploadedItemId); + } + + [Fact] + public async Task Cursor_persists_across_orchestrator_instances() + { + using var tmp = new TempDirectory(); + + var spool1 = new TelemetrySpool(tmp.Path, Clock()); + spool1.Enqueue(Draft("a")); + spool1.Enqueue(Draft("b")); + var uploader = new FakeBackendTelemetryUploader().QueueOk(); + using (var sut1 = new TelemetryUploadOrchestrator(spool1, uploader, Clock())) + { + await sut1.DrainAllAsync(); + } + + var spool2 = new TelemetrySpool(tmp.Path, Clock()); + Assert.Equal(1, spool2.LastUploadedItemId); + Assert.Empty(spool2.Peek(10)); + } + + [Fact] + public async Task Double_drain_is_idempotent_no_duplicate_uploads() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + spool.Enqueue(Draft("a")); + + var uploader = new FakeBackendTelemetryUploader().QueueOk(2); + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock()); + + var first = await sut.DrainAllAsync(); + var second = await sut.DrainAllAsync(); + + Assert.Equal(1, first.UploadedItems); + Assert.Equal(0, second.UploadedItems); + Assert.Equal(1, uploader.CallCount); // second drain saw empty spool + } + + [Fact] + public async Task Cancellation_stops_drain_loop() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + spool.Enqueue(Draft("a")); + + var uploader = new FakeBackendTelemetryUploader(); + // Never enqueue OK — uploader will return 'no-script' transient repeatedly. + + using var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock()); + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Pre-cancelled + + await Assert.ThrowsAnyAsync( + () => sut.DrainAllAsync(cts.Token)); + } + + [Fact] + public void Enqueue_after_dispose_throws() + { + using var tmp = new TempDirectory(); + var spool = new TelemetrySpool(tmp.Path, Clock()); + var uploader = new FakeBackendTelemetryUploader(); + var sut = new TelemetryUploadOrchestrator(spool, uploader, Clock()); + sut.Dispose(); + + Assert.Throws(() => sut.Enqueue(Draft("x"))); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/UploadCursorPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/UploadCursorPersistenceTests.cs new file mode 100644 index 00000000..02dc05a4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Transport/UploadCursorPersistenceTests.cs @@ -0,0 +1,54 @@ +using System.IO; +using System.Text; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport +{ + public sealed class UploadCursorPersistenceTests + { + [Fact] + public void Load_on_missing_file_returns_minus_one() + { + using var tmp = new TempDirectory(); + var sut = new UploadCursorPersistence(tmp.File("cursor.json")); + Assert.Equal(-1, sut.Load()); + } + + [Fact] + public void Save_then_Load_roundtrips() + { + using var tmp = new TempDirectory(); + var sut = new UploadCursorPersistence(tmp.File("cursor.json")); + + sut.Save(42); + Assert.Equal(42, sut.Load()); + } + + [Fact] + public void Save_overwrites_previous_value_atomically() + { + using var tmp = new TempDirectory(); + var sut = new UploadCursorPersistence(tmp.File("cursor.json")); + + sut.Save(10); + sut.Save(20); + sut.Save(30); + + Assert.Equal(30, sut.Load()); + Assert.False(File.Exists(tmp.File("cursor.json.tmp"))); + } + + [Fact] + public void Load_on_corrupt_file_returns_minus_one_not_throws() + { + using var tmp = new TempDirectory(); + var path = tmp.File("cursor.json"); + File.WriteAllText(path, "not valid json at all", Encoding.UTF8); + + var sut = new UploadCursorPersistence(path); + Assert.Equal(-1, sut.Load()); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj new file mode 100644 index 00000000..33fb21f2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj @@ -0,0 +1,60 @@ + + + + net48 + latest + AutopilotMonitor.Agent.V2.Core + + + + + + <_Parameter1>AutopilotMonitor.Agent.V2 + + + <_Parameter1>AutopilotMonitor.DecisionCore.Tests + + + <_Parameter1>AutopilotMonitor.Agent.V2.Core.Tests + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + + + + + + + + + + + + + + + + + + + $(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll + + + + + + + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs new file mode 100644 index 00000000..2f6f25f5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AgentConfiguration.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Configuration +{ + /// + /// Agent configuration settings + /// + public class AgentConfiguration + { + /// + /// Backend API base URL + /// + public string ApiBaseUrl { get; set; } + + /// + /// Session identifier for this enrollment + /// + public string SessionId { get; set; } + + /// + /// Tenant identifier + /// + public string TenantId { get; set; } + + /// + /// Local spool directory path + /// + public string SpoolDirectory { get; set; } + + /// + /// Agent log directory path + /// + public string LogDirectory { get; set; } + + /// + /// Upload interval in seconds + /// + public int UploadIntervalSeconds { get; set; } = 30; + + /// + /// Maximum batch size for events + /// + public int MaxBatchSize { get; set; } = 100; + + /// + /// Whether to use client certificate authentication + /// + public bool UseClientCertAuth { get; set; } = true; + + + /// + /// Log verbosity level: Info (default), Debug, Verbose. + /// Overridable via remote config. + /// + public AgentLogLevel LogLevel { get; set; } = AgentLogLevel.Info; + + /// + /// Maximum retry attempts for failed uploads + /// + public int MaxRetryAttempts { get; set; } = 5; + + /// + /// Whether to self-destruct when enrollment completes (remove Scheduled Task and all files). + /// This is the only cleanup mode — SelfDestruct always removes task + files. + /// + public bool SelfDestructOnComplete { get; set; } = true; + + /// + /// Name of the Scheduled Task to remove during self-destruct + /// + public string ScheduledTaskName { get; set; } = Constants.ScheduledTaskName; + + /// + /// Whether to reboot the device after enrollment completes + /// + public bool RebootOnComplete { get; set; } = false; + + /// + /// Delay in seconds before the reboot is initiated (shutdown.exe /r /t X). + /// Default: 10 seconds + /// + public int RebootDelaySeconds { get; set; } = 10; + + /// + /// When true, the log directory is preserved after self-destruct/cleanup. + /// All other files (binaries, config, spool, scheduled task) are still removed. + /// + public bool KeepLogFile { get; set; } = false; + + /// + /// Whether to enable geo-location detection (queries external IP services) + /// + public bool EnableGeoLocation { get; set; } = true; + + /// + /// NTP server address for time check during enrollment. + /// Default: "time.windows.com" + /// + public string NtpServer { get; set; } = "time.windows.com"; + + /// + /// Whether to automatically set the device timezone based on IP geolocation. + /// Requires EnableGeoLocation to be true. Uses tzutil /s to apply. + /// Default: false + /// + public bool EnableTimezoneAutoSet { get; set; } = false; + + /// + /// Optional custom path to IME logs directory for testing. + /// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path. + /// + public string ImeLogPathOverride { get; set; } + + /// + /// Optional path to write every IME log line that matched a pattern (for debugging). + /// If empty, no match log is written. Example: %ProgramData%\AutopilotMonitor\Logs\ime-matches.log + /// + public string ImeMatchLogPath { get; set; } + + /// + /// Path to directory with real IME log files for log replay. + /// When set, the agent replays these logs with time compression instead of reading live logs. + /// + public string ReplayLogDir { get; set; } + + /// + /// Time compression factor for log replay. + /// Higher values = faster replay. Default: 50 (e.g., 50 minutes of real logs = 1 minute of replay). + /// + public double ReplaySpeedFactor { get; set; } = 50; + + /// + /// Wait time in seconds after ESP exit before marking Hello as skipped. + /// When ESP exits, we wait this duration for Hello wizard to start (event 62404). + /// Default: 30 seconds (reasonable for systems under load). + /// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301). + /// + public int HelloWaitTimeoutSeconds { get; set; } = 30; + + /// + /// Maximum consecutive authentication failures (401/403) before the agent shuts down. + /// Prevents endless retry traffic when the device is not authorized. + /// 0 = disabled (retry forever). Default: 5. + /// + public int MaxAuthFailures { get; set; } = 5; + + /// + /// Maximum time in minutes the agent keeps retrying after the first auth failure. + /// 0 = disabled (no time limit, only MaxAuthFailures applies). Default: 0. + /// + public int AuthFailureTimeoutMinutes { get; set; } = 0; + + /// + /// Maximum agent lifetime in minutes. Safety net to prevent zombie agents. + /// The agent emits enrollment_failed with failureType "agent_timeout" when this expires. + /// 0 = disabled (no lifetime limit). Default: 360 (6 hours). + /// + public int AgentMaxLifetimeMinutes { get; set; } = 360; + + /// + /// Absolute maximum session age in hours across all agent restarts. + /// Emergency break: if the session has been alive longer than this, the agent + /// forces cleanup and self-destructs regardless of enrollment state. + /// Prevents zombie agents caused by unrecoverable logic errors. + /// Respects WhiteGlove scenarios (timer resets on Part 2 resume). + /// Default: 48 hours. + /// + public int AbsoluteMaxSessionHours { get; set; } = 48; + + /// + /// Bootstrap token for pre-MDM auth during OOBE. + /// Embedded by the OOBE bootstrapper script via --bootstrap-token CLI arg. + /// When set, sent as X-Bootstrap-Token header instead of cert auth. + /// + public string BootstrapToken { get; set; } + + /// + /// Whether to use bootstrap token auth (true until MDM cert becomes available). + /// + public bool UseBootstrapTokenAuth { get; set; } + + /// + /// Whether to show a visual enrollment summary dialog to the end user. + /// + public bool ShowEnrollmentSummary { get; set; } = false; + + /// + /// Auto-close timeout in seconds for the enrollment summary dialog. + /// 0 = no auto-close. Default: 60 + /// + public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60; + + /// + /// Optional URL to a branding image for the enrollment summary dialog banner. + /// + public string EnrollmentSummaryBrandingImageUrl { get; set; } + + /// + /// Maximum time in seconds the agent retries launching the enrollment summary dialog + /// when the user's desktop is locked by a credential UI (e.g. Windows Hello). + /// 0 = no retry (single attempt). Default: 120. + /// + public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120; + + /// + /// Whether diagnostics upload is configured for this tenant. + /// When true, the agent requests a short-lived upload URL from the backend just before uploading. + /// The SAS URL itself is never stored in config — it is fetched on-demand and used in memory only. + /// + public bool DiagnosticsUploadEnabled { get; set; } = false; + + /// + /// When to upload diagnostics packages: "Off", "Always", "OnFailure". + /// Default: "Off" + /// + public string DiagnosticsUploadMode { get; set; } = "Off"; + + /// + /// Merged list of log paths/wildcards to include in the diagnostics ZIP package. + /// Received from the backend config (global + tenant-specific). + /// Each entry is validated against DiagnosticsPathGuards before use. + /// + public List DiagnosticsLogPaths { get; set; } = new List(); + + /// + /// Whether the agent should send Trace-severity events to the backend. + /// Controlled per tenant via remote config. Default: true (on in preview). + /// + public bool SendTraceEvents { get; set; } = true; + + /// + /// When true, agent guardrails are relaxed: all registry, WMI, and command targets are allowed. + /// File/diagnostics paths allow everything except C:\Users. + /// Default: false. + /// + public bool UnrestrictedMode { get; set; } = false; + + /// + /// Sanitized command-line arguments for the agent_started event. + /// Secrets (e.g. bootstrap token) are redacted before assignment. + /// + public string CommandLineArgs { get; set; } + + /// + /// When true, the agent waits for an MDM certificate to appear before starting. + /// Used when deploying the agent before Intune enrollment completes. + /// The agent polls the certificate store every 5 seconds until a valid cert is found. + /// + public bool AwaitEnrollment { get; set; } = false; + + /// + /// Maximum time in minutes to wait for the MDM certificate in await-enrollment mode. + /// 0 = wait indefinitely. Default: 480 (8 hours). + /// + public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480; + + /// + /// Validates the configuration + /// + public bool IsValid() + { + return !string.IsNullOrEmpty(ApiBaseUrl) && + !string.IsNullOrEmpty(SessionId) && + !string.IsNullOrEmpty(TenantId); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AwaitEnrollmentConfigFile.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AwaitEnrollmentConfigFile.cs new file mode 100644 index 00000000..237b49ea --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/AwaitEnrollmentConfigFile.cs @@ -0,0 +1,16 @@ +namespace AutopilotMonitor.Agent.V2.Core.Configuration +{ + /// + /// Persisted await-enrollment configuration. Plan §4.x M4.6.α. + /// + /// Saved to %ProgramData%\AutopilotMonitor\await-enrollment.json by --install mode + /// so the Scheduled Task enters await-enrollment mode on startup (polls for the MDM certificate + /// before spinning up the DecisionEngine). Deleted once the MDM certificate is found — subsequent + /// restarts proceed normally. + /// + /// + public sealed class AwaitEnrollmentConfigFile + { + public int TimeoutMinutes { get; set; } = 480; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs new file mode 100644 index 00000000..6778b1cd --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BootstrapConfigFile.cs @@ -0,0 +1,16 @@ +namespace AutopilotMonitor.Agent.V2.Core.Configuration +{ + /// + /// Persisted bootstrap configuration for OOBE-bootstrapped agents. Plan §4.x M4.6.α. + /// + /// Saved to %ProgramData%\AutopilotMonitor\bootstrap-config.json by --install mode + /// so the Scheduled Task (whose command line has no args) can pick up the bootstrap token and + /// tenant id on the first post-install run. + /// + /// + public sealed class BootstrapConfigFile + { + public string BootstrapToken { get; set; } + public string TenantId { get; set; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs new file mode 100644 index 00000000..8bd7fe2d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs @@ -0,0 +1,203 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Configuration +{ + /// + /// Fetches and caches remote configuration from the backend API + /// Provides collector toggles and gather rules to the agent + /// + public class RemoteConfigService : IDisposable + { + private readonly BackendApiClient _apiClient; + private readonly string _tenantId; + private readonly AgentLogger _logger; + private readonly string _cacheFilePath; + private readonly EmergencyReporter _emergencyReporter; + private readonly DistressReporter _distressReporter; + + private AgentConfigResponse _currentConfig; + private readonly object _configLock = new object(); + + /// + /// Gets the current configuration (thread-safe) + /// + public AgentConfigResponse CurrentConfig + { + get + { + lock (_configLock) + { + return _currentConfig; + } + } + } + + public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null) + { + _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _emergencyReporter = emergencyReporter; + _distressReporter = distressReporter; + + var cacheDir = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config"); + _cacheFilePath = Path.Combine(cacheDir, "remote-config.json"); + } + + /// + /// Fetches the initial configuration from the backend + /// Falls back to cached config if the API is unreachable + /// + public async Task FetchConfigAsync() + { + try + { + _logger.Info("Fetching remote configuration from backend..."); + var config = await _apiClient.GetAgentConfigAsync(_tenantId); + + if (config != null) + { + _logger.Info($"Remote config fetched: ConfigVersion={config.ConfigVersion}, " + + $"GatherRules={config.GatherRules?.Count ?? 0}"); + + LogCollectorSettings(config.Collectors); + SetConfig(config); + CacheConfig(config); + return config; + } + + _logger.Warning("Remote config fetch returned empty response"); + } + catch (BackendAuthException ex) + { + _logger.Warning($"Config fetch authentication failed: {ex.Message}"); + + // Auth failure → use distress channel (pre-auth, works without cert) + if (_distressReporter != null) + { + _ = _distressReporter.TrySendAsync( + DistressErrorType.ConfigFetchDenied, + ex.Message, + httpStatusCode: ex.StatusCode); + } + } + catch (Exception ex) + { + _logger.Warning($"Failed to fetch remote config: {ex.Message}"); + + // Non-auth failure → use authenticated emergency channel + if (_emergencyReporter != null) + { + _ = _emergencyReporter.TrySendAsync( + AgentErrorType.ConfigFetchFailed, + ex.Message); + } + } + + // Fall back to cached config + var cached = LoadCachedConfig(); + if (cached != null) + { + _logger.Info("Using cached remote configuration"); + SetConfig(cached); + return cached; + } + + // Fall back to defaults + _logger.Info("No cached config available, using defaults (all optional collectors disabled)"); + var defaults = CreateDefaultConfig(); + SetConfig(defaults); + return defaults; + } + + private void SetConfig(AgentConfigResponse config) + { + lock (_configLock) + { + _currentConfig = config; + } + } + + private void LogCollectorSettings(CollectorConfiguration collectors) + { + if (collectors == null) return; + + _logger.Info(" Collector settings:"); + _logger.Info($" Performance: {(collectors.EnablePerformanceCollector ? "ON" : "OFF")} (interval: {collectors.PerformanceIntervalSeconds}s)"); + } + + private void CacheConfig(AgentConfigResponse config) + { + try + { + var dir = Path.GetDirectoryName(_cacheFilePath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + // SECURITY: Never persist UnrestrictedMode to disk — require live backend auth + var liveValue = config.UnrestrictedMode; + config.UnrestrictedMode = false; + var json = JsonConvert.SerializeObject(config, Formatting.Indented); + config.UnrestrictedMode = liveValue; + + File.WriteAllText(_cacheFilePath, json); + _logger.Debug("Remote config cached to disk"); + } + catch (Exception ex) + { + _logger.Debug($"Failed to cache remote config: {ex.Message}"); + } + } + + private AgentConfigResponse LoadCachedConfig() + { + try + { + if (File.Exists(_cacheFilePath)) + { + var json = File.ReadAllText(_cacheFilePath); + var config = JsonConvert.DeserializeObject(json); + if (config != null) + { + // SECURITY: Never trust cached UnrestrictedMode — always require live backend auth + config.UnrestrictedMode = false; + } + return config; + } + } + catch (Exception ex) + { + _logger.Debug($"Failed to load cached config: {ex.Message}"); + } + return null; + } + + private AgentConfigResponse CreateDefaultConfig() + { + return new AgentConfigResponse + { + ConfigVersion = 0, + UploadIntervalSeconds = 30, + SelfDestructOnComplete = true, + KeepLogFile = false, + EnableGeoLocation = true, + EnableImeMatchLog = false, + Collectors = CollectorConfiguration.CreateDefault(), + GatherRules = new System.Collections.Generic.List(), + ImeLogPatterns = new System.Collections.Generic.List() + }; + } + + public void Dispose() + { + // Nothing to dispose + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs new file mode 100644 index 00000000..f1f11070 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Logging/AgentLogger.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; + +namespace AutopilotMonitor.Agent.V2.Core.Logging +{ + /// + /// Log verbosity levels for the agent. + /// Info = normal operational messages (default) + /// Debug = includes DEBUG-tagged lines (component state, config, decisions) + /// Verbose = includes VERBOSE-tagged lines (per-event details, hot-path tracing) + /// + public enum AgentLogLevel + { + Info = 0, + Debug = 1, + Verbose = 2, + Trace = 3 + } + + /// + /// Simple file-based logger for the agent. + /// Optionally mirrors output to the console when EnableConsoleOutput is set. + /// + public class AgentLogger + { + private readonly string _logFilePath; + private AgentLogLevel _logLevel; + private readonly object _lockObject = new object(); + + /// + /// When true, log entries are also written to Console.Out (same format as the log file). + /// Set via --console flag or programmatically. + /// + public bool EnableConsoleOutput { get; set; } + + public AgentLogger(string logDirectory, AgentLogLevel logLevel = AgentLogLevel.Info) + { + _logLevel = logLevel; + + if (!Directory.Exists(logDirectory)) + { + Directory.CreateDirectory(logDirectory); + } + + var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log"; + _logFilePath = Path.Combine(logDirectory, logFileName); + } + + /// + /// Updates the active log level at runtime (e.g. after remote config is applied). + /// + public void SetLogLevel(AgentLogLevel level) + { + _logLevel = level; + } + + public AgentLogLevel LogLevel => _logLevel; + + public void Trace(string message) + { + if (_logLevel >= AgentLogLevel.Trace) + Log("TRACE", message); + } + + public void Verbose(string message) + { + if (_logLevel >= AgentLogLevel.Verbose) + Log("VERBOSE", message); + } + + public void Debug(string message) + { + if (_logLevel >= AgentLogLevel.Debug) + Log("DEBUG", message); + } + + public void Info(string message) + { + Log("INFO", message); + } + + public void Warning(string message) + { + Log("WARN", message); + } + + public void Error(string message, Exception ex = null) + { + var fullMessage = ex != null ? $"{message} - {ex}" : message; + Log("ERROR", fullMessage); + } + + private void Log(string level, string message) + { + try + { + lock (_lockObject) + { + var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff"); + var logEntry = $"[{timestamp}] [{level}] {SanitizeLogMessage(message)}"; + + File.AppendAllText(_logFilePath, logEntry + Environment.NewLine); + + if (EnableConsoleOutput) + { + try { Console.WriteLine(logEntry); } catch { } + } + } + } + catch + { + // Swallow logging errors to prevent crashes + } + } + + /// + /// Sanitizes log messages to prevent log injection attacks. + /// Escapes control characters that could forge log entries. + /// Redaction patterns can be added here later as needed. + /// + private static string SanitizeLogMessage(string message) + { + if (string.IsNullOrEmpty(message)) return message; + + // Escape newlines and carriage returns to prevent log injection + message = message.Replace("\r", "\\r").Replace("\n", "\\n"); + + return message; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageState.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageState.cs new file mode 100644 index 00000000..a1c336a3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageState.cs @@ -0,0 +1,548 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Installation state of an app package during enrollment. + /// Order matters: states are compared numerically for upgrade-only transitions. + /// + public enum AppInstallationState + { + Unknown = 0, + NotInstalled = 1, + InProgress = 2, + Downloading = 3, + Installing = 4, + Installed = 5, + Skipped = 6, + Postponed = 7, + Error = 8 + } + + /// + /// How the app should run + /// + public enum AppRunAs + { + Unknown = -1, + User = 0, + System = 1 + } + + /// + /// App install intent from Intune policy + /// + public enum AppIntent + { + Unknown = -1, + NotTargeted = 0, + Available = 1, + Install = 3, + Uninstall = 4 + } + + /// + /// How the app is targeted + /// + [Flags] + public enum AppTargeted + { + Unknown = 128, + Dependency = 0, + User = 1, + Device = 2 + } + + /// + /// Win32 app state values from IME + /// + public enum Win32AppState + { + Unknown = 0, + NotInstalled = 1, + InProgress = 2, + Completed = 3, + Error = 4 + } + + /// + /// Represents the state of a single app package during enrollment. + /// Adapted from EspOverlay PackageState with state transition protection. + /// + public class AppPackageState : IComparable + { + public static bool SortErrorsToTop = false; + + public string Id { get; } + public int ListPos { get; } + public string Name { get; private set; } + public AppRunAs RunAs { get; private set; } = AppRunAs.Unknown; + public AppIntent Intent { get; private set; } = AppIntent.Unknown; + public AppTargeted Targeted { get; private set; } = AppTargeted.Unknown; + public HashSet DependsOn { get; private set; } = new HashSet(); + public AppInstallationState InstallationState { get; private set; } = AppInstallationState.Unknown; + public long InstallationStateLastChangedTicks { get; private set; } = 0; + public bool DownloadingOrInstallingSeen { get; private set; } = false; + public int? ProgressPercent { get; private set; } = null; + public long BytesDownloaded { get; private set; } = 0; + public long BytesTotal { get; private set; } = 0; + public string ErrorPatternId { get; private set; } + public string ErrorDetail { get; private set; } + public string ErrorCode { get; private set; } + + // Installer result codes (captured from lpExitCode / hResultFromWin32 log lines) + public string ExitCode { get; private set; } + public string HResultFromWin32 { get; private set; } + + // App metadata (captured from IME log patterns — used by App Dashboard / reports) + /// Product version detected after install (e.g. "11.2.1787.0"). From ReportingManager DetectedIdentityVersion. + public string AppVersion { get; private set; } + /// App installer type: "Win32", "MSI", "WinGet", "Store", "LOB". Derived from [WinGetApp]/MSI markers. + public string AppType { get; private set; } + /// Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle. + public int AttemptNumber { get; private set; } + /// Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines. + public string DetectionResult { get; private set; } + + // Delivery Optimization telemetry (populated from [DO TEL] log entries) + public long DoFileSize { get; private set; } + public long DoTotalBytesDownloaded { get; private set; } + public long DoBytesFromPeers { get; private set; } + public int DoPercentPeerCaching { get; private set; } + public long DoBytesFromLanPeers { get; private set; } + public long DoBytesFromGroupPeers { get; private set; } + public long DoBytesFromInternetPeers { get; private set; } + public int DoDownloadMode { get; private set; } = -1; + public string DoDownloadDuration { get; private set; } + public long DoBytesFromHttp { get; private set; } + public bool HasDoTelemetry { get; private set; } + + private static readonly Dictionary Win32StateMap = + new Dictionary + { + { Win32AppState.Unknown, AppInstallationState.Unknown }, + { Win32AppState.InProgress, AppInstallationState.InProgress }, + { Win32AppState.Completed, AppInstallationState.Installed }, + { Win32AppState.Error, AppInstallationState.Error } + }; + + private static readonly Dictionary SortOrderOverrides = + new Dictionary + { + { AppInstallationState.Skipped, (int)AppInstallationState.Installed }, + { AppInstallationState.Postponed, (int)AppInstallationState.Installed }, + { AppInstallationState.Error, (int)AppInstallationState.Installed } + }; + + public AppPackageState(string id, int listPos) + { + Id = id; + ListPos = listPos; + } + + /// + /// Restores an AppPackageState from persisted data (used on agent restart). + /// + internal static AppPackageState Restore( + string id, int listPos, string name, + AppRunAs runAs, AppIntent intent, AppTargeted targeted, + HashSet dependsOn, + AppInstallationState installationState, bool downloadingOrInstallingSeen, + int? progressPercent, long bytesDownloaded, long bytesTotal, + string errorPatternId = null, string errorDetail = null, string errorCode = null, + string exitCode = null, string hresultFromWin32 = null, + long doFileSize = 0, long doTotalBytesDownloaded = 0, + long doBytesFromPeers = 0, int doPercentPeerCaching = 0, + long doBytesFromLanPeers = 0, long doBytesFromGroupPeers = 0, + long doBytesFromInternetPeers = 0, int doDownloadMode = -1, + string doDownloadDuration = null, long doBytesFromHttp = 0, + bool hasDoTelemetry = false, + string appVersion = null, string appType = null, + int attemptNumber = 0, string detectionResult = null) + { + var pkg = new AppPackageState(id, listPos) + { + Name = name, + RunAs = runAs, + Intent = intent, + Targeted = targeted, + DependsOn = dependsOn ?? new HashSet(), + InstallationState = installationState, + DownloadingOrInstallingSeen = downloadingOrInstallingSeen, + ProgressPercent = progressPercent, + BytesDownloaded = bytesDownloaded, + BytesTotal = bytesTotal, + ErrorPatternId = errorPatternId, + ErrorDetail = errorDetail, + ErrorCode = errorCode, + ExitCode = exitCode, + HResultFromWin32 = hresultFromWin32, + InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(), + DoFileSize = doFileSize, + DoTotalBytesDownloaded = doTotalBytesDownloaded, + DoBytesFromPeers = doBytesFromPeers, + DoPercentPeerCaching = doPercentPeerCaching, + DoBytesFromLanPeers = doBytesFromLanPeers, + DoBytesFromGroupPeers = doBytesFromGroupPeers, + DoBytesFromInternetPeers = doBytesFromInternetPeers, + DoDownloadMode = doDownloadMode, + DoDownloadDuration = doDownloadDuration, + DoBytesFromHttp = doBytesFromHttp, + HasDoTelemetry = hasDoTelemetry, + AppVersion = appVersion, + AppType = appType, + AttemptNumber = attemptNumber, + DetectionResult = detectionResult + }; + return pkg; + } + + /// + /// Updates the app name. Only updates if the new name is not a truncated version of the current name. + /// + public bool UpdateName(string newName) + { + if (string.IsNullOrEmpty(newName) || string.Equals(Name, newName)) + return false; + + // Avoid overwriting with a truncated version + if (Name != null && newName.Length > 0 && Name.StartsWith(newName)) + return false; + + Name = newName; + return true; + } + + /// + /// Updates RunAs property + /// + public bool UpdateRunAs(AppRunAs newRunAs) + { + if (RunAs == newRunAs) return false; + RunAs = newRunAs; + return true; + } + + /// + /// Updates Intent property + /// + public bool UpdateIntent(AppIntent newIntent) + { + if (Intent == newIntent) return false; + Intent = newIntent; + return true; + } + + /// + /// Updates Targeted property + /// + public bool UpdateTargeted(AppTargeted newTargeted) + { + if (Targeted == newTargeted) return false; + Targeted = newTargeted; + return true; + } + + /// + /// Updates DependsOn set + /// + public bool UpdateDependsOn(HashSet newDependsOn) + { + if (newDependsOn == null) newDependsOn = new HashSet(); + if (DependsOn.SetEquals(newDependsOn)) return false; + DependsOn = newDependsOn; + return true; + } + + /// + /// Sets the error context for the app (called when an error pattern is matched). + /// + public void SetErrorContext(string patternId, string detail, string errorCode = null) + { + ErrorPatternId = patternId; + ErrorDetail = detail; + if (!string.IsNullOrEmpty(errorCode)) + ErrorCode = errorCode; + } + + /// + /// Captures the installer exit code (lpExitCode) for the app. + /// + public void UpdateExitCode(string code) + { + ExitCode = code; + } + + /// + /// Captures the Win32 HRESULT (hResultFromWin32) for the app. + /// + public void UpdateHResult(string code) + { + HResultFromWin32 = code; + } + + /// + /// Updates the product version detected for this app (e.g. "11.2.1787.0"). + /// Only applies non-empty values so a later detection without version cannot wipe it. + /// + public bool UpdateAppVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) return false; + if (AppVersion == version) return false; + AppVersion = version.Trim(); + return true; + } + + /// + /// Updates the app installer type. Only sets when current is empty OR + /// when the incoming value is more specific than the existing one + /// (e.g. "WinGet"/"MSI" override the "Win32" default). + /// + public bool UpdateAppType(string type) + { + if (string.IsNullOrWhiteSpace(type)) return false; + if (AppType == type) return false; + // Do not let a generic "Win32" downgrade a previously determined "WinGet"/"MSI". + if (!string.IsNullOrEmpty(AppType) && type == "Win32" + && (AppType == "WinGet" || AppType == "MSI" || AppType == "Store" || AppType == "LOB")) + return false; + AppType = type; + return true; + } + + /// + /// Updates the install attempt number. Keeps the highest observed value + /// so it is monotonic across log batches. + /// + public bool UpdateAttemptNumber(int attempt) + { + if (attempt <= AttemptNumber) return false; + AttemptNumber = attempt; + return true; + } + + /// + /// Updates the detection rule result ("Detected" / "NotDetected"). + /// Applies any non-empty value so the latest detection wins. + /// + public bool UpdateDetectionResult(string result) + { + if (string.IsNullOrWhiteSpace(result)) return false; + if (DetectionResult == result) return false; + DetectionResult = result.Trim(); + return true; + } + + /// + /// Updates Delivery Optimization telemetry data for this app package. + /// Called when a [DO TEL] log entry is matched and linked to this app. + /// + public void UpdateDoTelemetry(long fileSize, long totalBytesDownloaded, + long bytesFromPeers, int percentPeerCaching, + long bytesFromLanPeers, long bytesFromGroupPeers, long bytesFromInternetPeers, + int downloadMode, string downloadDuration, long bytesFromHttp) + { + DoFileSize = fileSize; + DoTotalBytesDownloaded = totalBytesDownloaded; + DoBytesFromPeers = bytesFromPeers; + DoPercentPeerCaching = percentPeerCaching; + DoBytesFromLanPeers = bytesFromLanPeers; + DoBytesFromGroupPeers = bytesFromGroupPeers; + DoBytesFromInternetPeers = bytesFromInternetPeers; + DoDownloadMode = downloadMode; + DoDownloadDuration = downloadDuration; + DoBytesFromHttp = bytesFromHttp; + HasDoTelemetry = true; + } + + /// + /// Updates the installation state with transition protection. + /// Returns true if the state actually changed. + /// + public bool UpdateState(AppInstallationState newState, int? newProgressPercent = null, bool upgradeOnly = false, long bytesDownloaded = 0, long bytesTotal = 0) + { + // upgradeOnly: only allow "higher" states (used by Win32AppState mapping) + if (upgradeOnly && newState < InstallationState) + return false; + + // Cannot downgrade from Installed to Skipped or Postponed + if (InstallationState == AppInstallationState.Installed && + (newState == AppInstallationState.Skipped || newState == AppInstallationState.Postponed)) + return false; + + // Postponed cannot go back to Downloading or lower active states + if (InstallationState == AppInstallationState.Postponed && newState <= AppInstallationState.Downloading) + return false; + + // If "Installed" without ever seeing download/install -> auto-downgrade to Skipped + // This handles apps with "inverse" detection rules (e.g., uninstall packages marked as + // installed when old software is NOT detected) + if (newState == AppInstallationState.Installed && !DownloadingOrInstallingSeen) + newState = AppInstallationState.Skipped; + + // Compute the effective progress percent that will be applied + var effectiveProgressPercent = (newState == AppInstallationState.Installed && newProgressPercent == null) ? 100 : newProgressPercent; + // Compute effective bytes: only consider new values if non-zero, otherwise keep current + var effectiveBytesDownloaded = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesDownloaded : BytesDownloaded; + var effectiveBytesTotal = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesTotal : BytesTotal; + // After WinGet completion fix: if Installed and total > 0 and downloaded < total, downloaded becomes total + if (newState == AppInstallationState.Installed && effectiveBytesTotal > 0 && effectiveBytesDownloaded < effectiveBytesTotal) + effectiveBytesDownloaded = effectiveBytesTotal; + + // Skip if no actual change + if (newState == InstallationState && effectiveProgressPercent == ProgressPercent && effectiveBytesDownloaded == BytesDownloaded && effectiveBytesTotal == BytesTotal) + return false; + + var oldState = InstallationState; + InstallationState = newState; + InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(); + DownloadingOrInstallingSeen |= (InstallationState >= AppInstallationState.Downloading && + InstallationState <= AppInstallationState.Installing); + ProgressPercent = effectiveProgressPercent; + BytesDownloaded = effectiveBytesDownloaded; + BytesTotal = effectiveBytesTotal; + + return true; + } + + /// + /// Updates state from a Win32AppState string or integer string (e.g., "2" or "InProgress"). + /// Uses upgrade-only mode to prevent "InProgress" from destroying more detailed states. + /// + public bool UpdateStateFromWin32AppState(string win32AppStateStringOrIntString) + { + Win32AppState win32State; + if (Enum.TryParse(win32AppStateStringOrIntString, true, out win32State)) + return UpdateStateFromWin32AppState(win32State); + return false; + } + + /// + /// Updates state from Win32AppState enum value. + /// + public bool UpdateStateFromWin32AppState(Win32AppState win32State) + { + AppInstallationState newState; + if (Win32StateMap.TryGetValue(win32State, out newState)) + return UpdateState(newState, upgradeOnly: true); + return false; + } + + /// + /// Whether this app requires installation (Install or Uninstall intent) + /// + public bool IsRequired => Intent == AppIntent.Install || Intent == AppIntent.Uninstall; + + /// + /// Whether the app is currently being processed (InProgress, Downloading, or Installing) + /// + public bool IsActive => InstallationState >= AppInstallationState.InProgress && + InstallationState <= AppInstallationState.Installing; + + /// + /// Whether the app has reached a terminal state (Installed, Skipped, Postponed, Error) + /// + public bool IsCompleted => InstallationState >= AppInstallationState.Installed; + + /// + /// Whether the app is in an error state + /// + public bool IsError => InstallationState == AppInstallationState.Error; + + /// + /// Comparison for sorting: active apps first, then by state change time, then by list position. + /// + public int CompareTo(AppPackageState other) + { + // First: descending on sort order (active apps at top) + var result = -(SortOrder.CompareTo(other.SortOrder)); + if (result == 0) + { + // Then: by installation state last changed + result = InstallationStateLastChangedTicks.CompareTo(other.InstallationStateLastChangedTicks); + } + if (result == 0) + { + // Then: by original position in policy list + result = ListPos.CompareTo(other.ListPos); + } + return result; + } + + private int SortOrder + { + get + { + if (SortErrorsToTop && InstallationState == AppInstallationState.Error) + return -1; + + int sortOrder; + if (SortOrderOverrides.TryGetValue(InstallationState, out sortOrder)) + return sortOrder; + + return (int)InstallationState; + } + } + + /// + /// Returns a summary dictionary for event data + /// + public Dictionary ToEventData() + { + var data = new Dictionary + { + { "appId", Id }, + { "appName", Name ?? Id }, + { "state", InstallationState.ToString() }, + { "intent", Intent.ToString() }, + { "targeted", Targeted.ToString() }, + { "runAs", RunAs.ToString() }, + { "progressPercent", ProgressPercent ?? 0 }, + { "bytesDownloaded", BytesDownloaded }, + { "bytesTotal", BytesTotal }, + { "isError", IsError }, + { "isCompleted", IsCompleted } + }; + + if (!string.IsNullOrEmpty(ErrorPatternId)) + data["errorPatternId"] = ErrorPatternId; + if (!string.IsNullOrEmpty(ErrorDetail)) + data["errorDetail"] = ErrorDetail; + if (!string.IsNullOrEmpty(ErrorCode)) + data["errorCode"] = ErrorCode; + + if (!string.IsNullOrEmpty(ExitCode)) + data["exitCode"] = ExitCode; + if (!string.IsNullOrEmpty(HResultFromWin32)) + data["hresultFromWin32"] = HResultFromWin32; + + // App metadata fields (emitted whenever available so ingest can merge them). + if (!string.IsNullOrEmpty(AppVersion)) + data["appVersion"] = AppVersion; + if (!string.IsNullOrEmpty(AppType)) + data["appType"] = AppType; + if (AttemptNumber > 0) + data["attemptNumber"] = AttemptNumber; + if (!string.IsNullOrEmpty(DetectionResult)) + data["detectionResult"] = DetectionResult; + + if (HasDoTelemetry) + { + data["doFileSize"] = DoFileSize; + data["doTotalBytesDownloaded"] = DoTotalBytesDownloaded; + data["doBytesFromPeers"] = DoBytesFromPeers; + data["doPercentPeerCaching"] = DoPercentPeerCaching; + data["doBytesFromLanPeers"] = DoBytesFromLanPeers; + data["doBytesFromGroupPeers"] = DoBytesFromGroupPeers; + data["doBytesFromInternetPeers"] = DoBytesFromInternetPeers; + data["doDownloadMode"] = DoDownloadMode; + data["doDownloadDuration"] = DoDownloadDuration ?? ""; + data["doBytesFromHttp"] = DoBytesFromHttp; + } + + return data; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs new file mode 100644 index 00000000..30a3d432 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs @@ -0,0 +1,432 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Manages a collection of app package states during enrollment. + /// Tracks current app, ignore list, and dependency cascading. + /// Adapted from EspOverlay PackageStatesList. + /// + public class AppPackageStateList : List + { + /// + /// The ID of the app currently being processed by IME + /// + public string CurrentPackageId { get; set; } + + /// + /// Apps to ignore (e.g., user-targeted apps during device phase). + /// Case-insensitive because GUIDs may appear in different casing across IME log entries and policy JSON. + /// + public HashSet IgnoreList { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + private readonly AgentLogger _logger; + + // States that should also cascade to dependents + private static readonly HashSet StatesToCascade = + new HashSet + { + AppInstallationState.Postponed, + AppInstallationState.Error + }; + + public AppPackageStateList(AgentLogger logger) + { + _logger = logger; + } + + /// + /// Gets a package by ID, optionally creating it if not found. + /// Returns null if the ID is in the ignore list. + /// + public AppPackageState GetPackage(string id, bool createIfNotFound = false) + { + if (string.IsNullOrEmpty(id) || IgnoreList.Contains(id)) + return null; + + var result = this.SingleOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + if (result == null && createIfNotFound) + { + result = new AppPackageState(id, Count); + Add(result); + } + return result; + } + + /// + /// Adds/updates packages from a JSON policy list (from IME "Get policies" log line). + /// Returns true if any package was updated. + /// + public bool AddUpdateFromJsonPolicies(List> jsonPolicies, bool ignoreUserTargeted) + { + if (jsonPolicies == null) return false; + + var updated = false; + foreach (var policy in jsonPolicies) + { + string id; + object idObj; + if (!policy.TryGetValue("Id", out idObj) || (id = idObj?.ToString()) == null) + continue; + + // Check RunAs - ignore user-targeted during device phase + if (ignoreUserTargeted) + { + var runAs = GetRunAsFromPolicy(policy); + if (runAs == AppRunAs.User) + { + updated |= AddToIgnoreList(id); + continue; + } + } + + var pkg = GetPackage(id, createIfNotFound: true); + if (pkg == null) continue; + + // Update name + object nameObj; + if (policy.TryGetValue("Name", out nameObj)) + updated |= pkg.UpdateName(nameObj?.ToString()); + + // Update intent + object intentObj; + if (policy.TryGetValue("Intent", out intentObj)) + { + AppIntent intent; + if (Enum.TryParse(intentObj.ToString(), true, out intent)) + updated |= pkg.UpdateIntent(intent); + } + + // Update targeted + object targetObj; + if (policy.TryGetValue("TargetType", out targetObj)) + { + AppTargeted targeted; + if (Enum.TryParse(targetObj.ToString(), true, out targeted)) + updated |= pkg.UpdateTargeted(targeted); + } + + // Update RunAs from InstallEx + object installExObj; + if (policy.TryGetValue("InstallEx", out installExObj)) + { + var runAs = ExtractRunAsFromInstallEx(installExObj); + if (runAs != AppRunAs.Unknown) + updated |= pkg.UpdateRunAs(runAs); + } + + // Update dependencies from FlatDependencies + object flatDepsObj; + if (policy.TryGetValue("FlatDependencies", out flatDepsObj)) + { + var deps = ExtractDependencies(flatDepsObj, id); + if (deps != null) + updated |= pkg.UpdateDependsOn(deps); + } + } + + if (updated) + { + Sort(); + LogStates(); + } + + return updated; + } + + /// + /// Updates the name of a specific package + /// + public bool UpdateName(string id, string newName) + { + var pkg = GetPackage(id); + if (pkg == null) return false; + var updated = pkg.UpdateName(newName); + if (updated) LogStates(); + return updated; + } + + /// + /// Updates the installation state of a specific package. + /// Returns true if state actually changed. + /// Optionally cascades to dependent packages for error/postpone states. + /// + public bool UpdateState(string id, AppInstallationState newState, int? progressPercent = null) + { + var packageIds = new HashSet { id }; + + // Cascade error/postpone to dependents + if (StatesToCascade.Contains(newState)) + packageIds.UnionWith(GetDependentIdsDeep(id)); + + var packages = packageIds.Select(x => GetPackage(x)).Where(x => x != null).ToList(); + + var result = false; + foreach (var pkg in packages) + result |= pkg.UpdateState(newState, progressPercent); + + if (result) + { + AppPackageState.SortErrorsToTop = IsAllCompleted(); + Sort(); + } + + return result; + } + + /// + /// Updates state to Downloading with optional progress tracking + /// + public bool UpdateStateToDownloading(string id, string bytesDownloadedStr, string totalBytesStr) + { + int? progressPercent = null; + long bytesDownloaded = 0; + long bytesTotal = 0; + + try + { + bytesDownloaded = Convert.ToInt64(bytesDownloadedStr); + bytesTotal = Convert.ToInt64(totalBytesStr); + if (bytesTotal != 0) + progressPercent = (int)Math.Round((double)bytesDownloaded / bytesTotal * 100); + } + catch { } + + var pkg = GetPackage(id); + if (pkg != null && + pkg.InstallationState != AppInstallationState.Skipped && + pkg.InstallationState != AppInstallationState.Installed) + { + var result = pkg.UpdateState(AppInstallationState.Downloading, progressPercent, false, bytesDownloaded, bytesTotal); + if (result) + { + AppPackageState.SortErrorsToTop = IsAllCompleted(); + Sort(); + } + return result; + } + + return false; + } + + /// + /// Updates state from a Win32AppState string + /// + public bool UpdateStateFromWin32AppState(string id, string win32AppStateString) + { + var pkg = GetPackage(id); + if (pkg == null) return false; + var updated = pkg.UpdateStateFromWin32AppState(win32AppStateString); + if (updated) + { + AppPackageState.SortErrorsToTop = IsAllCompleted(); + Sort(); + } + return updated; + } + + /// + /// Sets the current package being processed + /// + public void SetCurrent(string id) + { + CurrentPackageId = id; + } + + /// + /// Adds an ID to the ignore list + /// + public bool AddToIgnoreList(string id) + { + if (string.IsNullOrEmpty(id)) + return false; + + return IgnoreList.Add(id); // HashSet.Add returns false if already present (case-insensitive) + } + + /// + /// Checks if all required apps are completed + /// + public bool IsAllCompleted() + { + if (!this.Any()) return false; + + var allRequiredComplete = this.Where(x => x.IsRequired).All(x => x.IsCompleted); + if (allRequiredComplete) + { + // Mark dependency-only packages that IME never touched as Skipped + foreach (var pkg in this.Where(x => !x.IsRequired && x.InstallationState == AppInstallationState.Unknown)) + pkg.UpdateState(AppInstallationState.Skipped); + } + return allRequiredComplete; + } + + /// + /// Gets the first non-completed package (for display) + /// + public AppPackageState ActivePackage => this.FirstOrDefault(x => !x.IsCompleted); + + public int CountAll => Count; + public int CountCompleted => this.Count(x => x.IsCompleted); + public bool HasError => this.Any(x => x.IsError); + public int ErrorCount => this.Count(x => x.IsError); + + /// + /// Gets summary data for event emission + /// + public Dictionary GetSummaryData() + { + return new Dictionary + { + { "totalApps", CountAll }, + { "completedApps", CountCompleted }, + { "errorCount", ErrorCount }, + { "deviceErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.Device) }, + { "userErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.User) }, + { "hasErrors", HasError }, + { "isAllCompleted", IsAllCompleted() }, + { "ignoredCount", IgnoreList.Count }, + // State-breakdown for UI progress displays (replaces the full "apps" array + // which could exceed Azure Table Storage's 64KB property limit with 150+ apps) + { "downloading", this.Count(x => x.InstallationState == AppInstallationState.Downloading) }, + { "installing", this.Count(x => x.InstallationState == AppInstallationState.Installing) }, + { "installed", this.Count(x => x.InstallationState == AppInstallationState.Installed) }, + { "skipped", this.Count(x => x.InstallationState == AppInstallationState.Skipped) }, + { "failed", this.Count(x => x.InstallationState == AppInstallationState.Error) }, + { "postponed", this.Count(x => x.InstallationState == AppInstallationState.Postponed) }, + { "pending", this.Count(x => x.InstallationState < AppInstallationState.Downloading) }, + // App names per status category for diagnostics (typically few entries per category, + // well within Azure Table Storage's 64KB/30K-char limit even with 150+ apps) + { "pendingNames", this.Where(x => x.InstallationState < AppInstallationState.Downloading).Select(x => x.Name).ToList() }, + { "failedNames", this.Where(x => x.InstallationState == AppInstallationState.Error).Select(x => x.Name).ToList() }, + { "postponedNames", this.Where(x => x.InstallationState == AppInstallationState.Postponed).Select(x => x.Name).ToList() }, + { "installedNames", this.Where(x => x.InstallationState == AppInstallationState.Installed).Select(x => x.Name).ToList() }, + { "skippedNames", this.Where(x => x.InstallationState == AppInstallationState.Skipped).Select(x => x.Name).ToList() } + }; + } + + /// + /// Gets all package states as a list of dictionaries for final status dump. + /// Reuses AppPackageState.ToEventData() which includes state, error info, DO telemetry. + /// + public List> ToFinalStatusList() + { + return this.Select(pkg => pkg.ToEventData()).ToList(); + } + + /// + /// Recursively gets all dependent package IDs (packages that depend on the given parent) + /// + private HashSet GetDependentIdsDeep(string parentId, int level = 0) + { + var result = new HashSet(); + try + { + level++; + if (level > 10) + { + _logger?.Warning("Dependency tree deeper than 10 levels - possible circular dependency"); + return result; + } + + foreach (var dependent in this.Where(x => x.DependsOn.Contains(parentId)).Select(x => x.Id)) + { + result.Add(dependent); + result.UnionWith(GetDependentIdsDeep(dependent, level)); + } + } + catch (Exception ex) + { + _logger?.Warning($"Error resolving dependencies for {parentId}: {ex.Message}"); + } + + return result; + } + + private static AppRunAs GetRunAsFromPolicy(Dictionary policy) + { + try + { + object installExObj; + if (policy.TryGetValue("InstallEx", out installExObj)) + return ExtractRunAsFromInstallEx(installExObj); + } + catch { } + return AppRunAs.Unknown; + } + + private static AppRunAs ExtractRunAsFromInstallEx(object installExObj) + { + try + { + if (installExObj is Dictionary installEx) + { + object runAsObj; + if (installEx.TryGetValue("RunAs", out runAsObj)) + return (AppRunAs)Convert.ToInt32(runAsObj); + } + // Handle as JSON string + var str = installExObj?.ToString(); + if (!string.IsNullOrEmpty(str)) + { + var json = System.Text.Json.JsonSerializer.Deserialize>(str); + if (json != null) + { + object runAsObj; + if (json.TryGetValue("RunAs", out runAsObj)) + return (AppRunAs)Convert.ToInt32(runAsObj.ToString()); + } + } + } + catch { } + return AppRunAs.Unknown; + } + + private static HashSet ExtractDependencies(object flatDepsObj, string appId) + { + try + { + if (flatDepsObj == null) return new HashSet(); + + var deps = flatDepsObj as List; + if (deps == null) return new HashSet(); + + var result = new HashSet(); + foreach (var dep in deps) + { + var depDict = dep as Dictionary; + if (depDict == null) continue; + + object actionObj, appIdObj, childIdObj, typeObj, levelObj; + if (depDict.TryGetValue("Action", out actionObj) && + depDict.TryGetValue("AppId", out appIdObj) && + depDict.TryGetValue("ChildId", out childIdObj) && + depDict.TryGetValue("Type", out typeObj) && + depDict.TryGetValue("Level", out levelObj)) + { + if (Convert.ToInt32(actionObj) == 10 && + string.Equals(appIdObj?.ToString(), appId, StringComparison.OrdinalIgnoreCase) && + Convert.ToInt32(typeObj) == 0 && + Convert.ToInt32(levelObj) == 0) + { + result.Add(childIdObj.ToString()); + } + } + } + return result; + } + catch { } + return new HashSet(); + } + + private void LogStates() + { + if (_logger == null) return; + var stateList = string.Join(", ", this.Select(x => $"{x.Name ?? x.Id}:{x.InstallationState}")); + _logger.Debug($"App states [{Count}]: {stateList}"); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/CmTraceLogParser.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/CmTraceLogParser.cs new file mode 100644 index 00000000..94ae6de8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/CmTraceLogParser.cs @@ -0,0 +1,129 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Represents a single parsed line from a CMTrace-format log file + /// + public class CmTraceLogEntry + { + public DateTime Timestamp { get; set; } + public string Message { get; set; } + public string Component { get; set; } + public int Type { get; set; } // 1=Info, 2=Warning, 3=Error + public string Thread { get; set; } + } + + /// + /// Parses CMTrace/SCCM log format used by IME and other Microsoft components. + /// Format: <![LOG[{message}]LOG]!><time="{time}" date="{date}" component="{comp}" context="" type="{type}" thread="{thread}" file=""> + /// + public static class CmTraceLogParser + { + // Pre-compiled regex for the CMTrace format + private static readonly Regex CmTraceRegex = new Regex( + @".*)\]LOG\]!>[\d:.]+)""\s+date=""(?[\d-]+)""\s+component=""(?[^""]*)""\s+context=""[^""]*""\s+type=""(?\d+)""\s+thread=""(?\d+)""\s+file=""[^""]*"">", + RegexOptions.Compiled | RegexOptions.Singleline + ); + + /// + /// Attempts to parse a single line of CMTrace-formatted log. + /// Returns true if parsing succeeded, false if the line doesn't match the format. + /// + public static bool TryParseLine(string line, out CmTraceLogEntry entry) + { + entry = null; + + if (string.IsNullOrEmpty(line) || !line.StartsWith(" 7) + fraction = fraction.Substring(0, 7); + normalizedTime = timeParts[0] + "." + fraction; + } + else + { + normalizedTime = timeStr; + } + + var combined = dateStr + " " + normalizedTime; + + // Try multiple date formats to handle varying date styles + string[] formats = new[] + { + "M-d-yyyy H:mm:ss.fffffff", + "M-d-yyyy H:mm:ss.ffffff", + "M-d-yyyy H:mm:ss.fffff", + "M-d-yyyy H:mm:ss.ffff", + "M-d-yyyy H:mm:ss.fff", + "M-d-yyyy H:mm:ss.ff", + "M-d-yyyy H:mm:ss.f", + "M-d-yyyy H:mm:ss", + "M-d-yyyy HH:mm:ss.fffffff", + "M-d-yyyy HH:mm:ss.ffffff", + "M-d-yyyy HH:mm:ss.fffff", + "M-d-yyyy HH:mm:ss.ffff", + "M-d-yyyy HH:mm:ss.fff", + "M-d-yyyy HH:mm:ss.ff", + "M-d-yyyy HH:mm:ss.f", + "M-d-yyyy HH:mm:ss" + }; + + // CMTrace timestamps are always in LOCAL time. Parse as local, then convert to UTC. + if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, out result)) + { + result = result.ToUniversalTime(); + return true; + } + return false; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs new file mode 100644 index 00000000..04b662cf --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs @@ -0,0 +1,580 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Partial: Event handlers for pattern matches (app state, scripts, ESP phases), + /// telemetry processing, and utility methods. + /// + public partial class ImeLogTracker + { + private void HandleDoTelemetry(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + // Extract app GUID from FileId + string fileId = root.TryGetProperty("FileId", out var fileIdProp) + ? fileIdProp.GetString() : null; + + if (string.IsNullOrEmpty(fileId)) + { + _logger.Debug("ImeLogTracker: DO TEL entry has no FileId, skipping"); + return; + } + + string appId = ExtractAppIdFromDoFileId(fileId); + if (string.IsNullOrEmpty(appId)) + { + appId = _packageStates.CurrentPackageId; + _logger.Debug($"ImeLogTracker: Could not extract app ID from DO FileId, using current app: {appId}"); + } + + if (string.IsNullOrEmpty(appId)) return; + + var pkg = _packageStates.GetPackage(appId); + if (pkg == null) + { + _logger.Debug($"ImeLogTracker: DO TEL for unknown app {appId}, skipping"); + return; + } + + // Extract all DO fields + long fileSize = root.TryGetProperty("FileSize", out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; + long totalBytes = root.TryGetProperty("TotalBytesDownloaded", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; + long bytesFromPeers = root.TryGetProperty("BytesFromPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; + int peerCachingPct = root.TryGetProperty("PercentPeerCaching", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0; + long bytesLanPeers = root.TryGetProperty("BytesFromLanPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; + long bytesGroupPeers = root.TryGetProperty("BytesFromGroupPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; + long bytesInternetPeers = root.TryGetProperty("BytesFromInternetPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; + int downloadMode = root.TryGetProperty("DownloadMode", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : -1; + string downloadDuration = root.TryGetProperty("DownloadDuration", out p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; + long bytesFromHttp = root.TryGetProperty("BytesFromHttp", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; + + pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct, + bytesLanPeers, bytesGroupPeers, bytesInternetPeers, + downloadMode, downloadDuration, bytesFromHttp); + + _logger.Info($"ImeLogTracker: DO TEL for {pkg.Name ?? appId}: " + + $"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " + + $"http={bytesFromHttp}, mode={downloadMode}, duration={downloadDuration}"); + + OnDoTelemetryReceived?.Invoke(pkg); + _stateDirty = true; + } + catch (Exception ex) + { + _logger.Warning($"ImeLogTracker: Failed to parse DO TEL JSON: {ex.Message}"); + } + } + + /// + /// Extracts app GUID from DO FileId string. + /// Format: ...intunewin-bin_{appGuid}_{number} + /// Falls back to trying the second-to-last GUID-like segment. + /// + internal static string ExtractAppIdFromDoFileId(string fileId) + { + // Primary: look for "intunewin-bin_" marker + const string marker = "intunewin-bin_"; + var idx = fileId.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + var afterMarker = fileId.Substring(idx + marker.Length); + if (afterMarker.Length >= 36) + { + var candidate = afterMarker.Substring(0, 36); + if (Guid.TryParse(candidate, out var guid)) + return guid.ToString().ToLowerInvariant(); + } + } + + // Fallback: split by underscore and find GUIDs, take the second-to-last one + var segments = fileId.Split('_'); + for (int i = segments.Length - 2; i >= 0; i--) + { + if (Guid.TryParse(segments[i], out var guid)) + return guid.ToString().ToLowerInvariant(); + } + + return null; + } + + // ----------------------------------------------------------------------- + // PowerShell script tracking handlers + // ----------------------------------------------------------------------- + + private ScriptExecutionState GetCurrentScript(Dictionary parameters) + { + var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null; + return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase) + ? _currentRemediationScript + : GetCurrentPlatformScript(); + } + + private ScriptExecutionState GetCurrentPlatformScript() + { + if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) && + _pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state)) + return state; + return null; + } + + private void HandleScriptStarted(Match match, Dictionary parameters) + { + var id = match.Groups["id"]?.Value; + var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform"; + var source = parameters != null && parameters.TryGetValue("source", out var src) ? src : null; + + if (string.IsNullOrEmpty(id)) + { + _logger.Debug("ImeLogTracker: scriptStarted with no policyId captured, skipping"); + return; + } + + if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)) + { + // Flush any pending remediation script (shouldn't happen normally, but defensive) + if (_currentRemediationScript != null) + { + _logger.Debug($"ImeLogTracker: flushing pending remediation script {_currentRemediationScript.PolicyId}"); + EmitScriptEvent(_currentRemediationScript); + } + + _currentRemediationScript = new ScriptExecutionState + { + PolicyId = id, + ScriptType = "remediation", + ScriptPart = "detection" // detection runs first; updated if remediation runs + }; + _logger.Info($"ImeLogTracker: remediation script started: {id}"); + } + else + { + // Platform script: AgentExecutor.log entries create/enrich, IME log entries also create + if (!_pendingPlatformScripts.ContainsKey(id)) + { + _pendingPlatformScripts[id] = new ScriptExecutionState + { + PolicyId = id, + ScriptType = "platform" + }; + } + _lastPlatformScriptPolicyId = id; + _logger.Info($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})"); + } + } + + private void HandleScriptContext(Match match, Dictionary parameters) + { + var context = match.Groups["context"]?.Value; + if (string.IsNullOrEmpty(context)) return; + + var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User"; + var script = GetCurrentScript(parameters); + if (script != null) + { + script.RunContext = runContext; + _logger.Debug($"ImeLogTracker: script context set to {runContext} for {script.PolicyId}"); + } + } + + private void HandleScriptExitCode(Match match, Dictionary parameters) + { + var exitCodeStr = match.Groups["exitCode"]?.Value; + if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return; + + var script = GetCurrentScript(parameters); + if (script != null) + { + script.ExitCode = exitCode; + _logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}"); + } + } + + private void HandleScriptOutput(Match match, Dictionary parameters) + { + var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null; + var script = GetCurrentScript(parameters); + if (script == null) return; + + // PS-AGENT-OUTPUT captures both stdout and stderr in one pattern + var output = match.Groups["output"]?.Value; + var error = match.Groups["error"]?.Value; + + if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error)) + { + // Combined pattern (write output done. output = ..., error = ...) + script.Stdout = TruncateOutput(output.Trim()); + script.Stderr = TruncateOutput(error.Trim()); + } + else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase)) + { + script.Stderr = TruncateOutput(output?.Trim()); + } + else + { + script.Stdout = TruncateOutput(output?.Trim()); + } + + _logger.Debug($"ImeLogTracker: script output captured for {script.PolicyId} (type: {outputType ?? "combined"})"); + } + + private void HandleScriptCompleted(Match match, Dictionary parameters) + { + var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform"; + var part = parameters != null && parameters.TryGetValue("part", out var p) ? p : null; + + if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)) + { + if (_currentRemediationScript == null) return; + + var compliance = match.Groups["compliance"]?.Value; + var id = match.Groups["id"]?.Value; + + _currentRemediationScript.ComplianceResult = compliance; + if (!string.IsNullOrEmpty(part)) + _currentRemediationScript.ScriptPart = part; + if (!string.IsNullOrEmpty(id)) + _currentRemediationScript.PolicyId = id; // confirm policyId from compliance line + + _logger.Info($"ImeLogTracker: remediation detection completed: {_currentRemediationScript.PolicyId}, " + + $"compliance={compliance}, exit={_currentRemediationScript.ExitCode}"); + + EmitScriptEvent(_currentRemediationScript); + _currentRemediationScript = null; + } + else + { + // Platform script: final result from IntuneManagementExtension.log + var id = match.Groups["id"]?.Value; + var result = match.Groups["result"]?.Value; + + if (string.IsNullOrEmpty(id)) return; + + // Merge with pending AgentExecutor data if available + if (!_pendingPlatformScripts.TryGetValue(id, out var script)) + { + script = new ScriptExecutionState + { + PolicyId = id, + ScriptType = "platform" + }; + } + + script.Result = result; + + _logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}"); + + EmitScriptEvent(script); + _pendingPlatformScripts.Remove(id); + if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase)) + _lastPlatformScriptPolicyId = null; + } + } + + private void EmitScriptEvent(ScriptExecutionState script) + { + if (script == null || string.IsNullOrEmpty(script.PolicyId)) return; + OnScriptCompleted?.Invoke(script); + } + + private static string TruncateOutput(string output) + { + if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength) + return output; + return output.Substring(0, MaxScriptOutputLength) + "...[truncated]"; + } + + // ----------------------------------------------------------------------- + // IME lifecycle handlers + // ----------------------------------------------------------------------- + + private void HandleImeStarted() + { + _logger.Info("ImeLogTracker: IME Agent Started detected"); + + // Log any currently active app — it will be re-evaluated by new log entries after IME restart. + // We intentionally do NOT mark it as Installed here because IME may retry the app. + if (!string.IsNullOrEmpty(_packageStates.CurrentPackageId)) + { + var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId); + if (currentPkg?.IsActive == true) + _logger.Info($"ImeLogTracker: Active package {currentPkg.Name ?? currentPkg.Id} ({currentPkg.InstallationState}) will be re-evaluated after IME restart"); + } + + OnImeStarted?.Invoke(); + } + + private void HandleImeShutdown() + { + _logger.Info("ImeLogTracker: IME shutdown detected — marking all active packages as Postponed"); + + foreach (var pkg in _packageStates.Where(p => p.IsActive).ToList()) + { + _logger.Info($"ImeLogTracker: Postponing active package {pkg.Name ?? pkg.Id} ({pkg.InstallationState})"); + UpdateStateWithCallback(pkg.Id, AppInstallationState.Postponed); + } + } + + private string _lastEspPhaseDetected; + + private void HandleEspPhaseDetected(string espPhaseString) + { + // Validate phase and get its ordinal for forward-only enforcement + int phaseOrd; + if (!EspPhaseOrder.TryGetValue(espPhaseString, out phaseOrd)) + return; // Not a recognized ESP phase + + bool logPhaseIsCurrentPhase = true; // Both DeviceSetup and AccountSetup are "current" + + // Forward-only phase progression: reject backward transitions. + // During AccountSetup, IME may re-evaluate device apps and log "In EspPhase: DeviceSetup" + // which would corrupt tracking if we allowed it to trigger a phase change. + if (phaseOrd < _currentPhaseOrder) + { + _logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})"); + return; + } + + // If the ESP phase actually changed (e.g. DeviceSetup -> AccountSetup), + // move ALL known app IDs into the ignore list so they won't emit events in the new phase. + // We use both _packageStates (apps from policiesdiscovered) AND _seenAppIds (all IDs from + // any pattern match) to ensure comprehensive coverage - apps seen via setcurrentapp, + // esptrackstatus etc. that never entered _packageStates are also silenced. + if (!string.Equals(_lastEspPhaseDetected, espPhaseString, StringComparison.OrdinalIgnoreCase)) + { + if (_lastEspPhaseDetected != null) // Not the first phase detection + { + var ignoredFromStates = 0; + var ignoredFromSeen = 0; + + foreach (var pkg in _packageStates) + { + if (_packageStates.AddToIgnoreList(pkg.Id)) + ignoredFromStates++; + } + foreach (var appId in _seenAppIds) + { + if (_packageStates.AddToIgnoreList(appId)) + ignoredFromSeen++; + } + + _logger.Info($"ImeLogTracker: ESP phase changed from {_lastEspPhaseDetected} to {espPhaseString} - " + + $"silenced {ignoredFromStates} packages + {ignoredFromSeen} additional seen IDs " + + $"(total ignore list: {_packageStates.IgnoreList.Count})"); + + // Snapshot package states from the completed phase before clearing + if (_packageStates.CountAll > 0) + { + _phasePackageSnapshots[_lastEspPhaseDetected] = _packageStates.ToFinalStatusList(); + _logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase"); + } + + _packageStates.Clear(); + _packageStates.SetCurrent(""); // Reset current package to avoid stale device-phase reference + _seenAppIds.Clear(); + _allAppsCompletedFired = false; + } + _lastEspPhaseDetected = espPhaseString; + _currentPhaseOrder = phaseOrd; + } + + _logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}"); + ActivatePatterns(logPhaseIsCurrentPhase); + + OnEspPhaseChanged?.Invoke(espPhaseString); + } + + private void HandleEspTrackStatus(Match match) + { + var from = match.Groups["from"]?.Value; + var to = match.Groups["to"]?.Value; + var id = match.Groups["id"]?.Value; + + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) return; + + var pkg = _packageStates.GetPackage(id); + var label = pkg?.Name ?? id; + + if (string.Equals(to, "InProgress", StringComparison.OrdinalIgnoreCase)) + { + _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> InProgress"); + _packageStates.SetCurrent(id); + UpdateStateWithCallback(id, AppInstallationState.Installing); + } + else if (string.Equals(to, "Completed", StringComparison.OrdinalIgnoreCase)) + { + _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Completed"); + UpdateStateWithCallback(id, AppInstallationState.Installed); + } + else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase)) + { + _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Error"); + UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: "IME-ESP-TRACK-STATUS", errorDetail: $"ESP track status changed from {from} to {to}"); + } + } + + private void HandlePoliciesDiscovered(string policiesJson) + { + try + { + var policies = System.Text.Json.JsonSerializer.Deserialize>>(policiesJson); + if (policies != null) + { + // Ignore user-targeted packages during device setup (we only track device phase in general) + _packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false); + _logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages"); + OnPoliciesDiscovered?.Invoke(policiesJson); + } + } + catch (Exception ex) + { + _logger.Warning($"ImeLogTracker: failed to parse policies JSON: {ex.Message}"); + } + } + + private void HandleCancelStuckAndSetCurrent(string newId) + { + if (string.IsNullOrEmpty(newId)) return; + + // If current app is stuck in an active state, mark it as skipped + var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId); + if (currentPkg != null && (currentPkg.InstallationState == AppInstallationState.Installing || + currentPkg.InstallationState == AppInstallationState.InProgress || + currentPkg.InstallationState == AppInstallationState.Downloading)) + { + _logger.Info($"ImeLogTracker: cancelling stuck app {currentPkg.Name ?? _packageStates.CurrentPackageId} ({currentPkg.InstallationState}), switching to {newId}"); + UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Skipped); + } + + _packageStates.SetCurrent(newId); + } + + private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null, string errorPatternId = null, string errorDetail = null, string errorCode = null) + { + var pkg = _packageStates.GetPackage(id); + if (pkg == null) return; + + // Set error context before state change so it's available in ToEventData() + if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(errorPatternId)) + pkg.SetErrorContext(errorPatternId, errorDetail, errorCode); + + var oldState = pkg.InstallationState; + var changed = _packageStates.UpdateState(id, newState, progressPercent); + + if (changed) + { + _logger.Info($"ImeLogTracker: {pkg.Name ?? id} state: {oldState} -> {newState}"); + OnAppStateChanged?.Invoke(pkg, oldState, newState); + + // Check if all apps are now completed - only fire once + if (!_allAppsCompletedFired && _packageStates.CountAll > 0 && _packageStates.IsAllCompleted()) + { + _allAppsCompletedFired = true; + _logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed"); + OnAllAppsCompleted?.Invoke(); + } + } + } + + private void UpdateDownloadingWithCallback(string id, string bytes, string ofbytes) + { + var pkg = _packageStates.GetPackage(id); + if (pkg == null) return; + + var oldState = pkg.InstallationState; + var changed = _packageStates.UpdateStateToDownloading(id, bytes, ofbytes); + + if (changed) + { + _logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}"); + OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading); + } + } + + private void ActivatePatterns(bool logPhaseIsCurrentPhase, bool force = false) + { + if (!force && _logPhaseIsCurrentPhase == logPhaseIsCurrentPhase) + return; + + var patterns = new List(_patternsAlways); + if (logPhaseIsCurrentPhase) + { + patterns.AddRange(_patternsCurrentPhase); + _lastLogTimestamp = DateTime.MinValue; // Reset simulation delay + } + else + { + patterns.AddRange(_patternsOtherPhases); + } + + _activePatterns = patterns; + _packageStates.SetCurrent(""); // Reset current package + _logPhaseIsCurrentPhase = logPhaseIsCurrentPhase; + } + + private async Task ApplySimulationDelay(DateTime logTimestamp, CancellationToken token) + { + if (!SimulationMode || logTimestamp == DateTime.MinValue) + return; + + if (_lastLogTimestamp != DateTime.MinValue) + { + var timeSpan = logTimestamp - _lastLogTimestamp; + if (timeSpan.TotalMilliseconds > 0) + { + var delayMs = (int)(timeSpan.TotalMilliseconds / SpeedFactor); + delayMs = Math.Max(0, Math.Min(delayMs, 5000)); // Cap at 5 seconds + if (delayMs > 0) + await Task.Delay(delayMs, token); + } + } + _lastLogTimestamp = logTimestamp; + } + + public void Dispose() + { + Stop(); + _cts?.Dispose(); + } + + private void WriteMatchLog(string sourceFile, string rawLine, string patternId) + { + if (string.IsNullOrEmpty(_matchLogPath)) return; + try + { + var entry = $"[{Path.GetFileName(sourceFile)}] [{patternId}] {rawLine}"; + lock (_matchLogLock) + { + File.AppendAllText(_matchLogPath, entry + Environment.NewLine); + } + } + catch { } + } + + /// + /// A compiled regex pattern with its action and parameters + /// + private class CompiledPattern + { + public string PatternId { get; set; } + public Regex Regex { get; set; } + public string Action { get; set; } + public Dictionary Parameters { get; set; } + } + } +} + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs new file mode 100644 index 00000000..ba411a0b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Partial: Log file polling and pattern matching logic. + /// + public partial class ImeLogTracker + { + private async Task CheckLogFilesAsync(CancellationToken token) + { + if (!Directory.Exists(_logFolder)) + return; + + // Get all matching log files, sorted by name (archived files come before current) + var files = new List(); + foreach (var pattern in LogFilePatterns) + { + try + { + files.AddRange(Directory.GetFiles(_logFolder, pattern)); + } + catch (DirectoryNotFoundException) { } + } + + files.Sort(StringComparer.OrdinalIgnoreCase); + + foreach (var filePath in files) + { + if (token.IsCancellationRequested) break; + + try + { + var fileInfo = new FileInfo(filePath); + if (!fileInfo.Exists) continue; + + var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length); + if (startPos >= fileInfo.Length) + { + _logger.Trace($"ImeLogTracker: {Path.GetFileName(filePath)} — no new data (pos={startPos}, size={fileInfo.Length})"); + continue; + } + _logger.Trace($"ImeLogTracker: reading {Path.GetFileName(filePath)} from pos {startPos} (size={fileInfo.Length}, delta={fileInfo.Length - startPos})"); + + using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + stream.Seek(startPos, SeekOrigin.Begin); + + using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true)) + { + // Buffer for multiline CMTrace entries (e.g. AgentExecutor.log + // "write output done. output = ..." spans many lines) + StringBuilder multiLineBuffer = null; + int multiLineCount = 0; + + string line; + while ((line = await reader.ReadLineAsync()) != null) + { + if (token.IsCancellationRequested) break; + + // --- Multiline CMTrace buffering --- + // CMTrace entries: + // When message contains newlines, the entry spans multiple lines. + // We buffer until we find the closing ]LOG]!> tag. + if (multiLineBuffer != null) + { + // Continuing a multiline entry + multiLineBuffer.Append('\n').Append(line); + multiLineCount++; + + if (line.Contains("]LOG]!>")) + { + // Entry complete — use the assembled line + line = multiLineBuffer.ToString(); + multiLineBuffer = null; + multiLineCount = 0; + } + else if (multiLineCount >= MaxMultiLineBufferLines) + { + // Safety limit — discard to prevent unbounded memory usage + _logger.Debug($"ImeLogTracker: discarding multiline CMTrace buffer after {multiLineCount} lines (corrupt entry?)"); + multiLineBuffer = null; + multiLineCount = 0; + continue; + } + else + { + // Still accumulating — read next line + continue; + } + } + else if (line.StartsWith("")) + { + // Start of a multiline CMTrace entry + multiLineBuffer = new StringBuilder(line); + multiLineCount = 1; + continue; + } + + // --- Normal processing (single-line or completed multiline) --- + CmTraceLogEntry entry; + string messageToMatch; + if (CmTraceLogParser.TryParseLine(line, out entry)) + { + messageToMatch = entry.Message; + } + else + { + // Non-CMTrace line - match raw + messageToMatch = line; + entry = null; + } + + if (string.IsNullOrEmpty(messageToMatch)) continue; + + // Simulation mode delay + if (SimulationMode && entry != null) + { + await ApplySimulationDelay(entry.Timestamp, token); + } + + // Match against active patterns + foreach (var pattern in _activePatterns) + { + try + { + var match = pattern.Regex.Match(messageToMatch); + if (match.Success) + { + WriteMatchLog(filePath, line, pattern.PatternId); + HandlePatternMatch(pattern, match, messageToMatch, entry); + } + } + catch (RegexMatchTimeoutException) + { + _logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' — skipped to prevent ReDoS"); + } + } + } + } + + _positionTracker.SetPosition(filePath, stream.Position); + _stateDirty = true; + } + } + catch (FileNotFoundException) { } + catch (IOException ex) + { + _logger.Debug($"ImeLogTracker: IO error reading {filePath}: {ex.Message}"); + } + } + } + + private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry) + { + LastMatchedPatternId = pattern.PatternId; + LastMatchedLogTimestamp = entry?.Timestamp; + + // Generic pattern-match hook (M4.4.4). Invoked before action-specific callbacks so + // subscribers (e.g. ImeLogTrackerAdapter emitting WhiteGloveSealingPatternDetected) + // see the match with LastMatchedPatternId already set. Wrapped to isolate subscriber + // exceptions from the action-dispatch that follows. + try { OnPatternMatched?.Invoke(pattern.PatternId); } + catch (Exception ex) { _logger?.Warning($"OnPatternMatched handler threw: {ex.Message}"); } + + try + { + var id = match.Groups["id"]?.Value; + var useCurrentApp = pattern.Parameters.ContainsKey("useCurrentApp") && + pattern.Parameters["useCurrentApp"] == "true"; + + if (useCurrentApp && string.IsNullOrEmpty(id)) + id = _packageStates.CurrentPackageId; + + // Track every app ID seen during the current phase for comprehensive ignore on phase change + if (!string.IsNullOrEmpty(id)) + _seenAppIds.Add(id); + + switch (pattern.Action?.ToLower()) + { + case "imestarted": + HandleImeStarted(); + break; + + case "imeshutdown": + HandleImeShutdown(); + break; + + case "imesessionchange": + var sessionChange = match.Groups["change"]?.Value; + _logger.Debug($"IME session change: {sessionChange}"); + OnImeSessionChange?.Invoke(sessionChange); + break; + + case "espphasedetected": + var phase = match.Groups["espPhase"]?.Value; + if (string.IsNullOrEmpty(phase) && pattern.Parameters.ContainsKey("phase")) + phase = pattern.Parameters["phase"]; + if (!string.IsNullOrEmpty(phase)) + HandleEspPhaseDetected(phase); + break; + + case "setcurrentapp": + if (!string.IsNullOrEmpty(id)) + _packageStates.SetCurrent(id); + break; + + case "imeagentversion": + var version = match.Groups["agentVersion"]?.Value; + if (!string.IsNullOrEmpty(version)) + OnImeAgentVersion?.Invoke(version); + break; + + case "imeimpersonation": + _logger.Debug($"IME impersonation: {match.Groups["user"]?.Value}"); + break; + + case "enrollmentcompleted": + _logger.Info("ImeLogTracker: User session completed detected"); + OnUserSessionCompleted?.Invoke(); + break; + + case "updatestateinstalled": + if (!string.IsNullOrEmpty(id)) + UpdateStateWithCallback(id, AppInstallationState.Installed); + break; + + case "updatestatedownloading": + if (!string.IsNullOrEmpty(id)) + { + var bytes = match.Groups["bytes"]?.Value; + var ofbytes = match.Groups["ofbytes"]?.Value; + if (!string.IsNullOrEmpty(bytes) && !string.IsNullOrEmpty(ofbytes)) + UpdateDownloadingWithCallback(id, bytes, ofbytes); + else + UpdateStateWithCallback(id, AppInstallationState.Downloading); + } + break; + + case "updatestateinstalling": + if (!string.IsNullOrEmpty(id)) + UpdateStateWithCallback(id, AppInstallationState.Installing); + break; + + case "updatestateskipped": + if (!string.IsNullOrEmpty(id)) + UpdateStateWithCallback(id, AppInstallationState.Skipped); + break; + + case "updatestateerror": + // Extract structured error code from named capture groups (exitCode, hresult, errorCode) + var extractedErrorCode = match.Groups["exitCode"]?.Value; + if (string.IsNullOrEmpty(extractedErrorCode)) + extractedErrorCode = match.Groups["hresult"]?.Value; + if (string.IsNullOrEmpty(extractedErrorCode)) + extractedErrorCode = match.Groups["errorCode"]?.Value; + + if (pattern.Parameters.ContainsKey("checkTo") && pattern.Parameters["checkTo"] == "true") + { + // Only apply error if the "to" value is "Error" + var toValue = match.Groups["to"]?.Value; + if (string.Equals(toValue, "Error", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(id)) + UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message, errorCode: extractedErrorCode); + } + else if (!string.IsNullOrEmpty(id)) + { + UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message, errorCode: extractedErrorCode); + } + break; + + case "captureexitcode": + var exitCodeVal = match.Groups["exitCode"]?.Value; + if (!string.IsNullOrEmpty(exitCodeVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId)) + _packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateExitCode(exitCodeVal); + break; + + case "capturehresult": + var hresultVal = match.Groups["hresult"]?.Value; + if (!string.IsNullOrEmpty(hresultVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId)) + _packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateHResult(hresultVal); + break; + + case "captureappversion": + var appVersionVal = match.Groups["appVersion"]?.Value; + if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(appVersionVal)) + _packageStates.GetPackage(id)?.UpdateAppVersion(appVersionVal); + break; + + case "captureapptypewinget": + if (!string.IsNullOrEmpty(id)) + _packageStates.GetPackage(id)?.UpdateAppType("WinGet"); + break; + + case "captureapptypemsi": + if (!string.IsNullOrEmpty(id)) + _packageStates.GetPackage(id)?.UpdateAppType("MSI"); + break; + + case "captureattemptnumber": + // IME logs "Execute retry 0" for the first attempt. We report attempt+1 so + // the human-friendly value starts at 1 (first attempt). + var attemptVal = match.Groups["attempt"]?.Value; + if (!string.IsNullOrEmpty(attemptVal) && int.TryParse(attemptVal, out var attemptIdx) + && !string.IsNullOrEmpty(_packageStates.CurrentPackageId)) + { + _packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateAttemptNumber(attemptIdx + 1); + } + break; + + case "capturedetectionresult": + var detectionVal = match.Groups["detection"]?.Value; + if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(detectionVal)) + _packageStates.GetPackage(id)?.UpdateDetectionResult(detectionVal); + break; + + case "updatestatepostponed": + if (!string.IsNullOrEmpty(id)) + { + // Only postpone if not already in a terminal state + var pkg = _packageStates.GetPackage(id); + if (pkg != null && pkg.InstallationState != AppInstallationState.Installed && + pkg.InstallationState != AppInstallationState.Error) + { + UpdateStateWithCallback(id, AppInstallationState.Postponed); + } + } + break; + + case "esptrackstatus": + HandleEspTrackStatus(match); + break; + + case "policiesdiscovered": + var policiesJson = match.Groups["policies"]?.Value; + if (!string.IsNullOrEmpty(policiesJson)) + HandlePoliciesDiscovered(policiesJson); + break; + + case "ignorecompletedapp": + _packageStates.AddToIgnoreList(_packageStates.CurrentPackageId); + break; + + case "updatename": + var name = match.Groups["name"]?.Value; + if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name)) + _packageStates.UpdateName(id, name); + break; + + case "updatewin32appstate": + var state = match.Groups["state"]?.Value; + if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(state)) + _packageStates.UpdateStateFromWin32AppState(id, state); + break; + + case "cancelstuckandsetcurrent": + HandleCancelStuckAndSetCurrent(id); + break; + + case "updatedotelemetry": + var doTelJson = match.Groups["doTelJson"]?.Value; + if (!string.IsNullOrEmpty(doTelJson)) + HandleDoTelemetry(doTelJson); + break; + + // PowerShell script tracking actions + case "scriptstarted": + HandleScriptStarted(match, pattern.Parameters); + break; + + case "scriptcontext": + HandleScriptContext(match, pattern.Parameters); + break; + + case "scriptexitcode": + HandleScriptExitCode(match, pattern.Parameters); + break; + + case "scriptoutput": + HandleScriptOutput(match, pattern.Parameters); + break; + + case "scriptcompleted": + HandleScriptCompleted(match, pattern.Parameters); + break; + + default: + _logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}"); + break; + } + } + catch (Exception ex) + { + _logger.Warning($"ImeLogTracker: error handling match for {pattern.PatternId}: {ex.Message}"); + } + } + + /// + /// Parses [DO TEL] JSON and links it to the correct app via FileId. + /// The FileId contains the app GUID in the format: ...intunewin-bin_{appGuid}_{number} + /// + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs new file mode 100644 index 00000000..b6359ae9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs @@ -0,0 +1,450 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Parses IME (Intune Management Extension) log files using regex patterns from the backend. + /// Tracks app installation state transitions and emits strategic events. + /// Split into partial classes: Core, LogProcessing, Handlers. + /// + /// Key design: Regex patterns are NOT hardcoded - they come from backend config via ImeLogPattern list. + /// This allows updating patterns without agent rebuild when Microsoft changes IME log output. + /// + public partial class ImeLogTracker : IDisposable + { + private readonly string _logFolder; + private readonly AgentLogger _logger; + private readonly LogFilePositionTracker _positionTracker = new LogFilePositionTracker(); + private readonly AppPackageStateList _packageStates; + private readonly int _pollingIntervalMs; + private readonly string _matchLogPath; // Optional: path to write every matched raw line + private readonly object _matchLogLock = new object(); + + // Compiled pattern matchers grouped by category + private List _patternsAlways = new List(); + private List _patternsCurrentPhase = new List(); + private List _patternsOtherPhases = new List(); + + // Active matchers (changes based on ESP phase) + private List _activePatterns = new List(); + private bool _logPhaseIsCurrentPhase = false; + + // Phase isolation: track ALL app IDs seen during pattern matching in the current phase. + // On phase change, these are added to the ignore list to prevent device-phase apps from + // bleeding into AccountSetup. This is more comprehensive than only ignoring packageStates + // because setcurrentapp, esptrackstatus etc. see IDs that never enter packageStates. + private readonly HashSet _seenAppIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Forward-only phase progression: prevents accidental phase bounce (e.g. DeviceSetup + // re-detected during AccountSetup if IME re-evaluates device apps and logs the old phase). + private static readonly Dictionary EspPhaseOrder = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "DeviceSetup", 1 }, + { "AccountSetup", 2 } + }; + private int _currentPhaseOrder = 0; + + // Snapshots of package states from completed ESP phases (e.g. DeviceSetup apps before AccountSetup starts) + private readonly Dictionary>> _phasePackageSnapshots = + new Dictionary>>(StringComparer.OrdinalIgnoreCase); + + // Simulation mode + public bool SimulationMode { get; set; } + public double SpeedFactor { get; set; } = 50; + private DateTime _lastLogTimestamp = DateTime.MinValue; + + // Background task + private Task _pollingTask; + private CancellationTokenSource _cts; + private bool _allAppsCompletedFired; + + // State persistence: saves tracker state to disk so agent restart continues + // from the exact log position without re-parsing or re-building ignore lists. + private readonly ImeTrackerStatePersistence _statePersistence; + private bool _stateDirty; + + // Standard GUID capture pattern used as {GUID} placeholder in patterns + private const string GuidPattern = @"(?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})"; + + // Log files to monitor + private static readonly string[] LogFilePatterns = new[] + { + "IntuneManagementExtension.log", + "_IntuneManagementExtension.log", + "IntuneManagementExtension-????????-??????.log", + "AppWorkload.log", + "AppWorkload-????????-??????.log", + // PowerShell script tracking + "AgentExecutor.log", + "AgentExecutor-????????-??????.log", + "HealthScripts.log", + "HealthScripts-????????-??????.log" + }; + + // Script execution state tracking (accumulates multi-line data before emitting events) + private ScriptExecutionState _currentRemediationScript; + private readonly Dictionary _pendingPlatformScripts = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private string _lastPlatformScriptPolicyId; + private const int MaxScriptOutputLength = 2048; + private const int MaxMultiLineBufferLines = 100; + + // Set synchronously during HandlePatternMatch so callbacks can read it + public string LastMatchedPatternId { get; private set; } + public DateTime? LastMatchedLogTimestamp { get; private set; } + + // Callbacks to EnrollmentTracker + public Action OnEspPhaseChanged { get; set; } + public Action OnImeAgentVersion { get; set; } + public Action OnImeStarted { get; set; } + public Action OnAppStateChanged { get; set; } + public Action OnPoliciesDiscovered { get; set; } + public Action OnAllAppsCompleted { get; set; } + public Action OnUserSessionCompleted { get; set; } + public Action OnImeSessionChange { get; set; } + public Action OnDoTelemetryReceived { get; set; } + public Action OnScriptCompleted { get; set; } + + /// + /// Fires on every pattern match with the matched PatternId. Plan §4.x M4.4.4. + /// Invoked by HandlePatternMatch after is + /// set, before action-specific callbacks — callers can read + /// / synchronously inside the handler. + /// Added to enable the WhiteGloveSealingPatternDetected signal path in + /// ImeLogTrackerAdapter without the legacy polling-on-LastMatchedPatternId anti-pattern. + /// + public Action OnPatternMatched { get; set; } + + /// + /// Access to the tracked package states + /// + public AppPackageStateList PackageStates => _packageStates; + + /// + /// Snapshots of package states from completed ESP phases (keyed by phase name, e.g. "DeviceSetup"). + /// Captured before package states are cleared on phase transition. + /// + public Dictionary>> PhasePackageSnapshots => _phasePackageSnapshots; + + public ImeLogTracker(string logFolder, List patterns, AgentLogger logger, int pollingIntervalMs = 100, string matchLogPath = null, string stateDirectory = null) + { + _logFolder = Environment.ExpandEnvironmentVariables(logFolder); + _logger = logger; + _pollingIntervalMs = pollingIntervalMs; + _matchLogPath = matchLogPath; + _packageStates = new AppPackageStateList(logger); + + // State persistence setup + if (!string.IsNullOrEmpty(stateDirectory)) + { + _statePersistence = new ImeTrackerStatePersistence(stateDirectory, logger); + } + + if (!string.IsNullOrEmpty(_matchLogPath)) + { + try { Directory.CreateDirectory(Path.GetDirectoryName(_matchLogPath)); } catch { } + _logger.Info($"ImeLogTracker: match log enabled -> {_matchLogPath}"); + } + + CompilePatterns(patterns); + ActivatePatterns(logPhaseIsCurrentPhase: false, force: true); + } + + /// + /// Compiles ImeLogPattern list from backend into Regex objects grouped by category. + /// + public void CompilePatterns(List patterns) + { + _patternsAlways = new List(); + _patternsCurrentPhase = new List(); + _patternsOtherPhases = new List(); + + if (patterns == null) return; + + foreach (var pattern in patterns.Where(p => p.Enabled)) + { + try + { + // Replace {GUID} placeholder with actual GUID capture regex + var regexStr = pattern.Pattern.Replace("{GUID}", GuidPattern); + // Singleline: make '.' match newlines too — required because multiline CMTrace + // entries are reassembled with '\n' chars (e.g. DO TEL JSON in IME >= 1.101) + var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, TimeSpan.FromSeconds(1)); + + var compiled = new CompiledPattern + { + PatternId = pattern.PatternId, + Regex = regex, + Action = pattern.Action, + Parameters = pattern.Parameters ?? new Dictionary() + }; + + switch (pattern.Category?.ToLower()) + { + case "always": + _patternsAlways.Add(compiled); + break; + case "currentphase": + _patternsCurrentPhase.Add(compiled); + break; + case "otherphases": + _patternsOtherPhases.Add(compiled); + break; + default: + _patternsAlways.Add(compiled); // Default to always + break; + } + } + catch (Exception ex) + { + _logger.Warning($"Failed to compile IME pattern {pattern.PatternId}: {ex.Message}"); + } + } + + _logger.Info($"ImeLogTracker: compiled {_patternsAlways.Count} always, {_patternsCurrentPhase.Count} currentPhase, {_patternsOtherPhases.Count} otherPhases patterns"); + + // Re-activate with current phase state + ActivatePatterns(_logPhaseIsCurrentPhase, force: true); + } + + /// + /// Starts the background polling task + /// + public void Start() + { + if (_pollingTask != null) return; + + // Restore persisted state from previous agent lifetime (handles agent restart mid-enrollment). + // This recovers phase order, ignore list, app states, and log file positions so we continue + // exactly where we left off — no re-parsing, no device-phase bleeding. + LoadState(); + + _cts = new CancellationTokenSource(); + var token = _cts.Token; + + _pollingTask = Task.Run(async () => + { + _logger.Info("ImeLogTracker: polling started"); + + while (!token.IsCancellationRequested) + { + try + { + await CheckLogFilesAsync(token); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + _logger.Warning($"ImeLogTracker: error during log check: {ex.Message}"); + try { await Task.Delay(1000, token); } catch (OperationCanceledException) { break; } + } + + // Persist state after each polling cycle so agent restart continues from here + if (_stateDirty) + { + SaveState(); + _stateDirty = false; + } + + try { await Task.Delay(_pollingIntervalMs, token); } catch (OperationCanceledException) { break; } + } + + // Final state save on shutdown + if (_stateDirty) + { + SaveState(); + _stateDirty = false; + } + + _logger.Info("ImeLogTracker: polling stopped"); + }, token); + } + + /// + /// Stops the background polling task + /// + public void Stop() + { + _cts?.Cancel(); + try { _pollingTask?.Wait(TimeSpan.FromSeconds(5)); } catch { } + _pollingTask = null; + } + + #region State Persistence + + /// + /// Loads persisted state from disk. Called on Start() to restore tracker state + /// after an agent restart, so parsing continues exactly where it left off. + /// + private void LoadState() + { + if (_statePersistence == null) return; + + var state = _statePersistence.Load(); + if (state == null) return; + + // Restore phase tracking + _currentPhaseOrder = state.CurrentPhaseOrder; + _lastEspPhaseDetected = state.LastEspPhaseDetected; + _allAppsCompletedFired = state.AllAppsCompletedFired; + _logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase; + + // Restore seen app IDs + _seenAppIds.Clear(); + if (state.SeenAppIds != null) + { + foreach (var id in state.SeenAppIds) + _seenAppIds.Add(id); + } + + // Restore ignore list + if (state.IgnoreList != null) + { + foreach (var id in state.IgnoreList) + _packageStates.AddToIgnoreList(id); + } + + // Restore current package ID + _packageStates.CurrentPackageId = state.CurrentPackageId; + + // Restore package states + if (state.Packages != null) + { + _packageStates.Clear(); + foreach (var p in state.Packages) + { + var pkg = AppPackageState.Restore( + p.Id, p.ListPos, p.Name, + (AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted, + p.DependsOn != null ? new HashSet(p.DependsOn) : new HashSet(), + (AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen, + p.ProgressPercent, p.BytesDownloaded, p.BytesTotal, + errorPatternId: p.ErrorPatternId, errorDetail: p.ErrorDetail, errorCode: p.ErrorCode, + exitCode: p.ExitCode, hresultFromWin32: p.HResultFromWin32, + doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded, + doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching, + doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers, + doBytesFromInternetPeers: p.DoBytesFromInternetPeers, doDownloadMode: p.DoDownloadMode, + doDownloadDuration: p.DoDownloadDuration, doBytesFromHttp: p.DoBytesFromHttp, + hasDoTelemetry: p.HasDoTelemetry, + appVersion: p.AppVersion, appType: p.AppType, + attemptNumber: p.AttemptNumber, detectionResult: p.DetectionResult); + _packageStates.Add(pkg); + } + } + + // Restore file positions + if (state.FilePositions != null) + { + foreach (var kvp in state.FilePositions) + { + var fullPath = Path.Combine(_logFolder, kvp.Key); + _positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize); + } + } + + // Re-activate patterns based on restored phase state + ActivatePatterns(_logPhaseIsCurrentPhase, force: true); + + _logger.Info($"ImeLogTracker: state restored - phase: {_lastEspPhaseDetected ?? "(none)"} (order: {_currentPhaseOrder}), " + + $"ignore list: {_packageStates.IgnoreList.Count}, packages: {_packageStates.Count}, " + + $"file positions: {state.FilePositions?.Count ?? 0}"); + } + + /// + /// Persists current tracker state to disk as JSON. + /// Called after each polling cycle when state has changed. + /// + private void SaveState() + { + if (_statePersistence == null) return; + + // Build state DTO + var state = new ImeTrackerStateData + { + CurrentPhaseOrder = _currentPhaseOrder, + LastEspPhaseDetected = _lastEspPhaseDetected, + AllAppsCompletedFired = _allAppsCompletedFired, + LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase, + SeenAppIds = _seenAppIds.ToList(), + IgnoreList = _packageStates.IgnoreList.ToList(), + CurrentPackageId = _packageStates.CurrentPackageId, + Packages = _packageStates.Select(p => new PackageStateData + { + Id = p.Id, + ListPos = p.ListPos, + Name = p.Name, + RunAs = (int)p.RunAs, + Intent = (int)p.Intent, + Targeted = (int)p.Targeted, + DependsOn = p.DependsOn?.ToList() ?? new List(), + InstallationState = (int)p.InstallationState, + DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen, + ProgressPercent = p.ProgressPercent, + BytesDownloaded = p.BytesDownloaded, + BytesTotal = p.BytesTotal, + ErrorPatternId = p.ErrorPatternId, + ErrorDetail = p.ErrorDetail, + ErrorCode = p.ErrorCode, + ExitCode = p.ExitCode, + HResultFromWin32 = p.HResultFromWin32, + DoFileSize = p.DoFileSize, + DoTotalBytesDownloaded = p.DoTotalBytesDownloaded, + DoBytesFromPeers = p.DoBytesFromPeers, + DoPercentPeerCaching = p.DoPercentPeerCaching, + DoBytesFromLanPeers = p.DoBytesFromLanPeers, + DoBytesFromGroupPeers = p.DoBytesFromGroupPeers, + DoBytesFromInternetPeers = p.DoBytesFromInternetPeers, + DoDownloadMode = p.DoDownloadMode, + DoDownloadDuration = p.DoDownloadDuration, + DoBytesFromHttp = p.DoBytesFromHttp, + HasDoTelemetry = p.HasDoTelemetry, + AppVersion = p.AppVersion, + AppType = p.AppType, + AttemptNumber = p.AttemptNumber, + DetectionResult = p.DetectionResult + }).ToList(), + FilePositions = new Dictionary() + }; + + // Store file positions by filename only (log folder is known) + foreach (var kvp in _positionTracker.GetAllPositions()) + { + var fileName = Path.GetFileName(kvp.Key); + state.FilePositions[fileName] = new FilePositionData + { + Position = kvp.Value.Position, + LastKnownSize = kvp.Value.LastKnownSize + }; + } + + _statePersistence.Save(state); + } + + /// + /// Deletes persisted state file. Called on enrollment complete to ensure + /// a fresh state on the next enrollment cycle. + /// + public void DeleteState() + { + _statePersistence?.Delete(); + } + + #endregion + + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs new file mode 100644 index 00000000..9530fbec --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Watches for IntuneManagementExtension.exe process exit. + /// Discovers the process once (with retry every 5s), then attaches Process.Exited — + /// no continuous polling after the process is found. + /// Emits ime_process_exited when the process terminates. + /// + public class ImeProcessWatcher : IDisposable + { + private const string ImeProcessName = "IntuneManagementExtension"; + private const int DiscoveryIntervalSeconds = 5; + + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly AgentLogger _logger; + + private Timer _discoveryTimer; + private Process _imeProcess; + private bool _disposed; + private readonly object _lock = new object(); + + public ImeProcessWatcher( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger) + { + _sessionId = sessionId; + _tenantId = tenantId; + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Start() + { + _logger.Info($"ImeProcessWatcher: starting (discovery every {DiscoveryIntervalSeconds}s until {ImeProcessName}.exe is found)"); + _discoveryTimer = new Timer(TryAttach, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(DiscoveryIntervalSeconds)); + } + + /// + /// Tries to find and attach to IntuneManagementExtension.exe. + /// Stops the discovery timer once attached — switches to pure event-driven from there. + /// + private void TryAttach(object state) + { + lock (_lock) + { + if (_disposed || _imeProcess != null) + return; + + Process[] candidates = null; + try + { + candidates = Process.GetProcessesByName(ImeProcessName); + if (candidates.Length == 0) + return; + + var proc = candidates[0]; + for (int i = 1; i < candidates.Length; i++) + candidates[i].Dispose(); + + proc.EnableRaisingEvents = true; + proc.Exited += OnImeExited; + _imeProcess = proc; + + // Suspend discovery timer — now event-driven + _discoveryTimer?.Change(Timeout.Infinite, Timeout.Infinite); + + _logger.Info($"ImeProcessWatcher: attached to {ImeProcessName}.exe (PID={proc.Id})"); + } + catch (Exception ex) + { + _logger.Debug($"ImeProcessWatcher: could not attach to {ImeProcessName}.exe: {ex.Message}"); + + // Dispose any leftover handles from the failed attempt + if (candidates != null) + foreach (var p in candidates) + try { p.Dispose(); } catch { } + } + } + } + + private void OnImeExited(object sender, EventArgs e) + { + if (!(sender is Process proc)) + return; + + int pid = -1; + int exitCode = -1; + double uptimeSeconds = 0; + + try { pid = proc.Id; } catch { } + try { exitCode = proc.ExitCode; } catch { } + try { uptimeSeconds = (proc.ExitTime - proc.StartTime).TotalSeconds; } catch { } + + _logger.Info($"ImeProcessWatcher: {ImeProcessName}.exe exited (PID={pid}, exit={exitCode}, uptime={uptimeSeconds:F0}s)"); + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = DateTime.UtcNow, + EventType = "ime_process_exited", + Severity = EventSeverity.Warning, + Source = "ImeProcessWatcher", + Phase = EnrollmentPhase.Unknown, + Message = $"{ImeProcessName}.exe exited (PID={pid}, exit code={exitCode})", + Data = new Dictionary + { + { "processName", ImeProcessName }, + { "pid", pid }, + { "exitCode", exitCode }, + { "imeUptimeSeconds", Math.Round(uptimeSeconds, 0) } + } + }); + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + + _discoveryTimer?.Dispose(); + _discoveryTimer = null; + + if (_imeProcess != null) + { + try { _imeProcess.Exited -= OnImeExited; } catch { } + _imeProcess.Dispose(); + _imeProcess = null; + } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs new file mode 100644 index 00000000..795d69cb --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Handles persistence of ImeLogTracker state to disk (JSON serialization). + /// Extracted from ImeLogTracker to separate I/O concerns from log parsing. + /// + public class ImeTrackerStatePersistence + { + private readonly string _stateDirectory; + private readonly string _stateFilePath; + private readonly AgentLogger _logger; + + public ImeTrackerStatePersistence(string stateDirectory, AgentLogger logger) + { + _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory); + _stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json"); + _logger = logger; + } + + public string StateFilePath => _stateFilePath; + + /// + /// Loads persisted state from disk. Returns null if no state file exists or on error. + /// + public ImeTrackerStateData Load() + { + if (!File.Exists(_stateFilePath)) + { + _logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)"); + return null; + } + + try + { + var json = File.ReadAllText(_stateFilePath); + var state = JsonConvert.DeserializeObject(json); + if (state == null) + { + _logger.Warning("ImeLogTracker: persisted state file was empty or invalid"); + return null; + } + return state; + } + catch (Exception ex) + { + _logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}"); + return null; + } + } + + /// + /// Persists tracker state to disk as JSON (atomic write via temp file). + /// + public void Save(ImeTrackerStateData state) + { + try + { + Directory.CreateDirectory(_stateDirectory); + var tempPath = _stateFilePath + ".tmp"; + File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented)); + File.Copy(tempPath, _stateFilePath, overwrite: true); + try { File.Delete(tempPath); } catch { } + } + catch (Exception ex) + { + _logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}"); + } + } + + /// + /// Deletes persisted state file. + /// + public void Delete() + { + try + { + if (File.Exists(_stateFilePath)) + { + File.Delete(_stateFilePath); + _logger.Info("ImeLogTracker: persisted state deleted"); + } + } + catch (Exception ex) + { + _logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}"); + } + } + } + + // ---- DTOs for state serialization ---- + + public class ImeTrackerStateData + { + public int CurrentPhaseOrder { get; set; } + public string LastEspPhaseDetected { get; set; } + public bool AllAppsCompletedFired { get; set; } + public bool LogPhaseIsCurrentPhase { get; set; } + public List SeenAppIds { get; set; } + public List IgnoreList { get; set; } + public string CurrentPackageId { get; set; } + public List Packages { get; set; } + public Dictionary FilePositions { get; set; } + } + + public class PackageStateData + { + public string Id { get; set; } + public int ListPos { get; set; } + public string Name { get; set; } + public int RunAs { get; set; } + public int Intent { get; set; } + public int Targeted { get; set; } + public List DependsOn { get; set; } + public int InstallationState { get; set; } + public bool DownloadingOrInstallingSeen { get; set; } + public int? ProgressPercent { get; set; } + public long BytesDownloaded { get; set; } + public long BytesTotal { get; set; } + + // Error context + public string ErrorPatternId { get; set; } + public string ErrorDetail { get; set; } + public string ErrorCode { get; set; } + + // Installer result codes + public string ExitCode { get; set; } + public string HResultFromWin32 { get; set; } + + // Delivery Optimization telemetry + public long DoFileSize { get; set; } + public long DoTotalBytesDownloaded { get; set; } + public long DoBytesFromPeers { get; set; } + public int DoPercentPeerCaching { get; set; } + public long DoBytesFromLanPeers { get; set; } + public long DoBytesFromGroupPeers { get; set; } + public long DoBytesFromInternetPeers { get; set; } + public int DoDownloadMode { get; set; } = -1; + public string DoDownloadDuration { get; set; } + public long DoBytesFromHttp { get; set; } + public bool HasDoTelemetry { get; set; } + + // App metadata (captured from IME logs for App Dashboard reporting) + public string AppVersion { get; set; } + public string AppType { get; set; } + public int AttemptNumber { get; set; } + public string DetectionResult { get; set; } + } + + public class FilePositionData + { + public long Position { get; set; } + public long LastKnownSize { get; set; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogFilePositionTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogFilePositionTracker.cs new file mode 100644 index 00000000..79b3e4a6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/LogFilePositionTracker.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime +{ + /// + /// Tracks file read positions for incremental log file reading. + /// In-memory only - resets on agent restart, which is acceptable since + /// we start fresh per enrollment session. + /// + public class LogFilePositionTracker + { + private readonly Dictionary _positions + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the safe read position for a file, detecting rollover/truncation. + /// If the current file size is smaller than the stored position, the file + /// has been rotated - resets to 0 to read from beginning. + /// Returns 0 if no position has been recorded yet. + /// + public long GetSafePosition(string filePath, long currentFileSize) + { + FilePositionState state; + if (!_positions.TryGetValue(filePath, out state)) + return 0; + + // Detect rollover: file is smaller than our stored position + if (currentFileSize < state.Position) + { + state.Position = 0; + state.LastKnownSize = currentFileSize; + return 0; + } + + return state.Position; + } + + /// + /// Stores the current read position for a file after successful reading. + /// + public void SetPosition(string filePath, long position) + { + FilePositionState state; + if (!_positions.TryGetValue(filePath, out state)) + { + state = new FilePositionState(); + _positions[filePath] = state; + } + + state.Position = position; + state.LastKnownSize = position; // Position is always <= file size + state.LastReadTime = DateTime.UtcNow; + } + + /// + /// Gets the stored position for a file, or 0 if not tracked. + /// Does not perform rollover detection. + /// + public long GetPosition(string filePath) + { + FilePositionState state; + if (_positions.TryGetValue(filePath, out state)) + return state.Position; + return 0; + } + + /// + /// Returns all tracked positions for state persistence. + /// Keys are full file paths. + /// + public Dictionary GetAllPositions() + { + return new Dictionary(_positions, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Restores a previously persisted position for a file. + /// Used on agent restart to continue reading from the last known position. + /// + public void RestorePosition(string filePath, long position, long lastKnownSize) + { + _positions[filePath] = new FilePositionState + { + Position = position, + LastKnownSize = lastKnownSize, + LastReadTime = DateTime.UtcNow + }; + } + } + + public class FilePositionState + { + public long Position { get; set; } + public long LastKnownSize { get; set; } + public DateTime LastReadTime { get; set; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs new file mode 100644 index 00000000..e6070d3e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs @@ -0,0 +1,35 @@ +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; + +/// +/// Accumulates multi-line script execution data from IME logs before emitting +/// a single consolidated event. Used for both platform scripts and remediation scripts. +/// +public class ScriptExecutionState +{ + /// Intune policy GUID identifying the script. + public string PolicyId { get; set; } + + /// "platform" or "remediation". + public string ScriptType { get; set; } + + /// "detection" or "remediation" (remediation scripts only). + public string ScriptPart { get; set; } + + /// "System" or "User" execution context. + public string RunContext { get; set; } + + /// PowerShell exit code. + public int? ExitCode { get; set; } + + /// Standard output (truncated). + public string Stdout { get; set; } + + /// Standard error output (truncated). + public string Stderr { get; set; } + + /// "Success" or "Failed" (platform scripts). + public string Result { get; set; } + + /// "True" or "False" compliance result (remediation detection only). + public string ComplianceResult { get; set; } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs new file mode 100644 index 00000000..f15b60e8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Reads AAD join information from the Windows registry and classifies the + /// signed-in user e-mail into "real user" vs. "provisioning placeholder". + /// + /// Registry path: HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\<thumbprint>\UserEmail + /// + /// Placeholder users () appear transiently during + /// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join. + /// + internal static class AadJoinInfo + { + internal const string JoinInfoRegistryPath = + @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"; + + /// + /// Reads the first JoinInfo sub-key and returns the UserEmail + thumbprint. + /// Returns true when ANY sub-key was found (even without UserEmail); the caller + /// inspects the out-parameters to decide semantics. + /// + /// Raw UserEmail value (may be null/empty). + /// The certificate thumbprint sub-key name (may be null). + /// + /// True when matches a known provisioning-placeholder + /// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join. + /// + internal static bool TryReadAadJoinedUser( + out string userEmail, + out string thumbprint, + out bool isPlaceholderUser) + { + userEmail = null; + thumbprint = null; + isPlaceholderUser = false; + + try + { + using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(JoinInfoRegistryPath)) + { + if (joinInfoKey == null) + return false; + + var subKeyNames = joinInfoKey.GetSubKeyNames(); + if (subKeyNames == null || subKeyNames.Length == 0) + return false; + + thumbprint = subKeyNames[0]; + using (var subKey = joinInfoKey.OpenSubKey(thumbprint)) + { + if (subKey == null) + return true; + + userEmail = subKey.GetValue("UserEmail")?.ToString(); + isPlaceholderUser = IsPlaceholderUserEmail(userEmail); + } + } + + return true; + } + catch + { + return false; + } + } + + /// + /// Returns true when the given email matches a known transient provisioning-account + /// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot device + /// preparation and must not be treated as a real signed-in user. + /// + internal static bool IsPlaceholderUserEmail(string email) + { + if (string.IsNullOrWhiteSpace(email)) + return false; + + return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase) + || email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs new file mode 100644 index 00000000..5a004648 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs @@ -0,0 +1,251 @@ +using System; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + public sealed class AadPlaceholderUserDetectedEventArgs : EventArgs + { + public string UserEmail { get; } + public string Thumbprint { get; } + public AadPlaceholderUserDetectedEventArgs(string userEmail, string thumbprint) + { + UserEmail = userEmail; + Thumbprint = thumbprint; + } + } + + public sealed class AadUserJoinedEventArgs : EventArgs + { + public string UserEmail { get; } + public string Thumbprint { get; } + public AadUserJoinedEventArgs(string userEmail, string thumbprint) + { + UserEmail = userEmail; + Thumbprint = thumbprint; + } + } + + /// + /// Watches HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo for a + /// late AAD user join. Uses (RegNotifyChangeKeyValue) for + /// instant, low-overhead change detection — no polling. + /// + /// Two distinct events are raised: + /// + /// — first time a transient + /// provisioning account (foouser@*, autopilot@*) appears. Fire-once. + /// The watcher keeps running afterwards, as the placeholder is expected to be + /// replaced by a real user. + /// — first time a REAL user e-mail + /// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards. + /// + /// + /// Retry semantics: the JoinInfo root key may not exist yet when the agent + /// starts — it is created during AAD provisioning. A retry timer polls for its + /// appearance every 2 seconds until it exists, then attaches the registry watcher. + /// + internal sealed class AadJoinWatcher : IDisposable + { + internal const int RetryIntervalSeconds = 2; + internal const int DebounceMilliseconds = 500; + + private readonly AgentLogger _logger; + private readonly object _stateLock = new object(); + + private RegistryWatcher _watcher; + private Timer _retryTimer; + private Timer _debounceTimer; + + private bool _placeholderFired; + private bool _realUserFired; + private bool _disposed; + + public event EventHandler PlaceholderUserDetected; + public event EventHandler AadUserJoined; + + public AadJoinWatcher(AgentLogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Start() + { + if (_disposed) throw new ObjectDisposedException(nameof(AadJoinWatcher)); + + // Initial synchronous read. If a real AAD user is already present (typical after + // DeviceInfoCollector ran at agent startup), fire AadUserJoined once and do NOT + // attach the registry watcher — there is nothing left to watch for. + CheckJoinInfo(); + bool terminalFromStartup; + lock (_stateLock) { terminalFromStartup = _realUserFired; } + if (terminalFromStartup) + { + _logger.Info("AadJoinWatcher: real AAD user already present at startup — skipping RegistryWatcher attach"); + return; + } + + if (!TryStartWatcher()) + { + _logger.Info("AadJoinWatcher: JoinInfo key not yet present — retrying every 2s"); + _retryTimer = new Timer( + _ => + { + if (TryStartWatcher()) + { + _retryTimer?.Dispose(); + _retryTimer = null; + } + }, + null, + TimeSpan.FromSeconds(RetryIntervalSeconds), + TimeSpan.FromSeconds(RetryIntervalSeconds)); + } + } + + public void Stop(string reason = "watcher_stopped") + { + try + { + lock (_stateLock) + { + _retryTimer?.Dispose(); + _retryTimer = null; + + _debounceTimer?.Dispose(); + _debounceTimer = null; + + if (_watcher != null) + { + _watcher.Dispose(); + _watcher = null; + _logger.Info($"AadJoinWatcher: stopped ({reason})"); + } + } + } + catch (Exception ex) + { + _logger.Error("AadJoinWatcher: error during Stop", ex); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop("disposed"); + } + + private bool TryStartWatcher() + { + try + { + using (var key = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath)) + { + if (key == null) return false; + } + + lock (_stateLock) + { + if (_watcher != null) return true; // Already running + + _debounceTimer = new Timer( + _ => CheckJoinInfo(), + null, + Timeout.Infinite, + Timeout.Infinite); + + _watcher = new RegistryWatcher( + RegistryHive.LocalMachine, + AadJoinInfo.JoinInfoRegistryPath, + watchSubtree: true, + filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet + | RegistryNativeMethods.RegChangeNotifyFilter.Name, + trace: msg => _logger.Trace($"AadJoinWatcher(reg): {msg}")); + + _watcher.Changed += (s, e) => + { + _logger.Trace("AadJoinWatcher: registry changed — debouncing"); + _debounceTimer?.Change(DebounceMilliseconds, Timeout.Infinite); + }; + _watcher.Error += (s, ex) => _logger.Warning($"AadJoinWatcher: watcher error: {ex.Message}"); + + _watcher.Start(); + _logger.Info("AadJoinWatcher: RegistryWatcher attached to JoinInfo"); + } + + return true; + } + catch (Exception ex) + { + _logger.Warning($"AadJoinWatcher: failed to start watcher: {ex.Message}"); + return false; + } + } + + private void CheckJoinInfo() + { + try + { + // Already reached terminal state? Stop any further processing. + lock (_stateLock) + { + if (_realUserFired) return; + } + + if (!AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)) + { + // Key gone or unreadable — keep watching, may come back + return; + } + + if (string.IsNullOrWhiteSpace(userEmail)) + { + // Sub-key exists but no UserEmail value yet — nothing to act on + return; + } + + bool firePlaceholder = false; + bool fireRealUser = false; + lock (_stateLock) + { + if (isPlaceholderUser && !_placeholderFired) + { + _placeholderFired = true; + firePlaceholder = true; + } + else if (!isPlaceholderUser && !_realUserFired) + { + _realUserFired = true; + fireRealUser = true; + } + } + + if (firePlaceholder) + { + _logger.Info($"AadJoinWatcher: placeholder user detected ({userEmail}) — pre-provisioning indicator"); + try { PlaceholderUserDetected?.Invoke(this, new AadPlaceholderUserDetectedEventArgs(userEmail, thumbprint)); } + catch (Exception ex) { _logger.Error("AadJoinWatcher: PlaceholderUserDetected handler threw", ex); } + } + + if (fireRealUser) + { + _logger.Info($"AadJoinWatcher: real AAD user joined ({userEmail}) — firing AadUserJoined and stopping watcher"); + try { AadUserJoined?.Invoke(this, new AadUserJoinedEventArgs(userEmail, thumbprint)); } + catch (Exception ex) { _logger.Error("AadJoinWatcher: AadUserJoined handler threw", ex); } + + // Terminal — stop the watcher. Run on a thread-pool thread to avoid + // callback-in-callback lock edge cases with the RegistryWatcher worker. + ThreadPool.QueueUserWorkItem(_ => Stop("real_user_joined")); + } + } + catch (Exception ex) + { + _logger.Warning($"AadJoinWatcher: CheckJoinInfo threw: {ex.Message}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs new file mode 100644 index 00000000..8063dd45 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Management; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Detects when a real user desktop becomes available (explorer.exe under a non-system user). + /// Fires DesktopArrived event exactly once per agent lifetime. + /// Used for enrollment completion in no-ESP scenarios (WDP v2, ESP disabled) and + /// as a backup signal for AccountSetup phase correction. + /// + public class DesktopArrivalDetector : IDisposable + { + private readonly AgentLogger _logger; + private Timer _pollingTimer; + private bool _desktopArrived; + private bool _excludedUserTraced; // Emit trace event only once for excluded-user skips + private const int PollingIntervalSeconds = 30; + + /// + /// System/service account names that should NOT be considered real users. + /// + private static readonly string[] ExcludedUserNames = + { + "SYSTEM", + "LOCAL SERVICE", + "NETWORK SERVICE", + "DefaultUser0", + "DefaultUser1", + "defaultuser0", + "defaultuser1" + }; + + /// + /// Fired exactly once when a real user desktop is detected (explorer.exe under a real user). + /// + public event EventHandler DesktopArrived; + + /// + /// Optional callback for trace events (decision, reason, context). + /// Wired by MonitoringService to emit agent_trace events to the backend. + /// + public Action> OnTraceEvent { get; set; } + + public DesktopArrivalDetector(AgentLogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Start() + { + _logger.Info("DesktopArrivalDetector: starting (polling every 30s)"); + _pollingTimer = new Timer( + PollForDesktop, + null, + TimeSpan.FromSeconds(5), // Initial check after 5s + TimeSpan.FromSeconds(PollingIntervalSeconds)); + } + + public void Stop() + { + _pollingTimer?.Dispose(); + _pollingTimer = null; + } + + private void PollForDesktop(object state) + { + if (_desktopArrived) + return; + + try + { + var explorerProcesses = Process.GetProcessesByName("explorer"); + foreach (var proc in explorerProcesses) + { + try + { + // Session 0 = SYSTEM session, skip + if (proc.SessionId == 0) + continue; + + var owner = GetProcessOwner(proc.Id); + if (owner == null) + continue; + + // Check against exclusion list + if (IsExcludedUser(owner)) + { + _logger.Debug($"DesktopArrivalDetector: explorer.exe PID {proc.Id} owned by excluded user '{owner}' — skipping"); + + // Trace this decision once so it's visible in the backend + if (!_excludedUserTraced) + { + _excludedUserTraced = true; + try + { + OnTraceEvent?.Invoke( + "desktop_excluded_user", + $"explorer.exe found but owned by excluded user '{owner}' — not a real user desktop", + new Dictionary { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", owner } }); + } + catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); } + } + continue; + } + + // Real user desktop detected + _desktopArrived = true; + _logger.Info($"DesktopArrivalDetector: real user desktop detected (explorer.exe PID {proc.Id}, session {proc.SessionId}, user '{owner}')"); + + try + { + OnTraceEvent?.Invoke( + "desktop_real_user_detected", + $"Real user desktop detected (explorer.exe PID {proc.Id}, user '[redacted]')", + new Dictionary { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", "[redacted]" } }); + } + catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); } + + // Stop polling + _pollingTimer?.Dispose(); + _pollingTimer = null; + + try { DesktopArrived?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Warning($"DesktopArrivalDetector: DesktopArrived handler failed: {ex.Message}"); } + return; + } + catch (Exception ex) + { + _logger.Debug($"DesktopArrivalDetector: error checking explorer.exe PID {proc.Id}: {ex.Message}"); + } + finally + { + proc.Dispose(); + } + } + } + catch (Exception ex) + { + _logger.Debug($"DesktopArrivalDetector: polling error: {ex.Message}"); + } + } + + /// + /// Gets the owner of a process using WMI (Win32_Process.GetOwner). + /// Returns "DOMAIN\User" or "User" string, or null on failure. + /// + private string GetProcessOwner(int processId) + { + try + { + using (var searcher = new ManagementObjectSearcher( + $"SELECT * FROM Win32_Process WHERE ProcessId = {processId}")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var outParams = new object[2]; + var result = (uint)obj.InvokeMethod("GetOwner", outParams); + if (result == 0) + { + var user = outParams[0]?.ToString(); + var domain = outParams[1]?.ToString(); + return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}"; + } + } + } + } + catch (Exception ex) + { + _logger.Debug($"DesktopArrivalDetector: WMI GetOwner failed for PID {processId}: {ex.Message}"); + } + + return null; + } + + /// + /// Returns true if the user name matches any excluded system/service account. + /// Handles both "User" and "DOMAIN\User" formats. + /// Also matches the pattern DefaultUser* (case-insensitive). + /// + internal static bool IsExcludedUser(string fullUserName) + { + if (string.IsNullOrEmpty(fullUserName)) + return true; + + // Extract just the username part (after backslash if present) + var userName = fullUserName; + var backslashIndex = fullUserName.LastIndexOf('\\'); + if (backslashIndex >= 0 && backslashIndex < fullUserName.Length - 1) + userName = fullUserName.Substring(backslashIndex + 1); + + // Check exact matches + foreach (var excluded in ExcludedUserNames) + { + if (string.Equals(userName, excluded, StringComparison.OrdinalIgnoreCase)) + return true; + } + + // Check DefaultUser* pattern + if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + public void Dispose() + { + Stop(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs new file mode 100644 index 00000000..913aa92d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs @@ -0,0 +1,320 @@ +using System; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Thin coordinator that composes four focused sub-trackers to deliver the ESP + Hello + /// monitoring surface expected by CollectorCoordinator and EnrollmentTracker: + /// + /// - — WHfB policy, UDR 300/301/358/360/362/376, + /// HelloForBusiness 3024/6045, Hello timers + /// - — Shell-Core 62404/62407 (ESP exit / failure, + /// WhiteGlove success, Hello wizard start) + /// - — Registry-driven ESP provisioning category + /// tracking + DeviceSetup fallback + /// - — ModernDeployment-Diagnostics-Provider live + /// capture + WhiteGlove Event 509 backfill + /// + /// Public API (events, properties, methods) is preserved byte-for-byte from the pre-split + /// monolith so callers don't change. + /// + public sealed class EspAndHelloTracker : IDisposable + { + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + + private readonly int _helloWaitTimeoutSeconds; + private readonly bool _modernDeploymentWatcherEnabled; + private readonly int _modernDeploymentLogLevelMax; + private readonly bool _modernDeploymentBackfillEnabled; + private readonly int _modernDeploymentBackfillLookbackMinutes; + private readonly string _stateDirectory; + private readonly int[] _modernDeploymentHarmlessEventIds; + + private HelloTracker _helloTracker; + private ShellCoreTracker _shellCoreTracker; + private ProvisioningStatusTracker _provisioningTracker; + private ModernDeploymentTracker _modernDeploymentTracker; + + /// + /// Callback invoked when Hello provisioning completes (successfully, failed, skipped, or timeout). + /// Based on events 300/301/362 only — NOT on event 360 (which is just a snapshot). + /// + public event EventHandler HelloCompleted; + + /// + /// Callback invoked when ESP exit or Hello wizard start is detected. + /// Triggers transition to FinalizingSetup phase in EnrollmentTracker. + /// + public event EventHandler FinalizingSetupPhaseTriggered; + + /// + /// Fired when WhiteGlove (Pre-Provisioning) completes successfully. + /// The device will shut down; the agent should terminate gracefully. + /// + public event EventHandler WhiteGloveCompleted; + + /// + /// Fired when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, + /// WhiteGlove_Failed, Provisioning_*_Failed, etc.). The string is the structured failure type. + /// + public event EventHandler EspFailureDetected; + + /// + /// Fired when DeviceSetup provisioning status shows categorySucceeded=true (or fallback confirmed). + /// Used as a completion signal for Self-Deploying mode where Shell-Core ESP exit and + /// desktop arrival signals may never arrive. + /// + public event EventHandler DeviceSetupProvisioningComplete; + + /// + /// Outcome of Hello provisioning. Set when Hello resolves (via events, timeout, or not configured). + /// Values: "completed", "skipped", "timeout", "not_configured", "wizard_not_started", null (not yet resolved). + /// + public string HelloOutcome => _helloTracker?.HelloOutcome; + + /// + /// True when Windows Hello for Business policy is configured (enabled or disabled). + /// + public bool IsPolicyConfigured => _helloTracker?.IsPolicyConfigured ?? false; + + /// + /// True when Windows Hello provisioning has completed (successfully, failed, or skipped). + /// + public bool IsHelloCompleted => _helloTracker?.IsHelloCompleted ?? false; + + /// + /// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR the fallback confirmed. + /// + public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false; + + /// + /// True when any AccountSetup subcategory has been tracked (resolved or in progress). + /// + public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false; + + /// + /// True once WhiteGlove start has been detected (EventID 509 or persisted from prior run). + /// Forwarded from ModernDeploymentTracker. + /// + public bool IsWhiteGloveStartDetected => _modernDeploymentTracker?.IsWhiteGloveStartDetected ?? false; + + /// + /// True when DeviceSetup contains a SaveWhiteGloveSuccessResult subcategory with state "succeeded". + /// This is a definitive WhiteGlove (Pre-Provisioning) confirmation signal from the ESP registry. + /// + public bool HasSaveWhiteGloveSuccessResult => _provisioningTracker?.HasSaveWhiteGloveSuccessResult ?? false; + + public EspAndHelloTracker( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger, + int helloWaitTimeoutSeconds = 30, + bool modernDeploymentWatcherEnabled = true, + int modernDeploymentLogLevelMax = 3, + bool modernDeploymentBackfillEnabled = true, + int modernDeploymentBackfillLookbackMinutes = 30, + string stateDirectory = null, + int[] modernDeploymentHarmlessEventIds = null) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _helloWaitTimeoutSeconds = helloWaitTimeoutSeconds; + _modernDeploymentWatcherEnabled = modernDeploymentWatcherEnabled; + _modernDeploymentLogLevelMax = modernDeploymentLogLevelMax; + _modernDeploymentBackfillEnabled = modernDeploymentBackfillEnabled; + _modernDeploymentBackfillLookbackMinutes = modernDeploymentBackfillLookbackMinutes; + _stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null; + _modernDeploymentHarmlessEventIds = modernDeploymentHarmlessEventIds; + } + + // ===================================================================== + // Forwarded state/snapshot methods + // ===================================================================== + + public System.Collections.Generic.Dictionary GetProvisioningCategorySnapshot() + => _provisioningTracker?.GetProvisioningCategorySnapshot() + ?? new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase); + + public EspProvisioningSnapshot GetProvisioningSnapshot() => _provisioningTracker?.GetProvisioningSnapshot(); + + // ===================================================================== + // Forwarded Hello coordination API + // ===================================================================== + + /// + /// Force-marks Hello as completed from an external caller (e.g. safety timeout in EnrollmentTracker). + /// Does NOT invoke the HelloCompleted event — the caller handles completion logic directly. + /// + public void ForceMarkHelloCompleted(string reason) => _helloTracker?.ForceMarkHelloCompleted(reason); + + /// + /// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits. + /// + public void StartHelloWaitTimer() => _helloTracker?.StartHelloWaitTimer(); + + /// + /// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join). + /// + public void ResetForEspResumption() => _helloTracker?.ResetForEspResumption(); + + /// + /// Backfills recent ESP exit and failure events from Shell-Core log on startup. + /// Secondary recovery mechanism when state persistence is unavailable. + /// + public void BackfillRecentEspExitEvents() => _shellCoreTracker?.BackfillRecentEspExitEvents(); + + // ===================================================================== + // Lifecycle + // ===================================================================== + + public void Start() + { + _logger.Info("Starting ESP and Hello tracker"); + + _helloTracker = new HelloTracker( + _sessionId, + _tenantId, + _onEventCollected, + _logger, + _helloWaitTimeoutSeconds); + _helloTracker.HelloCompleted += OnHelloCompleted; + _helloTracker.Start(); + + _shellCoreTracker = new ShellCoreTracker( + _sessionId, + _tenantId, + _onEventCollected, + _logger, + _helloTracker); + _shellCoreTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered; + _shellCoreTracker.WhiteGloveCompleted += OnWhiteGloveCompleted; + _shellCoreTracker.EspFailureDetected += OnEspFailureDetected; + _shellCoreTracker.Start(); + + _provisioningTracker = new ProvisioningStatusTracker( + _sessionId, + _tenantId, + _onEventCollected, + _logger); + _provisioningTracker.EspFailureDetected += OnEspFailureDetected; + _provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete; + _provisioningTracker.Start(); + + if (_modernDeploymentWatcherEnabled) + { + _modernDeploymentTracker = new ModernDeploymentTracker( + _sessionId, + _tenantId, + _onEventCollected, + _logger, + _modernDeploymentLogLevelMax, + _modernDeploymentBackfillEnabled, + _modernDeploymentBackfillLookbackMinutes, + _stateDirectory, + _modernDeploymentHarmlessEventIds); + _modernDeploymentTracker.Start(); + } + } + + public void Stop() + { + _logger.Info("Stopping ESP and Hello tracker"); + + DisposeTracker(ref _modernDeploymentTracker, "ModernDeployment", t => t.Stop()); + DisposeProvisioningTracker(); + DisposeShellCoreTracker(); + DisposeHelloTracker(); + } + + public void Dispose() => Stop(); + + private void DisposeHelloTracker() + { + if (_helloTracker == null) return; + try + { + _helloTracker.HelloCompleted -= OnHelloCompleted; + _helloTracker.Stop(); + } + catch (Exception ex) { _logger.Error("Error stopping Hello tracker", ex); } + _helloTracker = null; + } + + private void DisposeShellCoreTracker() + { + if (_shellCoreTracker == null) return; + try + { + _shellCoreTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered; + _shellCoreTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted; + _shellCoreTracker.EspFailureDetected -= OnEspFailureDetected; + _shellCoreTracker.Stop(); + } + catch (Exception ex) { _logger.Error("Error stopping Shell-Core tracker", ex); } + _shellCoreTracker = null; + } + + private void DisposeProvisioningTracker() + { + if (_provisioningTracker == null) return; + try + { + _provisioningTracker.EspFailureDetected -= OnEspFailureDetected; + _provisioningTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete; + _provisioningTracker.Stop("tracker_stopped"); + } + catch (Exception ex) { _logger.Error("Error stopping provisioning status tracker", ex); } + _provisioningTracker = null; + } + + private void DisposeTracker(ref T tracker, string name, Action stopper) where T : class + { + if (tracker == null) return; + try { stopper(tracker); } + catch (Exception ex) { _logger.Error($"Error stopping {name} tracker", ex); } + tracker = null; + } + + // ===================================================================== + // Event forwarders + // ===================================================================== + + private void OnHelloCompleted(object sender, EventArgs e) + { + try { HelloCompleted?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("Error forwarding HelloCompleted", ex); } + } + + private void OnFinalizingSetupPhaseTriggered(object sender, string reason) + { + try { FinalizingSetupPhaseTriggered?.Invoke(this, reason); } + catch (Exception ex) { _logger.Error("Error forwarding FinalizingSetupPhaseTriggered", ex); } + } + + private void OnWhiteGloveCompleted(object sender, EventArgs e) + { + try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("Error forwarding WhiteGloveCompleted", ex); } + } + + private void OnEspFailureDetected(object sender, string failureType) + { + try { EspFailureDetected?.Invoke(this, failureType); } + catch (Exception ex) { _logger.Error($"Error forwarding EspFailureDetected for '{failureType}'", ex); } + } + + private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e) + { + try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("Error forwarding DeviceSetupProvisioningComplete", ex); } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs new file mode 100644 index 00000000..d0d4fc52 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs @@ -0,0 +1,997 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; +using System.Text.RegularExpressions; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Tracks Windows Hello for Business (WHfB) provisioning during Autopilot enrollment. + /// + /// Signals: + /// - User Device Registration/Admin: 300/301/358/360/362/376 (NGC key lifecycle) + /// - HelloForBusiness/Operational: 3024/6045 (processing start/stop + skip HRESULT) + /// - Registry (CSP + GPO): PassportForWork policy detection + /// - External "wizard started" signal from ESP (Shell-Core 62404) + /// - External "ESP exited" signal from ESP (Shell-Core 62407) + /// + /// Completion outcomes: "completed", "skipped", "timeout", "not_configured", "wizard_not_started". + /// + internal sealed class HelloTracker : IDisposable + { + internal const string UdrEventLogChannel = "Microsoft-Windows-User Device Registration/Admin"; + internal const string HelloForBusinessEventLogChannel = "Microsoft-Windows-HelloForBusiness/Operational"; + + internal const string CspPolicyBasePath = @"SOFTWARE\Microsoft\Policies\PassportForWork"; + internal const string GpoPolicyPath = @"SOFTWARE\Policies\Microsoft\PassportForWork"; + + internal const int EventId_NgcKeyRegistered = 300; + internal const int EventId_NgcKeyRegistrationFailed = 301; + internal const int EventId_ProvisioningWillLaunch = 358; + internal const int EventId_ProvisioningWillNotLaunch = 360; + internal const int EventId_ProvisioningBlocked = 362; + internal const int EventId_PinStatus = 376; + + internal const int EventId_HelloForBusiness_ProcessingStarted = 3024; + internal const int EventId_HelloForBusiness_ProcessingStopped = 6045; + + internal const string HResult_UserSkippedHello = "0x801C044F"; + + internal const int HelloCompletionTimeoutSeconds = 300; + internal const int BackfillLookbackMinutes = 5; + + private static readonly HashSet TrackedUdrEventIds = new HashSet + { + EventId_NgcKeyRegistered, + EventId_NgcKeyRegistrationFailed, + EventId_ProvisioningWillLaunch, + EventId_ProvisioningWillNotLaunch, + EventId_ProvisioningBlocked, + EventId_PinStatus + }; + + private static readonly HashSet TrackedHelloForBusinessEventIds = new HashSet + { + EventId_HelloForBusiness_ProcessingStarted, + EventId_HelloForBusiness_ProcessingStopped + }; + + private static readonly Regex HResultPattern = new Regex(@"0x[0-9A-Fa-f]{8}", RegexOptions.Compiled); + + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly int _helloWaitTimeoutSeconds; + + private EventLogWatcher _udrWatcher; + private EventLogWatcher _helloForBusinessWatcher; + private System.Threading.Timer _policyCheckTimer; + private System.Threading.Timer _helloWaitTimer; + private System.Threading.Timer _helloCompletionTimer; + + private bool _isPolicyConfigured; + private bool _isHelloPolicyEnabled; + private bool _isHelloCompleted; + private bool _helloWizardStarted; + private bool _espExitSeen; + private readonly object _stateLock = new object(); + + public event EventHandler HelloCompleted; + + public string HelloOutcome { get; private set; } + + public bool IsPolicyConfigured { get { lock (_stateLock) { return _isPolicyConfigured; } } } + + public bool IsHelloCompleted { get { lock (_stateLock) { return _isHelloCompleted; } } } + + public HelloTracker( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger, + int helloWaitTimeoutSeconds = 30) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _helloWaitTimeoutSeconds = helloWaitTimeoutSeconds; + } + + // ===================================================================== + // Lifecycle + // ===================================================================== + + public void Start() + { + CheckHelloPolicy(); + + _policyCheckTimer = new System.Threading.Timer( + _ => CheckHelloPolicy(), + null, + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(10)); + + StartUdrEventLogWatcher(); + StartHelloForBusinessEventLogWatcher(); + + BackfillRecentTerminalHelloEvents(); + BackfillRecentHelloForBusinessEvents(); + } + + public void Stop() + { + DisposeTimer(ref _policyCheckTimer, "policy check"); + DisposeTimer(ref _helloWaitTimer, "Hello wait"); + DisposeTimer(ref _helloCompletionTimer, "Hello completion"); + + if (_udrWatcher != null) + { + try + { + _udrWatcher.Enabled = false; + _udrWatcher.EventRecordWritten -= OnUdrEventRecordWritten; + _udrWatcher.Dispose(); + _udrWatcher = null; + } + catch (Exception ex) { _logger.Error("Error stopping UDR watcher", ex); } + } + + if (_helloForBusinessWatcher != null) + { + try + { + _helloForBusinessWatcher.Enabled = false; + _helloForBusinessWatcher.EventRecordWritten -= OnHelloForBusinessEventRecordWritten; + _helloForBusinessWatcher.Dispose(); + _helloForBusinessWatcher = null; + } + catch (Exception ex) { _logger.Error("Error stopping HelloForBusiness watcher", ex); } + } + } + + public void Dispose() => Stop(); + + private void DisposeTimer(ref System.Threading.Timer timer, string name) + { + if (timer == null) return; + try { timer.Dispose(); } + catch (Exception ex) { _logger.Error($"Error stopping {name} timer", ex); } + timer = null; + } + + // ===================================================================== + // External coordination API (called by EspTracker / coordinator) + // ===================================================================== + + /// + /// Force-marks Hello as completed from an external caller (e.g. safety timeout in EnrollmentTracker). + /// Does NOT invoke the HelloCompleted event — the caller handles completion logic directly. + /// + public void ForceMarkHelloCompleted(string reason) + { + lock (_stateLock) + { + if (_isHelloCompleted) return; + _isHelloCompleted = true; + HelloOutcome = reason; + StopHelloCompletionTimerLocked(); + _logger.Warning($"Hello force-completed by external caller: {reason}"); + } + } + + /// + /// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join). + /// Stops all running timers and clears Hello state so the timer chain restarts fresh when + /// ESP exits again for real. + /// + public void ResetForEspResumption() + { + lock (_stateLock) + { + _helloWaitTimer?.Dispose(); + _helloWaitTimer = null; + _helloCompletionTimer?.Dispose(); + _helloCompletionTimer = null; + + _isHelloCompleted = false; + _helloWizardStarted = false; + _espExitSeen = false; + HelloOutcome = null; + } + _logger.Info("HelloTracker: Reset for ESP resumption — Hello tracking restarted"); + } + + /// + /// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits. + /// Waits for Hello wizard to start (Shell-Core event 62404, reported via + /// ) within the configured timeout. + /// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed. + /// + public void StartHelloWaitTimer() + { + lock (_stateLock) + { + if (_helloWizardStarted) + { + _logger.Debug("StartHelloWaitTimer called but Hello wizard already started - skipping"); + return; + } + + if (_isHelloCompleted) + { + _logger.Debug("StartHelloWaitTimer called but Hello already completed - skipping"); + return; + } + + if (_helloWaitTimer != null) + { + _logger.Debug("StartHelloWaitTimer called but timer already running - skipping"); + return; + } + + _logger.Info($"Starting Hello wait timer ({_helloWaitTimeoutSeconds}s) - waiting for Hello wizard to start"); + _helloWaitTimer = new System.Threading.Timer( + OnHelloWaitTimeout, + null, + TimeSpan.FromSeconds(_helloWaitTimeoutSeconds), + TimeSpan.FromMilliseconds(-1)); + } + } + + /// + /// Called by the ESP tracker when Shell-Core event 62404 fires with AADHello/NGC context. + /// Stops the wait timer (if running) and arms the long completion timer to catch cases + /// where the wizard appeared but no terminal Hello event arrives. + /// + public void NotifyHelloWizardStarted() + { + lock (_stateLock) + { + if (_helloWizardStarted) return; + _helloWizardStarted = true; + + if (_helloWaitTimer != null) + { + _helloWaitTimer.Dispose(); + _helloWaitTimer = null; + _logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer"); + } + + StartHelloCompletionTimerLocked(); + } + } + + /// + /// Called by the ESP tracker when the ESP exits (Shell-Core 62407 with OOBE_ESP*Exiting). + /// Used only for informational data fields in wait/completion timeout events. + /// + public void NotifyEspExited() + { + lock (_stateLock) + { + _espExitSeen = true; + } + } + + // ===================================================================== + // Policy detection (CSP + GPO) + // ===================================================================== + + private void CheckHelloPolicy() + { + try + { + var (isEnabled, source) = DetectHelloPolicy(); + + lock (_stateLock) + { + if (!_isPolicyConfigured && isEnabled.HasValue) + { + _isPolicyConfigured = true; + _isHelloPolicyEnabled = isEnabled.Value; + var status = isEnabled.Value ? "enabled" : "disabled"; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "hello_policy_detected", + Severity = EventSeverity.Info, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Windows Hello for Business policy detected: {status} (via {source})", + Data = new Dictionary + { + { "helloEnabled", isEnabled.Value }, + { "policySource", source } + } + }); + + _logger.Info($"WHfB policy detected: {status} (source: {source})"); + + if (_policyCheckTimer != null) + { + _policyCheckTimer.Dispose(); + _policyCheckTimer = null; + _logger.Debug("Stopped periodic Hello policy check - policy has been detected"); + } + } + else if (!_isPolicyConfigured && !isEnabled.HasValue) + { + _logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again"); + } + } + } + catch (Exception ex) + { + _logger.Warning($"Error checking WHfB policy: {ex.Message}"); + } + } + + private (bool? isEnabled, string source) DetectHelloPolicy() + { + try + { + using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false)) + { + if (baseCspKey != null) + { + foreach (var tenantSubKey in baseCspKey.GetSubKeyNames()) + { + using (var tenantKey = baseCspKey.OpenSubKey(tenantSubKey, false)) + { + if (tenantKey != null) + { + foreach (var scopeSubKey in tenantKey.GetSubKeyNames()) + { + using (var policiesKey = tenantKey.OpenSubKey($@"{scopeSubKey}\Policies", false)) + { + if (policiesKey != null) + { + var value = policiesKey.GetValue("UsePassportForWork"); + if (value != null) + { + var scope = scopeSubKey.Equals("Device", StringComparison.OrdinalIgnoreCase) + ? "device" + : "user"; + return (Convert.ToInt32(value) == 1, $"CSP/Intune ({scope}-scoped)"); + } + } + } + } + } + } + } + } + } + } + catch { } + + try + { + using (var gpoKey = Registry.LocalMachine.OpenSubKey(GpoPolicyPath, false)) + { + if (gpoKey != null) + { + var value = gpoKey.GetValue("Enabled"); + if (value != null) + { + return (Convert.ToInt32(value) == 1, "GPO"); + } + } + } + } + catch { } + + return (null, null); + } + + // ===================================================================== + // UDR event log watcher (events 300/301/358/360/362/376) + // ===================================================================== + + private void StartUdrEventLogWatcher() + { + try + { + var query = new EventLogQuery( + UdrEventLogChannel, + PathType.LogName, + "*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]"); + + _udrWatcher = new EventLogWatcher(query); + _udrWatcher.EventRecordWritten += OnUdrEventRecordWritten; + _udrWatcher.Enabled = true; + + _logger.Info($"Started watching: {UdrEventLogChannel}"); + } + catch (EventLogNotFoundException) + { + _logger.Warning($"Event log not found: {UdrEventLogChannel} (normal if not on a real device)"); + } + catch (Exception ex) + { + _logger.Error("Failed to start Hello event log watcher", ex); + } + } + + private void OnUdrEventRecordWritten(object sender, EventRecordWrittenEventArgs e) + { + if (e.EventRecord == null) return; + + try + { + var record = e.EventRecord; + var eventId = record.Id; + if (!TrackedUdrEventIds.Contains(eventId)) return; + + ProcessHelloEvent( + eventId, + (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(), + record.ProviderName ?? "", + isBackfill: false); + } + catch (Exception ex) + { + _logger.Error("Error processing Hello event record", ex); + } + } + + internal void ProcessHelloEvent(int eventId, DateTime timestamp, string providerName, bool isBackfill) + { + string eventType; + EventSeverity severity; + string message; + bool shouldTriggerHelloCompleted = false; + + switch (eventId) + { + case EventId_ProvisioningWillLaunch: // 358 + eventType = "hello_provisioning_willlaunch"; + severity = EventSeverity.Info; + message = "Windows Hello for Business provisioning will launch - prerequisites passed (snapshot only, not a final state)"; + _logger.Info("Windows Hello provisioning will launch - prerequisites passed (snapshot only, not a final state)"); + break; + + case EventId_NgcKeyRegistered: // 300 + eventType = "hello_provisioning_completed"; + severity = EventSeverity.Info; + message = "Windows Hello for Business provisioned successfully - NGC key registered"; + shouldTriggerHelloCompleted = MarkHelloCompleted(); + _logger.Info("Windows Hello provisioning COMPLETED successfully"); + break; + + case EventId_NgcKeyRegistrationFailed: // 301 + eventType = "hello_provisioning_failed"; + severity = EventSeverity.Error; + message = "Windows Hello for Business provisioning failed - NGC key registration error"; + shouldTriggerHelloCompleted = MarkHelloCompleted(); + _logger.Info("Windows Hello provisioning COMPLETED with failure"); + break; + + case EventId_ProvisioningWillNotLaunch: // 360 + eventType = "hello_provisioning_willnotlaunch"; + severity = EventSeverity.Warning; + message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not a final state)"; + // DO NOT mark Hello as completed - event 360 is just a snapshot and can change + _logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)"); + break; + + case EventId_ProvisioningBlocked: // 362 + eventType = "hello_provisioning_blocked"; + severity = EventSeverity.Warning; + message = "Windows Hello for Business provisioning blocked"; + shouldTriggerHelloCompleted = MarkHelloCompleted(); + _logger.Info("Windows Hello provisioning COMPLETED (blocked)"); + break; + + case EventId_PinStatus: // 376 + eventType = "hello_pin_status"; + severity = EventSeverity.Info; + message = "Windows Hello PIN status update"; + break; + + default: + return; + } + + // Keep payload minimal — full descriptions may contain sensitive data + // (server responses with key IDs, UPNs, tokens). + var data = new Dictionary + { + { "windowsEventId", eventId }, + { "providerName", providerName ?? "" }, + { "description", "truncated" }, + { "eventTime", timestamp.ToString("o") } + }; + + if (isBackfill) + { + data["backfill"] = true; + } + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = timestamp, + EventType = eventType, + Severity = severity, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = message, + Data = data, + ImmediateUpload = shouldTriggerHelloCompleted || eventType == "hello_provisioning_failed" + }); + + _logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})"); + + if (shouldTriggerHelloCompleted) + { + try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { } + } + } + + private bool MarkHelloCompleted() + { + lock (_stateLock) + { + if (_isHelloCompleted) + { + _logger.Debug("Hello terminal event received but Hello is already marked completed"); + return false; + } + + _isHelloCompleted = true; + HelloOutcome = "completed"; + StopHelloCompletionTimerLocked(); + return true; + } + } + + private void BackfillRecentTerminalHelloEvents() + { + try + { + var lookbackMs = BackfillLookbackMinutes * 60 * 1000; + var query = new EventLogQuery( + UdrEventLogChannel, + PathType.LogName, + $"*[System[(EventID=300 or EventID=301 or EventID=362) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"); + + using (var reader = new EventLogReader(query)) + { + DateTime latestTimestamp = DateTime.MinValue; + int? latestEventId = null; + string latestProvider = string.Empty; + + for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent()) + { + using (record) + { + var eventId = record.Id; + if (eventId != EventId_NgcKeyRegistered && eventId != EventId_NgcKeyRegistrationFailed && eventId != EventId_ProvisioningBlocked) + continue; + + var timestamp = record.TimeCreated ?? DateTime.MinValue; + if (timestamp < latestTimestamp) continue; + + latestTimestamp = timestamp; + latestEventId = eventId; + latestProvider = record.ProviderName ?? ""; + } + } + + if (latestEventId.HasValue) + { + _logger.Info($"Backfill found recent Hello terminal event: EventID {latestEventId.Value} at {latestTimestamp:O}"); + ProcessHelloEvent( + latestEventId.Value, + (latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(), + latestProvider, + isBackfill: true); + } + else + { + _logger.Debug($"Backfill found no terminal Hello events in last {BackfillLookbackMinutes} minutes"); + } + } + } + catch (Exception ex) + { + _logger.Warning($"Hello terminal event backfill failed: {ex.Message}"); + } + } + + // ===================================================================== + // HelloForBusiness/Operational watcher (events 3024/6045) + // ===================================================================== + + private void StartHelloForBusinessEventLogWatcher() + { + try + { + var query = new EventLogQuery( + HelloForBusinessEventLogChannel, + PathType.LogName, + "*[System[(EventID=3024 or EventID=6045)]]"); + + _helloForBusinessWatcher = new EventLogWatcher(query); + _helloForBusinessWatcher.EventRecordWritten += OnHelloForBusinessEventRecordWritten; + _helloForBusinessWatcher.Enabled = true; + + _logger.Info($"Started watching: {HelloForBusinessEventLogChannel}"); + } + catch (EventLogNotFoundException) + { + _logger.Warning($"Event log not found: {HelloForBusinessEventLogChannel} (normal if not on a real device)"); + } + catch (Exception ex) + { + _logger.Error("Failed to start HelloForBusiness event log watcher", ex); + } + } + + private void OnHelloForBusinessEventRecordWritten(object sender, EventRecordWrittenEventArgs e) + { + if (e.EventRecord == null) return; + + try + { + var record = e.EventRecord; + var eventId = record.Id; + if (!TrackedHelloForBusinessEventIds.Contains(eventId)) return; + + var description = record.FormatDescription() ?? $"Event ID {eventId}"; + var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(); + + ProcessHelloForBusinessEvent(eventId, timestamp, description, record.ProviderName ?? "", isBackfill: false); + } + catch (Exception ex) + { + _logger.Error("Error processing HelloForBusiness event record", ex); + } + } + + internal void ProcessHelloForBusinessEvent(int eventId, DateTime timestamp, string description, string providerName, bool isBackfill) + { + string eventType; + EventSeverity severity; + string message; + bool shouldTriggerHelloCompleted = false; + string hresult = null; + + switch (eventId) + { + case EventId_HelloForBusiness_ProcessingStarted: // 3024 + eventType = "hello_processing_started"; + severity = EventSeverity.Info; + message = "Windows Hello for Business processing started"; + _logger.Info("Hello for Business processing started (event 3024)"); + break; + + case EventId_HelloForBusiness_ProcessingStopped: // 6045 + hresult = ExtractHResultFromDescription(description); + + if (string.Equals(hresult, HResult_UserSkippedHello, StringComparison.OrdinalIgnoreCase)) + { + eventType = "hello_skipped"; + severity = EventSeverity.Warning; + message = $"Windows Hello for Business skipped by user ({HResult_UserSkippedHello})"; + shouldTriggerHelloCompleted = MarkHelloSkipped(); + _logger.Info($"Hello for Business SKIPPED by user (event 6045, HRESULT {hresult})"); + } + else + { + eventType = "hello_processing_stopped"; + severity = EventSeverity.Info; + message = $"Windows Hello for Business processing stopped (HRESULT: {hresult ?? "unknown"})"; + _logger.Info($"Hello for Business processing stopped (event 6045, HRESULT {hresult ?? "unknown"}) - not treated as terminal"); + } + break; + + default: + return; + } + + var data = new Dictionary + { + { "windowsEventId", eventId }, + { "providerName", providerName ?? "" }, + { "description", description }, + { "eventLogChannel", HelloForBusinessEventLogChannel }, + { "eventTime", timestamp.ToString("o") } + }; + + if (hresult != null) data["hresult"] = hresult; + if (isBackfill) data["backfill"] = true; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = timestamp, + EventType = eventType, + Severity = severity, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = message, + Data = data, + ImmediateUpload = shouldTriggerHelloCompleted + }); + + _logger.Info($"HelloForBusiness event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})"); + + if (shouldTriggerHelloCompleted) + { + try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { } + } + } + + private bool MarkHelloSkipped() + { + lock (_stateLock) + { + if (_isHelloCompleted) + { + _logger.Debug("Hello skip event received but Hello is already marked completed"); + return false; + } + + _isHelloCompleted = true; + HelloOutcome = "skipped"; + StopHelloCompletionTimerLocked(); + return true; + } + } + + internal static string ExtractHResultFromDescription(string description) + { + if (string.IsNullOrEmpty(description)) return null; + var match = HResultPattern.Match(description); + return match.Success ? match.Value : null; + } + + private void BackfillRecentHelloForBusinessEvents() + { + try + { + var lookbackMs = BackfillLookbackMinutes * 60 * 1000; + var query = new EventLogQuery( + HelloForBusinessEventLogChannel, + PathType.LogName, + $"*[System[(EventID=6045) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"); + + using (var reader = new EventLogReader(query)) + { + DateTime latestTimestamp = DateTime.MinValue; + string latestDescription = null; + string latestProvider = string.Empty; + + for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent()) + { + using (record) + { + var timestamp = record.TimeCreated ?? DateTime.MinValue; + if (timestamp < latestTimestamp) continue; + + latestTimestamp = timestamp; + latestDescription = record.FormatDescription() ?? $"Event ID {record.Id}"; + latestProvider = record.ProviderName ?? ""; + } + } + + if (latestDescription != null) + { + var hresult = ExtractHResultFromDescription(latestDescription); + if (string.Equals(hresult, HResult_UserSkippedHello, StringComparison.OrdinalIgnoreCase)) + { + _logger.Info($"Backfill found recent Hello skip event: HRESULT {hresult} at {latestTimestamp:O}"); + ProcessHelloForBusinessEvent( + EventId_HelloForBusiness_ProcessingStopped, + (latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(), + latestDescription, + latestProvider, + isBackfill: true); + } + else + { + _logger.Debug($"Backfill found HelloForBusiness event 6045 but HRESULT {hresult ?? "unknown"} is not a known terminal code - skipping"); + } + } + else + { + _logger.Debug($"Backfill found no HelloForBusiness 6045 events in last {BackfillLookbackMinutes} minutes"); + } + } + } + catch (Exception ex) + { + _logger.Warning($"HelloForBusiness event backfill failed: {ex.Message}"); + } + } + + // ===================================================================== + // Timers: wait → completion + // ===================================================================== + + private void OnHelloWaitTimeout(object state) + { + lock (_stateLock) + { + if (_helloWizardStarted) + { + _logger.Debug("Hello wait timeout fired but wizard already started - ignoring"); + return; + } + + if (_isHelloCompleted) + { + _logger.Debug("Hello wait timeout fired but Hello already completed - ignoring"); + return; + } + + // Timeout expired without Hello wizard starting. + // If Hello policy is known to be ENABLED, the wizard simply hasn't appeared yet + // (e.g. Windows is still setting up prerequisites). Do NOT declare enrollment + // complete — start the long HelloCompletion timer and keep waiting. + if (_isHelloPolicyEnabled) + { + _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " + + $"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)"); + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "hello_wait_timeout", + Severity = EventSeverity.Info, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " + + $"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard", + Data = new Dictionary + { + { "timeoutSeconds", _helloWaitTimeoutSeconds }, + { "espExitDetected", _espExitSeen }, + { "helloPolicyEnabled", true }, + { "action", "extended_wait" } + }, + ImmediateUpload = true + }); + + StartHelloCompletionTimerLocked(); + return; + } + + _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting"); + _logger.Info("Hello policy not detected as enabled — assuming Hello is not configured or was skipped"); + + _isHelloCompleted = true; + HelloOutcome = "not_configured"; + StopHelloCompletionTimerLocked(); + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "hello_wait_timeout", + Severity = EventSeverity.Info, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured", + Data = new Dictionary + { + { "timeoutSeconds", _helloWaitTimeoutSeconds }, + { "espExitDetected", _espExitSeen }, + { "helloPolicyEnabled", false }, + { "action", "enrollment_complete" } + }, + ImmediateUpload = true + }); + + try { HelloCompleted?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("Error invoking HelloCompleted from timeout", ex); } + } + } + + private void StartHelloCompletionTimerLocked() + { + if (_isHelloCompleted) return; + if (_helloCompletionTimer != null) return; + + _logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)"); + _helloCompletionTimer = new System.Threading.Timer( + OnHelloCompletionTimeout, + null, + TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds), + TimeSpan.FromMilliseconds(-1)); + } + + private void StopHelloCompletionTimerLocked() + { + if (_helloCompletionTimer == null) return; + try { _helloCompletionTimer.Dispose(); } catch { } + finally { _helloCompletionTimer = null; } + } + + private void OnHelloCompletionTimeout(object state) + { + lock (_stateLock) + { + if (_isHelloCompleted) + { + _logger.Debug("Hello completion timeout fired but Hello already completed - ignoring"); + return; + } + + _logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event"); + _isHelloCompleted = true; + HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started"; + StopHelloCompletionTimerLocked(); + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "hello_completion_timeout", + Severity = EventSeverity.Warning, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s", + Data = new Dictionary + { + { "timeoutSeconds", HelloCompletionTimeoutSeconds }, + { "helloWizardStarted", _helloWizardStarted } + }, + ImmediateUpload = true + }); + + try { HelloCompleted?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("Error invoking HelloCompleted from completion timeout", ex); } + } + } + + // ===================================================================== + // Test seams — allow tests to drive timer logic deterministically + // ===================================================================== + + /// Test-only: simulate policy detection without a real registry read. + internal void SetPolicyForTest(bool helloEnabled, string source) + { + lock (_stateLock) + { + _isPolicyConfigured = true; + _isHelloPolicyEnabled = helloEnabled; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "hello_policy_detected", + Severity = EventSeverity.Info, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Windows Hello for Business policy detected: {(helloEnabled ? "enabled" : "disabled")} (via {source})", + Data = new Dictionary + { + { "helloEnabled", helloEnabled }, + { "policySource", source } + } + }); + } + } + + internal void TriggerWaitTimeoutForTest() => OnHelloWaitTimeout(null); + internal void TriggerCompletionTimeoutForTest() => OnHelloCompletionTimeout(null); + + internal bool IsWaitTimerActiveForTest { get { lock (_stateLock) { return _helloWaitTimer != null; } } } + internal bool IsCompletionTimerActiveForTest { get { lock (_stateLock) { return _helloCompletionTimer != null; } } } + internal bool IsHelloWizardStartedForTest { get { lock (_stateLock) { return _helloWizardStarted; } } } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs new file mode 100644 index 00000000..ed0340fc --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs @@ -0,0 +1,480 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; +using System.IO; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Watches Microsoft-Windows-ModernDeployment-Diagnostics-Provider channels + /// (Autopilot + ManagementService) for Autopilot / ESP events and forwards them + /// to the event sink as modern_deployment_log/warning/error events. + /// + /// Additionally detects WhiteGlove (Pre-Provisioning) initiation via + /// ManagementService Event 509 with cross-restart dedup via disk persistence. + /// + internal sealed class ModernDeploymentTracker : IDisposable + { + internal const string AutopilotChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot"; + internal const string ManagementChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService"; + + /// + /// ManagementService Event ID 509: fires when a technician actually initiates + /// pre-provisioning (Win 5x → Provision). Level 4 (Informational) — bypasses + /// the default Level≤3 filter via a targeted OR clause in the XPath. + /// + internal const int EventId_WhiteGloveStart = 509; + + internal static readonly HashSet TargetedManagementServiceEventIds = new HashSet + { + EventId_WhiteGloveStart + }; + + internal const string WhiteGloveBackfillStateFileName = "whiteglove-backfill.json"; + + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly int _logLevelMax; + private readonly bool _backfillEnabled; + private readonly int _backfillLookbackMinutes; + private readonly string _stateDirectory; + private readonly HashSet _harmlessEventIds; + + private EventLogWatcher _autopilotWatcher; + private EventLogWatcher _managementWatcher; + private bool _whiteGloveStartDetected; + private readonly object _stateLock = new object(); + + public ModernDeploymentTracker( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger, + int logLevelMax = 3, + bool backfillEnabled = true, + int backfillLookbackMinutes = 30, + string stateDirectory = null, + int[] harmlessEventIds = null) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _logLevelMax = logLevelMax; + _backfillEnabled = backfillEnabled; + _backfillLookbackMinutes = backfillLookbackMinutes; + _stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null; + _harmlessEventIds = harmlessEventIds != null && harmlessEventIds.Length > 0 + ? new HashSet(harmlessEventIds) + : new HashSet { 100, 1005 }; + } + + /// + /// True once WhiteGlove start has been detected (either live Event 509 or persisted from prior run). + /// Exposed primarily for diagnostics/tests. + /// + internal bool IsWhiteGloveStartDetected + { + get { lock (_stateLock) { return _whiteGloveStartDetected; } } + } + + public void Start() + { + StartWatchers(); + + if (_backfillEnabled) + { + BackfillTargetedEvents(); + } + else + { + _logger.Info("ModernDeployment backfill disabled by config"); + } + } + + public void Stop() + { + StopWatcher(ref _autopilotWatcher, "Autopilot"); + StopWatcher(ref _managementWatcher, "ManagementService"); + } + + public void Dispose() => Stop(); + + // ----------------------------------------------------------------------- + // Watcher lifecycle + // ----------------------------------------------------------------------- + + private void StartWatchers() + { + _autopilotWatcher = TryStartWatcher(AutopilotChannel, "Autopilot", null); + _managementWatcher = TryStartWatcher(ManagementChannel, "ManagementService", TargetedManagementServiceEventIds); + } + + private EventLogWatcher TryStartWatcher(string channelName, string shortName, HashSet targetedEventIds) + { + try + { + var xpath = BuildXPath(_logLevelMax, targetedEventIds); + var query = new EventLogQuery(channelName, PathType.LogName, xpath); + var watcher = new EventLogWatcher(query); + watcher.EventRecordWritten += (sender, args) => OnEventRecordWritten(args, shortName, channelName); + watcher.Enabled = true; + _logger.Info($"ModernDeployment watcher started: {channelName} (levelMax={Math.Max(1, Math.Min(5, _logLevelMax))}, targetedIds={targetedEventIds?.Count ?? 0})"); + return watcher; + } + catch (EventLogNotFoundException) + { + _logger.Warning($"ModernDeployment event log not found: {channelName} (normal on non-Windows 10/11 test environments)"); + return null; + } + catch (UnauthorizedAccessException ex) + { + _logger.Warning($"ModernDeployment watcher access denied for {channelName}: {ex.Message}"); + return null; + } + catch (Exception ex) + { + _logger.Error($"Failed to start ModernDeployment watcher for {channelName}", ex); + return null; + } + } + + /// + /// Builds the XPath filter for the watcher. Exposed as internal for tests. + /// + internal static string BuildXPath(int logLevelMax, HashSet targetedEventIds) + { + var levelMax = Math.Max(1, Math.Min(5, logLevelMax)); + var levelFilter = $"Level=0 or (Level >= 1 and Level <= {levelMax})"; + if (targetedEventIds != null && targetedEventIds.Count > 0) + { + var idClauses = string.Join(" or ", targetedEventIds.Select(id => $"EventID={id}")); + levelFilter += $" or ({idClauses})"; + } + return $"*[System[{levelFilter}]]"; + } + + private void StopWatcher(ref EventLogWatcher watcher, string shortName) + { + if (watcher == null) return; + try + { + watcher.Enabled = false; + watcher.Dispose(); + _logger.Info($"ModernDeployment watcher stopped: {shortName}"); + } + catch (Exception ex) + { + _logger.Error($"Error stopping ModernDeployment watcher ({shortName})", ex); + } + finally + { + watcher = null; + } + } + + // ----------------------------------------------------------------------- + // Event processing + // ----------------------------------------------------------------------- + + private void OnEventRecordWritten(EventRecordWrittenEventArgs e, string shortName, string channelName) + { + if (e.EventRecord == null) return; + try + { + ProcessRecord(e.EventRecord, shortName, channelName, isBackfill: false); + } + catch (Exception ex) + { + _logger.Error($"Error processing ModernDeployment event from {shortName}", ex); + } + } + + private void ProcessRecord(EventRecord record, string shortName, string channelName, bool isBackfill) + { + string description = null; + try { description = record.FormatDescription(); } + catch { /* some events lack formatting resources */ } + + ProcessEvent( + eventId: record.Id, + level: record.Level, + levelDisplayName: record.LevelDisplayName, + providerName: record.ProviderName, + timeCreatedUtc: record.TimeCreated?.ToUniversalTime(), + formattedDescription: description, + shortName: shortName, + channelName: channelName, + isBackfill: isBackfill); + } + + /// + /// Core event-processing logic, extracted to primitive inputs so tests can drive it + /// without needing to synthesize an EventRecord (which is abstract + Windows-only). + /// + internal void ProcessEvent( + int eventId, + int? level, + string levelDisplayName, + string providerName, + DateTime? timeCreatedUtc, + string formattedDescription, + string shortName, + string channelName, + bool isBackfill) + { + if (eventId == EventId_WhiteGloveStart && shortName == "ManagementService") + { + HandleWhiteGloveStart(eventId, level, timeCreatedUtc, formattedDescription, isBackfill); + return; + } + + var effectiveLevel = level ?? 4; + string eventType; + EventSeverity severity; + switch (effectiveLevel) + { + case 1: + case 2: + eventType = Constants.EventTypes.ModernDeploymentError; + severity = EventSeverity.Error; + break; + case 3: + eventType = Constants.EventTypes.ModernDeploymentWarning; + severity = EventSeverity.Warning; + break; + default: + eventType = Constants.EventTypes.ModernDeploymentLog; + severity = EventSeverity.Info; + break; + } + + // Downgrade configurable harmless EventIDs to Debug. + // Level 1 (Critical) is NEVER downgraded — too risky to silence. Only Level 2 + // (Error) and Level 3 (Warning) are considered. The list is delivered by the + // backend via CollectorConfiguration.ModernDeploymentHarmlessEventIds and + // pre-seeded with known-harmless IDs (e.g. 100 "Autopilot policy not found", + // 1005 — no real enrollment impact). Events stay visible in the timeline + // (Debug severity) for troubleshooting. + if ((effectiveLevel == 2 || effectiveLevel == 3) && _harmlessEventIds.Contains(eventId)) + { + eventType = Constants.EventTypes.ModernDeploymentLog; + severity = EventSeverity.Debug; + } + + var description = string.IsNullOrEmpty(formattedDescription) + ? $"Event ID {eventId} (no formatted description)" + : formattedDescription; + + var truncated = description.Length > 1000 ? description.Substring(0, 1000) + "…" : description; + + var data = new Dictionary + { + { "channel", shortName }, + { "channelFullName", channelName }, + { "eventId", eventId }, + { "level", effectiveLevel }, + { "levelName", levelDisplayName ?? string.Empty }, + { "providerName", providerName ?? string.Empty }, + { "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty }, + { "backfilled", isBackfill } + }; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = eventType, + Severity = severity, + Source = "ModernDeploymentWatcher", + Phase = EnrollmentPhase.Unknown, + Message = $"[{shortName}] EventID {eventId}: {truncated}", + Data = data + }); + } + + private void HandleWhiteGloveStart(int eventId, int? level, DateTime? timeCreatedUtc, string formattedDescription, bool isBackfill) + { + var description = string.IsNullOrEmpty(formattedDescription) + ? $"Event ID {eventId} (no formatted description)" + : formattedDescription; + + if (description.IndexOf("WhiteGlove", StringComparison.OrdinalIgnoreCase) < 0) + { + _logger.Trace($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}"); + return; + } + + lock (_stateLock) + { + if (_whiteGloveStartDetected) return; + _whiteGloveStartDetected = true; + } + + _logger.Info($"WhiteGlove start detected via ManagementService Event 509 (backfill={isBackfill}): {description}"); + + PersistWhiteGloveBackfillState(timeCreatedUtc); + + var truncated = description.Length > 500 ? description.Substring(0, 500) + "…" : description; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "whiteglove_started", + Severity = EventSeverity.Info, + Source = "ModernDeploymentWatcher", + Phase = EnrollmentPhase.Unknown, + Message = $"First WhiteGlove (Pre-Provisioning) hint — ManagementService EventID {eventId} (soft signal, not yet confirmed)", + Data = new Dictionary + { + { "channel", "ManagementService" }, + { "channelFullName", ManagementChannel }, + { "eventId", eventId }, + { "level", level ?? 4 }, + { "description", truncated }, + { "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty }, + { "backfilled", isBackfill } + }, + ImmediateUpload = true + }); + } + + // ----------------------------------------------------------------------- + // Backfill + // ----------------------------------------------------------------------- + + private void BackfillTargetedEvents() + { + if (TargetedManagementServiceEventIds.Count == 0) return; + + var persistedState = LoadWhiteGloveBackfillState(); + if (persistedState != null && persistedState.WhiteGloveStartSeen) + { + _logger.Info($"WhiteGlove start already persisted from prior run (seen at {persistedState.SeenUtc:O}) — skipping backfill"); + lock (_stateLock) { _whiteGloveStartDetected = true; } + return; + } + + try + { + var lookbackMs = _backfillLookbackMinutes * 60 * 1000; + var idClauses = string.Join(" or ", + TargetedManagementServiceEventIds.Select(id => $"EventID={id}")); + var xpath = $"*[System[({idClauses}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"; + + _logger.Info($"ModernDeployment backfill: scanning {ManagementChannel} " + + $"(lookback={_backfillLookbackMinutes}min, targetedIds={TargetedManagementServiceEventIds.Count})"); + + var query = new EventLogQuery(ManagementChannel, PathType.LogName, xpath) + { + ReverseDirection = true + }; + + int found = 0; + using (var reader = new EventLogReader(query)) + { + EventRecord record; + while ((record = reader.ReadEvent()) != null) + { + using (record) + { + found++; + _logger.Info($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}"); + ProcessRecord(record, "ManagementService", ManagementChannel, isBackfill: true); + } + } + } + + if (found == 0) + _logger.Debug($"ModernDeployment backfill: no targeted events found in last {_backfillLookbackMinutes} minutes"); + else + _logger.Info($"ModernDeployment backfill: processed {found} event(s)"); + } + catch (EventLogNotFoundException) + { + _logger.Warning($"ModernDeployment event log not found during backfill: {ManagementChannel} (normal on non-Windows 10/11 test environments)"); + } + catch (UnauthorizedAccessException ex) + { + _logger.Warning($"ModernDeployment backfill access denied: {ex.Message}"); + } + catch (Exception ex) + { + _logger.Warning($"ModernDeployment backfill failed: {ex.Message}"); + } + } + + // ----------------------------------------------------------------------- + // Backfill state persistence + // ----------------------------------------------------------------------- + + internal void PersistWhiteGloveBackfillState(DateTime? eventTimeUtc) + { + if (string.IsNullOrEmpty(_stateDirectory)) return; + + try + { + Directory.CreateDirectory(_stateDirectory); + var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName); + var state = new WhiteGloveBackfillState + { + WhiteGloveStartSeen = true, + SeenUtc = eventTimeUtc ?? DateTime.UtcNow, + PersistedUtc = DateTime.UtcNow + }; + var json = JsonConvert.SerializeObject(state, Formatting.Indented); + var tempPath = filePath + ".tmp"; + File.WriteAllText(tempPath, json); + File.Copy(tempPath, filePath, overwrite: true); + try { File.Delete(tempPath); } catch { } + _logger.Info($"WhiteGlove backfill state persisted to {filePath}"); + } + catch (Exception ex) + { + _logger.Warning($"Failed to persist WhiteGlove backfill state: {ex.Message}"); + } + } + + internal WhiteGloveBackfillState LoadWhiteGloveBackfillState() + { + if (string.IsNullOrEmpty(_stateDirectory)) return null; + + var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName); + if (!File.Exists(filePath)) return null; + + try + { + var json = File.ReadAllText(filePath); + return JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + _logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}"); + return null; + } + } + + /// + /// Test-only: force the in-memory WhiteGlove-seen guard (simulates successful backfill-dedup). + /// + internal void MarkWhiteGloveStartDetectedForTest() + { + lock (_stateLock) { _whiteGloveStartDetected = true; } + } + + internal class WhiteGloveBackfillState + { + public bool WhiteGloveStartSeen { get; set; } + public DateTime SeenUtc { get; set; } + public DateTime PersistedUtc { get; set; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs new file mode 100644 index 00000000..ba958e40 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs @@ -0,0 +1,1086 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Watches ESP provisioning category status from the registry to detect failures + /// that Shell-Core event 62407 patterns may miss (e.g. Certificate provisioning failures). + /// + /// Registry path: HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings + /// Values: DevicePreparationCategory.Status, DeviceSetupCategory.Status, AccountSetupCategory.Status + /// + /// JSON format varies across Windows versions: + /// Flat: { "CertificatesSubcategory": "Certificates (1 of 1 applied)", "categorySucceeded": true, ... } + /// Nested: { "CertificatesSubcategory": { "subcategoryState": "succeeded", "subcategoryStatusText": "..." }, ... } + /// + /// Event emission strategy: + /// - Emit once when a category first appears (initial snapshot) + /// - Emit on every JSON change (progress updates, state transitions) + /// - Emit once when categorySucceeded resolves to true/false (final outcome) + /// - Fire on subcategory failure even if categorySucceeded is null + /// - Fire on DeviceSetup success (or fallback) + /// + /// Uses RegistryWatcher (RegNotifyChangeKeyValue) for instant registry change detection. + /// + internal sealed class ProvisioningStatusTracker : IDisposable + { + internal const string ProvisioningStatusRegistryPath = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"; + internal const int DeviceSetupFallbackDelaySeconds = 30; + internal const int ProvisioningDebounceMilliseconds = 1000; + + private static readonly string[] ProvisioningCategoryNames = + { + "DevicePreparationCategory.Status", + "DeviceSetupCategory.Status", + "AccountSetupCategory.Status" + }; + + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + + private RegistryWatcher _provisioningWatcher; + private Timer _provisioningWatcherRetryTimer; + private Timer _provisioningDebounceTimer; + private Timer _deviceSetupFallbackTimer; + + // Track the raw JSON per category — used to detect any changes at all + private Dictionary _lastProvisioningJson; + // Track which categories have been seen (for first-seen event) + private HashSet _provisioningCategorySeen; + // Track the last known categorySucceeded per category (null = not yet resolved) + private Dictionary _lastCategorySucceeded; + // Fire-once guard per category — prevent duplicate EspFailureDetected calls + private HashSet _provisioningFailureFired; + // Track which categories have reported a final categorySucceeded value + private HashSet _provisioningCategoriesResolved; + // Track subcategory states per category — detect meaningful state transitions + private Dictionary> _lastSubcategoryStates; + // Fire-once guard for DeviceSetupProvisioningComplete event + private bool _deviceSetupProvisioningCompleteFired; + // WhiteGlove confirmation: DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded + private bool _saveWhiteGloveSuccessResultSeen; + // Track SaveWhiteGloveSuccessResult state transitions for observability (null → notStarted → succeeded) + private string _lastSaveWhiteGloveState; + private readonly object _stateLock = new object(); + + public event EventHandler EspFailureDetected; + public event EventHandler DeviceSetupProvisioningComplete; + + public ProvisioningStatusTracker( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + // ===================================================================== + // Public snapshot / state API (forwarded by coordinator) + // ===================================================================== + + /// + /// Snapshot of current provisioning-category state for consumers like the signal-correlated + /// WhiteGlove detection. Thread-safe. + /// + public Dictionary GetProvisioningCategorySnapshot() + { + lock (_stateLock) + { + return _lastCategorySucceeded == null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(_lastCategorySucceeded, StringComparer.OrdinalIgnoreCase); + } + } + + /// + /// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR when the + /// fallback (all subcategories succeeded, categorySucceeded null) has been confirmed. + /// + public bool DeviceSetupCategorySucceeded + { + get + { + lock (_stateLock) + { + if (_deviceSetupProvisioningCompleteFired) + return true; + return _lastCategorySucceeded != null + && _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var v) + && v == true; + } + } + } + + /// + /// True when any AccountSetup subcategory has been tracked (resolved or in progress). + /// + public bool HasAccountSetupActivity + { + get + { + lock (_stateLock) + { + if (_lastSubcategoryStates == null) + return false; + return _lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs) + && subs != null + && subs.Count > 0; + } + } + } + + /// + /// 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. + /// + public bool HasSaveWhiteGloveSuccessResult + { + get { lock (_stateLock) { return _saveWhiteGloveSuccessResultSeen; } } + } + + /// + /// Returns a thread-safe snapshot of the current ESP provisioning category status. + /// All data is deep-copied under the lock so the caller can use it freely. + /// Returns null if no provisioning data has been observed yet. + /// + public EspProvisioningSnapshot GetProvisioningSnapshot() + { + lock (_stateLock) + { + if (_provisioningCategorySeen == null || _provisioningCategorySeen.Count == 0) + return null; + + var outcomes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cat in _provisioningCategorySeen) + { + var label = cat.Replace("Category.Status", ""); + if (_lastCategorySucceeded.TryGetValue(cat, out var succeeded)) + { + outcomes[label] = succeeded == true ? "success" + : succeeded == false ? "failed" + : "in_progress"; + } + else + { + outcomes[label] = "in_progress"; + } + } + + var subcats = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in _lastSubcategoryStates) + { + var label = kvp.Key.Replace("Category.Status", ""); + subcats[label] = new Dictionary(kvp.Value, StringComparer.OrdinalIgnoreCase); + } + + return new EspProvisioningSnapshot + { + CategoryOutcomes = outcomes, + SubcategoryStates = subcats, + CategoriesSeen = _provisioningCategorySeen.Count, + CategoriesResolved = _provisioningCategoriesResolved.Count, + AllResolved = _provisioningCategoriesResolved.Count >= _provisioningCategorySeen.Count + }; + } + } + + // ===================================================================== + // Lifecycle + // ===================================================================== + + public void Start() + { + _lastProvisioningJson = new Dictionary(StringComparer.OrdinalIgnoreCase); + _provisioningCategorySeen = new HashSet(StringComparer.OrdinalIgnoreCase); + _lastCategorySucceeded = new Dictionary(StringComparer.OrdinalIgnoreCase); + _provisioningFailureFired = new HashSet(StringComparer.OrdinalIgnoreCase); + _provisioningCategoriesResolved = new HashSet(StringComparer.OrdinalIgnoreCase); + _lastSubcategoryStates = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _deviceSetupProvisioningCompleteFired = false; + _deviceSetupFallbackTimer = null; + + // Try to start immediately; if key doesn't exist yet, retry every 2s + if (!TryStartWatcher()) + { + _logger.Info("Provisioning status registry key not yet present — retrying every 2s"); + _provisioningWatcherRetryTimer = new Timer( + _ => + { + if (TryStartWatcher()) + { + _provisioningWatcherRetryTimer?.Dispose(); + _provisioningWatcherRetryTimer = null; + } + }, + null, + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(2)); + } + } + + public void Stop(string reason = "tracker_stopped") + { + try + { + _provisioningWatcherRetryTimer?.Dispose(); + _provisioningWatcherRetryTimer = null; + + _provisioningDebounceTimer?.Dispose(); + _provisioningDebounceTimer = null; + + _deviceSetupFallbackTimer?.Dispose(); + _deviceSetupFallbackTimer = null; + + if (_provisioningWatcher != null) + { + _provisioningWatcher.Dispose(); + _provisioningWatcher = null; + _logger.Info($"Provisioning status watcher stopped: {reason}"); + } + } + catch (Exception ex) + { + _logger.Error("Error stopping provisioning status watcher", ex); + } + } + + public void Dispose() => Stop(); + + private bool TryStartWatcher() + { + try + { + using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath)) + { + if (key == null) + return false; + } + + // Capture initial state before watcher starts + _logger.Trace("ProvisioningWatcher: capturing initial state before watcher starts"); + CheckProvisioningStatus(); + + // Debounce timer — coalesces rapid-fire registry notifications into a single check + _provisioningDebounceTimer = new Timer( + _ => CheckProvisioningStatus(), + null, + Timeout.Infinite, + Timeout.Infinite); + + _provisioningWatcher = new RegistryWatcher( + RegistryHive.LocalMachine, + ProvisioningStatusRegistryPath, + watchSubtree: true, + filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet, + trace: msg => _logger.Trace($"RegistryWatcher: {msg}")); + + _provisioningWatcher.Changed += (s, e) => + { + _logger.Trace("ProvisioningWatcher: Changed event fired — debouncing CheckProvisioningStatus"); + _provisioningDebounceTimer?.Change(ProvisioningDebounceMilliseconds, Timeout.Infinite); + }; + _provisioningWatcher.Error += (s, ex) => _logger.Warning($"Provisioning watcher handler error: {ex.Message}"); + + _provisioningWatcher.Start(); + _logger.Info("Provisioning status registry watcher started"); + return true; + } + catch (Exception ex) + { + _logger.Warning($"Failed to start provisioning watcher: {ex.Message}"); + return false; + } + } + + // ===================================================================== + // Registry polling + processing + // ===================================================================== + + private void CheckProvisioningStatus() + { + try + { + lock (_stateLock) + { + using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath, writable: false)) + { + if (key == null) + { + _logger.Trace("CheckProvisioningStatus: registry key not found"); + return; + } + + bool failureDetected = false; + string failureType = null; + int changedCount = 0, unchangedCount = 0, missingCount = 0; + + foreach (var categoryName in ProvisioningCategoryNames) + { + var jsonValue = key.GetValue(categoryName)?.ToString(); + if (string.IsNullOrEmpty(jsonValue)) + { + missingCount++; + _logger.Trace($"CheckProvisioningStatus: {categoryName} — not present in registry"); + continue; + } + + if (_lastProvisioningJson.TryGetValue(categoryName, out var lastJson) && lastJson == jsonValue) + { + unchangedCount++; + continue; + } + + changedCount++; + bool isNew = !_lastProvisioningJson.ContainsKey(categoryName); + _logger.Trace($"CheckProvisioningStatus: {categoryName} — {(isNew ? "NEW" : "CHANGED")} (json length={jsonValue.Length})"); + _logger.Trace($"CheckProvisioningStatus: {categoryName} — raw JSON: {jsonValue}"); + + _lastProvisioningJson[categoryName] = jsonValue; + var result = ProcessCategoryStatus(categoryName, jsonValue); + + if (result.IsFailed) + { + failureDetected = true; + failureType = result.FailureType; + } + } + + _logger.Trace($"CheckProvisioningStatus: summary — changed={changedCount}, unchanged={unchangedCount}, missing={missingCount}, " + + $"seen={_provisioningCategorySeen.Count}, resolved={_provisioningCategoriesResolved.Count}/{_lastProvisioningJson.Count}, " + + $"failuresFired={_provisioningFailureFired.Count}"); + + // Self-termination: only auto-stop when all categories resolved with success (no failures). + if (_provisioningCategoriesResolved.Count > 0 + && _lastProvisioningJson.Count > 0 + && _provisioningCategoriesResolved.Count >= _lastProvisioningJson.Count + && !_provisioningFailureFired.Any()) + { + _logger.Info("Provisioning status watcher: all categories resolved with success — requesting stop"); + _provisioningWatcher?.RequestStop(); + } + + // Fire EspFailureDetected AFTER event emission (event in spool before agent reacts) + if (failureDetected && failureType != null) + { + try { EspFailureDetected?.Invoke(this, failureType); } + catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); } + } + + // Fire DeviceSetupProvisioningComplete when DeviceSetup resolves with success. + if (!_deviceSetupProvisioningCompleteFired + && _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsSucceeded) + && dsSucceeded == true) + { + _deviceSetupProvisioningCompleteFired = true; + _logger.Info("Provisioning status: DeviceSetup succeeded — firing DeviceSetupProvisioningComplete"); + try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed", ex); } + } + + // Fallback: all subcategories succeeded but categorySucceeded was never set by Windows. + // Some Windows builds (e.g. 25H2/26200) don't set the boolean even after all subcategories succeed. + if (!_deviceSetupProvisioningCompleteFired + && _deviceSetupFallbackTimer == null + && _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsFallbackState) + && dsFallbackState == null + && _lastSubcategoryStates.TryGetValue("DeviceSetupCategory.Status", out var dsFallbackSubStates) + && dsFallbackSubStates.Count > 0 + && dsFallbackSubStates.Values.All(s => + string.Equals(s, "succeeded", StringComparison.OrdinalIgnoreCase) || + string.Equals(s, "notRequired", StringComparison.OrdinalIgnoreCase))) + { + _logger.Info($"Provisioning status: DeviceSetup — all {dsFallbackSubStates.Count} subcategories succeeded " + + $"but categorySucceeded not set by Windows — starting {DeviceSetupFallbackDelaySeconds}s fallback timer"); + _deviceSetupFallbackTimer = new Timer( + OnDeviceSetupFallbackTimerExpired, + null, + TimeSpan.FromSeconds(DeviceSetupFallbackDelaySeconds), + Timeout.InfiniteTimeSpan); + } + } + } + } + catch (Exception ex) + { + _logger.Warning($"Provisioning status check failed: {ex.Message}"); + } + } + + private void OnDeviceSetupFallbackTimerExpired(object state) + { + try + { + lock (_stateLock) + { + if (_deviceSetupProvisioningCompleteFired) + { + _logger.Debug("DeviceSetup fallback timer expired but DeviceSetupProvisioningComplete already fired — ignoring"); + return; + } + + string registryJson = null; + using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath, writable: false)) + { + registryJson = key?.GetValue("DeviceSetupCategory.Status")?.ToString(); + } + + if (string.IsNullOrEmpty(registryJson)) + { + _logger.Warning("DeviceSetup fallback timer: registry value not found — aborting fallback"); + return; + } + + bool? categorySucceeded = null; + List subcategories = null; + try + { + using (var doc = JsonDocument.Parse(registryJson)) + { + categorySucceeded = SafeGetBool(doc.RootElement, "categorySucceeded"); + subcategories = ParseSubcategories(doc.RootElement); + } + } + catch (JsonException ex) + { + _logger.Warning($"DeviceSetup fallback timer: failed to parse registry JSON — aborting fallback: {ex.Message}"); + return; + } + + if (categorySucceeded.HasValue) + { + _logger.Info($"DeviceSetup fallback timer: categorySucceeded is now {categorySucceeded.Value} — normal path will handle this"); + CheckProvisioningStatus(); + return; + } + + if (subcategories == null || subcategories.Count == 0) + { + _logger.Warning("DeviceSetup fallback timer: no subcategories found — aborting fallback"); + return; + } + + var nonSucceeded = subcategories.Where(s => + !string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase) && + !string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (nonSucceeded.Count > 0) + { + _logger.Warning($"DeviceSetup fallback timer: {nonSucceeded.Count} subcategory/ies not succeeded " + + $"({string.Join(", ", nonSucceeded.Select(s => $"{s.Name}={s.State}"))}) — aborting fallback"); + return; + } + + EmitRawRegistryDump("DeviceSetup", registryJson, "fallback_confirmed"); + + _logger.Warning($"DeviceSetup fallback: all {subcategories.Count} subcategories succeeded but " + + $"categorySucceeded was not set by Windows after {DeviceSetupFallbackDelaySeconds}s — treating as complete"); + + var subcatData = new Dictionary(); + foreach (var sub in subcategories) + { + subcatData[sub.Name] = new Dictionary + { + { "state", sub.State }, + { "statusText", sub.StatusText } + }; + } + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.EspProvisioningStatus, + Severity = EventSeverity.Warning, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"ESP provisioning status: DeviceSetup — all {subcategories.Count} subcategories succeeded " + + $"but categorySucceeded was not confirmed by Windows — treating as complete (fallback after {DeviceSetupFallbackDelaySeconds}s)", + Data = new Dictionary + { + { "category", "DeviceSetup" }, + { "categorySucceeded", "in_progress" }, + { "fallbackApplied", true }, + { "fallbackReason", "all_subcategories_succeeded_category_unresolved" }, + { "fallbackDelaySeconds", DeviceSetupFallbackDelaySeconds }, + { "subcategoryCount", subcategories.Count }, + { "subcategories", subcatData } + } + }); + + _deviceSetupProvisioningCompleteFired = true; + try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed (fallback path)", ex); } + } + } + catch (Exception ex) + { + _logger.Error($"DeviceSetup fallback timer callback failed: {ex.Message}", ex); + } + } + + /// + /// Processes a single category status JSON value. + /// Emits events on first-seen, subcategory state transitions, and categorySucceeded resolution. + /// + internal ProvisioningResult ProcessCategoryStatus(string categoryName, string jsonValue) + { + var categoryLabel = categoryName.Replace("Category.Status", ""); + + try + { + using (var doc = JsonDocument.Parse(jsonValue)) + { + var root = doc.RootElement; + + bool? categorySucceeded = SafeGetBool(root, "categorySucceeded"); + 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). + if (string.Equals(categoryLabel, "DeviceSetup", StringComparison.OrdinalIgnoreCase)) + { + foreach (var prop in root.EnumerateObject()) + { + if (prop.Name.IndexOf("SaveWhiteGloveSuccessResult", StringComparison.OrdinalIgnoreCase) >= 0 + && prop.Value.ValueKind == JsonValueKind.Object) + { + var state = SafeGetString(prop.Value, "subcategoryState") ?? "unknown"; + + if (!string.Equals(state, _lastSaveWhiteGloveState, StringComparison.OrdinalIgnoreCase)) + { + var previousState = _lastSaveWhiteGloveState ?? "not_seen"; + _lastSaveWhiteGloveState = state; + + _logger.Info($"ProvisioningStatusTracker: SaveWhiteGloveSuccessResult state " + + $"transition: {previousState} -> {state}"); + + EmitRawRegistryDump(categoryLabel, jsonValue, + $"whiteglove_signal_{state}"); + + if (string.Equals(state, "succeeded", StringComparison.OrdinalIgnoreCase)) + { + _saveWhiteGloveSuccessResultSeen = true; + _logger.Info("ProvisioningStatusTracker: SaveWhiteGloveSuccessResult=succeeded " + + "— WhiteGlove confirmation signal"); + } + } + break; + } + } + } + + string statusText; + if (categoryStatusMessage != null) + statusText = categoryStatusMessage; + else if (categorySucceeded == true) + statusText = "Complete"; + else if (categorySucceeded == false) + statusText = "Failed"; + else + statusText = BuildProgressSummary(subcategories); + + bool isFirstSeen = !_provisioningCategorySeen.Contains(categoryName); + bool categorySucceededChanged = HasCategorySucceededChanged(categoryName, categorySucceeded); + + _logger.Trace($"ProcessCategory: {categoryLabel} — succeeded={categorySucceeded?.ToString() ?? "null"}, " + + $"isFirstSeen={isFirstSeen}, succeededChanged={categorySucceededChanged}, " + + $"subcategories={subcategories.Count} [{string.Join(", ", subcategories.Select(s => $"{s.Name}={s.State}"))}]"); + + if (isFirstSeen) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING first-seen event"); + _provisioningCategorySeen.Add(categoryName); + _lastCategorySucceeded[categoryName] = categorySucceeded; + StoreSubcategoryStates(categoryName, subcategories); + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info); + EmitRawRegistryDump(categoryLabel, jsonValue, "first_seen"); + } + else if (categorySucceededChanged) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING categorySucceeded change ({_lastCategorySucceeded[categoryName]?.ToString() ?? "null"} → {categorySucceeded?.ToString() ?? "null"})"); + _lastCategorySucceeded[categoryName] = categorySucceeded; + StoreSubcategoryStates(categoryName, subcategories); + var severity = categorySucceeded == false ? EventSeverity.Warning : EventSeverity.Info; + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, severity); + EmitRawRegistryDump(categoryLabel, jsonValue, $"category_resolved_{(categorySucceeded == true ? "success" : "failed")}"); + } + else + { + var transitions = DetectSubcategoryTransitions(categoryName, subcategories); + StoreSubcategoryStates(categoryName, subcategories); + + var failureTransitions = transitions.Where(t => t.IsFailure).ToList(); + if (transitions.Count > 0) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — subcategory transitions: " + + string.Join(", ", transitions.Select(t => $"{t.SubcategoryName}: {t.OldState}→{t.NewState} (failure={t.IsFailure})"))); + } + + if (failureTransitions.Count > 0) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING failure transition event"); + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Warning, failureTransitions); + } + else if (transitions.Count > 0) + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING subcategory transition event"); + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info, transitions); + } + else + { + _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING progress update (JSON changed, no state transitions)"); + EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info); + } + } + + if (categorySucceeded.HasValue) + _provisioningCategoriesResolved.Add(categoryName); + + if (categorySucceeded == false) + { + return TryFireProvisioningFailure(categoryName, categoryLabel, subcategories); + } + + var failedSub = FindFailedSubcategory(subcategories); + if (failedSub != null && categorySucceeded == null) + { + return TryFireProvisioningFailure(categoryName, categoryLabel, subcategories); + } + + return ProvisioningResult.NoAction; + } + } + catch (JsonException ex) + { + _logger.Warning($"Failed to parse provisioning status JSON for {categoryLabel}: {ex.Message}"); + return ProvisioningResult.NoAction; + } + catch (Exception ex) + { + _logger.Warning($"Unexpected error processing provisioning status for {categoryLabel}: {ex.Message}"); + return ProvisioningResult.NoAction; + } + } + + private ProvisioningResult TryFireProvisioningFailure(string categoryName, string categoryLabel, List subcategories) + { + if (_provisioningFailureFired.Contains(categoryName)) + return ProvisioningResult.NoAction; + + _provisioningFailureFired.Add(categoryName); + + var failedSubcategory = FindFailedSubcategory(subcategories); + var failureTypeName = failedSubcategory != null + ? $"Provisioning_{categoryLabel}_{failedSubcategory}_Failed" + : $"Provisioning_{categoryLabel}_Failed"; + + _logger.Info($"Provisioning subcategory failure: {categoryLabel}/{failedSubcategory ?? "category-level"} — escalating as {failureTypeName}"); + _logger.Warning($"Provisioning failure detected: {failureTypeName}"); + return ProvisioningResult.Failure(failureTypeName); + } + + private bool HasCategorySucceededChanged(string categoryName, bool? newValue) + { + if (!_lastCategorySucceeded.TryGetValue(categoryName, out var oldValue)) + return false; + return oldValue != newValue; + } + + private void StoreSubcategoryStates(string categoryName, List subcategories) + { + var states = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var sub in subcategories) + states[sub.Name] = sub.State; + _lastSubcategoryStates[categoryName] = states; + } + + private List DetectSubcategoryTransitions(string categoryName, List subcategories) + { + var transitions = new List(); + + if (!_lastSubcategoryStates.TryGetValue(categoryName, out var lastStates)) + return transitions; + + foreach (var sub in subcategories) + { + if (!lastStates.TryGetValue(sub.Name, out var oldState)) + continue; + + if (string.Equals(oldState, sub.State, StringComparison.OrdinalIgnoreCase)) + continue; + + bool isFailure = string.Equals(sub.State, "failed", StringComparison.OrdinalIgnoreCase); + bool isCompletion = string.Equals(sub.State, "succeeded", StringComparison.OrdinalIgnoreCase) + && !string.Equals(oldState, "succeeded", StringComparison.OrdinalIgnoreCase); + + bool isNoise = string.Equals(oldState, "notStarted", StringComparison.OrdinalIgnoreCase) + && string.Equals(sub.State, "in_progress", StringComparison.OrdinalIgnoreCase); + + if (isFailure || isCompletion) + { + transitions.Add(new SubcategoryTransition(sub.Name, oldState, sub.State, isFailure)); + } + else if (!isNoise) + { + if (isFailure) + transitions.Add(new SubcategoryTransition(sub.Name, oldState, sub.State, true)); + } + } + + return transitions; + } + + private void EmitProvisioningEvent(string categoryLabel, bool? succeeded, string statusText, + List subcategories, EventSeverity severity, + List transitions = null) + { + var eventData = new Dictionary + { + { "category", categoryLabel }, + { "categorySucceeded", succeeded?.ToString() ?? "in_progress" }, + { "categoryStatusMessage", statusText } + }; + + if (subcategories.Count > 0) + { + var subcatData = new Dictionary(); + foreach (var sub in subcategories) + { + subcatData[sub.Name] = new Dictionary + { + { "state", sub.State }, + { "statusText", sub.StatusText } + }; + } + eventData["subcategories"] = subcatData; + } + + if (transitions != null && transitions.Count > 0) + { + eventData["changeType"] = "subcategory_state_change"; + var transitionData = new List>(); + foreach (var t in transitions) + { + transitionData.Add(new Dictionary + { + { "subcategory", t.SubcategoryName }, + { "previousState", t.OldState }, + { "newState", t.NewState } + }); + } + eventData["transitions"] = transitionData; + + // Flat top-level field for RuleEngine matching — the RuleEngine cannot + // traverse nested collections via dataField, so we expose the names of + // subcategories that just transitioned to "failed" as a comma-separated + // string. Names come from registry keys and are language-invariant. + var failedNames = transitions + .Where(t => t.IsFailure) + .Select(t => t.SubcategoryName) + .ToList(); + if (failedNames.Count > 0) + { + eventData["failedSubcategories"] = string.Join(",", failedNames); + } + } + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.EspProvisioningStatus, + Severity = severity, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"ESP provisioning status: {categoryLabel} — {statusText}", + Data = eventData + }); + + _logger.Info($"Provisioning status event: {categoryLabel} — {statusText} (succeeded={succeeded?.ToString() ?? "in_progress"})"); + } + + private void EmitRawRegistryDump(string categoryLabel, string rawJson, string trigger) + { + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "esp_provisioning_raw", + Severity = EventSeverity.Trace, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"ESP provisioning raw registry: {categoryLabel} ({trigger})", + Data = new Dictionary + { + { "category", categoryLabel }, + { "trigger", trigger }, + { "registryValue", $"{categoryLabel}Category.Status" }, + { "rawJson", rawJson } + } + }); + } + + // ===================================================================== + // JSON parsing helpers (static — exposed as internal for tests) + // ===================================================================== + + internal static List ParseSubcategories(JsonElement root) + { + var result = new List(); + + foreach (var prop in root.EnumerateObject()) + { + if (prop.Name.IndexOf("Subcategory", StringComparison.OrdinalIgnoreCase) < 0) + continue; + + var name = CleanSubcategoryName(prop.Name); + + switch (prop.Value.ValueKind) + { + case JsonValueKind.String: + var text = prop.Value.GetString() ?? ""; + result.Add(new SubcategoryInfo + { + Name = name, + State = InferStateFromText(text), + StatusText = text + }); + break; + + case JsonValueKind.Object: + result.Add(new SubcategoryInfo + { + Name = name, + State = SafeGetString(prop.Value, "subcategoryState") ?? "unknown", + StatusText = SafeGetString(prop.Value, "subcategoryStatusText") ?? "" + }); + break; + + default: + result.Add(new SubcategoryInfo + { + Name = name, + State = "unknown", + StatusText = prop.Value.ToString() + }); + break; + } + } + + return result; + } + + /// + /// Cleans a subcategory property name to a readable short name. + /// "AccountSetup.CertificatesSubcategory" -> "Certificates" + /// "SecurityPoliciesSubcategory" -> "SecurityPolicies" + /// + internal static string CleanSubcategoryName(string rawName) + { + var name = rawName; + + var idx = name.IndexOf("Subcategory", StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + name = name.Substring(0, idx); + + var dotIdx = name.LastIndexOf('.'); + if (dotIdx >= 0 && dotIdx < name.Length - 1) + name = name.Substring(dotIdx + 1); + + return string.IsNullOrEmpty(name) ? rawName : name; + } + + internal static string InferStateFromText(string text) + { + if (string.IsNullOrEmpty(text)) + return "unknown"; + + if (text.IndexOf("Complete", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("applied", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("installed", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("added", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("No setup needed", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("Identified", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "succeeded"; + } + + if (text.IndexOf("Error", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("Failed", StringComparison.OrdinalIgnoreCase) >= 0 + || text.IndexOf("Failure", StringComparison.OrdinalIgnoreCase) >= 0) + { + return "failed"; + } + + return "in_progress"; + } + + internal static string FindFailedSubcategory(List subcategories) + { + foreach (var sub in subcategories) + { + if (string.Equals(sub.State, "failed", StringComparison.OrdinalIgnoreCase)) + return sub.Name; + } + return null; + } + + internal static string BuildProgressSummary(List subcategories) + { + if (subcategories.Count == 0) + return "In progress"; + + var succeeded = subcategories.Count(s => + string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase) + || string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)); + + var failed = subcategories.Count(s => + string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase)); + + if (failed > 0) + return $"{failed} of {subcategories.Count} subcategories failed"; + + return $"{succeeded} of {subcategories.Count} subcategories completed"; + } + + internal static bool? SafeGetBool(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var prop)) + return null; + + switch (prop.ValueKind) + { + case JsonValueKind.True: return true; + case JsonValueKind.False: return false; + case JsonValueKind.String: + return bool.TryParse(prop.GetString(), out var parsed) ? parsed : (bool?)null; + default: return null; + } + } + + internal static string SafeGetString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var prop)) + return null; + + return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null; + } + + // ===================================================================== + // Internal types + // ===================================================================== + + internal class SubcategoryInfo + { + public string Name { get; set; } + public string State { get; set; } // "succeeded", "failed", "in_progress", "unknown", "notRequired" + public string StatusText { get; set; } + } + + internal readonly struct SubcategoryTransition + { + public readonly string SubcategoryName; + public readonly string OldState; + public readonly string NewState; + public readonly bool IsFailure; + + public SubcategoryTransition(string subcategoryName, string oldState, string newState, bool isFailure) + { + SubcategoryName = subcategoryName; + OldState = oldState; + NewState = newState; + IsFailure = isFailure; + } + } + + internal readonly struct ProvisioningResult + { + public readonly bool IsFailed; + public readonly string FailureType; + + private ProvisioningResult(bool isFailed, string failureType) + { + IsFailed = isFailed; + FailureType = failureType; + } + + public static ProvisioningResult NoAction => new ProvisioningResult(false, null); + public static ProvisioningResult Failure(string failureType) => new ProvisioningResult(true, failureType); + } + + // ===================================================================== + // Test seams + // ===================================================================== + + /// Test-only: drive ProcessCategoryStatus without registry access. + internal ProvisioningResult ProcessCategoryStatusForTest(string categoryName, string jsonValue) + { + lock (_stateLock) + { + EnsureStateDictionariesInitialized(); + return ProcessCategoryStatus(categoryName, jsonValue); + } + } + + private void EnsureStateDictionariesInitialized() + { + if (_lastProvisioningJson == null) + _lastProvisioningJson = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (_provisioningCategorySeen == null) + _provisioningCategorySeen = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_lastCategorySucceeded == null) + _lastCategorySucceeded = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (_provisioningFailureFired == null) + _provisioningFailureFired = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_provisioningCategoriesResolved == null) + _provisioningCategoriesResolved = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_lastSubcategoryStates == null) + _lastSubcategoryStates = new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + } + + /// + /// Immutable snapshot of ESP provisioning category status at a point in time. + /// Used to attach to enrollment_complete events and for settle-wait decisions. + /// + public class EspProvisioningSnapshot + { + /// Per-category outcome: "success", "failed", or "in_progress". + public Dictionary CategoryOutcomes { get; set; } + + /// Per-category subcategory detail: subcategoryName -> state string. + public Dictionary> SubcategoryStates { get; set; } + + /// Number of categories seen in the registry. + public int CategoriesSeen { get; set; } + + /// Number of categories that have a final categorySucceeded value. + public int CategoriesResolved { get; set; } + + /// True if all seen categories are resolved (or none seen). + public bool AllResolved { get; set; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs new file mode 100644 index 00000000..eed9d23c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; +using System.Text.RegularExpressions; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Watches Microsoft-Windows-Shell-Core/Operational for ESP-related events: + /// 62404 — CloudExperienceHost Web App Activity Started (CXID: 'AADHello' / 'NGC' = Hello wizard) + /// 62407 — CloudExperienceHost Web App Event 2: + /// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit + /// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove complete + /// CommercialOOBE_ESPProgress_Failure/_Timeout/_Abort/WhiteGlove_Failed — ESP failure + /// + /// Raises , , + /// and . Cross-notifies the + /// on Hello wizard start and ESP exit so Hello timers can react. + /// + internal sealed class ShellCoreTracker : IDisposable + { + internal const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational"; + internal const int EventId_ShellCore_WebAppStarted = 62404; + internal const int EventId_ShellCore_WebAppEvent = 62407; + internal const int BackfillLookbackMinutes = 5; + + private static readonly HashSet TrackedShellCoreEventIds = new HashSet + { + EventId_ShellCore_WebAppStarted, + EventId_ShellCore_WebAppEvent + }; + + private static readonly Regex EspExitingPattern = new Regex( + @"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly HelloTracker _helloTracker; + + private EventLogWatcher _watcher; + private bool _espExitDetected; + private bool _whiteGloveDetected; + private readonly object _stateLock = new object(); + + public event EventHandler FinalizingSetupPhaseTriggered; + public event EventHandler WhiteGloveCompleted; + public event EventHandler EspFailureDetected; + + public ShellCoreTracker( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger, + HelloTracker helloTracker) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _helloTracker = helloTracker; // nullable — HelloTracker may be unavailable in some test setups + } + + internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } } + internal bool IsWhiteGloveDetectedForTest { get { lock (_stateLock) { return _whiteGloveDetected; } } } + + public void Start() + { + try + { + var query = new EventLogQuery( + ShellCoreEventLogChannel, + PathType.LogName, + "*[System[(EventID=62404 or EventID=62407)]]"); + + _watcher = new EventLogWatcher(query); + _watcher.EventRecordWritten += OnEventRecordWritten; + _watcher.Enabled = true; + + _logger.Info($"Started watching: {ShellCoreEventLogChannel}"); + } + catch (EventLogNotFoundException) + { + _logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)"); + } + catch (Exception ex) + { + _logger.Error("Failed to start Shell-Core event log watcher", ex); + } + } + + public void Stop() + { + if (_watcher == null) return; + try + { + _watcher.Enabled = false; + _watcher.EventRecordWritten -= OnEventRecordWritten; + _watcher.Dispose(); + } + catch (Exception ex) { _logger.Error("Error stopping Shell-Core event watcher", ex); } + finally { _watcher = null; } + } + + public void Dispose() => Stop(); + + // ===================================================================== + // Live event handler + // ===================================================================== + + private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e) + { + if (e.EventRecord == null) return; + + try + { + var record = e.EventRecord; + if (!TrackedShellCoreEventIds.Contains(record.Id)) return; + + var description = record.FormatDescription() ?? $"Event ID {record.Id}"; + var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(); + + ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false); + } + catch (Exception ex) + { + _logger.Error("Error processing Shell-Core event record", ex); + } + } + + /// + /// Core event-processing logic. Exposed as internal so tests can drive it without + /// needing to synthesize an (abstract + Windows-only). + /// + internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill) + { + string eventType; + EventSeverity severity = EventSeverity.Info; + string message; + bool triggerFinalizingSetup = false; + string finalizingSetupReason = null; + string detectedFailureType = null; + + switch (eventId) + { + case EventId_ShellCore_WebAppStarted: // 62404 + if (description.Contains("AADHello") || description.Contains("'NGC'")) + { + eventType = "hello_wizard_started"; + message = "Windows Hello wizard started (CloudExperienceHost)"; + triggerFinalizingSetup = true; + finalizingSetupReason = "hello_wizard_started"; + + _helloTracker?.NotifyHelloWizardStarted(); + + _logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404"); + } + else + { + return; + } + break; + + case EventId_ShellCore_WebAppEvent: // 62407 + if (description.IndexOf("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase) >= 0) + { + // Fire-once guard + lock (_stateLock) + { + if (_whiteGloveDetected) return; + _whiteGloveDetected = true; + } + + eventType = "whiteglove_complete"; + message = "WhiteGlove (Pre-Provisioning) completed successfully"; + // No FinalizingSetup transition — WhiteGlove terminates pre-provisioning entirely + + _logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407"); + } + else if (HasEspFailurePattern(description)) + { + detectedFailureType = ExtractEspFailureType(description); + eventType = "esp_failure"; + severity = EventSeverity.Error; + message = $"ESP (Enrollment Status Page) reported a failure: {detectedFailureType}"; + _logger.Warning($"ESP failure detected via Shell-Core event 62407: {detectedFailureType}"); + } + else if (EspExitingPattern.IsMatch(description)) + { + eventType = "esp_exiting"; + message = "ESP (Enrollment Status Page) phase exiting"; + triggerFinalizingSetup = true; + finalizingSetupReason = "esp_exiting"; + + lock (_stateLock) + { + _espExitDetected = true; + // Note: We do NOT start the Hello wait timer here! + // Event 62407 occurs at every ESP phase transition (Device->Account, Account->End) + // EnrollmentTracker will decide based on lastEspPhase whether to start the timer + } + _helloTracker?.NotifyEspExited(); + + _logger.Info("ESP phase exit detected - detected via Shell-Core event 62407"); + } + else + { + return; + } + break; + + default: + return; + } + + var eventData = new Dictionary + { + { "windowsEventId", eventId }, + { "providerName", providerName ?? "" }, + { "description", description }, + { "eventLogChannel", ShellCoreEventLogChannel }, + { "eventTime", timestamp.ToString("o") } + }; + + if (eventType == "esp_failure" && detectedFailureType != null) + { + eventData["failureType"] = detectedFailureType; + } + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = timestamp, + EventType = eventType, + Severity = severity, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = message, + Data = eventData, + ImmediateUpload = true + }); + + _logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})"); + + if (triggerFinalizingSetup) + { + try { FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason); } + catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); } + } + + // Fire WhiteGloveCompleted AFTER event emission so the whiteglove_complete event + // is in the spool before the agent exits. + if (eventType == "whiteglove_complete") + { + try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); } + } + + // Fire EspFailureDetected AFTER event emission so the esp_failure event is in the + // spool before the agent potentially shuts down. + if (eventType == "esp_failure" && detectedFailureType != null) + { + try { EspFailureDetected?.Invoke(this, detectedFailureType); } + catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{detectedFailureType}'", ex); } + } + } + + // ===================================================================== + // Backfill (public — called by coordinator) + // ===================================================================== + + /// + /// Backfills recent ESP exit and failure events from Shell-Core log on startup. + /// Secondary recovery mechanism when state persistence is unavailable. + /// + public void BackfillRecentEspExitEvents() + { + try + { + var lookbackMs = BackfillLookbackMinutes * 60 * 1000; + var query = new EventLogQuery( + ShellCoreEventLogChannel, + PathType.LogName, + $"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"); + + using (var reader = new EventLogReader(query)) + { + for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent()) + { + using (record) + { + var description = record.FormatDescription() ?? ""; + HandleBackfillRecord(description); + } + } + } + } + catch (Exception ex) + { + _logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}"); + } + } + + /// + /// Internal backfill record handler — extracted for testability and to keep the + /// backfill loop free of direct event-processing logic. + /// + internal void HandleBackfillRecord(string description) + { + if (EspExitingPattern.IsMatch(description)) + { + bool shouldNotify = false; + lock (_stateLock) + { + if (!_espExitDetected) + { + _espExitDetected = true; + shouldNotify = true; + } + } + if (shouldNotify) + { + _helloTracker?.NotifyEspExited(); + _logger.Info("Backfill: ESP exit event found in recent Shell-Core logs"); + try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); } + catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); } + } + } + + if (HasEspFailurePattern(description)) + { + var failureType = ExtractEspFailureType(description); + _logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}"); + try { EspFailureDetected?.Invoke(this, failureType); } + catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); } + } + } + + // ===================================================================== + // Pattern helpers + // ===================================================================== + + internal static bool HasEspFailurePattern(string description) + { + return description.IndexOf("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase) >= 0 + || description.IndexOf("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase) >= 0 + || description.IndexOf("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase) >= 0 + || description.IndexOf("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase) >= 0 + || description.IndexOf("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase) >= 0 + || description.IndexOf("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Extracts a structured failure type from the Shell-Core event description. + /// Returns e.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed", + /// or "Unknown_ESP_Failure" as a fallback. + /// + internal static string ExtractEspFailureType(string description) + { + string[] knownTypes = { + "ESPProgress_Failure", + "ESPProgress_Failed", + "ESPProgress_Timeout", + "ESPProgress_Abort", + "WhiteGlove_Failed", + "WhiteGlove_Failure" + }; + + foreach (var type in knownTypes) + { + if (description.IndexOf(type, StringComparison.OrdinalIgnoreCase) >= 0) + return type; + } + + return "Unknown_ESP_Failure"; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs new file mode 100644 index 00000000..f7974888 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs @@ -0,0 +1,551 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Ebene 2 — Defensive deep probes that run only when enrollment has been silent for a while. + /// + /// The collector is NOT a timer of its own. Instead, it is invoked from + /// (which runs every 60 s for the + /// entire agent lifetime). On each tick the collector inspects the idle duration and fires + /// a full scan at the configured thresholds (default: 2, 15, 30, 60, 180 min). + /// + /// Each probe scans up to four read-only sources (all wrapped in try/catch + hard timeout): + /// 1. Provisioning registry (HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings) + /// 2. Diagnostics registry (HKLM\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot) + /// 3. Windows event logs (ModernDeployment + Shell-Core + Application) + /// 4. AppWorkload.log tail (IME ESP app install state) + /// + /// The probe classifies the outcome as: + /// - anomaly → stall_probe_result (Warning) + optional EspFailureDetected + /// - activeInstalls → positive signal (healthy-in-progress), no alarm + /// - quiet → nothing found in any source + /// + /// Output policy (configurable via StallProbeTraceIndices, default = [2]): + /// - Trace-indexed probes: emit a stall_probe_check Trace event on quiet/activeInstalls. + /// - Non-trace probes: emit nothing when no anomaly is found (agent log only). + /// - Anomaly found: always emit stall_probe_result Warning (plus EspFailureDetected if terminal). + /// - Probe index configured as "session-stalled": emit fire-once session_stalled Warning. + /// + public class StallProbeCollector + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly AgentLogger _logger; + private readonly int[] _thresholdsMinutes; + private readonly HashSet _traceIndices; + private readonly HashSet _sources; + private readonly int _sessionStalledAfterProbeIndex; + private readonly HashSet _harmlessModernDeploymentEventIds; + + private readonly object _stateLock = new object(); + private readonly HashSet _firedProbeIndices = new HashSet(); + private bool _sessionStalledFired; + + public event EventHandler EspFailureDetected; + + // Per-source hard timeouts (ms) + private const int RegistryScanTimeoutMs = 500; + private const int DiagnosticsRegistryScanTimeoutMs = 500; + private const int EventLogScanTimeoutMs = 2000; + private const int AppWorkloadTailTimeoutMs = 1000; + + // Terminal ESP failure EventIDs in ModernDeployment-Diagnostics-Provider channels. + // Conservative list — we prefer to NOT auto-terminate on unknown IDs and let the user decide. + // + // TODO(stall-probe-classification): This is stellschraube #1 for promoting stall-probe findings + // to terminal failures. Currently empty by design: the ModernDeployment watcher (Ebene 1) runs + // in live-capture mode and forwards raw events so we can gather real production EventIDs first. + // Once we have 1–2 weeks of production data showing which EventIDs reliably correspond to + // terminal ESP failures (e.g. "app install failed terminally", "ESP category Error"), add them + // here. From that point on, Probe 1 (2 min) will detect them and fire EspFailureDetected → + // the session goes terminal-Failed within ~2 min instead of waiting for the 5h backend sweep. + // Cross-reference: the matching terminalReason should go into result.TerminalReason when you + // add entries (see ScanEventLogs around the TerminalModernDeploymentEventIds.Contains check). + private static readonly HashSet TerminalModernDeploymentEventIds = new HashSet + { + // Populated as real failure EventIDs are observed in production. + // Kept empty intentionally — live-capture mode (Etappe 3) collects the raw events first. + }; + + // Fallback list of ModernDeployment EventIDs that are known to be harmless and should NOT + // count as anomalies in the stall probe scan. The runtime-configurable list from the backend + // is injected via the constructor (CollectorConfiguration.ModernDeploymentHarmlessEventIds); + // this fallback is used only when no list is provided. + private static readonly int[] DefaultHarmlessModernDeploymentEventIds = new[] { 100, 1005 }; + + // AppWorkload.log active-install markers (positive signals) + private static readonly Regex ActiveInstallRegex = new Regex( + @"EnforcementState\s*[:=]\s*(Downloading|Installing|InProgress)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // AppWorkload.log failure markers (negative signals — but scanner only reports, no terminal decision). + // + // TODO(stall-probe-classification): This is stellschraube #2 for promoting stall-probe findings + // to terminal failures. Today any match of this regex produces a Warning-severity + // stall_probe_result event but does NOT set result.IsTerminal or fire EspFailureDetected — + // on purpose, because IME writes transient "Failed" lines that recover (retry, re-detect) and + // we do not want false-terminal verdicts during the live-capture phase. Once we can + // distinguish "retryable IME hiccup" from "terminal app install failure" based on real + // production data, tighten the regex (e.g. require a specific error code range or a second + // confirming line) and set result.IsTerminal = true + result.TerminalReason in ScanAppWorkloadTail. + // Ideas for stronger terminal signals to add here once validated: + // - EnforcementState: Failed + specific HRESULT ranges (0x87D1... Win32 app failures) + // - DetectionState: Error that persists across multiple tail samples + // - Explicit "Required app failed" lines from the ESP required-app enforcer + // + // TODO(stall-probe-imelog-scan): Also scan IntuneManagementExtension.log (not just + // AppWorkload.log). Learning from the Oriflame session b4b5d37e-a993-453e-b16b-9b75098022e4 + // (MS Intune AAD token service outage 2026-04-08): the ESP blocker was + // "Failed to get AAD token. errorCode = 3399548929" (= 0xCAA90001 AADSTS_SERVER_ERROR) + // and this string lives in IntuneManagementExtension.log, NOT AppWorkload.log. Add a third + // tail-scan source for `C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log` + // with a regex that catches repeated "Failed to get AAD token.*errorCode" lines (decimal or + // hex) as a strong "IME is blocked by service issue" signal. Watch out: IME retries every + // ~1h, so requiring at least 2 occurrences within the 15-min probe window dedup's single + // transient blips. The errorCode is DECIMAL in IME logs (3399548929), NOT hex, so the regex + // must handle both formats. A non-terminal Warning is probably the right severity — a + // service outage is recoverable and Windows ESP will eventually time out on its own if the + // outage persists, and at that point the ModernDeployment watcher (Ebene 1) should pick it up. + private static readonly Regex FailureRegex = new Regex( + @"EnforcementState\s*[:=]\s*Failed|Error\s+0x[0-9A-Fa-f]+|DetectionState\s*[:=]\s*Error", + RegexOptions.Compiled); + + public StallProbeCollector( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger, + int[] thresholdsMinutes, + int[] traceIndices, + string[] sources, + int sessionStalledAfterProbeIndex, + int[] harmlessModernDeploymentEventIds = null) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _thresholdsMinutes = thresholdsMinutes ?? throw new ArgumentNullException(nameof(thresholdsMinutes)); + _traceIndices = new HashSet(traceIndices ?? new int[0]); + _sources = new HashSet(sources ?? new string[0], StringComparer.OrdinalIgnoreCase); + _sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex; + _harmlessModernDeploymentEventIds = new HashSet( + harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0 + ? harmlessModernDeploymentEventIds + : DefaultHarmlessModernDeploymentEventIds); + } + + /// + /// Resets all per-probe fire-once state. Called when a new real (non-periodic) event arrives. + /// session_stalled stays fire-once across the whole session — we don't re-warn if the + /// session stalls a second time; the backend maintenance sweep handles late stalls. + /// + public void ResetProbes() + { + lock (_stateLock) + { + if (_firedProbeIndices.Count > 0) + { + _logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes"); + _firedProbeIndices.Clear(); + } + } + } + + /// + /// Called from the 60-s idle check tick. Decides whether any probe threshold has been + /// newly crossed and runs the corresponding probe. + /// + public void CheckAndRunProbes(double idleMinutes) + { + // Iterate thresholds from lowest to highest so probes fire in order. + for (int i = 0; i < _thresholdsMinutes.Length; i++) + { + var thresholdMinutes = _thresholdsMinutes[i]; + if (idleMinutes < thresholdMinutes) + return; + + int probeIndex = i + 1; // 1-based for user-facing output + + bool alreadyFired; + lock (_stateLock) + { + alreadyFired = _firedProbeIndices.Contains(probeIndex); + } + if (alreadyFired) + continue; + + RunProbe(probeIndex, thresholdMinutes, idleMinutes); + + lock (_stateLock) + { + _firedProbeIndices.Add(probeIndex); + } + } + } + + private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes) + { + var started = DateTime.UtcNow; + var result = new ProbeResult { ProbeIndex = probeIndex, IdleMinutes = Math.Round(idleMinutes, 1) }; + + // Run each source best-effort; one failure does not skip the others. + if (_sources.Contains("provisioning_registry")) + RunWithTimeout(() => ScanProvisioningRegistry(result), RegistryScanTimeoutMs, "provisioning_registry", result); + + if (_sources.Contains("diagnostics_registry")) + RunWithTimeout(() => ScanDiagnosticsRegistry(result), DiagnosticsRegistryScanTimeoutMs, "diagnostics_registry", result); + + if (_sources.Contains("eventlog")) + RunWithTimeout(() => ScanEventLogs(result), EventLogScanTimeoutMs, "eventlog", result); + + if (_sources.Contains("appworkload_log")) + RunWithTimeout(() => ScanAppWorkloadTail(result), AppWorkloadTailTimeoutMs, "appworkload_log", result); + + result.DurationMs = (int)(DateTime.UtcNow - started).TotalMilliseconds; + + // Always log locally for diagnostics — the agent_YYYYMMDD.log is the always-on observability. + var anomaliesStr = result.Anomalies.Count > 0 + ? $"anomalies=[{string.Join(",", result.Anomalies)}]" + : "quiet"; + var activeStr = result.ActiveInstalls.Count > 0 + ? $" activeInstalls=[{string.Join(",", result.ActiveInstalls)}]" + : string.Empty; + var failedStr = result.SourcesFailed.Count > 0 + ? $" sources_failed=[{string.Join(",", result.SourcesFailed)}]" + : string.Empty; + _logger.Info($"StallProbeCollector: Probe {probeIndex}/{_thresholdsMinutes.Length} ran at idle={result.IdleMinutes}min, {anomaliesStr}{activeStr}{failedStr} (duration={result.DurationMs}ms)"); + + bool hasAnomaly = result.Anomalies.Count > 0; + bool hasActiveInstalls = result.ActiveInstalls.Count > 0; + + // Emit stall_probe_result Warning on anomaly — always, regardless of probe index. + if (hasAnomaly) + { + EmitProbeResultEvent(result, severity: EventSeverity.Warning); + + if (result.IsTerminal) + { + _logger.Warning($"StallProbeCollector: terminal ESP failure detected in Probe {probeIndex} — firing EspFailureDetected"); + try { EspFailureDetected?.Invoke(this, result.TerminalReason ?? "stall_probe_terminal"); } + catch (Exception ex) { _logger.Error("EspFailureDetected handler threw", ex); } + } + } + else if (_traceIndices.Contains(probeIndex)) + { + // Quiet or activeInstalls — emit a Trace heartbeat at trace-indexed probes. + EmitProbeCheckTraceEvent(result); + } + // else: non-trace probe without anomaly → silent except for agent log. + + // session_stalled is fire-once across the whole session. It fires at the configured + // probe index UNLESS activeInstalls is observed (activeInstalls counts as progress). + if (probeIndex == _sessionStalledAfterProbeIndex && !hasActiveInstalls) + { + lock (_stateLock) + { + if (_sessionStalledFired) + return; + _sessionStalledFired = true; + } + EmitSessionStalledEvent(result); + } + } + + private void RunWithTimeout(Action action, int timeoutMs, string sourceName, ProbeResult result) + { + try + { + // Run on the calling thread but guard against exceptions. We intentionally do not + // spawn a new thread per source — the IdleCheckCallback already runs on a worker + // thread, and spinning extra threads would be heavier than the work itself. + // The timeoutMs is enforced via source-internal reads (FileStream, Registry handles + // are using-scoped and the reads themselves are bounded). + var sw = System.Diagnostics.Stopwatch.StartNew(); + action(); + sw.Stop(); + if (sw.ElapsedMilliseconds > timeoutMs) + { + _logger.Warning($"StallProbeCollector: {sourceName} scan exceeded soft timeout ({sw.ElapsedMilliseconds}ms > {timeoutMs}ms)"); + } + } + catch (Exception ex) + { + _logger.Warning($"StallProbeCollector: {sourceName} scan failed: {ex.Message}"); + result.SourcesFailed.Add(sourceName); + } + } + + private void ScanProvisioningRegistry(ProbeResult result) + { + using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings", writable: false)) + { + if (key == null) + return; + + foreach (var valueName in new[] { "DevicePreparationCategory.Status", "DeviceSetupCategory.Status", "AccountSetupCategory.Status" }) + { + var json = key.GetValue(valueName)?.ToString(); + if (string.IsNullOrEmpty(json)) + continue; + + // Rudimentary scan — only flag patterns that are definitively bad. + if (json.IndexOf("\"categorySucceeded\":false", StringComparison.OrdinalIgnoreCase) >= 0 || + json.IndexOf("\"subcategoryState\":\"failed\"", StringComparison.OrdinalIgnoreCase) >= 0) + { + result.Anomalies.Add($"provisioning_registry:{valueName}"); + if (result.RawSamples.Count < 5) + result.RawSamples.Add($"{valueName}: {Truncate(json, 400)}"); + } + } + } + } + + private void ScanDiagnosticsRegistry(ProbeResult result) + { + using (var diagRoot = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot", writable: false)) + { + if (diagRoot == null) + return; + + object deployErrorCode = diagRoot.GetValue("DeploymentErrorCode"); + object deployErrorReason = diagRoot.GetValue("DeploymentErrorReason"); + + if (deployErrorCode != null) + { + // DeploymentErrorCode is typically a DWORD. 0 = no error. + var asString = deployErrorCode.ToString(); + if (!string.IsNullOrEmpty(asString) && asString != "0" && asString != "0x0") + { + result.Anomalies.Add("diagnostics_registry:DeploymentErrorCode"); + result.RawSamples.Add($"DeploymentErrorCode={asString}; DeploymentErrorReason={deployErrorReason ?? "(null)"}"); + result.IsTerminal = true; + result.TerminalReason = $"DeploymentErrorCode={asString}"; + } + } + } + } + + private void ScanEventLogs(ProbeResult result) + { + // Scan a small window (last 15 min) of Autopilot/ESP-related channels at Level ≤ 3. + var since = DateTime.UtcNow.AddMinutes(-15); + var sinceStr = since.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + var xpath = $"*[System[(Level >= 1 and Level <= 3) and TimeCreated[@SystemTime >= '{sinceStr}']]]"; + + var channels = new[] + { + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService", + "Microsoft-Windows-Shell-Core/Operational" + }; + + int foundCount = 0; + foreach (var channel in channels) + { + try + { + var query = new EventLogQuery(channel, PathType.LogName, xpath) { ReverseDirection = true }; + using (var reader = new EventLogReader(query)) + { + int perChannelCap = 20; + EventRecord record; + while ((record = reader.ReadEvent()) != null && perChannelCap-- > 0) + { + using (record) + { + // Skip known harmless events — they are normal operational noise + // and should not be counted as stall anomalies. + if (_harmlessModernDeploymentEventIds.Contains(record.Id)) + continue; + + foundCount++; + if (result.RawSamples.Count < 5) + { + string desc = null; + try { desc = record.FormatDescription(); } + catch { } + desc = Truncate(desc ?? $"EventID {record.Id}", 400); + result.RawSamples.Add($"[{channel.Split('/').Last()}] ID={record.Id} Level={record.Level}: {desc}"); + } + + // Check for known terminal ModernDeployment failure event IDs. + if (TerminalModernDeploymentEventIds.Contains(record.Id)) + { + result.IsTerminal = true; + result.TerminalReason = $"ModernDeployment EventID {record.Id}"; + } + } + } + } + } + catch (EventLogNotFoundException) + { + // Channel not available on this OS — ignore silently. + } + catch (Exception ex) + { + _logger.Trace($"StallProbeCollector: eventlog scan of {channel} failed: {ex.Message}"); + } + } + + if (foundCount > 0) + { + result.Anomalies.Add($"eventlog:{foundCount}_events"); + } + } + + private void ScanAppWorkloadTail(ProbeResult result) + { + var path = @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log"; + if (!File.Exists(path)) + return; + + // Read the last ~200 KB. AppWorkload.log can be huge; we never read the whole file. + const int tailBytes = 200 * 1024; + using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + { + long start = Math.Max(0, fs.Length - tailBytes); + fs.Seek(start, SeekOrigin.Begin); + using (var reader = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true)) + { + string content = reader.ReadToEnd(); + + // Cut at the first newline to avoid starting mid-line. + var firstNewline = content.IndexOf('\n'); + if (firstNewline > 0 && firstNewline < content.Length - 1) + content = content.Substring(firstNewline + 1); + + // Positive-signal scan: active installations recently logged. + foreach (Match m in ActiveInstallRegex.Matches(content)) + { + if (result.ActiveInstalls.Count < 10 && !result.ActiveInstalls.Contains(m.Value)) + result.ActiveInstalls.Add(m.Value); + } + + // Negative-signal scan: failures. + var failureMatches = FailureRegex.Matches(content); + if (failureMatches.Count > 0) + { + result.Anomalies.Add($"appworkload_log:{failureMatches.Count}_errors"); + // Sample up to 3 failure lines for context. + int sampled = 0; + foreach (Match m in failureMatches) + { + if (sampled >= 3 || result.RawSamples.Count >= 5) + break; + // Extract the enclosing line for context. + int lineStart = content.LastIndexOf('\n', Math.Max(0, m.Index)); + int lineEnd = content.IndexOf('\n', m.Index); + if (lineStart < 0) lineStart = 0; + if (lineEnd < 0) lineEnd = content.Length; + var line = content.Substring(lineStart, Math.Min(400, lineEnd - lineStart)).Trim(); + result.RawSamples.Add($"AppWorkload: {line}"); + sampled++; + } + } + } + } + } + + private void EmitProbeResultEvent(ProbeResult result, EventSeverity severity) + { + var data = BuildDataJson(result); + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.StallProbeResult, + Severity = severity, + Source = "StallProbeCollector", + Phase = EnrollmentPhase.Unknown, + Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length}: {result.Anomalies.Count} anomaly/ies after {result.IdleMinutes}min idle", + Data = data + }); + } + + private void EmitProbeCheckTraceEvent(ProbeResult result) + { + var data = BuildDataJson(result); + var summary = result.ActiveInstalls.Count > 0 + ? $"activeInstalls={result.ActiveInstalls.Count}" + : "quiet"; + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.StallProbeCheck, + Severity = EventSeverity.Trace, + Source = "StallProbeCollector", + Phase = EnrollmentPhase.Unknown, + Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length} heartbeat: {summary} after {result.IdleMinutes}min idle", + Data = data + }); + } + + private void EmitSessionStalledEvent(ProbeResult result) + { + var data = BuildDataJson(result); + data["probeIndexTriggered"] = result.ProbeIndex; + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.SessionStalled, + Severity = EventSeverity.Warning, + Source = "StallProbeCollector", + Phase = EnrollmentPhase.Unknown, + Message = $"Session stalled: no enrollment progress for {result.IdleMinutes}min (stall probe {result.ProbeIndex})", + Data = data + }); + } + + private static Dictionary BuildDataJson(ProbeResult r) + { + return new Dictionary + { + { "probeIndex", r.ProbeIndex }, + { "idleMinutes", r.IdleMinutes }, + { "probeDurationMs", r.DurationMs }, + { "anomalyTypes", r.Anomalies.ToArray() }, + { "activeInstalls", r.ActiveInstalls.ToArray() }, + { "sources_failed", r.SourcesFailed.ToArray() }, + { "rawSamples", r.RawSamples.ToArray() }, + { "isTerminal", r.IsTerminal }, + { "terminalReason", r.TerminalReason ?? string.Empty } + }; + } + + private static string Truncate(string s, int max) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + return s.Length <= max ? s : s.Substring(0, max) + "…"; + } + + private class ProbeResult + { + public int ProbeIndex; + public double IdleMinutes; + public int DurationMs; + public readonly List Anomalies = new List(); + public readonly List ActiveInstalls = new List(); + public readonly List SourcesFailed = new List(); + public readonly List RawSamples = new List(); + public bool IsTerminal; + public string TerminalReason; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/MemoryNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/MemoryNativeMethods.cs new file mode 100644 index 00000000..00f970ad --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/MemoryNativeMethods.cs @@ -0,0 +1,45 @@ +using System; +using System.Runtime.InteropServices; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop +{ + internal static class MemoryNativeMethods + { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct MEMORYSTATUSEX + { + public uint dwLength; + public uint dwMemoryLoad; + public ulong ullTotalPhys; + public ulong ullAvailPhys; + public ulong ullTotalPageFile; + public ulong ullAvailPageFile; + public ulong ullTotalVirtual; + public ulong ullAvailVirtual; + public ulong ullAvailExtendedVirtual; + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer); + + public static bool TryGetMemoryInfo(out ulong availBytes, out ulong totalBytes, out uint loadPercent) + { + var status = new MEMORYSTATUSEX(); + status.dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX)); + + if (GlobalMemoryStatusEx(ref status)) + { + availBytes = status.ullAvailPhys; + totalBytes = status.ullTotalPhys; + loadPercent = status.dwMemoryLoad; + return true; + } + + availBytes = 0; + totalBytes = 0; + loadPercent = 0; + return false; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ProcessNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ProcessNativeMethods.cs new file mode 100644 index 00000000..fe715ad7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ProcessNativeMethods.cs @@ -0,0 +1,99 @@ +using System; +using System.Runtime.InteropServices; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop +{ + /// + /// Win32 P/Invoke declarations for process creation in user session. + /// Used by UserSessionProcessLauncher to launch processes in interactive desktop sessions from SYSTEM context. + /// + internal static class ProcessNativeMethods + { + public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400; + public const int STARTF_USESHOWWINDOW = 0x00000001; + public const int STARTF_FORCEONFEEDBACK = 0x00000040; + public const short SW_SHOW = 5; + public const uint WAIT_TIMEOUT = 258; + public const uint MAXIMUM_ALLOWED = 0x02000000; + public const int SECURITY_IMPERSONATION = 2; + public const int TOKEN_PRIMARY = 1; + public const int TokenSessionId = 12; + + [StructLayout(LayoutKind.Sequential)] + public struct SECURITY_ATTRIBUTES + { + public int nLength; + public IntPtr lpSecurityDescriptor; + public bool bInheritHandle; + } + + [StructLayout(LayoutKind.Sequential)] + public struct STARTUPINFO + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public int dwProcessId; + public int dwThreadId; + } + + [DllImport("Wtsapi32.dll", SetLastError = true)] + public static extern bool WTSQueryUserToken(uint SessionId, out IntPtr phToken); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool DuplicateTokenEx( + IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes, + int ImpersonationLevel, int TokenType, out IntPtr phNewToken); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool SetTokenInformation( + IntPtr TokenHandle, int TokenInformationClass, + ref uint TokenInformation, uint TokenInformationLength); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CreateProcessAsUser( + IntPtr hToken, string lpApplicationName, string lpCommandLine, + ref SECURITY_ATTRIBUTES lpProcessAttributes, + ref SECURITY_ATTRIBUTES lpThreadAttributes, + bool bInheritHandles, uint dwCreationFlags, + IntPtr lpEnvironment, string lpCurrentDirectory, + ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("userenv.dll", SetLastError = true)] + public static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit); + + [DllImport("userenv.dll", SetLastError = true)] + public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll")] + public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/RegistryNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/RegistryNativeMethods.cs new file mode 100644 index 00000000..1fb25fb7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/RegistryNativeMethods.cs @@ -0,0 +1,60 @@ +using System; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop +{ + internal static class RegistryNativeMethods + { + public const int KEY_NOTIFY = 0x0010; + public const int KEY_WOW64_64KEY = 0x0100; + public const int KEY_WOW64_32KEY = 0x0200; + + [Flags] + public enum RegChangeNotifyFilter : uint + { + Name = 0x00000001, + Attributes = 0x00000002, + LastSet = 0x00000004, + Security = 0x00000008, + + // Supported on Windows 8+ + ThreadAgnostic = 0x10000000 + } + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern int RegOpenKeyEx( + UIntPtr hKey, + string lpSubKey, + uint ulOptions, + int samDesired, + out SafeRegistryHandle phkResult); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern int RegNotifyChangeKeyValue( + SafeRegistryHandle hKey, + bool bWatchSubtree, + RegChangeNotifyFilter dwNotifyFilter, + SafeWaitHandle hEvent, + bool fAsynchronous); + + public static int GetSamDesired(RegistryView view) + { + switch (view) + { + case RegistryView.Registry64: + return KEY_NOTIFY | KEY_WOW64_64KEY; + + case RegistryView.Registry32: + return KEY_NOTIFY | KEY_WOW64_32KEY; + + case RegistryView.Default: + return KEY_NOTIFY; + + default: + throw new ArgumentOutOfRangeException(nameof(view)); + } + } + } +} \ No newline at end of file diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CleanupService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CleanupService.cs new file mode 100644 index 00000000..f1470941 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/CleanupService.cs @@ -0,0 +1,124 @@ +using System; +using System.Diagnostics; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Security; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Handles agent cleanup on enrollment completion: + /// removes the Scheduled Task and deletes all agent files (self-destruct). + /// + public class CleanupService + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + + public CleanupService(AgentConfiguration configuration, AgentLogger logger) + { + _configuration = configuration; + _logger = logger; + } + + /// + /// Executes full self-destruct: removes scheduled task and deletes all files + /// + public virtual void ExecuteSelfDestruct() + { + try + { + _logger.Info($"Executing FULL SELF-DESTRUCT (Scheduled Task + Files{(_configuration.RebootOnComplete ? " + Reboot" : "")})"); + + var currentProcessId = Process.GetCurrentProcess().Id; + var agentBasePath = Path.GetFullPath(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "AutopilotMonitor" + )); + + var logDir = Path.GetFullPath(Path.Combine(agentBasePath, "Logs")); + var keepLogs = _configuration.KeepLogFile; + + // Create a self-deleting PowerShell script + var cleanupScript = $@" +$scriptPath = $MyInvocation.MyCommand.Path + +# Wait for agent process to exit +Start-Sleep -Seconds 2 +Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30 +Start-Sleep -Seconds 1 + +# Remove Scheduled Task +try {{ + Stop-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -Confirm:$false -ErrorAction SilentlyContinue +}} catch {{ }} + +{(keepLogs ? $@" +# Delete everything except Logs directory +Get-ChildItem -Path '{agentBasePath}' -Exclude 'Logs' | ForEach-Object {{ + $dest = $_.FullName + '.del' + try {{ Rename-Item -Path $_.FullName -NewName $dest -Force -ErrorAction SilentlyContinue }} catch {{ }} + Remove-Item -Path $dest -Recurse -Force -ErrorAction SilentlyContinue +}} +" : $@" +# Rename folder then delete. Retry a few times to let the OS release handles. +$renamedPath = '{agentBasePath}.del' +$renamed = $false +for ($i = 1; $i -le 10; $i++) {{ + try {{ + Rename-Item -Path '{agentBasePath}' -NewName $renamedPath -Force -ErrorAction Stop + $renamed = $true + break + }} catch {{ + Start-Sleep -Seconds 2 + }} +}} +if ($renamed) {{ + Remove-Item -Path $renamedPath -Recurse -Force -ErrorAction SilentlyContinue +}} else {{ + Remove-Item -Path '{agentBasePath}' -Recurse -Force -ErrorAction SilentlyContinue +}} +")} +{(_configuration.RebootOnComplete ? @" +Restart-Computer -Force -Delay 10 -Comment 'Autopilot enrollment completed - Autopilot Monitor is rebooting' +" : "")} +Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue +"; + + // Write cleanup script to temp location (outside of agent folder) + var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1"); + File.WriteAllText(tempScriptPath, cleanupScript); + _logger.Info($"Cleanup script written to {tempScriptPath}"); + + // Change CWD to temp so this process no longer holds a reference into + // the AutopilotMonitor folder tree - Windows won't allow renaming a + // directory that any process has as its current working directory. + try { Directory.SetCurrentDirectory(Path.GetTempPath()); } catch { } + + // Launch via cmd /c start so the powershell process is created outside the + // current Job Object (Scheduled Task job). cmd's 'start' command always + // creates a new process group that breaks job inheritance, even under SYSTEM. + var psi = new ProcessStartInfo + { + FileName = SystemPaths.Cmd, + // Absolute path to powershell.exe is passed to `start` to prevent PATH hijacking + // (the `start` builtin does its own PATH resolution for the target command). + Arguments = $"/c start \"\" /b \"{SystemPaths.PowerShell}\" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetTempPath() + }; + + Process.Start(psi); + _logger.Info("Cleanup script launched. Agent will now exit."); + } + catch (Exception ex) + { + _logger.Error("Error executing self-destruct", ex); + } + } + + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs new file mode 100644 index 00000000..197b3c2b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs @@ -0,0 +1,366 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Result of a diagnostics package upload attempt. + /// + public class DiagnosticsUploadResult + { + /// Blob name on success, null on failure or skip. + public string BlobName { get; set; } + + /// + /// First part of the SAS URL (up to and including the first query param) with the token truncated, + /// so the event shows the target container without leaking the full signature. + /// Example: "https://account.blob.core.windows.net/diagnostics?sp=(truncated)" + /// + public string SasUrlPrefix { get; set; } + + /// Human-readable error code/message when upload failed. Null on success or skip. + public string ErrorCode { get; set; } + + public bool Success => BlobName != null; + } + + /// + /// Creates and uploads a diagnostics ZIP package (agent logs + IME logs + session info) + /// to the tenant's Azure Blob Storage container via a short-lived SAS URL. + /// The SAS URL is fetched on-demand just before upload and never stored in config or on disk. + /// + public class DiagnosticsPackageService + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly BackendApiClient _apiClient; + private readonly HttpClient _httpClient; + + private static readonly string ImeLogFolder = + Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs"); + + private static readonly string AgentLogFolder = + Environment.ExpandEnvironmentVariables(Constants.LogDirectory); + + /// + /// File extensions collected from built-in log folders (Agent + IME). + /// Covers standard logs, structured data, traces, event logs, and diagnostics archives. + /// + private static readonly string[] LogFilePatterns = new[] + { + "*.log", "*.txt", "*.json", "*.jsonl", + "*.etl", "*.evtx", "*.xml", "*.csv", "*.cab" + }; + + public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient) + { + _configuration = configuration; + _logger = logger; + _apiClient = apiClient; + _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + } + + /// + /// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container. + /// Returns a DiagnosticsUploadResult with BlobName set on success, or ErrorCode set on failure. + /// Returns null if the upload was skipped (mode=Off, not configured, or OnFailure+succeeded). + /// This method is non-fatal: all exceptions are caught and logged. + /// + /// + /// True for a successful enrollment (affects OnFailure mode check and sessioninfo.txt content). + /// Pass true for WhiteGlove pre-provisioning (it succeeded up to this point). + /// + /// + /// Optional suffix inserted before the .zip extension. + /// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip + /// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip + /// + public virtual async Task CreateAndUploadAsync(bool enrollmentSucceeded, string fileNameSuffix = null) + { + try + { + // Check if upload is needed based on configuration + var mode = _configuration.DiagnosticsUploadMode ?? "Off"; + if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) + { + _logger.Debug("Diagnostics upload disabled (mode=Off)"); + return null; + } + + if (!_configuration.DiagnosticsUploadEnabled) + { + _logger.Debug("Diagnostics upload skipped: not configured for this tenant"); + return null; + } + + if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded) + { + _logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure"); + return null; + } + + _logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})..."); + + // Build ZIP in memory + var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + var suffix = string.IsNullOrEmpty(fileNameSuffix) ? "" : $"-{fileNameSuffix}"; + var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}{suffix}.zip"; + + byte[] zipBytes; + using (var ms = new MemoryStream()) + { + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + // 1. sessioninfo.txt + AddSessionInfo(archive, enrollmentSucceeded); + + // 2. Agent logs + foreach (var pattern in LogFilePatterns) + AddLogFiles(archive, AgentLogFolder, "AgentLogs", pattern); + + // 3. IME logs (all relevant files in the IME log folder) + foreach (var pattern in LogFilePatterns) + AddLogFiles(archive, ImeLogFolder, "ImeLogs", pattern); + + // 4. Configured additional log paths (global + tenant, validated by guards) + foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List()) + { + // Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception + var userProfilePath = UserProfileResolver.ContainsUserProfileToken(entry.Path) + ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; + + if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath)) + { + _logger.Warning($"Diagnostics path blocked by guard: {entry.Path}"); + continue; + } + var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path); + if (expandedPath == null) + { + _logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}"); + continue; + } + var folder = Path.GetDirectoryName(expandedPath); + var pattern = Path.GetFileName(expandedPath); + if (string.IsNullOrEmpty(folder)) continue; + if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*"; + var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}"; + AddLogFiles(archive, folder, zipFolder, pattern, entry.IncludeSubfolders); + } + } + + zipBytes = ms.ToArray(); + } + + _logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)"); + + // Fetch a short-lived upload URL just before uploading — never stored in config or on disk + _logger.Info("Requesting diagnostics upload URL from backend..."); + var uploadUrlResponse = await _apiClient.GetDiagnosticsUploadUrlAsync( + _configuration.TenantId, + _configuration.SessionId, + zipFileName); + + if (uploadUrlResponse == null || !uploadUrlResponse.Success || string.IsNullOrEmpty(uploadUrlResponse.UploadUrl)) + { + var errorCode = uploadUrlResponse?.Message ?? "Failed to get diagnostics upload URL from backend"; + _logger.Warning($"Failed to get diagnostics upload URL from backend — skipping upload: {errorCode}"); + return new DiagnosticsUploadResult { ErrorCode = errorCode }; + } + + var sasUrlPrefix = BuildSasUrlPrefix(uploadUrlResponse.UploadUrl); + + // Upload to Blob Storage using the freshly obtained URL + var (uploaded, uploadErrorCode) = await UploadToBlobStorageAsync(zipFileName, zipBytes, uploadUrlResponse.UploadUrl); + if (uploaded) + { + _logger.Info($"Diagnostics package uploaded successfully: {zipFileName}"); + return new DiagnosticsUploadResult { BlobName = zipFileName, SasUrlPrefix = sasUrlPrefix }; + } + + return new DiagnosticsUploadResult { SasUrlPrefix = sasUrlPrefix, ErrorCode = uploadErrorCode }; + } + catch (Exception ex) + { + _logger.Warning($"Diagnostics package creation/upload failed (non-fatal): {ex.Message}"); + return new DiagnosticsUploadResult { ErrorCode = ex.Message }; + } + } + + /// + /// Returns a safe, truncated SAS URL prefix for logging — shows account/container but not the token. + /// Example: "https://account.blob.core.windows.net/diagnostics?sp=(truncated)" + /// + private static string BuildSasUrlPrefix(string sasUrl) + { + try + { + var qIndex = sasUrl.IndexOf('?'); + if (qIndex < 0) return sasUrl; + + // Keep only the first query param name to show which container/permissions are set + var basePath = sasUrl.Substring(0, qIndex); + var query = sasUrl.Substring(qIndex + 1); + var firstParam = query.Split('&')[0].Split('=')[0]; + return $"{basePath}?{firstParam}=(truncated)"; + } + catch + { + return "(url unavailable)"; + } + } + + private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded) + { + var sb = new StringBuilder(); + sb.AppendLine($"Session ID: {_configuration.SessionId}"); + sb.AppendLine($"Tenant ID: {_configuration.TenantId}"); + sb.AppendLine($"Device Name: {Environment.MachineName}"); + sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}"); + sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}"); + + // Hardware info via existing DeviceInfoProvider (WMI) + sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}"); + sb.AppendLine($"Model: {DeviceInfoProvider.GetModel()}"); + sb.AppendLine($"Serial Number: {DeviceInfoProvider.GetSerialNumber()}"); + + var entry = archive.CreateEntry("sessioninfo.txt"); + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); + writer.Write(sb.ToString()); + } + + private void AddLogFiles(ZipArchive archive, string sourceFolder, string zipFolder, string searchPattern, + bool includeSubfolders = false) + { + if (!Directory.Exists(sourceFolder)) + { + _logger.Debug($"Log folder not found, skipping: {sourceFolder}"); + return; + } + + try + { + var searchOption = includeSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var files = Directory.GetFiles(sourceFolder, searchPattern, searchOption); + foreach (var file in files) + { + try + { + // Preserve subfolder structure in the ZIP when includeSubfolders is enabled + var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar); + var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}"; + + // Read with FileShare.ReadWrite to avoid locking conflicts with active log writers + byte[] content; + using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var reader = new MemoryStream()) + { + fs.CopyTo(reader); + content = reader.ToArray(); + } + + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var entryStream = entry.Open(); + entryStream.Write(content, 0, content.Length); + + _logger.Debug($"Added to diagnostics package: {entryName} ({content.Length / 1024} KB)"); + } + catch (Exception ex) + { + _logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}"); + } + } + } + catch (Exception ex) + { + _logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}"); + } + } + + /// + /// Uploads the ZIP bytes to Azure Blob Storage via PUT with the provided container SAS URL. + /// Container SAS URL format: https://account.blob.core.windows.net/container?sv=...&sig=... + /// Blob URL is constructed by inserting the blob name before the query string. + /// Returns (success, errorCode) — errorCode is null on success, otherwise the last HTTP error. + /// + private async Task<(bool success, string errorCode)> UploadToBlobStorageAsync(string blobName, byte[] data, string containerSasUrl) + { + // Build blob URL: split at '?' to insert blob name + var questionMarkIndex = containerSasUrl.IndexOf('?'); + string blobUrl; + if (questionMarkIndex >= 0) + { + var basePath = containerSasUrl.Substring(0, questionMarkIndex).TrimEnd('/'); + var queryString = containerSasUrl.Substring(questionMarkIndex); + blobUrl = $"{basePath}/{blobName}{queryString}"; + } + else + { + // No query string (unlikely for SAS but handle gracefully) + blobUrl = $"{containerSasUrl.TrimEnd('/')}/{blobName}"; + } + + const int maxRetries = 3; + string lastErrorCode = null; + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + _logger.Info($"Uploading diagnostics package (attempt {attempt}/{maxRetries}, {data.Length / 1024} KB)..."); + + using var content = new ByteArrayContent(data); + content.Headers.Add("x-ms-blob-type", "BlockBlob"); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip"); + + var response = await _httpClient.PutAsync(blobUrl, content); + + if (response.IsSuccessStatusCode) + { + return (true, null); + } + + var statusCode = (int)response.StatusCode; + lastErrorCode = $"HTTP {statusCode} {response.ReasonPhrase}"; + var responseBody = await response.Content.ReadAsStringAsync(); + + // Auth errors (401/403) are permanent — SAS token invalid or expired, retrying won't help + if (statusCode == 401 || statusCode == 403) + { + _logger.Warning($"Blob upload auth error (not retryable): {lastErrorCode} - {responseBody}"); + return (false, lastErrorCode); + } + + _logger.Warning($"Blob upload attempt {attempt} failed: {lastErrorCode} - {responseBody}"); + } + catch (Exception ex) + { + lastErrorCode = ex.Message; + _logger.Warning($"Blob upload attempt {attempt} failed: {ex.Message}"); + } + + if (attempt < maxRetries) + { + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 2s, 4s, 8s + _logger.Info($"Retrying blob upload in {delay.TotalSeconds}s..."); + await Task.Delay(delay); + } + } + + _logger.Warning($"Diagnostics package upload failed after {maxRetries} attempts"); + return (false, lastErrorCode); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/RegistryWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/RegistryWatcher.cs new file mode 100644 index 00000000..d013433a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/RegistryWatcher.cs @@ -0,0 +1,311 @@ +using System; +using System.ComponentModel; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Watches a registry key for changes using RegNotifyChangeKeyValue. + /// Raises Changed whenever the watched key/subtree changes. + /// + /// Notes: + /// - Notification is for the key scope, not one individual value. + /// - To track a specific value, read/compare that value in the Changed handler. + /// - Uses a dedicated background thread for robustness. + /// + internal sealed class RegistryWatcher : IDisposable + { + private readonly UIntPtr _hive; + private readonly string _subKey; + private readonly bool _watchSubtree; + private readonly RegistryView _view; + private readonly RegistryNativeMethods.RegChangeNotifyFilter _filter; + private readonly Action _trace; + + private readonly object _sync = new object(); + + private CancellationTokenSource _cts; + private Thread _thread; + private Exception _backgroundException; + private bool _disposed; + + public event EventHandler Changed; + public event EventHandler Error; + + /// Optional trace callback for diagnostic logging (called on every loop iteration). + public RegistryWatcher( + RegistryHive hive, + string subKey, + bool watchSubtree = false, + RegistryView view = RegistryView.Default, + RegistryNativeMethods.RegChangeNotifyFilter filter = + RegistryNativeMethods.RegChangeNotifyFilter.LastSet | + RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic, + Action trace = null) + { + _hive = HiveToPointer(hive); + _subKey = subKey ?? throw new ArgumentNullException(nameof(subKey)); + _watchSubtree = watchSubtree; + _view = view; + _filter = filter; + _trace = trace; + } + + public bool IsRunning + { + get + { + lock (_sync) + { + return _thread != null; + } + } + } + + public void Start() + { + ThrowIfDisposed(); + + lock (_sync) + { + if (_thread != null) + throw new InvalidOperationException("Watcher is already running."); + + _backgroundException = null; + _cts = new CancellationTokenSource(); + + _thread = new Thread(WatchThreadMain) + { + IsBackground = true, + Name = $"RegistryWatcher: {_subKey}" + }; + + _thread.Start(_cts.Token); + } + } + + /// + /// Requests stop without blocking. + /// Safe to call from Changed handlers. + /// + public void RequestStop() + { + lock (_sync) + { + _cts?.Cancel(); + } + } + + /// + /// Stops and waits for the watcher thread to exit. + /// Avoid calling from inside Changed; use RequestStop there. + /// + public void Stop() + { + Thread threadToJoin = null; + CancellationTokenSource ctsToDispose = null; + Exception backgroundException = null; + + lock (_sync) + { + if (_thread == null) + return; + + _cts.Cancel(); + threadToJoin = _thread; + } + + threadToJoin.Join(); + + lock (_sync) + { + backgroundException = _backgroundException; + + ctsToDispose = _cts; + _cts = null; + _thread = null; + _backgroundException = null; + } + + ctsToDispose?.Dispose(); + + if (backgroundException != null && + !(backgroundException is OperationCanceledException)) + { + throw new InvalidOperationException( + "Registry watcher stopped because the background thread failed.", + backgroundException); + } + } + + private void WatchThreadMain(object state) + { + var cancellationToken = (CancellationToken)state; + + try + { + WatchLoop(cancellationToken); + } + catch (OperationCanceledException ex) + { + lock (_sync) + { + _backgroundException = ex; + } + } + catch (Exception ex) + { + lock (_sync) + { + _backgroundException = ex; + } + + try + { + Error?.Invoke(this, ex); + } + catch + { + // Never let handler exceptions escape the background thread. + } + } + } + + private void WatchLoop(CancellationToken cancellationToken) + { + int samDesired = RegistryNativeMethods.GetSamDesired(_view); + + _trace?.Invoke($"RegOpenKeyEx '{_subKey}' (view: {_view}, sam: 0x{samDesired:X})"); + + int openResult = RegistryNativeMethods.RegOpenKeyEx( + _hive, + _subKey, + 0, + samDesired, + out SafeRegistryHandle keyHandle); + + if (openResult != 0) + { + throw new Win32Exception( + openResult, + $"RegOpenKeyEx failed for '{_subKey}' (view: {_view})."); + } + + _trace?.Invoke($"RegOpenKeyEx succeeded — handle valid: {!keyHandle.IsInvalid}"); + + using (keyHandle) + using (var changedEvent = new AutoResetEvent(false)) + using (var cancelEvent = new ManualResetEvent(false)) + using (cancellationToken.Register(() => cancelEvent.Set())) + { + WaitHandle[] waitHandles = { changedEvent, cancelEvent }; + int iteration = 0; + + while (!cancellationToken.IsCancellationRequested) + { + iteration++; + + _trace?.Invoke($"Iteration {iteration}: calling RegNotifyChangeKeyValue (subtree={_watchSubtree}, filter=0x{(uint)_filter:X})"); + + int notifyResult = RegistryNativeMethods.RegNotifyChangeKeyValue( + keyHandle, + _watchSubtree, + _filter, + changedEvent.SafeWaitHandle, + true); + + if (notifyResult != 0) + { + throw new Win32Exception( + notifyResult, + $"RegNotifyChangeKeyValue failed for '{_subKey}'."); + } + + _trace?.Invoke($"Iteration {iteration}: waiting for change notification..."); + + int signaled = WaitHandle.WaitAny(waitHandles); + + if (signaled == WaitHandle.WaitTimeout) + { + _trace?.Invoke($"Iteration {iteration}: WaitAny returned WaitTimeout — continuing"); + continue; + } + + if (signaled == 1) + { + _trace?.Invoke($"Iteration {iteration}: cancel event signaled — exiting loop"); + break; + } + + _trace?.Invoke($"Iteration {iteration}: change notification received — invoking Changed handler"); + + try + { + Changed?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + try + { + Error?.Invoke(this, ex); + } + catch + { + // Ignore secondary error-handler failures. + } + } + + _trace?.Invoke($"Iteration {iteration}: Changed handler completed"); + } + + _trace?.Invoke("WatchLoop exiting (cancellation requested)"); + cancellationToken.ThrowIfCancellationRequested(); + } + } + + public void Dispose() + { + if (_disposed) + return; + + try + { + Stop(); + } + catch + { + // Dispose must not throw. + } + + _disposed = true; + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(RegistryWatcher)); + } + + private static UIntPtr HiveToPointer(RegistryHive hive) + { + switch (hive) + { + case RegistryHive.ClassesRoot: + return (UIntPtr)0x80000000u; + case RegistryHive.CurrentUser: + return (UIntPtr)0x80000001u; + case RegistryHive.LocalMachine: + return (UIntPtr)0x80000002u; + case RegistryHive.Users: + return (UIntPtr)0x80000003u; + case RegistryHive.CurrentConfig: + return (UIntPtr)0x80000005u; + default: + throw new ArgumentOutOfRangeException(nameof(hive)); + } + } + } +} \ No newline at end of file diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs new file mode 100644 index 00000000..d9ce3c92 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs @@ -0,0 +1,804 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Shared; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Fast self-update at agent startup: checks version.json in blob storage, + /// downloads the ZIP if newer, swaps files (rename-trick for locked binaries), + /// and restarts via PowerShell Wait-Process. + /// + /// Design priority: speed over update — better to run the old version than delay startup. + /// + static class SelfUpdater + { + private const int VersionCheckTimeoutMs = 2500; // 2.5s — covers cold DNS/TLS on fresh Autopilot boot + private const int DownloadTimeoutMs = 10000; // 10s — abort if too slow + private const string OldFileSuffix = ".old"; + + /// + /// Writes an init banner to the log file to visually separate this agent process + /// from install-mode logs that share the same file. + /// + public static void LogInit(string agentVersion) + { + LogToFile($"======================= Agent init (v{agentVersion}) ======================="); + } + + /// + /// Writes a message to the agent log file. Used for pre-update logging + /// when the full AgentLogger is not yet initialized. + /// + public static void Log(string message) + { + LogToFile(message); + } + + /// + /// Deletes leftover .old files from a previous self-update. + /// Called early in startup before any other logic. + /// + public static void CleanupPreviousUpdate(string agentDir, Action log) + { + try + { + if (!Directory.Exists(agentDir)) + return; + + foreach (var oldFile in Directory.GetFiles(agentDir, "*" + OldFileSuffix)) + { + try + { + File.Delete(oldFile); + } + catch + { + // Best-effort: file may still be locked if previous process hasn't fully exited + } + } + + // Also clean up any leftover staging directory + var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir); + if (Directory.Exists(stagingDir)) + { + try { Directory.Delete(stagingDir, true); } catch { } + } + } + catch (Exception ex) + { + log?.Invoke($"Self-update cleanup warning: {ex.Message}"); + } + } + + /// + /// Optional: backend-provided SHA-256 hash of the latest agent ZIP. + /// Set from the last AgentConfigResponse before calling CheckAndApplyUpdateAsync. + /// Takes priority over the hash in version.json (separate trust channel). + /// + public static string BackendExpectedSha256 { get; set; } + + /// + /// Checks for a newer agent version and applies the update if available. + /// On success, restarts the process and never returns. + /// On any failure, returns normally so the current version continues. + /// + /// + /// When true, bypasses the IsNewerVersion check — used by the runtime-hash-mismatch trigger + /// to cover hotfixes that reuse the same version number. + /// + /// + /// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the + /// agent_version_check event (outcome=updated) can distinguish the two paths. + /// + /// + /// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve + /// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running + /// and we know an update is required. + /// + /// + /// When false (default), a latest version strictly lower than + /// is rejected even when is true — prevents the production + /// version.json from silently overwriting a higher-versioned dev/pre-release agent via + /// the runtime hash-mismatch path. Set to true only for controlled rollback scenarios. + /// + public static async Task CheckAndApplyUpdateAsync( + string currentVersion, + string agentDir, + bool consoleMode, + bool forceUpdate = false, + string triggerReason = "startup", + int? downloadTimeoutMsOverride = null, + bool allowDowngrade = false) + { + Action log = msg => + { + LogToFile(msg); + if (consoleMode) Console.WriteLine(msg); + }; + + var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal); + var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs; + var totalSw = Stopwatch.StartNew(); + long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0; + long zipSizeBytes = 0; + string latestVersion = null; + + try + { + // Step 1: Fetch version.json (1s timeout) + var versionSw = Stopwatch.StartNew(); + string manifestSha256; + (latestVersion, manifestSha256) = await GetLatestVersionAsync(log); + versionSw.Stop(); + versionCheckMs = versionSw.ElapsedMilliseconds; + + if (latestVersion == null) + { + if (isStartupTrigger) + WriteSkipMarker("version_check_failed", currentVersion, null, "version.json fetch failed or timed out", log); + return; // Could not determine latest version — continue with current + } + + // Step 2: Version comparison. Three-way decision: + // cmp < 0 → latest < current = downgrade + // cmp == 0 → latest == current = same version (hash repair only, requires forceUpdate) + // cmp > 0 → latest > current = upgrade + var cmp = CompareVersions(currentVersion, latestVersion); + + if (cmp < 0) + { + // Downgrade — forward-only policy blocks this by default even when forceUpdate=true. + // Protects dev/pre-release builds from being silently replaced by the production + // version advertised by the prod backend (runtime_hash_mismatch force path). + if (!allowDowngrade) + { + log($"Self-update: downgrade BLOCKED — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}, allowDowngrade=false"); + WriteDowngradeBlockedMarker(currentVersion, latestVersion, triggerReason, log); + return; + } + log($"Self-update: downgrade ALLOWED (admin override) — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}"); + } + else if (cmp == 0) + { + // Same version: only proceed when forceUpdate (e.g. runtime hash mismatch = binary repair). + if (!forceUpdate) + { + log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})"); + if (isStartupTrigger) + WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log); + return; + } + log($"Self-update: same-version REPAIR (trigger={triggerReason}) — reinstalling {currentVersion}"); + } + else if (cmp > 0) + { + log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}"); + } + else + { + // cmp == int.MinValue sentinel from CompareVersions → unparsable version string on + // either side. Fall back to the pre-fix behaviour: proceed only when forceUpdate. + if (!forceUpdate) + { + log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping"); + if (isStartupTrigger) + WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log); + return; + } + log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, proceeding despite unparsable versions"); + } + + // Step 3: Download ZIP (timeout = downloadTimeoutMs) + var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip"); + var downloadTimerSw = Stopwatch.StartNew(); + var downloadOk = await DownloadZipAsync(zipPath, downloadTimeoutMs, log); + downloadTimerSw.Stop(); + downloadMs = downloadTimerSw.ElapsedMilliseconds; + if (!downloadOk) + { + if (isStartupTrigger) + WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log); + return; + } + try { zipSizeBytes = new FileInfo(zipPath).Length; } catch { /* best-effort */ } + + // Step 3b: Verify SHA-256 integrity (backend hash has priority over version.json hash) + string expectedSha256; + if (!string.IsNullOrEmpty(BackendExpectedSha256)) + { + expectedSha256 = BackendExpectedSha256; + log("Self-update: using backend hash for integrity verification (cached config — trusted channel)"); + } + else if (!string.IsNullOrEmpty(manifestSha256)) + { + expectedSha256 = manifestSha256; + log("Self-update: using version.json hash for integrity verification (blob storage)"); + } + else + { + expectedSha256 = null; + } + + var verifySw = Stopwatch.StartNew(); + var integrityOk = VerifyZipIntegrity(zipPath, expectedSha256, log); + verifySw.Stop(); + verifyMs = verifySw.ElapsedMilliseconds; + + if (!integrityOk) + { + CleanupStaging(null, zipPath); + if (isStartupTrigger) + WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log); + return; + } + + // Step 4: Extract to staging directory + var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir); + var extractSw = Stopwatch.StartNew(); + var extractOk = ExtractToStaging(zipPath, stagingDir, log); + extractSw.Stop(); + extractMs = extractSw.ElapsedMilliseconds; + if (!extractOk) + { + if (isStartupTrigger) + WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log); + return; + } + + // Step 5: Validate staging + var stagedExe = Path.Combine(stagingDir, "AutopilotMonitor.Agent.exe"); + if (!File.Exists(stagedExe)) + { + log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP"); + CleanupStaging(stagingDir, zipPath); + if (isStartupTrigger) + WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log); + return; + } + + // Step 6: Swap files (rename locked → .old, copy new) + var swapTimerSw = Stopwatch.StartNew(); + var swapOk = SwapFiles(agentDir, stagingDir, log); + swapTimerSw.Stop(); + swapMs = swapTimerSw.ElapsedMilliseconds; + if (!swapOk) + { + CleanupStaging(stagingDir, zipPath); + if (isStartupTrigger) + WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log); + return; + } + + // Step 7: Clean up staging + temp ZIP + CleanupStaging(stagingDir, zipPath); + + // Step 8: Write marker file so the new agent can emit an event on next startup + totalSw.Stop(); + WriteSelfUpdateMarker( + previousVersion: currentVersion, + newVersion: latestVersion, + triggerReason: triggerReason, + versionCheckMs: versionCheckMs, + downloadMs: downloadMs, + zipSizeBytes: zipSizeBytes, + verifyMs: verifyMs, + extractMs: extractMs, + swapMs: swapMs, + totalUpdateMs: totalSw.ElapsedMilliseconds, + log: log); + + // Step 9: Restart via PowerShell Wait-Process + log($"Self-update: files swapped successfully, restarting agent (v{latestVersion})..."); + RestartAgent(agentDir, log); + + // RestartAgent calls Environment.Exit — we should never reach here + } + catch (Exception ex) + { + log($"Self-update: unexpected error — {ex.Message}. Continuing with current version."); + if (isStartupTrigger) + { + try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { } + } + } + } + + /// + /// Fetches version.json from blob storage and returns the version string and optional SHA-256 hash. + /// Returns (null, null) if the check fails or times out. + /// + private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action log) + { + try + { + var versionUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentVersionFileName}"; + + using (var handler = new HttpClientHandler()) + using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) }) + { + var json = await client.GetStringAsync(versionUrl); + var obj = JObject.Parse(json); + var version = obj["version"]?.ToString(); + + if (string.IsNullOrWhiteSpace(version)) + { + log("Self-update: version.json has no 'version' field"); + return (null, null); + } + + var sha256 = obj["sha256"]?.ToString(); + if (!string.IsNullOrWhiteSpace(sha256)) + log($"Self-update: version.json has SHA-256 hash for integrity verification"); + + return (version.Trim(), sha256?.Trim()); + } + } + catch (TaskCanceledException) + { + log($"Self-update: version check timed out ({VersionCheckTimeoutMs}ms) — skipping update"); + return (null, null); + } + catch (HttpRequestException ex) when (ex.Message.Contains("404")) + { + log("Self-update: version.json not found (404) — skipping update"); + return (null, null); + } + catch (Exception ex) + { + log($"Self-update: version check failed — {ex.Message}"); + return (null, null); + } + } + + /// + /// Verifies the SHA-256 hash of the downloaded ZIP against the expected hash. + /// Returns true if the hash matches or if no expected hash is available (backward compat). + /// + private static bool VerifyZipIntegrity(string zipPath, string expectedSha256, Action log) + { + if (string.IsNullOrWhiteSpace(expectedSha256)) + { + log("Self-update: no SHA-256 hash available — skipping integrity check (backward compat)"); + return true; + } + + try + { + using (var sha256 = SHA256.Create()) + using (var stream = File.OpenRead(zipPath)) + { + var hashBytes = sha256.ComputeHash(stream); + var actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + + if (string.Equals(actualHash, expectedSha256.ToLowerInvariant(), StringComparison.Ordinal)) + { + log("Self-update: SHA-256 integrity check passed"); + return true; + } + + log($"Self-update: SHA-256 MISMATCH — expected={expectedSha256}, actual={actualHash}. Aborting update."); + return false; + } + } + catch (Exception ex) + { + log($"Self-update: SHA-256 verification failed — {ex.Message}. Aborting update."); + return false; + } + } + + /// + /// Compares two version strings. Returns true if latest is newer than current. + /// Strips SemVer suffixes (+metadata, -prerelease) before parsing because + /// System.Version cannot handle them (e.g. "1.0.386+b7f8d3c..." would fail). + /// + private static bool IsNewerVersion(string current, string latest) + { + return CompareVersions(current, latest) > 0; + } + + /// + /// Compares two version strings. Returns negative when latest is older than current, + /// zero when equal, positive when latest is newer than current. Returns + /// when either side is not a parseable + /// (callers must treat this as "cannot compare" rather than an ordered result). + /// Strips SemVer suffixes (+metadata, -prerelease) before parsing because + /// System.Version cannot handle them. + /// + internal static int CompareVersions(string current, string latest) + { + if (!Version.TryParse(StripVersionSuffix(current), out var currentVer)) + return int.MinValue; + if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer)) + return int.MinValue; + + return latestVer.CompareTo(currentVer); + } + + private static string StripVersionSuffix(string version) + { + if (string.IsNullOrEmpty(version)) + return version; + + var plusIndex = version.IndexOf('+'); + if (plusIndex >= 0) + version = version.Substring(0, plusIndex); + + var dashIndex = version.IndexOf('-'); + if (dashIndex >= 0) + version = version.Substring(0, dashIndex); + + return version; + } + + /// + /// Downloads the agent ZIP to a temp path. Returns false on failure. + /// + private static async Task DownloadZipAsync(string zipPath, int timeoutMs, Action log) + { + try + { + var zipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileName}"; + + // Clean up any previous download + if (File.Exists(zipPath)) + File.Delete(zipPath); + + using (var handler = new HttpClientHandler()) + using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) }) + { + using (var response = await client.GetAsync(zipUrl)) + { + response.EnsureSuccessStatusCode(); + using (var fileStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await response.Content.CopyToAsync(fileStream); + } + } + } + + log($"Self-update: ZIP downloaded ({new FileInfo(zipPath).Length / 1024}KB)"); + return true; + } + catch (TaskCanceledException) + { + log($"Self-update: ZIP download timed out ({timeoutMs}ms) — aborting update"); + try { File.Delete(zipPath); } catch { } + return false; + } + catch (Exception ex) + { + log($"Self-update: ZIP download failed — {ex.Message}"); + try { File.Delete(zipPath); } catch { } + return false; + } + } + + /// + /// Extracts the ZIP to the staging directory. Returns false on failure. + /// + private static bool ExtractToStaging(string zipPath, string stagingDir, Action log) + { + try + { + // Clean up any previous staging + if (Directory.Exists(stagingDir)) + Directory.Delete(stagingDir, true); + + ZipFile.ExtractToDirectory(zipPath, stagingDir); + log($"Self-update: ZIP extracted to staging ({Directory.GetFiles(stagingDir).Length} files)"); + return true; + } + catch (Exception ex) + { + log($"Self-update: ZIP extraction failed — {ex.Message}"); + return false; + } + } + + /// + /// Swaps files from staging into the agent directory. + /// For locked files: rename to .old (Windows allows renaming locked files), then copy new. + /// Returns false if the swap fails critically. + /// + private static bool SwapFiles(string agentDir, string stagingDir, Action log) + { + try + { + var stagedFiles = Directory.GetFiles(stagingDir); + int swapped = 0; + + foreach (var stagedFile in stagedFiles) + { + var fileName = Path.GetFileName(stagedFile); + var targetPath = Path.Combine(agentDir, fileName); + + try + { + if (File.Exists(targetPath)) + { + // Try direct overwrite first + try + { + File.Copy(stagedFile, targetPath, overwrite: true); + swapped++; + continue; + } + catch (IOException) + { + // File is locked — use rename trick + } + + // Rename locked file to .old (Windows allows this even for locked files) + var oldPath = targetPath + OldFileSuffix; + if (File.Exists(oldPath)) + { + try { File.Delete(oldPath); } catch { } + } + + File.Move(targetPath, oldPath); + } + + File.Copy(stagedFile, targetPath); + swapped++; + } + catch (Exception ex) + { + log($"Self-update: failed to swap {fileName} — {ex.Message}"); + // Continue with other files; partial update is better than no update + // for non-critical files (configs, etc.) + } + } + + log($"Self-update: swapped {swapped}/{stagedFiles.Length} files"); + + // Critical check: the main exe must have been swapped + var mainExe = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe"); + if (!File.Exists(mainExe)) + { + log("Self-update: CRITICAL — main exe missing after swap, aborting"); + return false; + } + + return true; + } + catch (Exception ex) + { + log($"Self-update: file swap failed — {ex.Message}"); + return false; + } + } + + /// + /// Restarts the agent using PowerShell Wait-Process (same pattern as SummaryDialog.SelfCleanup). + /// Wait-Process uses OS handles — zero polling, exits within milliseconds of process termination. + /// + private static void RestartAgent(string agentDir, Action log) + { + var pid = Process.GetCurrentProcess().Id; + var agentExePath = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe"); + + // PowerShell Wait-Process: waits for our process to actually exit (no polling), + // then immediately starts the new agent. 30s timeout as safety net. + var psScript = $"Wait-Process -Id {pid} -Timeout 30 -ErrorAction SilentlyContinue; " + + $"& '{agentExePath}'"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(psScript)); + + var psi = new ProcessStartInfo + { + FileName = SystemPaths.PowerShell, + Arguments = $"-NoProfile -NonInteractive -EncodedCommand {encoded}", + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + UseShellExecute = false + }; + Process.Start(psi); + + log("Self-update: restart script launched, exiting current process"); + Environment.Exit(0); + } + + /// + /// Writes a small JSON marker so the next agent startup can emit an agent_version_check event + /// with outcome=updated. Best-effort — failure here must not block the update. Includes phase + /// timings, trigger reason, and exit timestamp so the downstream event can carry aggregable + /// telemetry (downtime, total update duration, etc.). + /// + private static void WriteSelfUpdateMarker( + string previousVersion, + string newVersion, + string triggerReason, + long versionCheckMs, + long downloadMs, + long zipSizeBytes, + long verifyMs, + long extractMs, + long swapMs, + long totalUpdateMs, + Action log) + { + try + { + var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateMarkerFile); + var dir = Path.GetDirectoryName(markerPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var now = DateTime.UtcNow; + var payload = new JObject + { + ["outcome"] = "updated", + ["previousVersion"] = previousVersion ?? "unknown", + ["newVersion"] = newVersion ?? "unknown", + ["triggerReason"] = triggerReason ?? "startup", + ["updatedAtUtc"] = now.ToString("O"), + // exitAtUtc is written here — actual Environment.Exit happens a few hundred ms later + // via PowerShell Wait-Process, which is close enough for downtime telemetry. + ["exitAtUtc"] = now.ToString("O"), + ["versionCheckMs"] = versionCheckMs, + ["downloadMs"] = downloadMs, + ["zipSizeBytes"] = zipSizeBytes, + ["verifyMs"] = verifyMs, + ["extractMs"] = extractMs, + ["swapMs"] = swapMs, + ["totalUpdateMs"] = totalUpdateMs + }; + + File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None)); + log("Self-update: marker file written for next-startup event"); + } + catch (Exception ex) + { + log($"Self-update: could not write marker file — {ex.Message} (non-critical)"); + } + } + + /// + /// Writes a small JSON marker so the next agent startup can emit an agent_version_check event + /// with outcome=skipped (or check_failed if the version.json fetch itself failed). Only called + /// on the startup trigger path (runtime-triggered failures are logged normally because the full + /// logger is already up). Best-effort — never throws. + /// + private static void WriteSkipMarker(string reason, string currentVersion, string latestVersion, string errorDetail, Action log) + { + try + { + var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile); + var dir = Path.GetDirectoryName(markerPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var detail = errorDetail ?? string.Empty; + if (detail.Length > 200) detail = detail.Substring(0, 200); + + // version_check_failed = we never got a latestVersion; everything else = check succeeded + // but the download/verify/extract/swap step failed. + var outcome = string.Equals(reason, "version_check_failed", StringComparison.Ordinal) + ? "check_failed" + : "skipped"; + + var payload = new JObject + { + ["outcome"] = outcome, + ["reason"] = reason ?? "unknown", + ["currentVersion"] = currentVersion ?? "unknown", + ["latestVersion"] = latestVersion, // may be null if version check failed + ["skippedAtUtc"] = DateTime.UtcNow.ToString("O"), + ["errorDetail"] = detail + }; + + File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None)); + log($"Self-update: skip marker written (outcome={outcome}, reason={reason})"); + } + catch (Exception ex) + { + log($"Self-update: could not write skip marker — {ex.Message} (non-critical)"); + } + } + + /// + /// Writes a downgrade-blocked marker so the next agent startup can emit an + /// agent_version_check event with outcome=downgrade_blocked. Uses the + /// existing skipped-marker channel so + /// picks it up alongside other skip reasons. Best-effort — never throws. + /// + private static void WriteDowngradeBlockedMarker(string currentVersion, string latestVersion, string triggerReason, Action log) + { + try + { + var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile); + var dir = Path.GetDirectoryName(markerPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var payload = new JObject + { + ["outcome"] = "downgrade_blocked", + ["reason"] = "downgrade_blocked", + ["currentVersion"] = currentVersion ?? "unknown", + ["latestVersion"] = latestVersion ?? "unknown", + ["triggerReason"] = triggerReason ?? "unknown", + ["skippedAtUtc"] = DateTime.UtcNow.ToString("O"), + ["errorDetail"] = string.Empty + }; + + File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None)); + log($"Self-update: downgrade-blocked marker written (current={currentVersion}, latest={latestVersion}, trigger={triggerReason})"); + } + catch (Exception ex) + { + log($"Self-update: could not write downgrade-blocked marker — {ex.Message} (non-critical)"); + } + } + + /// + /// Writes a small JSON marker on the happy path (current version is already up to date) so + /// the next agent startup can emit an agent_version_check event with outcome=up_to_date. + /// Subject to session-scoped dedup by the MonitoringService emitter. Best-effort — never throws. + /// + private static void WriteCheckedMarker(string currentVersion, string latestVersion, long versionCheckMs, Action log) + { + try + { + var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateCheckedMarkerFile); + var dir = Path.GetDirectoryName(markerPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var payload = new JObject + { + ["outcome"] = "up_to_date", + ["currentVersion"] = currentVersion ?? "unknown", + ["latestVersion"] = latestVersion ?? "unknown", + ["versionCheckMs"] = versionCheckMs, + ["checkedAtUtc"] = DateTime.UtcNow.ToString("O") + }; + + File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None)); + log("Self-update: checked marker written (up_to_date) for next-startup event"); + } + catch (Exception ex) + { + log($"Self-update: could not write checked marker — {ex.Message} (non-critical)"); + } + } + + /// + /// Cleans up staging directory and temp ZIP file. + /// + private static void CleanupStaging(string stagingDir, string zipPath) + { + try { if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, true); } catch { } + try { if (File.Exists(zipPath)) File.Delete(zipPath); } catch { } + } + + /// + /// Appends a log line to the main agent log file (logger isn't initialized yet at update time). + /// Uses the same date-based naming and format as AgentLogger: agent_YYYYMMDD.log + /// + private static void LogToFile(string message) + { + try + { + var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); + Directory.CreateDirectory(logDir); + var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log"; + var logPath = Path.Combine(logDir, logFileName); + File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}] [INFO] {message}{Environment.NewLine}"); + } + catch + { + // Best-effort logging — never block on log failure + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs new file mode 100644 index 00000000..64a35287 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/ServerActionDispatcher.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Executes server-issued actions delivered via ingest responses. + /// + /// Design notes: + /// - Idempotent per type: each handler tolerates repeated delivery (at-least-once channel). + /// - Unknown action types are logged as server_action_unknown and skipped — rolling out a new + /// type on the server must not break older agents. + /// - Every action emits two telemetry events: server_action_received on entry and either + /// server_action_executed or server_action_failed on exit, so operators can audit the + /// end-to-end lifecycle from backend App Insights → agent session events. + /// - All three concrete handlers are passed in as callbacks so the dispatcher itself does not + /// depend on the internal wiring of , , + /// or the orchestrator's shutdown sequence. + /// + public class ServerActionDispatcher + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly Func> _rotateConfigAsync; + private readonly Func> _uploadDiagnosticsAsync; + private readonly Func _onTerminateRequested; + private readonly Action _emitEvent; + + /// + /// Refetch remote config from the backend and apply to the agent's live state. + /// Return true on success, false on failure (the handler will emit the appropriate telemetry). + /// Typically wired to RemoteConfigService.FetchConfigAsync. + /// + /// + /// Trigger a diagnostics package upload with the given file-name suffix. Return the result + /// (success/failure, blob name, error code). Typically wired to + /// DiagnosticsPackageService.CreateAndUploadAsync. + /// + /// + /// Called for terminate_session actions. The dispatcher does NOT execute the shutdown + /// itself — shutdown requires coordinating timers, spool flush, and cleanup that lives in the + /// orchestrator. The callback is responsible for the actual termination sequence. + /// + public ServerActionDispatcher( + AgentConfiguration configuration, + AgentLogger logger, + Func> rotateConfigAsync, + Func> uploadDiagnosticsAsync, + Func onTerminateRequested, + Action emitEvent) + { + _configuration = configuration; + _logger = logger; + _rotateConfigAsync = rotateConfigAsync; + _uploadDiagnosticsAsync = uploadDiagnosticsAsync; + _onTerminateRequested = onTerminateRequested; + _emitEvent = emitEvent; + } + + /// + /// Routes a batch of actions to their handlers. Processes sequentially — ordering is defined + /// by the order actions were queued on the server. A failing handler does not abort the batch; + /// subsequent actions still run so a bad rotate_config doesn't block a terminate_session. + /// + public virtual async Task DispatchAsync(List actions) + { + if (actions == null || actions.Count == 0) + return; + + foreach (var action in actions) + { + if (action == null) continue; + + EmitReceived(action); + + try + { + switch (action.Type?.ToLowerInvariant()) + { + case ServerActionTypes.RotateConfig: + await HandleRotateConfigAsync(action); + break; + + case ServerActionTypes.RequestDiagnostics: + await HandleRequestDiagnosticsAsync(action); + break; + + case ServerActionTypes.TerminateSession: + if (_onTerminateRequested != null) + await _onTerminateRequested(action); + else + EmitFailed(action, "no_terminate_handler_wired"); + break; + + default: + _logger.Warning($"ServerActionDispatcher: unknown action type '{action.Type}' — skipping"); + EmitFailed(action, "unknown_action_type"); + break; + } + } + catch (Exception ex) + { + _logger.Error($"ServerActionDispatcher: handler for '{action.Type}' threw", ex); + EmitFailed(action, ex.GetType().Name + ": " + ex.Message); + } + } + } + + private async Task HandleRotateConfigAsync(ServerAction action) + { + if (_rotateConfigAsync == null) + { + EmitFailed(action, "no_rotate_config_handler"); + return; + } + + _logger.Info($"ServerAction rotate_config: refetching remote config (reason: {action.Reason})"); + var ok = await _rotateConfigAsync(); + if (ok) + EmitExecuted(action); + else + EmitFailed(action, "config_fetch_failed"); + } + + private async Task HandleRequestDiagnosticsAsync(ServerAction action) + { + if (_uploadDiagnosticsAsync == null) + { + EmitFailed(action, "no_diagnostics_handler"); + return; + } + + _logger.Info($"ServerAction request_diagnostics: initiating best-effort upload (reason: {action.Reason})"); + var result = await _uploadDiagnosticsAsync("server-requested"); + if (result != null && result.Success) + { + EmitExecuted(action, extraData: new Dictionary + { + { "blobName", result.BlobName ?? string.Empty } + }); + } + else + { + EmitFailed(action, result?.ErrorCode ?? "diagnostics_upload_failed"); + } + } + + // ---- Telemetry helpers ---- + + private void EmitReceived(ServerAction action) + { + _emitEvent?.Invoke(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "server_action_received", + Severity = EventSeverity.Info, + Source = "ServerActionDispatcher", + Phase = EnrollmentPhase.Unknown, + Message = $"Received server action '{action.Type}'", + Timestamp = DateTime.UtcNow, + Data = BuildTelemetryData(action) + }); + } + + private void EmitExecuted(ServerAction action, Dictionary extraData = null) + { + var data = BuildTelemetryData(action); + if (extraData != null) + { + foreach (var kvp in extraData) + data[kvp.Key] = kvp.Value; + } + + _emitEvent?.Invoke(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "server_action_executed", + Severity = EventSeverity.Info, + Source = "ServerActionDispatcher", + Phase = EnrollmentPhase.Unknown, + Message = $"Executed server action '{action.Type}'", + Timestamp = DateTime.UtcNow, + Data = data + }); + } + + private void EmitFailed(ServerAction action, string reason) + { + var data = BuildTelemetryData(action); + data["failureReason"] = reason ?? string.Empty; + + _emitEvent?.Invoke(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "server_action_failed", + Severity = EventSeverity.Warning, + Source = "ServerActionDispatcher", + Phase = EnrollmentPhase.Unknown, + Message = $"Failed to execute server action '{action.Type}': {reason}", + Timestamp = DateTime.UtcNow, + Data = data + }); + } + + private static Dictionary BuildTelemetryData(ServerAction action) + { + return new Dictionary + { + { "actionType", action?.Type ?? string.Empty }, + { "reason", action?.Reason ?? string.Empty }, + { "ruleId", action?.RuleId ?? string.Empty }, + { "queuedAt", action?.QueuedAt.ToString("O") ?? string.Empty } + }; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs new file mode 100644 index 00000000..8b440057 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserProfileResolver.cs @@ -0,0 +1,191 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Management; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Resolves the custom %LOGGED_ON_USER_PROFILE% token to the logged-on user's profile path. + /// + /// The agent runs as SYSTEM, so standard environment variables like %USERPROFILE% or + /// %LOCALAPPDATA% resolve to the SYSTEM profile — not the logged-on user. This class + /// detects the real user via explorer.exe ownership (WMI) and caches the result for + /// the agent's lifetime. + /// + /// Usage in paths: %LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log + /// + public static class UserProfileResolver + { + public const string Token = "%LOGGED_ON_USER_PROFILE%"; + + private static readonly object Lock = new object(); + private static bool _resolved; + private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null if no user detected + + /// + /// Returns the logged-on user's profile path (e.g. C:\Users\JohnDoe) or null + /// if no interactive user session has been detected yet. + /// Result is cached after first successful detection. + /// + public static string GetLoggedOnUserProfilePath() + { + if (_resolved) + return _userProfilePath; + + lock (Lock) + { + if (_resolved) + return _userProfilePath; + + _userProfilePath = DetectLoggedOnUserProfile(); + _resolved = true; + return _userProfilePath; + } + } + + /// + /// Expands the custom %LOGGED_ON_USER_PROFILE% token, then delegates to + /// Environment.ExpandEnvironmentVariables for standard tokens. + /// Returns null if the path contains the token but no user is logged on. + /// + public static string ExpandCustomTokens(string rawPath) + { + if (string.IsNullOrEmpty(rawPath)) + return rawPath; + + if (rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0) + { + var profilePath = GetLoggedOnUserProfilePath(); + if (profilePath == null) + return null; // No user detected — caller should skip this path + + rawPath = ReplaceCaseInsensitive(rawPath, Token, profilePath); + } + + return Environment.ExpandEnvironmentVariables(rawPath); + } + + /// + /// Returns true if the raw (unexpanded) path contains the custom user profile token. + /// + public static bool ContainsUserProfileToken(string rawPath) + { + return !string.IsNullOrEmpty(rawPath) && + rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// Resets the cached state. Only used for testing. + /// + internal static void Reset() + { + lock (Lock) + { + _resolved = false; + _userProfilePath = null; + } + } + + /// + /// Allows tests to inject a specific profile path without WMI. + /// + internal static void SetForTesting(string profilePath) + { + lock (Lock) + { + _userProfilePath = profilePath; + _resolved = true; + } + } + + private static string DetectLoggedOnUserProfile() + { + try + { + var explorerProcesses = Process.GetProcessesByName("explorer"); + foreach (var proc in explorerProcesses) + { + try + { + // Session 0 = SYSTEM session, skip + if (proc.SessionId == 0) + continue; + + var userName = GetProcessOwnerUserName(proc.Id); + if (userName == null) + continue; + + if (DesktopArrivalDetector.IsExcludedUser(userName)) + continue; + + // Extract just the username part (after backslash if DOMAIN\User) + var backslashIndex = userName.LastIndexOf('\\'); + if (backslashIndex >= 0 && backslashIndex < userName.Length - 1) + userName = userName.Substring(backslashIndex + 1); + + var profilePath = Path.Combine(@"C:\Users", userName); + if (Directory.Exists(profilePath)) + return profilePath; + } + catch + { + // Continue checking other explorer instances + } + finally + { + proc.Dispose(); + } + } + } + catch + { + // WMI or process enumeration failure — no user detected + } + + return null; + } + + /// + /// Gets the owner of a process using WMI (Win32_Process.GetOwner). + /// Returns "DOMAIN\User" or "User" string, or null on failure. + /// + private static string GetProcessOwnerUserName(int processId) + { + try + { + using (var searcher = new ManagementObjectSearcher( + $"SELECT * FROM Win32_Process WHERE ProcessId = {processId}")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var outParams = new object[2]; + var result = (uint)obj.InvokeMethod("GetOwner", outParams); + if (result == 0) + { + var user = outParams[0]?.ToString(); + var domain = outParams[1]?.ToString(); + return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}"; + } + } + } + } + catch + { + // WMI failure — return null + } + + return null; + } + + private static string ReplaceCaseInsensitive(string source, string oldValue, string newValue) + { + var index = source.IndexOf(oldValue, StringComparison.OrdinalIgnoreCase); + if (index < 0) return source; + return source.Substring(0, index) + newValue + source.Substring(index + oldValue.Length); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserSessionProcessLauncher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserSessionProcessLauncher.cs new file mode 100644 index 00000000..d707429b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/UserSessionProcessLauncher.cs @@ -0,0 +1,370 @@ +using System; +using System.Diagnostics; +using System.Management; +using System.Runtime.InteropServices; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Launches a process in the logged-in user's desktop session from a SYSTEM context. + /// Uses WTSQueryUserToken + DuplicateTokenEx + SetTokenInformation(TokenSessionId) + /// to create a session-stamped token, then CreateProcessAsUser to launch in the + /// correct interactive session (not Session 0). + /// + internal static class UserSessionProcessLauncher + { + /// + /// System/service account names that should NOT be considered real users. + /// + private static readonly string[] ExcludedUserNames = + { + "SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE", + "DefaultUser0", "DefaultUser1", "DefaultUser2", + "defaultuser0", "defaultuser1", "defaultuser2" + }; + + /// + /// Launches the specified executable in the logged-in user's desktop session. + /// Returns true if the process was launched successfully, false otherwise. + /// This is fire-and-forget — the caller does not wait for the process to exit. + /// If the process fails with 0x8007045A (DLL init failed), retries for up to maxRetrySeconds. + /// + public static bool LaunchInUserSession(string exePath, string arguments, AgentLogger logger, int maxRetrySeconds = 120) + { + IntPtr userToken = IntPtr.Zero; + IntPtr dupToken = IntPtr.Zero; + IntPtr environment = IntPtr.Zero; + + try + { + // Find explorer.exe in a non-zero session owned by a real user. + var explorerProcess = FindExplorerProcess(logger); + if (explorerProcess == null) + { + logger.Warning("UserSessionProcessLauncher: No explorer.exe found in user session — cannot launch dialog"); + return false; + } + + uint sessionId; + using (explorerProcess) + { + sessionId = (uint)explorerProcess.SessionId; + logger.Info($"UserSessionProcessLauncher: Found explorer.exe PID {explorerProcess.Id} in session {sessionId}"); + } + + // Get the interactive session token via WTS. + if (!ProcessNativeMethods.WTSQueryUserToken(sessionId, out userToken)) + { + var error = Marshal.GetLastWin32Error(); + logger.Warning($"UserSessionProcessLauncher: WTSQueryUserToken failed for session {sessionId} (error {error})"); + return false; + } + + // Duplicate token and stamp the target session ID. + // WTSQueryUserToken gives us the user's identity, but CreateProcessAsUser + // needs TokenSessionId explicitly set to avoid Session 0 placement + // (the calling service runs in Session 0; without stamping, the child + // process inherits Session 0 even though the token belongs to the user). + if (!ProcessNativeMethods.DuplicateTokenEx(userToken, ProcessNativeMethods.MAXIMUM_ALLOWED, IntPtr.Zero, + ProcessNativeMethods.SECURITY_IMPERSONATION, ProcessNativeMethods.TOKEN_PRIMARY, out dupToken)) + { + logger.Warning($"UserSessionProcessLauncher: DuplicateTokenEx failed (error {Marshal.GetLastWin32Error()})"); + return false; + } + + if (!ProcessNativeMethods.SetTokenInformation(dupToken, ProcessNativeMethods.TokenSessionId, + ref sessionId, (uint)Marshal.SizeOf(typeof(uint)))) + { + logger.Warning($"UserSessionProcessLauncher: SetTokenInformation(SessionId={sessionId}) failed (error {Marshal.GetLastWin32Error()})"); + return false; + } + + logger.Info($"UserSessionProcessLauncher: Token duplicated and stamped to session {sessionId}"); + + // Create an environment block for the user (using session-stamped token) + if (!ProcessNativeMethods.CreateEnvironmentBlock(out environment, dupToken, false)) + { + logger.Warning($"UserSessionProcessLauncher: CreateEnvironmentBlock failed (error {Marshal.GetLastWin32Error()})"); + environment = IntPtr.Zero; + } + + // Set up startup info — empty lpDesktop lets Windows select the correct + // desktop for the session-stamped token. + var si = new ProcessNativeMethods.STARTUPINFO(); + si.cb = Marshal.SizeOf(si); + si.lpDesktop = ""; + si.dwFlags = ProcessNativeMethods.STARTF_USESHOWWINDOW | ProcessNativeMethods.STARTF_FORCEONFEEDBACK; + si.wShowWindow = ProcessNativeMethods.SW_SHOW; + + uint creationFlags = 0; + if (environment != IntPtr.Zero) + creationFlags |= ProcessNativeMethods.CREATE_UNICODE_ENVIRONMENT; + + var commandLine = $"\"{exePath}\" {arguments}"; + var workingDirectory = System.IO.Path.GetDirectoryName(exePath); + + // Retry loop with integrated CXH monitoring. + // See WaitForCxhOrSleep for the CXH wake-up strategy. + const int retryIntervalSeconds = 10; + const string defaultDesktop = ""; + const string explicitDesktop = "WinSta0\\Default"; + const int defaultDesktopOnlyAttempts = 3; + var retryStopwatch = System.Diagnostics.Stopwatch.StartNew(); + int attempt = 0; + + // One-time CXH lookup — track for the lifetime of the retry loop + Process cxhProcess = null; + if (maxRetrySeconds > 0) + { + try + { + cxhProcess = FindProcessInSession("CloudExperienceHost", sessionId); + if (cxhProcess != null) + logger.Info($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " + + $"in session {sessionId} — will monitor for exit during retries"); + } + catch { } + } + + try + { + while (true) + { + attempt++; + + // First N attempts use default desktop (""), then alternate with explicit desktop. + // Default works ~90% of the time; explicit fallback catches post-Hello desktop issues. + bool useExplicitDesktop = (attempt > defaultDesktopOnlyAttempts) && (attempt % 2 == 0); + si.lpDesktop = useExplicitDesktop ? explicitDesktop : defaultDesktop; + + // CreateProcessAsUser with session-stamped token. + // The duplicated token has TokenSessionId set to the user's session, + // ensuring the process lands on the interactive desktop (not Session 0). + bool created; + ProcessNativeMethods.PROCESS_INFORMATION pi; + + var procSa = new ProcessNativeMethods.SECURITY_ATTRIBUTES(); + procSa.nLength = Marshal.SizeOf(procSa); + var threadSa = new ProcessNativeMethods.SECURITY_ATTRIBUTES(); + threadSa.nLength = Marshal.SizeOf(threadSa); + + created = ProcessNativeMethods.CreateProcessAsUser( + dupToken, null, commandLine, + ref procSa, ref threadSa, false, + creationFlags, environment, workingDirectory, + ref si, out pi); + + if (!created) + { + logger.Warning($"UserSessionProcessLauncher: CreateProcessAsUser failed (error {Marshal.GetLastWin32Error()})"); + return false; + } + + ProcessNativeMethods.CloseHandle(pi.hThread); + logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}" + + $" [{(useExplicitDesktop ? "explicit" : "default")} desktop], verifying startup..."); + + // Wait 3s to verify the process survives DLL init + CLR bootstrap. + var waitResult = ProcessNativeMethods.WaitForSingleObject(pi.hProcess, 3000); + if (waitResult == ProcessNativeMethods.WAIT_TIMEOUT) + { + // Process is still running after 3s — success + ProcessNativeMethods.CloseHandle(pi.hProcess); + logger.Info($"UserSessionProcessLauncher: Successfully launched PID {pi.dwProcessId} in user session" + + $" [{(useExplicitDesktop ? "explicit" : "default")} desktop]" + + (attempt > 1 ? $" (after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : "")); + return true; + } + + // Process exited within 3s — check why + ProcessNativeMethods.GetExitCodeProcess(pi.hProcess, out var exitCode); + ProcessNativeMethods.CloseHandle(pi.hProcess); + + // 0x8007045A = ERROR_DLL_INIT_FAILED — profile or desktop issue. + // Retry until the desktop becomes accessible or we exceed the retry window. + if (exitCode == 0x8007045A && retryStopwatch.Elapsed.TotalSeconds < maxRetrySeconds) + { + // If CXH is tracked and still running, wait for its exit instead of blind sleep. + // On 24H2+/≤22H2: CXH exits → we wake up instantly. + // On 23H2: CXH never exits → WaitForExit times out → same as regular retry interval. + if (cxhProcess != null) + { + logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)..."); + if (cxhProcess.WaitForExit(retryIntervalSeconds * 1000)) + { + logger.Info("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available"); + cxhProcess.Dispose(); + cxhProcess = null; + System.Threading.Thread.Sleep(500); // brief delay for desktop init + } + } + else + { + logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s..."); + System.Threading.Thread.Sleep(retryIntervalSeconds * 1000); + } + continue; + } + + // Non-retryable error, or retry timeout exceeded + logger.Warning($"UserSessionProcessLauncher: Process PID {pi.dwProcessId} exited within 3s — " + + $"exit code 0x{exitCode:X8}" + + $" [{(useExplicitDesktop ? "explicit" : "default")} desktop]" + + (attempt > 1 ? $" (gave up after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : "")); + return false; + } + } + finally + { + cxhProcess?.Dispose(); + } + } + catch (Exception ex) + { + logger.Warning($"UserSessionProcessLauncher: Exception: {ex.Message}"); + return false; + } + finally + { + if (environment != IntPtr.Zero) + ProcessNativeMethods.DestroyEnvironmentBlock(environment); + if (dupToken != IntPtr.Zero) + ProcessNativeMethods.CloseHandle(dupToken); + if (userToken != IntPtr.Zero) + ProcessNativeMethods.CloseHandle(userToken); + } + } + + /// + /// Finds a process by name running in a specific session. + /// Returns the first matching Process handle, or null if none found. + /// + private static Process FindProcessInSession(string processName, uint sessionId) + { + try + { + var processes = Process.GetProcessesByName(processName); + foreach (var proc in processes) + { + try + { + if ((uint)proc.SessionId == sessionId) + { + foreach (var other in processes) + { + if (other.Id != proc.Id) + try { other.Dispose(); } catch { } + } + return proc; + } + proc.Dispose(); + } + catch + { + proc.Dispose(); + } + } + } + catch { } + return null; + } + + /// + /// Finds an explorer.exe process running in a non-SYSTEM user session. + /// Returns the Process handle, or null if none found. + /// + private static Process FindExplorerProcess(AgentLogger logger) + { + try + { + var explorerProcesses = Process.GetProcessesByName("explorer"); + foreach (var proc in explorerProcesses) + { + try + { + if (proc.SessionId == 0) + { + proc.Dispose(); + continue; + } + + var owner = GetProcessOwner(proc.Id); + if (owner == null || IsExcludedUser(owner)) + { + proc.Dispose(); + continue; + } + + foreach (var other in explorerProcesses) + { + if (other.Id != proc.Id) + { + try { other.Dispose(); } catch { } + } + } + return proc; + } + catch + { + proc.Dispose(); + } + } + } + catch (Exception ex) + { + logger.Debug($"UserSessionProcessLauncher: Error finding explorer.exe: {ex.Message}"); + } + + return null; + } + + private static string GetProcessOwner(int processId) + { + try + { + using (var searcher = new ManagementObjectSearcher( + $"SELECT * FROM Win32_Process WHERE ProcessId = {processId}")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var outParams = new object[2]; + var result = (uint)obj.InvokeMethod("GetOwner", outParams); + if (result == 0) + { + var user = outParams[0]?.ToString(); + var domain = outParams[1]?.ToString(); + return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}"; + } + } + } + } + catch { } + return null; + } + + private static bool IsExcludedUser(string fullUserName) + { + if (string.IsNullOrEmpty(fullUserName)) + return true; + + var userName = fullUserName; + var backslashIndex = fullUserName.LastIndexOf('\\'); + if (backslashIndex >= 0 && backslashIndex < fullUserName.Length - 1) + userName = fullUserName.Substring(backslashIndex + 1); + + foreach (var excluded in ExcludedUserNames) + { + if (string.Equals(userName, excluded, StringComparison.OrdinalIgnoreCase)) + return true; + } + + if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs new file mode 100644 index 00000000..12760e0c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Consumes the three self-update marker files written by SelfUpdater on every agent startup + /// (updated / skipped / checked) and converts them into a single agent_version_check + /// event with an outcome discriminator. Applies session-scoped dedup for the + /// up_to_date outcome so repeated reboots in the same session don't spam the timeline. + /// + /// All filesystem paths are injectable so this can be exercised in unit tests without touching + /// %ProgramData%. + /// + public static class VersionCheckEventBuilder + { + public sealed class Paths + { + public string UpdatedMarker { get; set; } + public string SkippedMarker { get; set; } + public string CheckedMarker { get; set; } + public string LastEmit { get; set; } + + public static Paths Default() => new Paths + { + UpdatedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateMarkerFile), + SkippedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile), + CheckedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateCheckedMarkerFile), + LastEmit = Environment.ExpandEnvironmentVariables(Constants.LastVersionCheckEmitFile) + }; + } + + public sealed class BuildResult + { + /// Event to emit; null when nothing to report (no marker) or when deduped. + public EnrollmentEvent Event { get; set; } + + /// True when a marker was read but the event was suppressed by dedup. + public bool Deduped { get; set; } + + /// Outcome that was detected (even if deduped), for logging. Null when no marker. + public string Outcome { get; set; } + + /// Human-readable message for warning logs when a marker file was malformed. + public string ParseError { get; set; } + } + + /// + /// Reads available markers, applies dedup, updates the last-emit file and deletes consumed markers. + /// Returns the event to emit (or null). Never throws — errors are returned via ParseError. + /// + /// Priority: Updated > Skipped > Checked. If more than one marker exists (rare — stale files + /// from a crashed run), the highest-priority one wins and the others are deleted defensively. + /// + public static BuildResult TryBuild( + string sessionId, + string tenantId, + DateTime agentStartTimeUtc, + Paths paths = null) + { + paths = paths ?? Paths.Default(); + var result = new BuildResult(); + + try + { + // Priority order — once consumed, all three are deleted. + if (File.Exists(paths.UpdatedMarker)) + { + result.Outcome = "updated"; + result.Event = BuildUpdatedEvent(paths.UpdatedMarker, sessionId, tenantId, agentStartTimeUtc); + } + else if (File.Exists(paths.SkippedMarker)) + { + var skipEvent = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome); + result.Outcome = skipOutcome; + + // Session-scoped dedup for downgrade_blocked: the runtime hash-mismatch path + // re-fires on every agent restart, and each restart would otherwise emit another + // Warning event for the same current→latest pair. Suppress duplicates within the + // same session when the same downgrade is advertised by the backend. + if (string.Equals(skipOutcome, "downgrade_blocked", StringComparison.Ordinal)) + { + var latestVersion = ExtractLatestVersion(skipEvent); + var lastEmit = TryReadLastEmit(paths.LastEmit); + if (lastEmit != null && + string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) && + string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) && + string.Equals(lastEmit.Outcome, "downgrade_blocked", StringComparison.Ordinal)) + { + result.Deduped = true; + } + else + { + result.Event = skipEvent; + } + } + else + { + result.Event = skipEvent; + } + } + else if (File.Exists(paths.CheckedMarker)) + { + result.Outcome = "up_to_date"; + var marker = ReadMarker(paths.CheckedMarker); + var latestVersion = (string)marker["latestVersion"] ?? "unknown"; + + // Session-scoped dedup: suppress repeated up_to_date events for the same + // latestVersion within the same session. Update/Skip/Failure are never deduped. + var lastEmit = TryReadLastEmit(paths.LastEmit); + if (lastEmit != null && + string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) && + string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) && + string.Equals(lastEmit.Outcome, "up_to_date", StringComparison.Ordinal)) + { + result.Deduped = true; + } + else + { + result.Event = BuildUpToDateEvent(marker, sessionId, tenantId); + } + } + else + { + return result; // no marker — nothing to do + } + } + catch (Exception ex) + { + result.ParseError = ex.Message; + } + + // Persist the last-emit state before cleanup so a crash right after emit doesn't re-emit. + if (result.Event != null) + { + var latestVersion = ExtractLatestVersion(result.Event); + TryWriteLastEmit(paths.LastEmit, sessionId, latestVersion, result.Outcome); + } + + // Always delete markers once consumed (or attempted) — stale markers would cause loops. + TryDelete(paths.UpdatedMarker); + TryDelete(paths.SkippedMarker); + TryDelete(paths.CheckedMarker); + + return result; + } + + // ── Individual builders ───────────────────────────────────────────── + + private static EnrollmentEvent BuildUpdatedEvent(string path, string sessionId, string tenantId, DateTime agentStartTimeUtc) + { + var marker = ReadMarker(path); + + var previousVersion = (string)marker["previousVersion"] ?? "unknown"; + var newVersion = (string)marker["newVersion"] ?? "unknown"; + var updatedAtUtc = (string)marker["updatedAtUtc"] ?? "unknown"; + var triggerReason = (string)marker["triggerReason"] ?? "startup"; + var exitAtUtc = (string)marker["exitAtUtc"]; + + long? downtimeMs = null; + if (!string.IsNullOrEmpty(exitAtUtc) && + DateTime.TryParse(exitAtUtc, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.RoundtripKind, out var exitDt)) + { + downtimeMs = (long)(agentStartTimeUtc - exitDt).TotalMilliseconds; + } + + var data = new Dictionary + { + { "outcome", "updated" }, + { "previousVersion", previousVersion }, + { "newVersion", newVersion }, + { "latestVersion", newVersion }, + { "updatedAtUtc", updatedAtUtc }, + { "triggerReason", triggerReason } + }; + if (exitAtUtc != null) data["exitAtUtc"] = exitAtUtc; + CopyLong(marker, "versionCheckMs", data); + CopyLong(marker, "downloadMs", data); + CopyLong(marker, "zipSizeBytes", data); + CopyLong(marker, "verifyMs", data); + CopyLong(marker, "extractMs", data); + CopyLong(marker, "swapMs", data); + CopyLong(marker, "totalUpdateMs", data); + if (downtimeMs.HasValue) data["downtimeMs"] = downtimeMs.Value; + + var severity = string.Equals(triggerReason, "runtime_hash_mismatch", StringComparison.Ordinal) + ? EventSeverity.Warning + : EventSeverity.Info; + + return new EnrollmentEvent + { + SessionId = sessionId, + TenantId = tenantId, + EventType = Constants.EventTypes.AgentVersionCheck, + Severity = severity, + Source = "Agent", + Message = $"Agent self-updated from {previousVersion} to {newVersion} (trigger={triggerReason})", + Data = data, + ImmediateUpload = true + }; + } + + private static EnrollmentEvent BuildSkippedEvent(string path, string sessionId, string tenantId, out string outcome) + { + var marker = ReadMarker(path); + + var reason = (string)marker["reason"] ?? "unknown"; + var currentVersion = (string)marker["currentVersion"] ?? "unknown"; + var latestVersion = (string)marker["latestVersion"]; + var skippedAtUtc = (string)marker["skippedAtUtc"] ?? "unknown"; + var errorDetail = (string)marker["errorDetail"] ?? string.Empty; + var triggerReason = (string)marker["triggerReason"]; + + // Marker may already carry outcome (new SelfUpdater). For forward-compat with old markers + // lying around across an upgrade, derive it from the reason. + outcome = (string)marker["outcome"] + ?? (string.Equals(reason, "version_check_failed", StringComparison.Ordinal) ? "check_failed" : "skipped"); + + var data = new Dictionary + { + { "outcome", outcome }, + { "reason", reason }, + { "currentVersion", currentVersion }, + { "skippedAtUtc", skippedAtUtc }, + { "errorDetail", errorDetail } + }; + if (latestVersion != null) + data["latestVersion"] = latestVersion; + if (!string.IsNullOrEmpty(triggerReason)) + data["triggerReason"] = triggerReason; + + string message; + if (outcome == "check_failed") + message = $"Agent version check failed (reason={reason})"; + else if (outcome == "downgrade_blocked") + message = $"Agent self-update blocked: downgrade from {currentVersion} to {latestVersion ?? "unknown"} (trigger={triggerReason ?? "unknown"}, allowDowngrade=false)"; + else + message = $"Agent self-update skipped (reason={reason})"; + + return new EnrollmentEvent + { + SessionId = sessionId, + TenantId = tenantId, + EventType = Constants.EventTypes.AgentVersionCheck, + Severity = EventSeverity.Warning, + Source = "Agent", + Message = message, + Data = data, + ImmediateUpload = true + }; + } + + private static EnrollmentEvent BuildUpToDateEvent(JObject marker, string sessionId, string tenantId) + { + var currentVersion = (string)marker["currentVersion"] ?? "unknown"; + var latestVersion = (string)marker["latestVersion"] ?? "unknown"; + var checkedAtUtc = (string)marker["checkedAtUtc"] ?? "unknown"; + + var data = new Dictionary + { + { "outcome", "up_to_date" }, + { "currentVersion", currentVersion }, + { "latestVersion", latestVersion }, + { "checkedAtUtc", checkedAtUtc } + }; + CopyLong(marker, "versionCheckMs", data); + + return new EnrollmentEvent + { + SessionId = sessionId, + TenantId = tenantId, + EventType = Constants.EventTypes.AgentVersionCheck, + Severity = EventSeverity.Info, + Source = "Agent", + Message = $"Agent version check: up to date ({currentVersion})", + Data = data, + ImmediateUpload = true + }; + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private sealed class LastEmit + { + public string SessionId { get; set; } + public string LatestVersion { get; set; } + public string Outcome { get; set; } + } + + private static JObject ReadMarker(string path) => JObject.Parse(File.ReadAllText(path)); + + private static void CopyLong(JObject marker, string key, Dictionary target) + { + var token = marker[key]; + if (token == null || token.Type == JTokenType.Null) return; + try { target[key] = token.ToObject(); } catch { /* skip malformed field */ } + } + + private static string ExtractLatestVersion(EnrollmentEvent evt) + { + if (evt?.Data == null) return "unknown"; + return evt.Data.TryGetValue("latestVersion", out var v) && v != null ? v.ToString() : "unknown"; + } + + private static LastEmit TryReadLastEmit(string path) + { + try + { + if (!File.Exists(path)) return null; + var obj = JObject.Parse(File.ReadAllText(path)); + return new LastEmit + { + SessionId = (string)obj["sessionId"], + LatestVersion = (string)obj["latestVersion"], + Outcome = (string)obj["outcome"] + }; + } + catch { return null; } + } + + private static void TryWriteLastEmit(string path, string sessionId, string latestVersion, string outcome) + { + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var payload = new JObject + { + ["sessionId"] = sessionId ?? string.Empty, + ["latestVersion"] = latestVersion ?? "unknown", + ["outcome"] = outcome ?? "unknown", + ["emittedAtUtc"] = DateTime.UtcNow.ToString("O") + }; + File.WriteAllText(path, payload.ToString(Newtonsoft.Json.Formatting.None)); + } + catch { /* best-effort */ } + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs new file mode 100644 index 00000000..3cd15d8a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs @@ -0,0 +1,33 @@ +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers +{ + /// + /// Represents an agent-side security or configuration analyzer. + /// Unlike collectors which stream raw telemetry, analyzers run targeted checks, + /// produce a confidence-scored finding, and emit a single structured event. + /// + /// Lifecycle: + /// AnalyzeAtStartup() — called once after agent starts, before enrollment phases begin + /// AnalyzeAtShutdown() — called once at enrollment end / agent stop (enables delta detection) + /// + public interface IAgentAnalyzer + { + /// + /// Human-readable name for logging (e.g., "LocalAdminAnalyzer"). + /// + string Name { get; } + + /// + /// Run analysis at agent startup. + /// Emits a structured finding event via the callback supplied at construction. + /// + void AnalyzeAtStartup(); + + /// + /// Run analysis at agent shutdown / enrollment completion (delta detection). + /// Emits a second finding event for end-state comparison against the startup result. + /// IMPORTANT: Emitted events MUST use Phase=Unknown. Analyzers are not phase-declaration + /// events — see EnrollmentPhase doc for the phase strategy. + /// + void AnalyzeAtShutdown(); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs new file mode 100644 index 00000000..e9480b58 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs @@ -0,0 +1,510 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers +{ + /// + /// Detects Windows 11 "unsupported hardware" installations where the PC Health Check / + /// Setup-time hardware gates were bypassed via registry flags, and flags suspicious + /// post-OOBE setup scripts. Such devices may complete enrollment successfully yet lack + /// TPM-backed features (WHfB, BitLocker TPM protector, Device Health Attestation, + /// Credential Guard) — a silent quality risk not otherwise surfaced by enrollment_complete. + /// + /// Checks performed: + /// 1. HKLM\SYSTEM\Setup\LabConfig\Bypass{TPM,SecureBoot,RAM,Storage,Disk,CPU}Check (install-time) + /// 2. HKLM\SYSTEM\Setup\MoSetup\AllowUpgradesWithUnsupportedTPMOrCPU (in-place upgrade) + /// 3. HKU\<SID>\SOFTWARE\Microsoft\PCHC\UpgradeEligibility (WU upgrade bypass, per-user) + /// 4. %WINDIR%\Setup\Scripts\SetupComplete.cmd + ErrorHandler.cmd (suspicious post-OOBE hooks) + /// + /// Correlation: + /// Bypass flags are cross-checked against current TPM and SecureBoot state; a bypass + /// combined with a missing security feature is escalated to "critical". + /// + /// Runs only at startup: the inspected artefacts are pre-enrollment state (install-time + /// registry keys, OEM/bypass scripts) that do not change during enrollment. Shutdown + /// would just re-emit the same data — the analyzer treats AnalyzeAtShutdown as a no-op. + /// + public class IntegrityBypassAnalyzer : IAgentAnalyzer + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _emitEvent; + private readonly AgentLogger _logger; + + public string Name => "IntegrityBypassAnalyzer"; + + // LabConfig bypass value names. Both BypassStorageCheck and BypassDiskCheck are observed + // in the wild — scripts differ, both should be captured. + private static readonly string[] LabConfigBypassValues = new[] + { + "BypassTPMCheck", + "BypassSecureBootCheck", + "BypassRAMCheck", + "BypassStorageCheck", + "BypassDiskCheck", + "BypassCPUCheck" + }; + + // Registry base SIDs that are NOT real user profiles + private static readonly HashSet SystemSids = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".DEFAULT", + "S-1-5-18", // LocalSystem + "S-1-5-19", // LocalService + "S-1-5-20" // NetworkService + }; + + public IntegrityBypassAnalyzer( + string sessionId, + string tenantId, + Action emitEvent, + AgentLogger logger) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void AnalyzeAtStartup() + { + _logger.Info($"{Name}: Running startup analysis"); + RunAnalysis("startup"); + } + + public void AnalyzeAtShutdown() + { + // Pre-enrollment state does not change during enrollment — skip the shutdown run. + _logger.Debug($"{Name}: Shutdown no-op (bypass state is pre-enrollment, already captured at startup)"); + } + + // ----------------------------------------------------------------------- + // Core analysis + // ----------------------------------------------------------------------- + + private void RunAnalysis(string trigger) + { + try + { + var labConfig = CheckLabConfig(); + var moSetup = CheckMoSetupAllowUnsupported(); + var pchc = CheckPchcUpgradeEligibility(); + var setupScripts = CheckSetupScripts(); + + var tpmPresent = CheckTpmPresentAndEnabled(); + var secureBootEnabled = CheckSecureBootEnabled(); + + // --------- Severity aggregation --------- + var severity = EventSeverity.Info; + var findings = new List(); + + bool anyLabConfigBypass = labConfig.ActiveBypasses.Count > 0; + bool labConfigKeyExists = labConfig.KeyExists; + bool moSetupBypass = moSetup.Value == 1; + bool pchcBypass = pchc.AnyUserWithFlag; + bool scriptsPresent = setupScripts.Any(s => s.Exists); + + // Critical: bypass + corresponding feature missing + if (labConfig.ActiveBypasses.Contains("BypassTPMCheck") && tpmPresent.HasValue && tpmPresent.Value == false) + { + severity = Escalate(severity, EventSeverity.Critical); + findings.Add("tpm_bypass_with_missing_tpm"); + } + if (labConfig.ActiveBypasses.Contains("BypassSecureBootCheck") && secureBootEnabled.HasValue && secureBootEnabled.Value == false) + { + severity = Escalate(severity, EventSeverity.Critical); + findings.Add("secureboot_bypass_with_disabled_secureboot"); + } + if (moSetupBypass && ((tpmPresent.HasValue && tpmPresent.Value == false) || (secureBootEnabled.HasValue && secureBootEnabled.Value == false))) + { + severity = Escalate(severity, EventSeverity.Critical); + findings.Add("upgrade_bypass_with_missing_feature"); + } + + // Warning: bypass active but feature currently still OK, or scripts present, or PCHC flag + if (anyLabConfigBypass && severity < EventSeverity.Warning) + { + severity = Escalate(severity, EventSeverity.Warning); + findings.Add("labconfig_bypass_active"); + } + if (moSetupBypass && severity < EventSeverity.Warning) + { + severity = Escalate(severity, EventSeverity.Warning); + findings.Add("mosetup_upgrade_bypass_active"); + } + if (pchcBypass) + { + severity = Escalate(severity, EventSeverity.Warning); + findings.Add("pchc_upgrade_eligibility_forced"); + } + if (scriptsPresent) + { + severity = Escalate(severity, EventSeverity.Warning); + findings.Add("suspicious_setup_scripts_present"); + } + + // Info: key exists but nothing active + if (severity == EventSeverity.Info) + { + if (labConfigKeyExists) + findings.Add("labconfig_key_present_inactive"); + else + findings.Add("no_bypass_indicators"); + } + + string findingLabel = findings.FirstOrDefault() ?? "no_bypass_indicators"; + string message = severity >= EventSeverity.Warning + ? $"{Name}: Integrity bypass indicators detected ({string.Join(",", findings)})" + : $"{Name}: No integrity bypass indicators"; + + _logger.Info( + $"{Name}: severity={severity}, findings=[{string.Join(",", findings)}], " + + $"labConfigBypasses={labConfig.ActiveBypasses.Count}, " + + $"moSetupBypass={moSetupBypass}, pchcBypass={pchcBypass}, " + + $"scriptsPresent={scriptsPresent}, tpm={FormatNullableBool(tpmPresent)}, " + + $"secureBoot={FormatNullableBool(secureBootEnabled)}"); + + var data = new Dictionary + { + { "severity", severity.ToString().ToLowerInvariant() }, + { "finding", findingLabel }, + { "findings", findings }, + { "triggered_at", trigger }, + { "enrollment_phase_at_check", EnrollmentPhase.Unknown.ToString() }, + { "checks", new Dictionary + { + { "lab_config", new Dictionary + { + { "key_exists", labConfig.KeyExists }, + { "values", labConfig.Values }, + { "active_bypasses", labConfig.ActiveBypasses } + } + }, + { "mo_setup", new Dictionary + { + { "key_exists", moSetup.KeyExists }, + { "value", moSetup.Value }, + { "flagged", moSetupBypass } + } + }, + { "pchc_upgrade_eligibility", new Dictionary + { + { "users_checked", pchc.UsersChecked }, + { "users_with_flag", pchc.UsersWithFlag }, + { "any_user_with_flag", pchc.AnyUserWithFlag } + } + }, + { "setup_scripts", setupScripts.Select(s => new Dictionary + { + { "path", s.Path }, + { "exists", s.Exists }, + { "size_bytes", s.SizeBytes }, + { "last_modified_utc", s.LastModifiedUtc.HasValue + ? s.LastModifiedUtc.Value.ToString("O") + : null } + }).ToList() + }, + { "correlation", new Dictionary + { + { "tpm_present_and_enabled", tpmPresent }, + { "secure_boot_enabled", secureBootEnabled } + } + } + } + } + }; + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "integrity_bypass_analysis", + Severity = severity, + Source = Name, + Phase = EnrollmentPhase.Unknown, + Message = message, + Data = data + }); + } + catch (Exception ex) + { + _logger.Error($"{Name}: Analysis failed unexpectedly", ex); + } + } + + private static EventSeverity Escalate(EventSeverity current, EventSeverity candidate) + => candidate > current ? candidate : current; + + private static string FormatNullableBool(bool? value) + => value.HasValue ? value.Value.ToString() : "unknown"; + + // ----------------------------------------------------------------------- + // Individual checks + // ----------------------------------------------------------------------- + + private LabConfigCheckResult CheckLabConfig() + { + var result = new LabConfigCheckResult(); + try + { + using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\Setup\LabConfig", writable: false)) + { + if (key == null) + { + _logger.Debug($"{Name}: HKLM\\SYSTEM\\Setup\\LabConfig not present"); + return result; + } + + result.KeyExists = true; + foreach (var valueName in LabConfigBypassValues) + { + var raw = key.GetValue(valueName); + int intValue = 0; + if (raw != null) + { + try { intValue = Convert.ToInt32(raw); } + catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException) + { + _logger.Warning($"{Name}: LabConfig '{valueName}' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}"); + intValue = 0; + } + } + result.Values[valueName] = intValue; + if (intValue == 1) + { + result.ActiveBypasses.Add(valueName); + _logger.Debug($"{Name}: LabConfig {valueName}=1 (active bypass)"); + } + } + } + } + catch (Exception ex) + { + _logger.Warning($"{Name}: Failed to read LabConfig registry: {ex.Message}"); + } + return result; + } + + private MoSetupCheckResult CheckMoSetupAllowUnsupported() + { + try + { + using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\Setup\MoSetup", writable: false)) + { + if (key == null) + return new MoSetupCheckResult { KeyExists = false, Value = 0 }; + + var raw = key.GetValue("AllowUpgradesWithUnsupportedTPMOrCPU"); + if (raw == null) + return new MoSetupCheckResult { KeyExists = true, Value = 0 }; + + int intValue = 0; + try { intValue = Convert.ToInt32(raw); } + catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException) + { + _logger.Warning($"{Name}: MoSetup 'AllowUpgradesWithUnsupportedTPMOrCPU' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}"); + } + return new MoSetupCheckResult { KeyExists = true, Value = intValue }; + } + } + catch (Exception ex) + { + _logger.Warning($"{Name}: Failed to read MoSetup registry: {ex.Message}"); + return new MoSetupCheckResult { KeyExists = false, Value = 0 }; + } + } + + /// + /// Enumerates HKEY_USERS and checks SOFTWARE\Microsoft\PCHC\UpgradeEligibility for each + /// real user profile. Agent runs as SYSTEM — loaded hives include all interactive user + /// profiles that Windows has mounted. SIDs for .DEFAULT and system accounts are skipped. + /// Profiles whose hive is not currently loaded are not visible here; that is acceptable — + /// the relevant user during enrollment is the primary logged-in user whose hive is loaded. + /// + private PchcCheckResult CheckPchcUpgradeEligibility() + { + var result = new PchcCheckResult(); + try + { + using (var users = Registry.Users) + { + foreach (var sid in users.GetSubKeyNames()) + { + if (string.IsNullOrEmpty(sid)) continue; + if (SystemSids.Contains(sid)) continue; + // Skip the Classes-hive companions + if (sid.EndsWith("_Classes", StringComparison.OrdinalIgnoreCase)) continue; + + result.UsersChecked.Add(sid); + + try + { + using (var pchcKey = users.OpenSubKey($@"{sid}\SOFTWARE\Microsoft\PCHC", writable: false)) + { + if (pchcKey == null) continue; + + var raw = pchcKey.GetValue("UpgradeEligibility"); + if (raw == null) continue; + + int intValue = 0; + try { intValue = Convert.ToInt32(raw); } + catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException) + { + _logger.Warning($"{Name}: PCHC UpgradeEligibility for SID {sid} has unparseable value (raw type={raw.GetType().Name}): {ex.Message}"); + continue; + } + + if (intValue == 1) + { + result.UsersWithFlag.Add(sid); + _logger.Debug($"{Name}: PCHC UpgradeEligibility=1 for SID {sid}"); + } + } + } + catch (Exception ex) + { + _logger.Debug($"{Name}: Failed to read PCHC for SID {sid}: {ex.Message}"); + } + } + } + } + catch (Exception ex) + { + _logger.Warning($"{Name}: Failed to enumerate HKEY_USERS: {ex.Message}"); + } + return result; + } + + private List CheckSetupScripts() + { + var results = new List(); + var windir = Environment.GetEnvironmentVariable("WINDIR"); + if (string.IsNullOrEmpty(windir)) + windir = @"C:\Windows"; + + var candidates = new[] + { + Path.Combine(windir, @"Setup\Scripts\SetupComplete.cmd"), + Path.Combine(windir, @"Setup\Scripts\ErrorHandler.cmd") + }; + + foreach (var path in candidates) + { + var entry = new SetupScriptCheckResult { Path = path, Exists = false }; + try + { + if (File.Exists(path)) + { + entry.Exists = true; + var fi = new FileInfo(path); + entry.SizeBytes = fi.Length; + entry.LastModifiedUtc = fi.LastWriteTimeUtc; + _logger.Debug($"{Name}: Setup script present: {path} ({fi.Length} bytes)"); + } + } + catch (Exception ex) + { + _logger.Debug($"{Name}: Failed to stat {path}: {ex.Message}"); + } + results.Add(entry); + } + return results; + } + + /// + /// Returns true if a TPM is present and enabled, false if definitely absent/disabled, + /// null if indeterminate (query failed). Uses Win32_Tpm in root\CIMV2\Security\MicrosoftTpm. + /// + private bool? CheckTpmPresentAndEnabled() + { + try + { + using (var searcher = new ManagementObjectSearcher( + @"root\CIMV2\Security\MicrosoftTpm", + "SELECT IsEnabled_InitialValue, IsActivated_InitialValue FROM Win32_Tpm")) + using (var results = searcher.Get()) + { + foreach (ManagementObject tpm in results) + { + using (tpm) + { + var isEnabled = tpm["IsEnabled_InitialValue"] != null + && Convert.ToBoolean(tpm["IsEnabled_InitialValue"]); + // At least one Tpm instance exists — return its enabled state + return isEnabled; + } + } + } + // No Win32_Tpm instance returned — no TPM present + return false; + } + catch (Exception ex) + { + _logger.Debug($"{Name}: TPM WMI query failed: {ex.Message}"); + return null; + } + } + + /// + /// Returns true if UEFI Secure Boot is enabled, false if disabled, null if not + /// queryable (e.g. BIOS firmware without the registry key). + /// + private bool? CheckSecureBootEnabled() + { + try + { + using (var key = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\SecureBoot\State", writable: false)) + { + if (key == null) return null; + var raw = key.GetValue("UEFISecureBootEnabled"); + if (raw == null) return null; + return Convert.ToInt32(raw) == 1; + } + } + catch (Exception ex) + { + _logger.Debug($"{Name}: SecureBoot registry read failed: {ex.Message}"); + return null; + } + } + + // ----------------------------------------------------------------------- + // Private result types + // ----------------------------------------------------------------------- + + private class LabConfigCheckResult + { + public bool KeyExists { get; set; } + public Dictionary Values { get; set; } = new Dictionary(); + public List ActiveBypasses { get; set; } = new List(); + } + + private class MoSetupCheckResult + { + public bool KeyExists { get; set; } + public int Value { get; set; } + } + + private class PchcCheckResult + { + public List UsersChecked { get; set; } = new List(); + public List UsersWithFlag { get; set; } = new List(); + public bool AnyUserWithFlag => UsersWithFlag.Count > 0; + } + + private class SetupScriptCheckResult + { + public string Path { get; set; } + public bool Exists { get; set; } + public long SizeBytes { get; set; } + public DateTime? LastModifiedUtc { get; set; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs new file mode 100644 index 00000000..3927e44b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers +{ + /// + /// Analyzes local administrator accounts and user profiles on the device + /// to detect pre-enrollment admin account creation — a known Autopilot bypass technique. + /// + /// Checks performed: + /// 1. BypassNRO registry flag (HKLM\...\OOBE\BypassNRO = 1) + /// 2. Unexpected local user accounts (via WMI Win32_UserAccount) + /// 3. Unexpected C:\Users profile directories + /// + /// Confidence scoring: + /// BypassNRO = 1 → +20 (low indicator) + /// Unexpected local account found → +40 (medium indicator) + /// Account + matching C:\Users profile → +40 (high indicator, profile overlap) + /// + /// Emits a single "local_admin_analysis" event at startup and at shutdown, + /// enabling delta detection between pre- and post-enrollment state. + /// + public class LocalAdminAnalyzer : IAgentAnalyzer + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _emitEvent; + private readonly AgentLogger _logger; + private readonly List _allowedAccounts; + + // Built-in accounts and profile folders always present on a freshly imaged Windows device. + // "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts. + // "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment. + private static readonly List BuiltInAllowedAccounts = new List + { + "Administrator", + "Guest", + "DefaultAccount", + "WDAGUtilityAccount", + "defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment + "defaultuser1", // Sometimes seen in OOBE, but not always present + "defaultuser2", // Sometimes seen in OOBE, but not always present + "Public", // Profile folder (not a user account) + "Default", // Default user profile template + "Default User", // Symlink to Default in some Windows versions + "All Users" // Junction pointing to C:\ProgramData, always present + }; + + public string Name => "LocalAdminAnalyzer"; + + public LocalAdminAnalyzer( + string sessionId, + string tenantId, + Action emitEvent, + AgentLogger logger, + List allowedAccounts = null) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // Tenant-supplied accounts are additive (union with built-ins, not replacement) + _allowedAccounts = new List(BuiltInAllowedAccounts); + if (allowedAccounts != null) + { + foreach (var account in allowedAccounts) + { + if (!string.IsNullOrWhiteSpace(account) && + !_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase))) + { + _allowedAccounts.Add(account); + } + } + } + } + + public void AnalyzeAtStartup() + { + _logger.Info($"{Name}: Running startup analysis"); + RunAnalysis("startup", EnrollmentPhase.Unknown); + } + + public void AnalyzeAtShutdown() + { + _logger.Info($"{Name}: Running shutdown analysis"); + + // At shutdown a user has logged in — their profile folder and account are expected. + // Detect logged-in users via explorer.exe owner (same technique as DesktopArrivalDetector) + // and add them dynamically so they don't trigger false positives. + var loggedInUsers = GetLoggedInUserNames(); + // Phase=Unknown: this is an analysis event, not a phase declaration. + // Phase-tagged events are reserved for explicit phase transitions (esp_phase_changed etc.). + RunAnalysis("shutdown", EnrollmentPhase.Unknown, loggedInUsers); + } + + // ----------------------------------------------------------------------- + // Core analysis + // ----------------------------------------------------------------------- + + private void RunAnalysis(string trigger, EnrollmentPhase phase, List dynamicAllowedUsers = null) + { + try + { + // Build effective allowed list: static + optional dynamic (logged-in users at shutdown) + var effectiveAllowed = _allowedAccounts; + if (dynamicAllowedUsers != null && dynamicAllowedUsers.Count > 0) + { + effectiveAllowed = new List(_allowedAccounts); + foreach (var user in dynamicAllowedUsers) + { + if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase))) + { + effectiveAllowed.Add(user); + _logger.Info($"{Name}: Dynamically allowing logged-in user: {user}"); + } + } + } + + var bypassNroResult = CheckBypassNroRegistry(); + var accountsResult = CheckLocalAdminAccounts(effectiveAllowed); + var profilesResult = CheckUserProfiles(effectiveAllowed); + + int confidenceScore = 0; + + if (bypassNroResult.Value == 1) + confidenceScore += 20; + + if (accountsResult.Unexpected.Count > 0) + confidenceScore += 40; + + // Profile overlap: unexpected account AND matching C:\Users folder + bool profileOverlap = accountsResult.Unexpected.Any(a => + profilesResult.Unexpected.Any(p => + string.Equals(a, p, StringComparison.OrdinalIgnoreCase))); + if (profileOverlap) + confidenceScore += 40; + + confidenceScore = Math.Min(confidenceScore, 100); + + EventSeverity severity; + string findingLabel; + + if (confidenceScore == 0) + { + severity = EventSeverity.Info; + findingLabel = "no_unexpected_admins_detected"; + } + else if (confidenceScore < 40) + { + severity = EventSeverity.Info; + findingLabel = "bypass_nro_flag_only"; + } + else if (confidenceScore < 80) + { + severity = EventSeverity.Warning; + findingLabel = "unexpected_local_admins_detected"; + } + else + { + severity = EventSeverity.Error; + findingLabel = "unexpected_local_admins_detected"; + } + + _logger.Info( + $"{Name}: confidence={confidenceScore}, finding={findingLabel}, " + + $"bypassNro={bypassNroResult.Value}, " + + $"unexpectedAccounts={accountsResult.Unexpected.Count}, " + + $"unexpectedProfiles={profilesResult.Unexpected.Count}"); + + var data = new Dictionary + { + { "confidence_score", confidenceScore }, + { "severity", severity.ToString().ToLower() }, + { "finding", findingLabel }, + { "triggered_at", trigger }, + { "enrollment_phase_at_check", phase.ToString() }, + { "allowed_accounts", effectiveAllowed }, + { "dynamically_allowed_users", dynamicAllowedUsers ?? new List() }, + { "checks", new Dictionary + { + { "bypass_nro", new Dictionary + { + { "value", bypassNroResult.Value }, + { "flagged", bypassNroResult.Value == 1 } + } + }, + { "unexpected_accounts", accountsResult.Unexpected }, + { "unexpected_profiles", profilesResult.Unexpected }, + { "accounts_checked", accountsResult.AllChecked }, + { "profiles_found", profilesResult.AllFound } + } + } + }; + + var message = confidenceScore == 0 + ? $"{Name}: No unexpected local admins detected" + : $"{Name}: Unexpected admin activity detected (confidence={confidenceScore})"; + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "local_admin_analysis", + Severity = severity, + Source = Name, + Phase = phase, + Message = message, + Data = data + }); + } + catch (Exception ex) + { + _logger.Error($"{Name}: Analysis failed unexpectedly", ex); + } + } + + // ----------------------------------------------------------------------- + // Individual checks + // ----------------------------------------------------------------------- + + private BypassNroCheckResult CheckBypassNroRegistry() + { + try + { + const string keyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE"; + const string valueName = "BypassNRO"; + + using (var key = Registry.LocalMachine.OpenSubKey(keyPath, writable: false)) + { + if (key == null) + { + _logger.Debug($"{Name}: BypassNRO registry key not found"); + return new BypassNroCheckResult { Value = 0, KeyExists = false }; + } + + var raw = key.GetValue(valueName); + if (raw == null) + { + _logger.Debug($"{Name}: BypassNRO value not present"); + return new BypassNroCheckResult { Value = 0, KeyExists = true }; + } + + var intValue = Convert.ToInt32(raw); + _logger.Debug($"{Name}: BypassNRO = {intValue}"); + return new BypassNroCheckResult { Value = intValue, KeyExists = true }; + } + } + catch (Exception ex) + { + _logger.Warning($"{Name}: Failed to read BypassNRO registry: {ex.Message}"); + return new BypassNroCheckResult { Value = 0, KeyExists = false }; + } + } + + private LocalAccountCheckResult CheckLocalAdminAccounts(List allowedAccounts) + { + var allChecked = new List(); + var unexpected = new List(); + + try + { + using (var searcher = new ManagementObjectSearcher( + "SELECT Name, Disabled FROM Win32_UserAccount WHERE LocalAccount = True")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var name = obj["Name"]?.ToString() ?? string.Empty; + var disabled = obj["Disabled"] != null && Convert.ToBoolean(obj["Disabled"]); + + if (string.IsNullOrEmpty(name)) + continue; + + allChecked.Add(name); + + // Skip disabled accounts — they cannot be used to log in + if (disabled) + continue; + + if (!allowedAccounts.Any(a => + string.Equals(a, name, StringComparison.OrdinalIgnoreCase))) + { + unexpected.Add(name); + _logger.Debug($"{Name}: Unexpected local account: {name}"); + } + } + } + } + catch (Exception ex) + { + _logger.Warning($"{Name}: Failed to enumerate local accounts via WMI: {ex.Message}"); + } + + return new LocalAccountCheckResult { AllChecked = allChecked, Unexpected = unexpected }; + } + + private UserProfileCheckResult CheckUserProfiles(List allowedAccounts) + { + var allFound = new List(); + var unexpected = new List(); + + try + { + const string usersRoot = @"C:\Users"; + + if (!Directory.Exists(usersRoot)) + { + _logger.Debug($"{Name}: C:\\Users does not exist"); + return new UserProfileCheckResult { AllFound = allFound, Unexpected = unexpected }; + } + + var dirs = Directory.GetDirectories(usersRoot, "*", SearchOption.TopDirectoryOnly); + foreach (var dir in dirs) + { + var folderName = Path.GetFileName(dir); + if (string.IsNullOrEmpty(folderName)) + continue; + + allFound.Add(folderName); + + if (!allowedAccounts.Any(a => + string.Equals(a, folderName, StringComparison.OrdinalIgnoreCase))) + { + unexpected.Add(folderName); + _logger.Debug($"{Name}: Unexpected profile folder: {folderName}"); + } + } + } + catch (Exception ex) + { + _logger.Warning($"{Name}: Failed to enumerate user profiles: {ex.Message}"); + } + + return new UserProfileCheckResult { AllFound = allFound, Unexpected = unexpected }; + } + + /// + /// Detects currently logged-in user(s) by finding explorer.exe processes owned by + /// real (non-system) users. Returns the plain username part (without domain prefix). + /// Uses the same approach as DesktopArrivalDetector. + /// + private List GetLoggedInUserNames() + { + var users = new List(); + + try + { + var explorerProcesses = System.Diagnostics.Process.GetProcessesByName("explorer"); + foreach (var proc in explorerProcesses) + { + try + { + // Session 0 = SYSTEM session, skip + if (proc.SessionId == 0) + continue; + + string owner = GetExplorerOwner(proc.Id); + if (string.IsNullOrEmpty(owner)) + continue; + + // Extract username part (after backslash if DOMAIN\User format) + var userName = owner; + var bsIdx = owner.LastIndexOf('\\'); + if (bsIdx >= 0 && bsIdx < owner.Length - 1) + userName = owner.Substring(bsIdx + 1); + + // Skip system/service accounts + if (IsSystemAccount(userName)) + continue; + + if (!users.Any(u => string.Equals(u, userName, StringComparison.OrdinalIgnoreCase))) + { + users.Add(userName); + _logger.Debug($"{Name}: Detected logged-in user: {userName}"); + } + } + catch (Exception ex) + { + _logger.Debug($"{Name}: Error checking explorer.exe PID {proc.Id}: {ex.Message}"); + } + finally + { + proc.Dispose(); + } + } + } + catch (Exception ex) + { + _logger.Warning($"{Name}: Failed to detect logged-in users: {ex.Message}"); + } + + return users; + } + + /// + /// Gets the owner of a process via WMI Win32_Process.GetOwner. + /// Returns "DOMAIN\User" or "User", or null on failure. + /// + private string GetExplorerOwner(int processId) + { + try + { + using (var searcher = new ManagementObjectSearcher( + $"SELECT * FROM Win32_Process WHERE ProcessId = {processId}")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var outParams = new object[2]; + var result = (uint)obj.InvokeMethod("GetOwner", outParams); + if (result == 0) + { + var user = outParams[0]?.ToString(); + var domain = outParams[1]?.ToString(); + return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}"; + } + } + } + } + catch (Exception ex) + { + _logger.Debug($"{Name}: WMI GetOwner failed for PID {processId}: {ex.Message}"); + } + + return null; + } + + /// + /// Returns true for system/service accounts that are not real enrolled users. + /// + private static bool IsSystemAccount(string userName) + { + if (string.IsNullOrEmpty(userName)) + return true; + + var systemNames = new[] { "SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE" }; + foreach (var sn in systemNames) + { + if (string.Equals(userName, sn, StringComparison.OrdinalIgnoreCase)) + return true; + } + + // DefaultUser* pattern (OOBE system accounts) + if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + // ----------------------------------------------------------------------- + // Private result types + // ----------------------------------------------------------------------- + + private class BypassNroCheckResult + { + public int Value { get; set; } + public bool KeyExists { get; set; } + } + + private class LocalAccountCheckResult + { + public List AllChecked { get; set; } = new List(); + public List Unexpected { get; set; } = new List(); + } + + private class UserProfileCheckResult + { + public List AllFound { get; set; } = new List(); + public List Unexpected { get; set; } = new List(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs new file mode 100644 index 00000000..02c6835e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs @@ -0,0 +1,929 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers +{ + /// + /// Collects installed software inventory from the Windows registry, normalizes + /// vendor/product/version strings, and emits structured events for vulnerability correlation. + /// + /// Lifecycle: + /// AnalyzeAtStartup() — baseline inventory snapshot (before enrollment installs) + /// AnalyzeAtShutdown() — final inventory + delta (what was installed during enrollment) + /// + /// The actual CVE/KEV correlation happens server-side after the events are ingested. + /// + public class SoftwareInventoryAnalyzer : IAgentAnalyzer + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _emitEvent; + private readonly AgentLogger _logger; + + // Captured at startup for delta detection at shutdown + private List _startupInventory; + + public string Name => "SoftwareInventoryAnalyzer"; + + // Maximum items per event chunk to stay within Table Storage property size limits (~64 KB) + private const int ChunkSize = 75; + + // ----------------------------------------------------------------------- + // Registry paths for installed software + // ----------------------------------------------------------------------- + + private static readonly string[] UninstallRegistryPaths = new[] + { + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + }; + + private const string UninstallRegistryPathHkcu = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + + private const string ProfileListPath = + @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"; + + private const string AppxAllUserStorePath = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore\Applications"; + + // ----------------------------------------------------------------------- + // Publisher normalization map + // ----------------------------------------------------------------------- + + private static readonly Dictionary PublisherMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "microsoft corporation", "microsoft" }, + { "microsoft", "microsoft" }, + { "google llc", "google" }, + { "google inc", "google" }, + { "google inc.", "google" }, + { "adobe inc.", "adobe" }, + { "adobe systems incorporated", "adobe" }, + { "adobe systems, incorporated", "adobe" }, + { "mozilla corporation", "mozilla" }, + { "mozilla", "mozilla" }, + { "oracle corporation", "oracle" }, + { "oracle", "oracle" }, + { "apple inc.", "apple" }, + { "apple inc", "apple" }, + { "dell inc.", "dell" }, + { "dell inc", "dell" }, + { "dell technologies", "dell" }, + { "hewlett-packard", "hp" }, + { "hewlett packard enterprise", "hp" }, + { "hp inc.", "hp" }, + { "hp inc", "hp" }, + { "lenovo", "lenovo" }, + { "lenovo group limited", "lenovo" }, + { "cisco systems, inc.", "cisco" }, + { "cisco systems", "cisco" }, + { "vmware, inc.", "vmware" }, + { "vmware", "vmware" }, + { "citrix systems, inc.", "citrix" }, + { "citrix", "citrix" }, + { "zoom video communications, inc.", "zoom" }, + { "slack technologies, inc.", "slack" }, + { "slack technologies, llc", "slack" }, + { "7-zip", "7-zip" }, + { "igor pavlov", "7-zip" }, + { "the 7-zip developers", "7-zip" }, + { "notepad++ team", "notepad++" }, + { "don ho", "notepad++" }, + { "putty", "putty" }, + { "simon tatham", "putty" }, + { "winscp", "winscp" }, + { "martin prikryl", "winscp" }, + { "teamviewer", "teamviewer" }, + { "teamviewer germany gmbh", "teamviewer" }, + { "fortinet technologies (canada) inc.", "fortinet" }, + { "fortinet inc", "fortinet" }, + { "palo alto networks", "paloaltonetworks" }, + { "crowdstrike, inc.", "crowdstrike" }, + { "crowdstrike inc.", "crowdstrike" }, + { "git", "git" }, + { "the git development community", "git" }, + { "python software foundation", "python" }, + { "node.js", "nodejs" }, + { "node.js foundation", "nodejs" }, + { "openjs foundation", "nodejs" }, + + // Endpoint security & AV + { "sophos", "sophos" }, + { "sophos limited", "sophos" }, + { "eset, spol. s r.o.", "eset" }, + { "eset", "eset" }, + { "malwarebytes", "malwarebytes" }, + { "kaspersky", "kaspersky" }, + { "kaspersky lab", "kaspersky" }, + { "trend micro", "trendmicro" }, + { "trend micro inc.", "trendmicro" }, + { "mcafee, inc.", "mcafee" }, + { "mcafee, llc", "mcafee" }, + { "symantec corporation", "symantec" }, + { "broadcom inc.", "broadcom" }, + { "carbon black, inc.", "carbonblack" }, + { "sentinelone", "sentinelone" }, + + // VPN & network security + { "zscaler, inc.", "zscaler" }, + { "zscaler", "zscaler" }, + { "netskope", "netskope" }, + { "netskope, inc.", "netskope" }, + { "pulse secure, llc", "pulsesecure" }, + { "juniper networks", "juniper" }, + { "openvpn inc.", "openvpn" }, + { "openvpn technologies", "openvpn" }, + { "wireguard", "wireguard" }, + + // Device management & enterprise + { "glueckkanja ag", "glueckkanja" }, + { "glueckkanja-gab ag", "glueckkanja" }, + { "beyond trust", "beyondtrust" }, + { "beyondtrust software", "beyondtrust" }, + { "beyondtrust corporation", "beyondtrust" }, + { "ivanti", "ivanti" }, + { "ivanti, inc.", "ivanti" }, + { "1e", "1e" }, + { "tanium", "tanium" }, + { "tanium inc.", "tanium" }, + { "bigfix", "bigfix" }, + { "hcl technologies limited", "hcl" }, + { "sap se", "sap" }, + { "sap", "sap" }, + { "servicenow", "servicenow" }, + + // Communication & collaboration + { "webex communications", "webex" }, + { "discord inc.", "discord" }, + { "signal messenger, llc", "signal" }, + { "signal", "signal" }, + + // Multimedia & documents + { "videolan", "videolan" }, + { "videolan team", "videolan" }, + { "the document foundation", "libreoffice" }, + { "foxit software inc.", "foxit" }, + { "foxit", "foxit" }, + { "tracker software products", "pdfxchange" }, + { "audacity team", "audacity" }, + { "gimp", "gimp" }, + { "the gimp team", "gimp" }, + { "irfanview", "irfanview" }, + { "irfan skiljan", "irfanview" }, + + // Remote access + { "anydesk software gmbh", "anydesk" }, + { "anydesk", "anydesk" }, + { "rustdesk", "rustdesk" }, + { "realvnc", "realvnc" }, + { "realvnc ltd", "realvnc" }, + { "devolutions", "devolutions" }, + { "devolutions inc.", "devolutions" }, + { "splashtop", "splashtop" }, + { "splashtop inc.", "splashtop" }, + { "bomgar", "bomgar" }, + + // Development tools + { "jetbrains s.r.o.", "jetbrains" }, + { "jetbrains", "jetbrains" }, + { "postman, inc.", "postman" }, + { "postman", "postman" }, + { "docker inc.", "docker" }, + { "docker", "docker" }, + { "github, inc.", "github" }, + { "github", "github" }, + { "atlassian", "atlassian" }, + { "atlassian pty ltd", "atlassian" }, + { "hashicorp", "hashicorp" }, + { "hashicorp, inc.", "hashicorp" }, + { "sublime hq pty ltd", "sublimetext" }, + + // Utilities + { "filezilla project", "filezilla" }, + { "tim kosse", "filezilla" }, + { "keepass", "keepass" }, + { "dominik reichl", "keepass" }, + { "greenshot", "greenshot" }, + { "paint.net", "paintnet" }, + { "dotpdn llc", "paintnet" }, + { "win32diskimager", "win32diskimager" }, + { "angusj", "7-zip" }, + { "rarlab", "winrar" }, + { "alexander roshal", "winrar" }, + { "winrar", "winrar" }, + { "wireshark foundation", "wireshark" }, + { "the wireshark team", "wireshark" }, + { "nmap project", "nmap" }, + { "insecure.com", "nmap" }, + { "voidtools", "everything" }, + + // Hardware vendors + { "intel corporation", "intel" }, + { "intel", "intel" }, + { "nvidia corporation", "nvidia" }, + { "nvidia", "nvidia" }, + { "amd", "amd" }, + { "advanced micro devices, inc.", "amd" }, + { "realtek semiconductor", "realtek" }, + { "realtek", "realtek" }, + { "logitech", "logitech" }, + { "logitech inc.", "logitech" }, + + // Database & data tools + { "dbeaver corp", "dbeaver" }, + { "mysql ab", "mysql" }, + { "mariadb", "mariadb" }, + { "the postgresql global development group", "postgresql" }, + { "pgadmin development team", "pgadmin" }, + + // Backup & imaging + { "veeam software", "veeam" }, + { "veeam", "veeam" }, + { "acronis", "acronis" }, + { "acronis international gmbh", "acronis" }, + { "macrium software", "macrium" }, + { "corel corporation", "corel" }, + }; + + // ----------------------------------------------------------------------- + // Publisher suffix stripping + // ----------------------------------------------------------------------- + + private static readonly string[] PublisherSuffixes = new[] + { + ", inc.", " inc.", " inc", ", llc", " llc", ", ltd", " ltd", " ltd.", + " corporation", " corp.", " corp", " gmbh", " ag", " se", + " co.", " co", " group", " limited" + }; + + // ----------------------------------------------------------------------- + // Exclude patterns for filtering noise + // ----------------------------------------------------------------------- + + private static readonly Regex KbUpdatePattern = new Regex(@"^KB\d{6,}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly string[] ExcludeContains = new[] + { + "Language Pack", + "MUI Pack", + "Spell Checking", + "Proofing Tools", + "Microsoft .NET Host", + "Microsoft .NET Runtime", + "Microsoft .NET AppHost", + "Microsoft ASP.NET", + "Microsoft Windows Desktop Runtime", + "Additional Runtime", + "Minimum Runtime", + "Microsoft Update Health Tools", + "Update for Windows", + "Security Update for", + "Hotfix for", + }; + + private static readonly string[] ExcludeStartsWith = new[] + { + "vs_", // Visual Studio installer bootstrapper components + "MSVC", // MSVC redistributable sub-components (the parent is kept) + }; + + // ----------------------------------------------------------------------- + // AppX package filtering — strict whitelist for all publishers + // Sandboxed AppX/MSIX apps rarely have CVEs and the sandbox limits + // blast radius, so we only surface packages with known security or + // enterprise relevance to keep the report clean. + // ----------------------------------------------------------------------- + + private static readonly HashSet AppxWhitelist = new HashSet(StringComparer.OrdinalIgnoreCase) + { + // Intune / device management + "Microsoft.CompanyPortal", + "Microsoft.ManagementApp", + + // Communication & productivity (CVE-relevant, enterprise-deployed) + "Microsoft.Teams", + "MSTeams", + "MicrosoftTeams", + "Microsoft.Office.Desktop", + "Microsoft.OutlookForWindows", + + // Remote access (security surface) + "MicrosoftCorporationII.WindowsApp", + "MicrosoftCorporationII.WindowsSubsystemForLinux", + + // Developer tools (CVE-relevant) + "Microsoft.WindowsTerminal", + "Microsoft.VisualStudioCode", + + // Power Platform (enterprise) + "Microsoft.PowerAutomateDesktop", + }; + + // Pattern: {Publisher.PackageName}_{Version}_{Arch}__{PublisherHash} + private static readonly Regex AppxPackagePattern = new Regex( + @"^(.+?)_(\d+(?:\.\d+)*)_([^_]*)__([a-z0-9]+)$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + // ----------------------------------------------------------------------- + // Regex patterns for normalization + // ----------------------------------------------------------------------- + + private static readonly Regex ArchitecturePattern = new Regex( + @"\s*[\(\-]\s*(?:x64|x86|64-bit|32-bit|amd64|arm64)\s*[\)]?", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex TrailingVersionPattern = new Regex( + @"\s+v?\d+(?:\.\d+)*\s*$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex VersionExtractPattern = new Regex( + @"(\d+(?:\.\d+){0,3})", + RegexOptions.Compiled); + + public SoftwareInventoryAnalyzer( + string sessionId, + string tenantId, + Action emitEvent, + AgentLogger logger) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void AnalyzeAtStartup() + { + _logger.Info($"{Name}: Running startup analysis (baseline inventory)"); + try + { + _startupInventory = CollectAndNormalize(); + EmitInventoryEvents("startup", EnrollmentPhase.Unknown, _startupInventory, null); + } + catch (Exception ex) + { + _logger.Error($"{Name}: Startup analysis failed", ex); + } + } + + public void AnalyzeAtShutdown() + { + AnalyzeAtShutdown(whiteGlovePart: null); + } + + /// + /// Shutdown analysis with optional WhiteGlove part tag. + /// Phase is always Unknown — analyzer events are NOT phase-declaration events. + /// Only explicit phase-transition events (esp_phase_changed, agent_started) may carry + /// a non-Unknown phase. Context is conveyed via DataJson (triggered_at, whiteglove_part). + /// + public void AnalyzeAtShutdown(int? whiteGlovePart) + { + _logger.Info($"{Name}: Running shutdown analysis (delta detection, whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})"); + try + { + var currentInventory = CollectAndNormalize(); + var newInstalls = ComputeDelta(_startupInventory ?? new List(), currentInventory); + EmitInventoryEvents("shutdown", EnrollmentPhase.Unknown, currentInventory, newInstalls, whiteGlovePart); + } + catch (Exception ex) + { + _logger.Error($"{Name}: Shutdown analysis failed", ex); + } + } + + // ----------------------------------------------------------------------- + // Collection + // ----------------------------------------------------------------------- + + private List CollectAndNormalize() + { + var entries = new List(); + + // HKLM 64-bit and WOW6432Node (32-bit) + foreach (var path in UninstallRegistryPaths) + { + var source = path.Contains("WOW6432Node") ? "HKLM_32" : "HKLM_64"; + CollectFromKey(Registry.LocalMachine, path, source, entries); + } + + // HKCU (may be empty during OOBE when running as SYSTEM) + try + { + CollectFromKey(Registry.CurrentUser, UninstallRegistryPathHkcu, "HKCU", entries); + } + catch (Exception ex) + { + _logger.Debug($"{Name}: HKCU read skipped (expected during OOBE): {ex.Message}"); + } + + // HKU per-user Uninstall keys — catches per-user installs (VS Code user, Teams Classic, Spotify, etc.) + CollectFromAllUserProfiles(entries); + + // AppX/MSIX packages — catches modern packaged apps (Company Portal, new Teams, etc.) + CollectAppxPackages(entries); + + // Normalize all entries + foreach (var entry in entries) + { + NormalizeEntry(entry); + } + + _logger.Info($"{Name}: Collected {entries.Count} software entries"); + return entries; + } + + private void CollectFromKey(RegistryKey rootKey, string subKeyPath, string source, List results) + { + try + { + using (var key = rootKey.OpenSubKey(subKeyPath, writable: false)) + { + if (key == null) + { + _logger.Debug($"{Name}: Registry key not found: {source}\\{subKeyPath}"); + return; + } + + var subKeyNames = key.GetSubKeyNames(); + foreach (var subKeyName in subKeyNames) + { + try + { + using (var subKey = key.OpenSubKey(subKeyName, writable: false)) + { + if (subKey == null) + continue; + + var displayName = subKey.GetValue("DisplayName")?.ToString(); + var systemComponent = subKey.GetValue("SystemComponent"); + var parentKeyName = subKey.GetValue("ParentKeyName")?.ToString(); + + if (ShouldExclude(displayName, systemComponent, parentKeyName)) + continue; + + results.Add(new SoftwareEntry + { + DisplayName = displayName, + DisplayVersion = subKey.GetValue("DisplayVersion")?.ToString(), + Publisher = subKey.GetValue("Publisher")?.ToString(), + InstallDate = subKey.GetValue("InstallDate")?.ToString(), + InstallLocation = subKey.GetValue("InstallLocation")?.ToString(), + UninstallString = subKey.GetValue("UninstallString")?.ToString(), + IsWindowsInstaller = Convert.ToInt32(subKey.GetValue("WindowsInstaller") ?? 0) == 1, + RegistrySource = source + }); + } + } + catch (Exception ex) + { + _logger.Debug($"{Name}: Error reading subkey {subKeyName}: {ex.Message}"); + } + } + } + } + catch (Exception ex) + { + _logger.Warning($"{Name}: Failed to read {source}\\{subKeyPath}: {ex.Message}"); + } + } + + // ----------------------------------------------------------------------- + // HKU per-user profile enumeration + // ----------------------------------------------------------------------- + + private void CollectFromAllUserProfiles(List results) + { + try + { + using (var profileList = Registry.LocalMachine.OpenSubKey(ProfileListPath, writable: false)) + { + if (profileList == null) + { + _logger.Debug($"{Name}: ProfileList key not found"); + return; + } + + // Track SIDs we already read via HKCU to avoid duplicates + var currentUserSid = GetCurrentUserSid(); + + foreach (var sid in profileList.GetSubKeyNames()) + { + // Only real user profiles (S-1-5-21-*), skip well-known SIDs + if (!sid.StartsWith("S-1-5-21-", StringComparison.OrdinalIgnoreCase)) + continue; + + // Skip current user SID — already covered by HKCU read above + if (string.Equals(sid, currentUserSid, StringComparison.OrdinalIgnoreCase)) + continue; + + try + { + // HKU\ is only available if the user's hive is loaded + var hkuPath = $@"{sid}\{UninstallRegistryPathHkcu}"; + CollectFromKey(Registry.Users, hkuPath, $"HKU_{sid.Substring(sid.LastIndexOf('-') + 1)}", results); + } + catch (Exception ex) + { + _logger.Debug($"{Name}: HKU read skipped for {sid}: {ex.Message}"); + } + } + } + } + catch (Exception ex) + { + _logger.Debug($"{Name}: Per-user profile enumeration failed: {ex.Message}"); + } + } + + private static string GetCurrentUserSid() + { + try + { + using (var identity = System.Security.Principal.WindowsIdentity.GetCurrent()) + { + return identity.User?.Value; + } + } + catch + { + return null; + } + } + + // ----------------------------------------------------------------------- + // AppX/MSIX package enumeration + // ----------------------------------------------------------------------- + + private void CollectAppxPackages(List results) + { + try + { + using (var appxKey = Registry.LocalMachine.OpenSubKey(AppxAllUserStorePath, writable: false)) + { + if (appxKey == null) + { + _logger.Debug($"{Name}: AppX AllUserStore key not found"); + return; + } + + int appxCount = 0; + + foreach (var subKeyName in appxKey.GetSubKeyNames()) + { + try + { + if (ShouldExcludeAppx(subKeyName)) + continue; + + var parsed = ParseAppxPackageName(subKeyName); + if (parsed == null) + continue; + + results.Add(parsed); + appxCount++; + } + catch (Exception ex) + { + _logger.Debug($"{Name}: Error parsing AppX entry {subKeyName}: {ex.Message}"); + } + } + + _logger.Info($"{Name}: Collected {appxCount} AppX packages"); + } + } + catch (Exception ex) + { + _logger.Debug($"{Name}: AppX enumeration failed: {ex.Message}"); + } + } + + private static bool ShouldExcludeAppx(string packageFullName) + { + // Extract the package identity (everything before the first underscore) + var underscoreIndex = packageFullName.IndexOf('_'); + var packageId = underscoreIndex > 0 + ? packageFullName.Substring(0, underscoreIndex) + : packageFullName; + + // Strict whitelist — only explicitly listed packages pass through + return !AppxWhitelist.Contains(packageId); + } + + private static SoftwareEntry ParseAppxPackageName(string packageFullName) + { + // Format: {Publisher.PackageName}_{Version}_{Arch}__{PublisherHash} + // Example: Microsoft.CompanyPortal_5.0.6155.0_x64__8wekyb3d8bbwe + var match = AppxPackagePattern.Match(packageFullName); + if (!match.Success) + return null; + + var packageId = match.Groups[1].Value; // e.g. "Microsoft.CompanyPortal" + var version = match.Groups[2].Value; // e.g. "5.0.6155.0" + + // Split publisher from product: "Microsoft.CompanyPortal" → ("Microsoft", "CompanyPortal") + var dotIndex = packageId.IndexOf('.'); + string publisher; + string productName; + if (dotIndex > 0) + { + publisher = packageId.Substring(0, dotIndex); + productName = packageId.Substring(dotIndex + 1); + } + else + { + publisher = packageId; + productName = packageId; + } + + // Make display name human-readable: "CompanyPortal" → "Company Portal" + var displayName = SpacePascalCase(productName); + + return new SoftwareEntry + { + DisplayName = displayName, + DisplayVersion = version, + Publisher = publisher, + RegistrySource = "AppX", + IsWindowsInstaller = false + }; + } + + private static string SpacePascalCase(string input) + { + if (string.IsNullOrEmpty(input)) + return input; + + // Insert space before uppercase letters that follow lowercase letters + // "CompanyPortal" → "Company Portal", but "MSTeams" → "MS Teams" + var result = new System.Text.StringBuilder(input.Length + 4); + result.Append(input[0]); + for (int i = 1; i < input.Length; i++) + { + if (char.IsUpper(input[i]) && char.IsLower(input[i - 1])) + result.Append(' '); + result.Append(input[i]); + } + return result.ToString(); + } + + // ----------------------------------------------------------------------- + // Filtering + // ----------------------------------------------------------------------- + + private static bool ShouldExclude(string displayName, object systemComponent, string parentKeyName) + { + // No display name = not a user-visible application + if (string.IsNullOrWhiteSpace(displayName)) + return true; + + // System component flag + if (systemComponent != null) + { + try + { + if (Convert.ToInt32(systemComponent) == 1) + return true; + } + catch { /* non-integer value, ignore */ } + } + + // Child component / update (has a parent) + if (!string.IsNullOrEmpty(parentKeyName)) + return true; + + // KB updates (Windows Updates) + if (KbUpdatePattern.IsMatch(displayName)) + return true; + + // Known noise patterns (contains) + foreach (var pattern in ExcludeContains) + { + if (displayName.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + + // Known noise patterns (starts with) + foreach (var pattern in ExcludeStartsWith) + { + if (displayName.StartsWith(pattern, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + // ----------------------------------------------------------------------- + // Normalization + // ----------------------------------------------------------------------- + + private void NormalizeEntry(SoftwareEntry entry) + { + entry.NormalizedPublisher = NormalizePublisher(entry.Publisher); + entry.NormalizedName = NormalizeName(entry.DisplayName); + entry.NormalizedVersion = NormalizeVersion(entry.DisplayVersion); + AssessConfidence(entry); + } + + private static string NormalizePublisher(string publisher) + { + if (string.IsNullOrWhiteSpace(publisher)) + return string.Empty; + + var trimmed = publisher.Trim(); + + // Check known publisher map + if (PublisherMap.TryGetValue(trimmed, out var mapped)) + return mapped; + + // Fallback: lowercase + strip common suffixes + var lower = trimmed.ToLowerInvariant(); + foreach (var suffix in PublisherSuffixes) + { + if (lower.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + lower = lower.Substring(0, lower.Length - suffix.Length).TrimEnd(); + break; // strip only one suffix + } + } + + return lower; + } + + private static string NormalizeName(string displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) + return string.Empty; + + var name = displayName.Trim().ToLowerInvariant(); + + // Strip architecture markers: (x64), (x86), (64-bit), - x64, etc. + name = ArchitecturePattern.Replace(name, ""); + + // Strip trailing version-like suffix: "chrome 134.0.6998.89" → "chrome" + name = TrailingVersionPattern.Replace(name, ""); + + return name.Trim(); + } + + private static string NormalizeVersion(string displayVersion) + { + if (string.IsNullOrWhiteSpace(displayVersion)) + return string.Empty; + + var match = VersionExtractPattern.Match(displayVersion.Trim()); + return match.Success ? match.Groups[1].Value : displayVersion.Trim(); + } + + private void AssessConfidence(SoftwareEntry entry) + { + bool publisherKnown = !string.IsNullOrEmpty(entry.Publisher) && + PublisherMap.ContainsKey(entry.Publisher.Trim()); + bool versionClean = !string.IsNullOrEmpty(entry.DisplayVersion) && + VersionExtractPattern.IsMatch(entry.DisplayVersion); + + if (publisherKnown && versionClean) + entry.NormalizationConfidence = "high"; + else if (publisherKnown || versionClean) + entry.NormalizationConfidence = "medium"; + else + entry.NormalizationConfidence = "low"; + } + + // ----------------------------------------------------------------------- + // Delta computation + // ----------------------------------------------------------------------- + + private static List ComputeDelta(List baseline, List current) + { + var baselineKeys = new HashSet( + baseline.Select(e => MakeKey(e)), + StringComparer.OrdinalIgnoreCase); + + return current.Where(e => !baselineKeys.Contains(MakeKey(e))).ToList(); + } + + private static string MakeKey(SoftwareEntry e) + { + return $"{e.RegistrySource}|{e.DisplayName}|{e.DisplayVersion ?? ""}"; + } + + // ----------------------------------------------------------------------- + // Event emission (with chunking for large inventories) + // ----------------------------------------------------------------------- + + private void EmitInventoryEvents( + string trigger, + EnrollmentPhase phase, + List inventory, + List newInstalls, + int? whiteGlovePart = null) + { + int totalChunks = Math.Max(1, (int)Math.Ceiling(inventory.Count / (double)ChunkSize)); + + var highCount = inventory.Count(e => e.NormalizationConfidence == "high"); + var medCount = inventory.Count(e => e.NormalizationConfidence == "medium"); + var lowCount = inventory.Count(e => e.NormalizationConfidence == "low"); + + for (int i = 0; i < totalChunks; i++) + { + var chunk = inventory.Skip(i * ChunkSize).Take(ChunkSize).ToList(); + + var data = new Dictionary + { + { "triggered_at", trigger }, + { "total_count", inventory.Count }, + { "chunk_index", i }, + { "chunk_count", totalChunks }, + { "inventory", chunk.Select(SerializeEntry).ToList() } + }; + + // White Glove part tag (1 = pre-provisioning, 2 = user enrollment) + if (whiteGlovePart.HasValue) + { + data["whiteglove_part"] = whiteGlovePart.Value; + } + + // Confidence summary only in first chunk + if (i == 0) + { + data["confidence_summary"] = new Dictionary + { + { "high", highCount }, + { "medium", medCount }, + { "low", lowCount } + }; + } + + // New installs only in last chunk + if (i == totalChunks - 1 && newInstalls != null) + { + data["new_installs_during_enrollment"] = newInstalls.Select(SerializeEntry).ToList(); + data["new_installs_count"] = newInstalls.Count; + } + + var message = i == 0 + ? (trigger == "startup" + ? $"{Name}: Baseline inventory ({inventory.Count} items, {highCount} high-confidence)" + : $"{Name}: Shutdown inventory ({inventory.Count} items, {newInstalls?.Count ?? 0} new during enrollment)") + : $"{Name}: Inventory chunk {i + 1}/{totalChunks}"; + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "software_inventory_analysis", + Severity = EventSeverity.Info, + Source = Name, + Phase = phase, + Message = message, + Data = data + }); + } + } + + private static Dictionary SerializeEntry(SoftwareEntry e) + { + return new Dictionary + { + { "displayName", e.DisplayName ?? "" }, + { "displayVersion", e.DisplayVersion ?? "" }, + { "publisher", e.Publisher ?? "" }, + { "installDate", e.InstallDate ?? "" }, + { "registrySource", e.RegistrySource ?? "" }, + { "normalizedPublisher", e.NormalizedPublisher ?? "" }, + { "normalizedName", e.NormalizedName ?? "" }, + { "normalizedVersion", e.NormalizedVersion ?? "" }, + { "normalizationConfidence", e.NormalizationConfidence ?? "low" } + }; + } + + // ----------------------------------------------------------------------- + // Private model + // ----------------------------------------------------------------------- + + private class SoftwareEntry + { + public string DisplayName { get; set; } + public string DisplayVersion { get; set; } + public string Publisher { get; set; } + public string InstallDate { get; set; } + public string InstallLocation { get; set; } + public string UninstallString { get; set; } + public bool IsWindowsInstaller { get; set; } + public string RegistrySource { get; set; } + + // Normalized fields + public string NormalizedPublisher { get; set; } + public string NormalizedName { get; set; } + public string NormalizedVersion { get; set; } + public string NormalizationConfidence { get; set; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/CollectorBase.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/CollectorBase.cs new file mode 100644 index 00000000..cec8b5c0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/CollectorBase.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry +{ + /// + /// Abstract base for interval-based event collectors. + /// Handles Timer lifecycle (Start/Stop/Dispose) so subclasses only implement Collect(). + /// + public abstract class CollectorBase : IDisposable + { + protected readonly string SessionId; + protected readonly string TenantId; + protected readonly Action EmitEvent; + protected readonly AgentLogger Logger; + + private readonly int _intervalSeconds; + private Timer _timer; + private bool _disposed; + + protected CollectorBase( + string sessionId, + string tenantId, + Action emitEvent, + AgentLogger logger, + int intervalSeconds) + { + SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + EmitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _intervalSeconds = intervalSeconds; + } + + /// Starts the collection interval timer. + public void Start() + { + if (_disposed) return; + OnBeforeStart(); + var interval = TimeSpan.FromSeconds(_intervalSeconds); + _timer = new Timer(_ => CollectSafe(), null, GetInitialDelay(), interval); + Logger.Info($"[{GetType().Name}] Started (interval={_intervalSeconds}s)"); + } + + /// Stops the collection timer without disposing the collector. + public void Stop() + { + _timer?.Dispose(); + _timer = null; + Logger.Info($"[{GetType().Name}] Stopped"); + OnAfterStop(); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + } + + /// Override to perform collection. Called on each timer tick. + protected abstract void Collect(); + + /// + /// Override to return the initial delay before the first tick. + /// Default: one full interval (same as the repeat interval). + /// + protected virtual TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(_intervalSeconds); + + /// Optional hook called before the timer starts (e.g. to initialise counters). + protected virtual void OnBeforeStart() { } + + /// Optional hook called after the timer stops (e.g. to dispose counters). + protected virtual void OnAfterStop() { } + + /// Pauses the collection timer without stopping/disposing the collector. + protected void PauseTimer() + { + _timer?.Change(Timeout.Infinite, Timeout.Infinite); + } + + /// Resumes the collection timer after a pause. + protected void ResumeTimer() + { + if (_disposed || _timer == null) return; + var interval = TimeSpan.FromSeconds(_intervalSeconds); + _timer.Change(TimeSpan.Zero, interval); + } + + private void CollectSafe() + { + try { Collect(); } + catch (Exception ex) { Logger.Error($"[{GetType().Name}] Collection error: {ex.Message}", ex); } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.HardwareSpec.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.HardwareSpec.cs new file mode 100644 index 00000000..05a9d738 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.HardwareSpec.cs @@ -0,0 +1,457 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo +{ + /// + /// Partial: Static hardware specifications (CPU, RAM, Disk, BIOS, Battery, GPU). + /// Collected once at agent startup — these values do not change during enrollment. + /// + public partial class DeviceInfoCollector + { + private void CollectHardwareSpec() + { + var data = new Dictionary(); + + try + { + CollectCpuInfo(data); + CollectMemoryInfo(data); + CollectDiskInfo(data); + CollectBiosInfo(data); + CollectBatteryInfo(data); + CollectGpuInfo(data); + + var message = BuildHardwareSpecMessage(data); + EmitDeviceInfoEvent("hardware_spec", message, data); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect hardware spec: {ex.Message}"); + } + } + + private void CollectCpuInfo(Dictionary data) + { + try + { + using (var searcher = new ManagementObjectSearcher( + "SELECT Name, NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed FROM Win32_Processor")) + { + foreach (ManagementObject obj in searcher.Get()) + { + data["cpuName"] = obj["Name"]?.ToString()?.Trim(); + + if (obj["NumberOfCores"] != null) + data["cpuCores"] = Convert.ToInt32(obj["NumberOfCores"]); + + if (obj["NumberOfLogicalProcessors"] != null) + data["cpuLogicalProcessors"] = Convert.ToInt32(obj["NumberOfLogicalProcessors"]); + + if (obj["MaxClockSpeed"] != null) + { + var mhz = Convert.ToInt32(obj["MaxClockSpeed"]); + data["cpuMaxClockSpeedGHz"] = Math.Round(mhz / 1000.0, 2); + } + + break; // first processor only + } + } + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect CPU info: {ex.Message}"); + } + } + + private void CollectMemoryInfo(Dictionary data) + { + try + { + // Total physical memory from Win32_ComputerSystem + using (var searcher = new ManagementObjectSearcher( + "SELECT TotalPhysicalMemory FROM Win32_ComputerSystem")) + { + foreach (ManagementObject obj in searcher.Get()) + { + if (obj["TotalPhysicalMemory"] != null) + { + var bytes = Convert.ToInt64(obj["TotalPhysicalMemory"]); + data["ramTotalGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1); + } + break; + } + } + + // DIMM details from Win32_PhysicalMemory + var dimms = new List>(); + using (var searcher = new ManagementObjectSearcher( + "SELECT Capacity, Speed, SMBIOSMemoryType, FormFactor FROM Win32_PhysicalMemory")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var dimm = new Dictionary(); + + if (obj["Capacity"] != null) + { + var bytes = Convert.ToInt64(obj["Capacity"]); + dimm["capacityGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1); + } + + if (obj["Speed"] != null) + dimm["speed"] = Convert.ToInt32(obj["Speed"]); + + if (obj["SMBIOSMemoryType"] != null) + dimm["type"] = MapMemoryType(Convert.ToInt32(obj["SMBIOSMemoryType"])); + + if (obj["FormFactor"] != null) + dimm["formFactor"] = MapFormFactor(Convert.ToInt32(obj["FormFactor"])); + + dimms.Add(dimm); + } + } + + data["ramDimmCount"] = dimms.Count; + + if (dimms.Count > 0) + { + var first = dimms[0]; + if (first.ContainsKey("speed")) + data["ramSpeed"] = first["speed"]; + if (first.ContainsKey("type")) + data["ramType"] = first["type"]; + if (first.ContainsKey("formFactor")) + data["ramFormFactor"] = first["formFactor"]; + } + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect memory info: {ex.Message}"); + } + } + + private void CollectDiskInfo(Dictionary data) + { + try + { + var disks = new List>(); + bool collected = false; + + // Primary: MSFT_PhysicalDisk from Storage namespace (better SSD/NVMe detection) + try + { + using (var searcher = new ManagementObjectSearcher( + new ManagementScope(@"\\.\root\Microsoft\Windows\Storage"), + new ObjectQuery("SELECT FriendlyName, MediaType, Size, BusType FROM MSFT_PhysicalDisk"))) + { + foreach (ManagementObject obj in searcher.Get()) + { + var disk = new Dictionary(); + + disk["model"] = obj["FriendlyName"]?.ToString()?.Trim(); + + if (obj["Size"] != null) + { + var bytes = Convert.ToInt64(obj["Size"]); + disk["sizeGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 0); + } + + var mediaType = obj["MediaType"] != null ? Convert.ToInt32(obj["MediaType"]) : 0; + var busType = obj["BusType"] != null ? Convert.ToInt32(obj["BusType"]) : 0; + disk["mediaType"] = MapDiskMediaType(mediaType, busType); + disk["busType"] = MapDiskBusType(busType); + + disks.Add(disk); + } + collected = true; + } + } + catch + { + // Storage namespace not available — fall back to Win32_DiskDrive + } + + // Fallback: Win32_DiskDrive + if (!collected) + { + using (var searcher = new ManagementObjectSearcher( + "SELECT Model, Size, InterfaceType, MediaType FROM Win32_DiskDrive")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var disk = new Dictionary(); + + disk["model"] = obj["Model"]?.ToString()?.Trim(); + + if (obj["Size"] != null) + { + var bytes = Convert.ToInt64(obj["Size"]); + disk["sizeGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 0); + } + + disk["mediaType"] = obj["MediaType"]?.ToString() ?? "Unknown"; + disk["busType"] = obj["InterfaceType"]?.ToString() ?? "Unknown"; + + disks.Add(disk); + } + } + } + + data["disks"] = disks; + data["diskCount"] = disks.Count; + + // System drive free space + try + { + var systemDrive = new DriveInfo("C"); + if (systemDrive.IsReady) + { + data["systemDriveFreeGB"] = Math.Round(systemDrive.AvailableFreeSpace / (1024.0 * 1024.0 * 1024.0), 1); + data["systemDriveTotalGB"] = Math.Round(systemDrive.TotalSize / (1024.0 * 1024.0 * 1024.0), 0); + } + } + catch { /* C: drive info not available */ } + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect disk info: {ex.Message}"); + } + } + + private void CollectBiosInfo(Dictionary data) + { + try + { + using (var searcher = new ManagementObjectSearcher( + "SELECT SMBIOSBIOSVersion, ReleaseDate, SMBIOSMajorVersion, SMBIOSMinorVersion FROM Win32_BIOS")) + { + foreach (ManagementObject obj in searcher.Get()) + { + data["biosVersion"] = obj["SMBIOSBIOSVersion"]?.ToString(); + + if (obj["ReleaseDate"] != null) + { + var wmiDate = obj["ReleaseDate"].ToString(); + data["biosReleaseDate"] = ParseWmiDateTime(wmiDate); + } + + var major = obj["SMBIOSMajorVersion"]?.ToString(); + var minor = obj["SMBIOSMinorVersion"]?.ToString(); + if (major != null && minor != null) + data["smbiosVersion"] = $"{major}.{minor}"; + + break; + } + } + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect BIOS info: {ex.Message}"); + } + } + + private void CollectBatteryInfo(Dictionary data) + { + try + { + using (var searcher = new ManagementObjectSearcher( + "SELECT DesignCapacity, FullChargeCapacity, EstimatedChargeRemaining, BatteryStatus FROM Win32_Battery")) + { + var results = searcher.Get(); + bool found = false; + + foreach (ManagementObject obj in results) + { + found = true; + data["batteryPresent"] = true; + + var designCapacity = obj["DesignCapacity"] != null ? Convert.ToInt32(obj["DesignCapacity"]) : 0; + var fullChargeCapacity = obj["FullChargeCapacity"] != null ? Convert.ToInt32(obj["FullChargeCapacity"]) : 0; + + if (designCapacity > 0) + data["batteryDesignCapacityMWh"] = designCapacity; + + if (fullChargeCapacity > 0) + data["batteryFullChargeCapacityMWh"] = fullChargeCapacity; + + if (designCapacity > 0 && fullChargeCapacity > 0) + data["batteryHealthPercent"] = (int)Math.Round(fullChargeCapacity * 100.0 / designCapacity); + + if (obj["EstimatedChargeRemaining"] != null) + data["batteryChargePercent"] = Convert.ToInt32(obj["EstimatedChargeRemaining"]); + + if (obj["BatteryStatus"] != null) + data["batteryStatus"] = MapBatteryStatus(Convert.ToInt32(obj["BatteryStatus"])); + + break; // first battery only + } + + if (!found) + data["batteryPresent"] = false; + } + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect battery info: {ex.Message}"); + } + } + + private void CollectGpuInfo(Dictionary data) + { + try + { + var gpus = new List>(); + + using (var searcher = new ManagementObjectSearcher( + "SELECT Name, AdapterRAM, DriverVersion FROM Win32_VideoController")) + { + foreach (ManagementObject obj in searcher.Get()) + { + var gpu = new Dictionary(); + + gpu["name"] = obj["Name"]?.ToString()?.Trim(); + + if (obj["AdapterRAM"] != null) + { + var bytes = Convert.ToUInt32(obj["AdapterRAM"]); + // WMI AdapterRAM is uint32 — caps at ~4GB. If value equals uint.MaxValue, it's unreliable. + if (bytes > 0 && bytes < uint.MaxValue) + gpu["adapterRAMGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1); + } + + gpu["driverVersion"] = obj["DriverVersion"]?.ToString(); + + gpus.Add(gpu); + } + } + + data["gpus"] = gpus; + data["gpuCount"] = gpus.Count; + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect GPU info: {ex.Message}"); + } + } + + #region Hardware Spec Helpers + + private static string BuildHardwareSpecMessage(Dictionary data) + { + var parts = new List(); + + if (data.ContainsKey("cpuName")) + parts.Add(data["cpuName"].ToString()); + + if (data.ContainsKey("ramTotalGB")) + { + var ram = $"{data["ramTotalGB"]} GB"; + if (data.ContainsKey("ramType")) + ram += $" {data["ramType"]}"; + parts.Add(ram + " RAM"); + } + + if (data.ContainsKey("disks") && data["disks"] is List> disks && disks.Count > 0) + { + // Prefer non-USB disk for the summary message (USB drives are often boot media, not the actual system disk) + var d = disks.FirstOrDefault(dk => dk.ContainsKey("busType") && !string.Equals(dk["busType"]?.ToString(), "USB", StringComparison.OrdinalIgnoreCase)) + ?? disks[0]; + var diskDesc = d.ContainsKey("sizeGB") ? $"{d["sizeGB"]} GB" : ""; + if (d.ContainsKey("mediaType")) + diskDesc += $" {d["mediaType"]}"; + parts.Add(diskDesc.Trim()); + } + + return parts.Count > 0 ? string.Join(", ", parts) : "Hardware spec collected"; + } + + private static string MapMemoryType(int smbiosType) + { + switch (smbiosType) + { + case 20: return "DDR"; + case 21: return "DDR2"; + case 24: return "DDR3"; + case 26: return "DDR4"; + case 34: return "DDR5"; + default: return smbiosType > 0 ? $"Type {smbiosType}" : "Unknown"; + } + } + + private static string MapFormFactor(int formFactor) + { + switch (formFactor) + { + case 8: return "DIMM"; + case 12: return "SODIMM"; + default: return formFactor > 0 ? $"FormFactor {formFactor}" : "Unknown"; + } + } + + private static string MapDiskMediaType(int mediaType, int busType) + { + // MSFT_PhysicalDisk MediaType: 3=HDD, 4=SSD, 5=SCM + // BusType: 17=NVMe + switch (mediaType) + { + case 3: return "HDD"; + case 4: return busType == 17 ? "NVMe SSD" : "SSD"; + case 5: return "SCM"; + default: return "Unknown"; + } + } + + private static string MapDiskBusType(int busType) + { + switch (busType) + { + case 3: return "ATA"; + case 7: return "USB"; + case 11: return "SATA"; + case 17: return "NVMe"; + default: return busType > 0 ? $"BusType {busType}" : "Unknown"; + } + } + + private static string MapBatteryStatus(int status) + { + switch (status) + { + case 1: return "Discharging"; + case 2: return "AC Power"; + case 3: return "Fully Charged"; + case 4: return "Low"; + case 5: return "Critical"; + case 6: return "Charging"; + case 7: return "Charging High"; + case 8: return "Charging Low"; + case 9: return "Charging Critical"; + default: return $"Unknown ({status})"; + } + } + + private static string ParseWmiDateTime(string wmiDate) + { + // WMI datetime format: yyyyMMddHHmmss.ffffff+zzz (e.g., "20240115000000.000000+000") + if (string.IsNullOrEmpty(wmiDate) || wmiDate.Length < 8) + return wmiDate; + + try + { + var year = wmiDate.Substring(0, 4); + var month = wmiDate.Substring(4, 2); + var day = wmiDate.Substring(6, 2); + return $"{year}-{month}-{day}"; + } + catch + { + return wmiDate; + } + } + + #endregion + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs new file mode 100644 index 00000000..194b82ce --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs @@ -0,0 +1,789 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Management; +using System.Net.NetworkInformation; +using System.Text.Json; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo +{ + /// + /// Partial: Network, proxy, Autopilot profile, security (SecureBoot, BitLocker, TPM, AAD join), + /// ESP configuration, WiFi signal, and helper methods. + /// + public partial class DeviceInfoCollector + { + /// + /// True when AAD join status shows "Azure AD Joined" with a non-empty userEmail + /// that is NOT a provisioning placeholder (see ). + /// Used by EnrollmentTracker to distinguish user-driven from device-only deployments + /// when SkipUserStatusPage=true (which admins commonly set for user-driven enrollments too). + /// + public bool HasAadJoinedUser { get; private set; } + + /// + /// True when the AAD join userEmail matches a known transient provisioning-account + /// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot + /// pre-provisioning (WhiteGlove) and are NOT a real AAD join — they are a soft + /// positive WhiteGlove indicator and feed into . + /// + public bool IsFooUserDetected { get; private set; } + + private void CollectNetworkAndDnsConfiguration() + { + try + { + var adapters = new List>(); + var dnsServers = new List>(); + + using (var searcher = new ManagementObjectSearcher( + "SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, MACAddress, DHCPEnabled, DHCPServer, DNSServerSearchOrder " + + "FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True")) + { + foreach (ManagementObject obj in searcher.Get()) + { + // Network adapter info + var adapter = new Dictionary + { + { "description", obj["Description"]?.ToString() }, + { "macAddress", obj["MACAddress"]?.ToString() }, + { "dhcpEnabled", obj["DHCPEnabled"]?.ToString() }, + { "dhcpServer", obj["DHCPServer"]?.ToString() } + }; + + var ipAddresses = obj["IPAddress"] as string[]; + if (ipAddresses != null) + adapter["ipAddresses"] = string.Join(", ", ipAddresses); + + var subnets = obj["IPSubnet"] as string[]; + if (subnets != null) + adapter["subnets"] = string.Join(", ", subnets); + + var gateways = obj["DefaultIPGateway"] as string[]; + if (gateways != null) + adapter["gateways"] = string.Join(", ", gateways); + + adapters.Add(adapter); + + // DNS info (extracted from the same result set) + var servers = obj["DNSServerSearchOrder"] as string[]; + if (servers != null && servers.Length > 0) + { + dnsServers.Add(new Dictionary + { + { "adapter", obj["Description"]?.ToString() }, + { "servers", string.Join(", ", servers) } + }); + } + } + } + + EmitDeviceInfoEvent("network_adapters", "Network adapters configuration", + new Dictionary + { + { "adapterCount", adapters.Count }, + { "adapters", adapters } + }); + + EmitDeviceInfoEvent("dns_configuration", "DNS server configuration", + new Dictionary + { + { "dnsEntries", dnsServers } + }); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect network/DNS config: {ex.Message}"); + } + } + + private void CollectProxyConfiguration() + { + try + { + var data = new Dictionary(); + + using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings")) + { + if (key != null) + { + data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1"; + data["proxyServer"] = key.GetValue("ProxyServer")?.ToString(); + data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString(); + data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString(); + } + } + + // Also check WinHTTP proxy + using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections")) + { + data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null; + } + + var proxyType = "Direct"; + if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"]) + proxyType = "Proxy"; + else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null) + proxyType = "PAC"; + + data["proxyType"] = proxyType; + + EmitDeviceInfoEvent("proxy_configuration", $"Proxy configuration: {proxyType}", data); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect proxy config: {ex.Message}"); + } + } + + /// + /// Reads the Autopilot profile from registry and detects enrollment type and Hybrid Join status. + /// Returns enrollment type ("v1"/"v2") and whether the profile indicates Hybrid Azure AD Join. + /// Note: AutopilotMode and DomainJoinMethod reflect profile capabilities (what is allowed), + /// not the actual live enrollment state. + /// + private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile() + { + var detectedType = "v1"; + var isHybridJoin = false; + int? detectedAutopilotMode = null; + + try + { + var data = new Dictionary(); + + // Read JSON from AutopilotPolicyCache registry key (contains all Autopilot profile info) + using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache")) + { + if (key != null) + { + // The registry key contains individual values that together form the Autopilot profile + // Read all available values and add them to data dictionary + foreach (var valueName in key.GetValueNames()) + { + var value = key.GetValue(valueName); + if (value != null) + { + // Store with original casing from registry + var valueAsString = value.ToString(); + if (string.Equals(valueName, "PolicyJsonCache", StringComparison.OrdinalIgnoreCase) || + string.Equals(valueName, "CloudAssignedAadServerData", StringComparison.OrdinalIgnoreCase)) + { + valueAsString = TryFormatJson(valueAsString); + } + + data[valueName] = valueAsString; + } + } + + // Extract key values for enrollment type detection + var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration") + ? data["CloudAssignedDeviceRegistration"]?.ToString() + : null; + var espEnabled = data.ContainsKey("CloudAssignedEspEnabled") + ? data["CloudAssignedEspEnabled"]?.ToString() + : null; + + // Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled + if (deviceReg == "2" || espEnabled == "0") + detectedType = "v2"; + else + detectedType = "v1"; + + // --- Interpret AutopilotMode (profile capability, not live state) --- + // 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove) + var autopilotModeStr = data.ContainsKey("AutopilotMode") + ? data["AutopilotMode"]?.ToString() + : null; + if (autopilotModeStr != null && int.TryParse(autopilotModeStr, out var autopilotMode)) + { + detectedAutopilotMode = autopilotMode; + // Only mode 0 is confirmed — modes 1, 2, 3+ are shown as raw integers + // until their meaning is verified across all Autopilot profile variants. + string modeLabel; + switch (autopilotMode) + { + case 0: modeLabel = $"User Driven ({autopilotMode})"; break; + default: modeLabel = $"{autopilotMode}"; break; + } + data["autopilotModeLabel"] = modeLabel; + _logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})"); + } + + // --- Interpret CloudAssignedDomainJoinMethod --- + // 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join + // This value may be a top-level registry value OR embedded in PolicyJsonCache JSON. + var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod") + ? data["CloudAssignedDomainJoinMethod"]?.ToString() + : null; + + // Fallback: extract from PolicyJsonCache if not a top-level registry value + if (domainJoinMethodStr == null && data.ContainsKey("PolicyJsonCache")) + { + try + { + var policyRaw = data["PolicyJsonCache"]?.ToString(); + if (!string.IsNullOrWhiteSpace(policyRaw)) + { + using (var pDoc = System.Text.Json.JsonDocument.Parse(policyRaw)) + { + if (pDoc.RootElement.TryGetProperty("CloudAssignedDomainJoinMethod", out var djmProp)) + { + domainJoinMethodStr = djmProp.ToString(); + } + } + } + } + catch (Exception ex) + { + _logger.Trace($"EnrollmentTracker: Failed to parse PolicyJsonCache for DomainJoinMethod: {ex.Message}"); + } + } + + if (domainJoinMethodStr != null && int.TryParse(domainJoinMethodStr, out var domainJoinMethod)) + { + string joinLabel; + switch (domainJoinMethod) + { + case 0: joinLabel = "Entra Join"; break; + case 1: joinLabel = "Hybrid Azure AD Join"; break; + default: joinLabel = $"Unknown ({domainJoinMethod})"; break; + } + data["domainJoinMethodLabel"] = joinLabel; + + // Hybrid Join is reliably indicated by DomainJoinMethod == 1 + isHybridJoin = domainJoinMethod == 1; + } + + data["isHybridJoin"] = isHybridJoin; + + if (isHybridJoin) + { + _logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected"); + } + + _logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache"); + } + else + { + _logger.Warning("EnrollmentTracker: AutopilotPolicyCache registry key not found"); + } + } + + // Include enrollment type in autopilot_profile event data + data["enrollmentType"] = detectedType; + + EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data); + + // Emit dedicated enrollment_type_detected event for easy filtering + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "enrollment_type_detected", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Unknown, + Message = detectedType == "v2" + ? "Enrollment type: Autopilot v2 (Windows Device Preparation)" + : "Enrollment type: Autopilot v1 (Classic ESP)", + Data = new Dictionary + { + { "enrollmentType", detectedType }, + { "CloudAssignedDeviceRegistration", data.ContainsKey("CloudAssignedDeviceRegistration") ? data["CloudAssignedDeviceRegistration"] : "n/a" }, + { "CloudAssignedEspEnabled", data.ContainsKey("CloudAssignedEspEnabled") ? data["CloudAssignedEspEnabled"] : "n/a" }, + { "detectionRule", detectedType == "v2" + ? (data.ContainsKey("CloudAssignedDeviceRegistration") && data["CloudAssignedDeviceRegistration"]?.ToString() == "2" + ? "CloudAssignedDeviceRegistration=2" + : "CloudAssignedEspEnabled=0") + : "default (no v2 indicators)" } + } + }); + + _logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}"); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}"); + } + + return (detectedType, isHybridJoin, detectedAutopilotMode); + } + + private void CollectSecureBootStatus() + { + try + { + var data = new Dictionary(); + + using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\State")) + { + if (key != null) + { + var value = key.GetValue("UEFISecureBootEnabled"); + data["uefiSecureBootEnabled"] = value != null && Convert.ToInt32(value) == 1; + } + else + { + data["uefiSecureBootEnabled"] = false; + data["note"] = "SecureBoot registry key not found"; + } + } + + // UEFI CA 2023 certificate deployment status (expires June 2026) + using (var servicingKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\Servicing")) + { + if (servicingKey != null) + { + data["uefiCA2023Status"] = servicingKey.GetValue("UEFICA2023Status")?.ToString() ?? "unknown"; + var capable = servicingKey.GetValue("WindowsUEFICA2023Capable"); + if (capable != null) + data["windowsUefiCA2023Capable"] = Convert.ToInt32(capable); + var error = servicingKey.GetValue("UEFICA2023Error"); + if (error != null && Convert.ToInt32(error) != 0) + data["uefiCA2023Error"] = Convert.ToInt32(error); + data["confidenceLevel"] = servicingKey.GetValue("ConfidenceLevel")?.ToString() ?? "unknown"; + } + else + { + data["uefiCA2023Status"] = "notfound"; + } + } + + EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}, CA2023: {data["uefiCA2023Status"]}", data); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect SecureBoot status: {ex.Message}"); + } + } + + private void CollectBitLockerStatus() + { + try + { + var data = new Dictionary(); + var volumes = new List>(); + + using (var searcher = new ManagementObjectSearcher( + new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftVolumeEncryption"), + new ObjectQuery("SELECT DriveLetter, ProtectionStatus, ConversionStatus, EncryptionMethod FROM Win32_EncryptableVolume"))) + { + foreach (ManagementObject obj in searcher.Get()) + { + volumes.Add(new Dictionary + { + { "driveLetter", obj["DriveLetter"]?.ToString() }, + { "protectionStatus", obj["ProtectionStatus"]?.ToString() }, + { "conversionStatus", obj["ConversionStatus"]?.ToString() }, + { "encryptionMethod", obj["EncryptionMethod"]?.ToString() } + }); + } + } + + data["volumes"] = volumes; + data["systemDriveProtected"] = volumes.Any(v => + v["driveLetter"]?.ToString() == "C:" && v["protectionStatus"]?.ToString() == "1"); + + EmitDeviceInfoEvent("bitlocker_status", "BitLocker encryption status", data); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect BitLocker status: {ex.Message}"); + } + } + + private void CollectTpmStatus() + { + try + { + var data = new Dictionary(); + + using (var searcher = new ManagementObjectSearcher( + new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"), + new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm"))) + { + foreach (ManagementObject obj in searcher.Get()) + { + data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True"; + data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True"; + data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True"; + data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString(); + data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString(); + + // SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version + var specVersion = obj["SpecVersion"]?.ToString(); + if (!string.IsNullOrEmpty(specVersion)) + { + var parts = specVersion.Split(','); + data["specVersion"] = parts[0].Trim(); + } + } + } + + if (data.Count > 0) + { + EmitDeviceInfoEvent("tpm_status", $"TPM: {data["manufacturerName"]} v{data["manufacturerVersion"]}", data); + } + else + { + data["available"] = false; + EmitDeviceInfoEvent("tpm_status", "TPM: not available", data); + } + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect TPM status: {ex.Message}"); + } + } + + private void CollectAadJoinStatus() + { + try + { + var data = new Dictionary(); + + // Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@) + // are transient provisioning accounts and must NOT be treated as a real AAD join; + // HasAadJoinedUser is only set for real user emails. + if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)) + { + // Still need TenantId — open the subkey directly when we have a thumbprint. + if (!string.IsNullOrEmpty(thumbprint)) + { + using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath)) + using (var subKey = joinInfoKey?.OpenSubKey(thumbprint)) + { + data["tenantId"] = subKey?.GetValue("TenantId")?.ToString(); + } + + data["userEmail"] = userEmail; + data["joinType"] = "Azure AD Joined"; + data["thumbprint"] = thumbprint; + + if (!string.IsNullOrWhiteSpace(userEmail)) + { + if (isPlaceholderUser) + { + IsFooUserDetected = true; + data["isFooUser"] = true; + _logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join"); + } + else + { + HasAadJoinedUser = true; + } + } + } + else + { + data["joinType"] = "Not Joined"; + } + } + else + { + data["joinType"] = "Not Joined"; + } + + object joinTypeValue; + var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown"; + EmitDeviceInfoEvent("aad_join_status", $"AAD join: {joinType}", data); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect AAD join status: {ex.Message}"); + } + } + + /// + /// Reads ESP skip configuration from the enrollment FirstSync registry key. + /// CSP SkipUserStatusPage/SkipDeviceStatusPage are written as DWORD: + /// 0xFFFFFFFF = skip (ESP page not shown), 0 = show, key missing = unknown. + /// We use defensive interpretation: non-null AND non-zero = skip. + /// + private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration() + { + bool? skipUser = null; + bool? skipDevice = null; + string enrollmentGuid = null; + + try + { + const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; + + using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath)) + { + if (enrollmentsKey == null) + { + _logger.Debug("EnrollmentTracker: Enrollments registry key not found — ESP config unknown"); + return (null, null); + } + + // Find the MDM enrollment entry (EnrollmentType == 6) + foreach (var guid in enrollmentsKey.GetSubKeyNames()) + { + using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid)) + { + if (enrollmentKey == null) continue; + + var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); + if (enrollmentType == null || Convert.ToInt32(enrollmentType) != 6) + continue; + + enrollmentGuid = guid; + + // Read FirstSync subkey + using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync")) + { + if (firstSyncKey == null) + { + _logger.Debug($"EnrollmentTracker: FirstSync subkey not found for enrollment {guid}"); + break; + } + + var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage"); + if (rawSkipUser != null) + skipUser = Convert.ToInt32(rawSkipUser) != 0; + + var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage"); + if (rawSkipDevice != null) + skipDevice = Convert.ToInt32(rawSkipDevice) != 0; + } + + break; // Found the MDM enrollment entry + } + } + } + + var data = new Dictionary + { + { "source", "registry_firstsync" } + }; + if (enrollmentGuid != null) data["enrollmentGuid"] = enrollmentGuid; + if (skipUser.HasValue) data["skipUserStatusPage"] = skipUser.Value; + if (skipDevice.HasValue) data["skipDeviceStatusPage"] = skipDevice.Value; + + var summary = $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}"; + _logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}"); + EmitDeviceInfoEvent("esp_config_detected", $"ESP configuration: {summary}", data); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect ESP configuration: {ex.Message}"); + } + + return (skipUser, skipDevice); + } + + /// + /// Collects active network interface details (name, type, link speed, MAC) using + /// System.Net.NetworkInformation — instant, no process spawn. + /// If the active interface is WiFi, fires off a separate async task to collect + /// WiFi signal info via netsh (fire-and-forget, never blocks). + /// + private void CollectActiveNetworkInterfaceInfo() + { + try + { + var activeNic = FindActiveNetworkInterface(); + + if (activeNic == null) + { + EmitDeviceInfoEvent("network_interface_info", "No active network interface found", + new Dictionary { { "status", "no_active_interface" } }); + return; + } + + var data = new Dictionary + { + { "adapterName", activeNic.Name }, + { "adapterDescription", activeNic.Description }, + { "macAddress", activeNic.GetPhysicalAddress().ToString() }, + { "interfaceType", activeNic.NetworkInterfaceType.ToString() }, + { "linkSpeedMbps", activeNic.Speed / 1_000_000 }, + { "interfaceId", activeNic.Id } + }; + + var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211; + data["connectionType"] = isWifi ? "WiFi" : "Ethernet"; + + var gateways = activeNic.GetIPProperties().GatewayAddresses; + if (gateways.Count > 0) + { + var gwList = new List(); + foreach (var gw in gateways) + gwList.Add(gw.Address.ToString()); + data["gateways"] = string.Join(", ", gwList); + } + + var message = $"Active interface: {activeNic.Description} ({data["connectionType"]}, {data["linkSpeedMbps"]} Mbps)"; + EmitDeviceInfoEvent("network_interface_info", message, data); + + // Fire-and-forget WiFi signal collection — separate event, never blocks + if (isWifi) + { + Task.Run(() => CollectWiFiSignalInfo()); + } + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect active network interface info: {ex.Message}"); + } + } + + /// + /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. + /// + private static NetworkInterface FindActiveNetworkInterface() + { + try + { + var interfaces = NetworkInterface.GetAllNetworkInterfaces(); + foreach (var nic in interfaces) + { + if (nic.OperationalStatus != OperationalStatus.Up) + continue; + if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback || + nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + continue; + + var ipProps = nic.GetIPProperties(); + foreach (var gw in ipProps.GatewayAddresses) + { + if (gw.Address.ToString() != "0.0.0.0") + return nic; + } + } + } + catch + { + // Caller handles null + } + return null; + } + + /// + /// Collects WiFi signal info via netsh wlan show interfaces and emits a separate + /// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run — + /// never blocks other collection. VMs without WiFi service simply get no event. + /// + private void CollectWiFiSignalInfo() + { + try + { + var psi = new ProcessStartInfo + { + FileName = SystemPaths.Netsh, + Arguments = "wlan show interfaces", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + string output; + using (var process = Process.Start(psi)) + { + output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(5000); + } + + if (string.IsNullOrEmpty(output)) + return; + + var data = new Dictionary(); + + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = line.Trim(); + var colonIndex = trimmed.IndexOf(':'); + if (colonIndex < 0) continue; + + var key = trimmed.Substring(0, colonIndex).Trim(); + var value = trimmed.Substring(colonIndex + 1).Trim(); + + if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) && + !key.Equals("BSSID", StringComparison.OrdinalIgnoreCase)) + { + data["wifiSsid"] = value; + } + else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(value.TrimEnd('%'), out var signal)) + data["wifiSignalPercent"] = signal; + } + else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase)) + { + data["wifiRadioType"] = value; + } + else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(value, out var channel)) + data["wifiChannel"] = channel; + } + } + + if (data.Count > 0) + { + var message = data.ContainsKey("wifiSsid") + ? $"WiFi: {data["wifiSsid"]}" + : "WiFi signal info"; + if (data.ContainsKey("wifiSignalPercent")) + message += $", Signal: {data["wifiSignalPercent"]}%"; + if (data.ContainsKey("wifiRadioType")) + message += $" ({data["wifiRadioType"]})"; + + EmitDeviceInfoEvent("wifi_signal_info", message, data); + } + } + catch (Exception ex) + { + _logger.Debug($"WiFi signal info collection failed: {ex.Message}"); + } + } + + private void EmitDeviceInfoEvent(string eventType, string message, Dictionary data) + { + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = eventType, + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Unknown, + Message = message, + Data = data + }); + } + + private static string TryFormatJson(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return input ?? string.Empty; + + try + { + using (var doc = JsonDocument.Parse(input)) + { + return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions + { + WriteIndented = true + }); + } + } + catch + { + return input; + } + } + } +} + diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs new file mode 100644 index 00000000..0754b8aa --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Management; +using System.Net.NetworkInformation; +using System.Text.Json; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo +{ + /// + /// Collects static and semi-static device information at enrollment startup and end. + /// Emits device info events (os_info, boot_time, network_adapters, dns_configuration, + /// proxy_configuration, autopilot_profile, enrollment_type_detected, secureboot_status, + /// bitlocker_status, aad_join_status) via the provided emitEvent action. + /// + public partial class DeviceInfoCollector + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _emitEvent; + private readonly AgentLogger _logger; + + private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + + public DeviceInfoCollector( + string sessionId, + string tenantId, + Action emitEvent, + AgentLogger logger) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Runs all device info collectors at agent startup. + /// Returns the detected enrollment type, Hybrid Join flag, and ESP skip configuration from registry. + /// + public (string enrollmentType, bool isHybridJoin, bool? skipUserStatusPage, bool? skipDeviceStatusPage, int? autopilotMode, bool hasAadJoinedUser, bool isFooUserDetected) CollectAll() + { + _logger.Info("EnrollmentTracker: collecting device info (at start)"); + + CollectOSInfoAndBootTime(); + CollectNetworkAndDnsConfiguration(); + CollectProxyConfiguration(); + var profileResult = CollectAutopilotProfile(); + var espConfig = CollectEspConfiguration(); + CollectSecureBootStatus(); + CollectBitLockerStatus(); + CollectTpmStatus(); + CollectAadJoinStatus(); + CollectActiveNetworkInterfaceInfo(); + CollectHardwareSpec(); + + return (profileResult.enrollmentType, profileResult.isHybridJoin, espConfig.skipUserStatusPage, espConfig.skipDeviceStatusPage, profileResult.autopilotMode, HasAadJoinedUser, IsFooUserDetected); + } + + /// + /// Re-collects enrollment-dependent device info that was likely empty/incomplete at startup. + /// Called when DeviceSetup phase is first detected (ESP started = MDM enrollment + AAD join complete). + /// Re-emits: aad_join_status, autopilot_profile, esp_config_detected, tpm_status. + /// + public void CollectAtEnrollmentStart() + { + _logger.Info("EnrollmentTracker: collecting device info (at enrollment start)"); + + CollectAadJoinStatus(); + CollectAutopilotProfile(); + CollectEspConfiguration(); + CollectTpmStatus(); + } + + /// + /// Re-collects device info that may change during enrollment (e.g. BitLocker enabled via policy). + /// Called at enrollment complete / FinalizingSetup transition to capture final state. + /// + public void CollectAtEnd() + { + _logger.Info("EnrollmentTracker: collecting device info (at end)"); + + // BitLocker can be enabled during enrollment via policy + CollectBitLockerStatus(); + + // Re-collect active network interface info (link speed, WiFi signal may have changed) + CollectActiveNetworkInterfaceInfo(); + } + + /// + /// Collects OS info and boot time in a single WMI query against Win32_OperatingSystem. + /// Previously these were three separate queries (Caption, LastBootUpTime via two searchers); + /// consolidated to a single round-trip. + /// Emits two events (os_info, boot_time) for backward compatibility. + /// + private void CollectOSInfoAndBootTime() + { + string osName = string.Empty; + var bootData = new Dictionary(); + + // Single WMI query for both Caption (OS name) and LastBootUpTime + try + { + using (var searcher = new ManagementObjectSearcher("SELECT Caption, LastBootUpTime FROM Win32_OperatingSystem")) + { + foreach (ManagementObject obj in searcher.Get()) + { + osName = obj["Caption"]?.ToString() ?? string.Empty; + + var lastBootUpTimeStr = obj["LastBootUpTime"]?.ToString(); + if (!string.IsNullOrEmpty(lastBootUpTimeStr)) + { + var bootTimeLocal = ManagementDateTimeConverter.ToDateTime(lastBootUpTimeStr); + var bootTimeUtc = DateTime.SpecifyKind(bootTimeLocal.ToUniversalTime(), DateTimeKind.Utc); + + bootData["bootTimeUtc"] = bootTimeUtc.ToString("o"); + bootData["bootTime"] = bootTimeUtc.ToString("o"); + + var uptime = DateTime.UtcNow - bootTimeUtc; + bootData["uptimeMinutes"] = (int)uptime.TotalMinutes; + bootData["uptimeHours"] = uptime.TotalHours; + + _logger.Debug($"Boot time (UTC): {bootTimeUtc:o}, Uptime: {uptime.TotalMinutes:F1} minutes"); + } + break; + } + } + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to query Win32_OperatingSystem: {ex.Message}"); + } + + // Emit os_info event + try + { + // Read individual registry values; reuse DeviceInfoProvider helpers where possible + var edition = (Registry.GetValue(RegKeyWindowsCurrentVersion, "EditionID", string.Empty) ?? string.Empty).ToString(); + var compositionEdition = (Registry.GetValue(RegKeyWindowsCurrentVersion, "CompositionEditionID", string.Empty) ?? string.Empty).ToString(); + var currentBuild = (Registry.GetValue(RegKeyWindowsCurrentVersion, "CurrentBuild", string.Empty) ?? string.Empty).ToString(); + var buildBranch = (Registry.GetValue(RegKeyWindowsCurrentVersion, "BuildBranch", string.Empty) ?? string.Empty).ToString(); + var displayVersion = DeviceInfoProvider.GetOsDisplayVersion(); + var buildRevision = (Registry.GetValue(RegKeyWindowsCurrentVersion, "UBR", string.Empty) ?? string.Empty).ToString(); + + var data = new Dictionary + { + { "version", Environment.OSVersion.Version.ToString() }, + { "osVersion", osName }, + { "edition", edition }, + { "compositionEdition", compositionEdition }, + { "currentBuild", currentBuild }, + { "buildBranch", buildBranch }, + { "displayVersion", displayVersion }, + { "buildRevision", buildRevision } + }; + + var osVersion = osName; + + var message = string.IsNullOrWhiteSpace(osVersion) ? "OS information collected" : osVersion; + if (!string.IsNullOrWhiteSpace(displayVersion)) + { + message += $" {displayVersion}"; + } + + if (!string.IsNullOrWhiteSpace(currentBuild)) + { + message += string.IsNullOrWhiteSpace(buildRevision) + ? $" (Build {currentBuild})" + : $" (Build {currentBuild}.{buildRevision})"; + } + + EmitDeviceInfoEvent("os_info", message, data); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect OS info: {ex.Message}"); + } + + // Emit boot_time event + try + { + if (!bootData.ContainsKey("bootTime")) + { + bootData["note"] = "Boot time could not be determined"; + } + + EmitDeviceInfoEvent("boot_time", + bootData.ContainsKey("bootTime") + ? $"Last boot: {bootData["bootTime"]}" + : "Boot time unavailable", + bootData); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to emit boot time event: {ex.Message}"); + } + } + + /// + /// Collects network adapter and DNS configuration in a single WMI query. + /// Previously these were two separate queries against Win32_NetworkAdapterConfiguration; + /// consolidated to halve the WMI round-trips (each creates a new COM connection). + /// Emits two events (network_adapters, dns_configuration) for backward compatibility. + /// + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs new file mode 100644 index 00000000..513402ae --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs @@ -0,0 +1,144 @@ +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo +{ + /// + /// Provides device hardware and OS information via WMI and registry queries. + /// + public static class DeviceInfoProvider + { + public static string GetSerialNumber() + { + try + { + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS")) + { + foreach (var obj in searcher.Get()) + { + return obj["SerialNumber"]?.ToString() ?? "Unknown"; + } + } + } + catch { } + return "Unknown"; + } + + public static string GetManufacturer() + { + try + { + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Manufacturer FROM Win32_ComputerSystem")) + { + foreach (var obj in searcher.Get()) + { + return obj["Manufacturer"]?.ToString() ?? "Unknown"; + } + } + } + catch { } + return "Unknown"; + } + + public static string GetModel() + { + try + { + var manufacturer = GetManufacturer(); + if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0) + { + // Lenovo stores the friendly model name (e.g. "ThinkPad X1 Carbon Gen 9") + // in Win32_ComputerSystemProduct.Version instead of Win32_ComputerSystem.Model + // which only contains the internal type number (e.g. "20Y3S0FP00") + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct")) + { + foreach (var obj in searcher.Get()) + { + return obj["Version"]?.ToString() ?? "Unknown"; + } + } + } + + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Model FROM Win32_ComputerSystem")) + { + foreach (var obj in searcher.Get()) + { + return obj["Model"]?.ToString() ?? "Unknown"; + } + } + } + catch { } + return "Unknown"; + } + + public static string GetOsEdition() + { + try + { + using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion")) + { + return key?.GetValue("EditionID")?.ToString() ?? "Unknown"; + } + } + catch { } + return "Unknown"; + } + + /// + /// Returns the OS product name, e.g. "Microsoft Windows 11 Pro". + /// Uses WMI Win32_OperatingSystem.Caption which is the authoritative source. + /// + public static string GetOsName() + { + try + { + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem")) + { + foreach (var obj in searcher.Get()) + { + return obj["Caption"]?.ToString() ?? string.Empty; + } + } + } + catch { } + return string.Empty; + } + + /// + /// Returns the real OS build string, e.g. "26220.7934" (CurrentBuild.UBR). + /// Falls back to CurrentBuild alone if UBR is unavailable. + /// + public static string GetOsBuild() + { + try + { + using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion")) + { + var currentBuild = key?.GetValue("CurrentBuild")?.ToString(); + if (string.IsNullOrEmpty(currentBuild)) + return string.Empty; + + var ubr = key?.GetValue("UBR"); + return ubr != null ? $"{currentBuild}.{ubr}" : currentBuild; + } + } + catch { } + return string.Empty; + } + + /// + /// Returns the OS display version, e.g. "25H2", "24H2". + /// + public static string GetOsDisplayVersion() + { + try + { + using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion")) + { + return key?.GetValue("DisplayVersion")?.ToString() ?? string.Empty; + } + } + catch { } + return string.Empty; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/CommandCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/CommandCollector.cs new file mode 100644 index 00000000..01bf9bdc --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/CommandCollector.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors +{ + public class CommandCollector : IGatherRuleCollector + { + public string CollectorType => "command_allowlisted"; + + public Dictionary Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + var command = rule.Target; + + if (string.IsNullOrEmpty(command)) + return data; + + // SECURITY: Check command against allowlist + if (!GatherRuleGuards.IsCommandAllowed(command, context.UnrestrictedMode)) + { + context.Logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})"); + data["blocked"] = true; + data["reason"] = "Command not on allowlist"; + data["command"] = command; + + context.OnEventCollected(new EnrollmentEvent + { + SessionId = context.SessionId, + TenantId = context.TenantId, + Timestamp = DateTime.UtcNow, + EventType = "security_warning", + Severity = EventSeverity.Warning, + Source = "GatherRuleExecutor", + Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})", + Data = data + }); + + return data; + } + + try + { + var isPowerShell = !command.StartsWith("netsh", StringComparison.OrdinalIgnoreCase) && + !command.StartsWith("ipconfig", StringComparison.OrdinalIgnoreCase) && + !command.StartsWith("nltest", StringComparison.OrdinalIgnoreCase) && + !command.StartsWith("certutil", StringComparison.OrdinalIgnoreCase) && + !command.StartsWith("dsregcmd", StringComparison.OrdinalIgnoreCase); + + ProcessStartInfo psi; + if (isPowerShell) + { + // Use -EncodedCommand with Base64-encoded UTF-16LE to prevent command injection + var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command)); + psi = new ProcessStartInfo + { + FileName = SystemPaths.PowerShell, + Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + } + else + { + psi = new ProcessStartInfo + { + FileName = SystemPaths.Cmd, + Arguments = $"/c {command}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + } + + using (var process = Process.Start(psi)) + { + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(30000); // 30 second timeout + + data["command"] = command; + data["exit_code"] = process.ExitCode; + + if (!string.IsNullOrWhiteSpace(output)) + { + data["output"] = output.Length > 32000 ? output.Substring(0, 32000) + "... (truncated)" : output; + } + + if (!string.IsNullOrWhiteSpace(error)) + { + data["error_output"] = error.Length > 8000 ? error.Substring(0, 8000) + "... (truncated)" : error; + } + } + } + catch (Exception ex) + { + data["error"] = ex.Message; + } + + return data; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/EventLogCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/EventLogCollector.cs new file mode 100644 index 00000000..0c9846b0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/EventLogCollector.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors +{ + public class EventLogCollector : IGatherRuleCollector + { + public string CollectorType => "eventlog"; + + public Dictionary Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + var logName = rule.Target; + + if (string.IsNullOrEmpty(logName)) + return data; + + // Optional: escalate severity when matching entries are found + string severityIfFound = null; + rule.Parameters?.TryGetValue("severityIfFound", out severityIfFound); + + try + { + int maxEntries = 10; + if (rule.Parameters != null && rule.Parameters.TryGetValue("maxEntries", out var maxEntriesStr)) + { + int.TryParse(maxEntriesStr, out maxEntries); + maxEntries = Math.Min(maxEntries, 50); // Cap at 50 + } + + // Build XPath query with optional filters + var conditions = new List(); + + string sourceFilter = null; + if (rule.Parameters != null && rule.Parameters.TryGetValue("source", out sourceFilter) + && !string.IsNullOrEmpty(sourceFilter)) + { + conditions.Add($"Provider[@Name='{EscapeXPath(sourceFilter)}']"); + } + + string eventIdFilter = null; + if (rule.Parameters != null && rule.Parameters.TryGetValue("eventId", out eventIdFilter) + && !string.IsNullOrEmpty(eventIdFilter)) + { + conditions.Add($"EventID={eventIdFilter}"); + } + + string xpath = conditions.Count > 0 + ? $"*[System[{string.Join(" and ", conditions)}]]" + : "*"; + + string messageFilter = null; + rule.Parameters?.TryGetValue("messageFilter", out messageFilter); + + var query = new EventLogQuery(logName, PathType.LogName, xpath) + { + ReverseDirection = true // newest first + }; + + data["log_name"] = logName; + var entries = new List(); + + using (var reader = new EventLogReader(query)) + { + EventRecord record; + while ((record = reader.ReadEvent()) != null && entries.Count < maxEntries) + { + using (record) + { + var message = ""; + try { message = record.FormatDescription() ?? ""; } + catch { /* Some events lack formatting resources */ } + + // Apply message filter (wildcard contains) + if (!string.IsNullOrEmpty(messageFilter)) + { + var pattern = messageFilter.Trim('*'); + if (string.IsNullOrEmpty(pattern) || + message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0) + continue; + } + + var level = ""; + try { level = record.LevelDisplayName ?? record.Level?.ToString() ?? "Unknown"; } + catch { level = record.Level?.ToString() ?? "Unknown"; } + + var truncMsg = message.Length > 500 ? message.Substring(0, 500) : message; + entries.Add($"[{record.TimeCreated:yyyy-MM-dd HH:mm:ss}] [{level}] [ID:{record.Id}] {truncMsg}"); + } + } + } + + for (int i = 0; i < entries.Count; i++) + { + data[$"entry_{i + 1}"] = entries[i]; + } + data["entries_returned"] = entries.Count; + + // Apply conditional severity when matching entries are found + if (entries.Count > 0 && !string.IsNullOrEmpty(severityIfFound)) + data["_severityOverride"] = severityIfFound; + } + catch (Exception ex) + { + data["error"] = ex.Message; + } + + return data; + } + + private static string EscapeXPath(string value) + { + return value.Replace("'", "'"); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/FileCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/FileCollector.cs new file mode 100644 index 00000000..abe9c1ca --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/FileCollector.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AutopilotMonitor.Shared.Models; + +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors +{ + public class FileCollector : IGatherRuleCollector + { + public string CollectorType => "file"; + + public Dictionary Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + var filePath = rule.Target; + + if (string.IsNullOrEmpty(filePath)) + return data; + + // Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables + var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath) + ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; + filePath = UserProfileResolver.ExpandCustomTokens(filePath); + if (filePath == null) + return data; // Token present but no user logged on — skip silently + + // Guard: only allow enrollment-relevant file paths + if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath)) + return context.EmitSecurityWarning(rule, "file", filePath); + + // Optional conditional severity based on existence + string severityIfExists = null; + string severityIfNotExists = null; + rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists); + rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists); + + try + { + if (File.Exists(filePath)) + { + var info = new FileInfo(filePath); + data["exists"] = true; + data["path"] = filePath; + data["size_bytes"] = info.Length; + data["last_modified"] = info.LastWriteTimeUtc.ToString("o"); + data["created"] = info.CreationTimeUtc.ToString("o"); + + // Read content if requested and file is small enough + string readContent; + if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) && + readContent == "true" && info.Length < 50000) // Max 50KB + { + // Read the last 4000 chars — most useful for log files where recent entries matter + const int maxChars = 4000; + string content; + bool truncated = false; + using (var reader = new StreamReader(filePath)) + { + var full = reader.ReadToEnd(); + if (full.Length > maxChars) + { + content = full.Substring(full.Length - maxChars); + truncated = true; + } + else + { + content = full; + } + } + data["content"] = truncated ? "(truncated) ..." + content : content; + } + } + else if (Directory.Exists(filePath)) + { + data["exists"] = true; + data["is_directory"] = true; + data["path"] = filePath; + + var files = Directory.GetFiles(filePath); + data["file_count"] = files.Length; + data["files"] = string.Join(", ", files.Select(Path.GetFileName).Take(20)); + } + else + { + data["exists"] = false; + data["path"] = filePath; + } + } + catch (Exception ex) + { + data["error"] = ex.Message; + } + + // Apply conditional severity based on existence + if (data.ContainsKey("exists")) + { + var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True"; + if (exists && !string.IsNullOrEmpty(severityIfExists)) + data["_severityOverride"] = severityIfExists; + else if (!exists && !string.IsNullOrEmpty(severityIfNotExists)) + data["_severityOverride"] = severityIfNotExists; + } + + return data; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs new file mode 100644 index 00000000..b09f3463 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json.Linq; + +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors +{ + public class JsonCollector : IGatherRuleCollector + { + public string CollectorType => "json"; + + public Dictionary Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + var filePath = rule.Target; + + if (string.IsNullOrEmpty(filePath)) + return data; + + // Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables + var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath) + ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; + filePath = UserProfileResolver.ExpandCustomTokens(filePath); + if (filePath == null) + return data; // Token present but no user logged on — skip silently + + if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath)) + return context.EmitSecurityWarning(rule, "json", filePath); + + string jsonPath; + if (rule.Parameters == null || !rule.Parameters.TryGetValue("jsonpath", out jsonPath) || + string.IsNullOrEmpty(jsonPath)) + { + context.Logger.Warning($"JSON rule {rule.RuleId} has no 'jsonpath' parameter"); + data["error"] = "Missing required 'jsonpath' parameter"; + return data; + } + + int maxResults = 20; + string maxResultsStr; + if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr)) + int.TryParse(maxResultsStr, out maxResults); + maxResults = Math.Min(maxResults, 100); + + try + { + if (!File.Exists(filePath)) + { + data["exists"] = false; + data["path"] = filePath; + return data; + } + + var info = new FileInfo(filePath); + if (info.Length > 200 * 1024) // 200 KB limit + { + data["error"] = $"File too large ({info.Length} bytes, max 200 KB)"; + data["path"] = filePath; + return data; + } + + string content; + using (var reader = new StreamReader(filePath)) + { + content = reader.ReadToEnd(); + } + + var token = JToken.Parse(content); + var results = token.SelectTokens(jsonPath).Take(maxResults).ToList(); + + data["path"] = filePath; + data["query"] = jsonPath; + data["matchCount"] = results.Count; + + if (results.Count == 1) + { + data["value"] = TruncateMessage(results[0].ToString(), 2000); + } + + if (results.Count > 0) + { + var matchStrings = results.Select(r => TruncateMessage(r.ToString(), 500)).ToList(); + data["matches"] = string.Join("\n---\n", matchStrings); + } + } + catch (Exception ex) + { + data["error"] = ex.Message; + data["path"] = filePath; + data["query"] = jsonPath; + } + + return data; + } + + private static string TruncateMessage(string message, int maxLength) + { + if (string.IsNullOrEmpty(message) || message.Length <= maxLength) + return message; + return message.Substring(0, maxLength) + "..."; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs new file mode 100644 index 00000000..099ccdb8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using AutopilotMonitor.Shared.Models; + +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors +{ + public class LogParserCollector : IGatherRuleCollector + { + public string CollectorType => "logparser"; + + /// + /// Executes the log parser rule. Returns null because logparser emits events directly + /// via context.OnEventCollected rather than returning a single result dictionary. + /// + public Dictionary Execute(GatherRule rule, GatherRuleContext context) + { + var filePath = rule.Target; + if (string.IsNullOrEmpty(filePath)) + return null; + + // Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables + var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath) + ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; + filePath = UserProfileResolver.ExpandCustomTokens(filePath); + if (filePath == null) + return null; // Token present but no user logged on — skip silently + + // Apply IME log path override if set + if (!string.IsNullOrEmpty(context.ImeLogPathOverride)) + { + var fileName = Path.GetFileName(filePath); + filePath = Path.Combine(context.ImeLogPathOverride, fileName); + } + + // Get parameters + string patternStr; + if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) || + string.IsNullOrEmpty(patternStr)) + { + context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter"); + return null; + } + + string trackPositionStr; + bool trackPosition = true; + if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr)) + bool.TryParse(trackPositionStr, out trackPosition); + + string maxLinesStr; + int maxLines = 1000; + if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr)) + int.TryParse(maxLinesStr, out maxLines); + + // Determine format: "cmtrace" (default) or "text" for plain text logs + string formatStr; + bool isTextMode = false; + if (rule.Parameters.TryGetValue("format", out formatStr)) + isTextMode = string.Equals(formatStr, "text", StringComparison.OrdinalIgnoreCase); + + Regex pattern; + try + { + pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + } + catch (Exception ex) + { + context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}"); + return null; + } + + // Resolve file paths — supports wildcards (* and ?) in the filename portion + var resolvedPaths = ResolveLogPaths(filePath, rule.RuleId, context); + if (resolvedPaths.Count == 0) + { + context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}"); + return null; + } + + foreach (var resolvedPath in resolvedPaths) + { + ProcessLogFile(resolvedPath, rule, pattern, trackPosition, maxLines, isTextMode, context); + } + + return null; + } + + private static List ResolveLogPaths(string filePath, string ruleId, GatherRuleContext context) + { + var fileNamePart = Path.GetFileName(filePath); + + // No wildcards — single file + if (!fileNamePart.Contains("*") && !fileNamePart.Contains("?")) + { + if (File.Exists(filePath)) + return new List { filePath }; + return new List(); + } + + // Wildcard expansion + var directory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + return new List(); + + try + { + // Return matched files sorted by last write time (newest first), capped at 20 + return Directory.GetFiles(directory, fileNamePart) + .OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc) + .Take(20) + .ToList(); + } + catch (Exception ex) + { + context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}"); + return new List(); + } + } + + private static void ProcessLogFile(string filePath, GatherRule rule, Regex pattern, + bool trackPosition, int maxLines, bool isTextMode, GatherRuleContext context) + { + try + { + var fileInfo = new FileInfo(filePath); + long startPosition = trackPosition + ? context.FilePositionTracker.GetSafePosition(filePath, fileInfo.Length) + : 0; + + // Nothing new to read + if (startPosition >= fileInfo.Length) + return; + + int matchCount = 0; + int linesRead = 0; + long endPosition = startPosition; + + using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + stream.Seek(startPosition, SeekOrigin.Begin); + + using (var reader = new StreamReader(stream, Encoding.UTF8)) + { + string line; + while ((line = reader.ReadLine()) != null && linesRead < maxLines) + { + linesRead++; + + if (isTextMode) + { + // Text mode: match regex directly against the raw line + Match match; + try + { + match = pattern.Match(line); + } + catch (RegexMatchTimeoutException) + { + continue; + } + if (!match.Success) + continue; + + var data = new Dictionary(); + foreach (var groupName in pattern.GetGroupNames()) + { + if (groupName == "0") continue; + var group = match.Groups[groupName]; + if (group.Success) + data[groupName] = group.Value; + } + + data["logLine"] = TruncateMessage(line, 500); + data["logLineNumber"] = linesRead; + data["logFile"] = Path.GetFileName(filePath); + data["ruleId"] = rule.RuleId; + data["ruleTitle"] = rule.Title; + + var eventType = !string.IsNullOrEmpty(rule.OutputEventType) + ? rule.OutputEventType + : "logparser_match"; + var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity); + + context.OnEventCollected(new EnrollmentEvent + { + SessionId = context.SessionId, + TenantId = context.TenantId, + Timestamp = DateTime.UtcNow, + EventType = eventType, + Severity = severity, + Source = "GatherRuleExecutor", + Message = $"Gather: {rule.Title}", + Data = data + }); + + matchCount++; + } + else + { + // CMTrace mode: parse line as CMTrace format, match regex against message + CmTraceLogEntry entry; + if (!CmTraceLogParser.TryParseLine(line, out entry)) + continue; + + Match match; + try + { + match = pattern.Match(entry.Message); + } + catch (RegexMatchTimeoutException) + { + continue; + } + if (!match.Success) + continue; + + var data = new Dictionary(); + foreach (var groupName in pattern.GetGroupNames()) + { + if (groupName == "0") continue; + var group = match.Groups[groupName]; + if (group.Success) + data[groupName] = group.Value; + } + + data["logTimestamp"] = entry.Timestamp.ToString("o"); + data["logComponent"] = entry.Component; + data["logType"] = entry.Type; + data["logMessage"] = TruncateMessage(entry.Message, 500); + data["logFile"] = Path.GetFileName(filePath); + data["ruleId"] = rule.RuleId; + data["ruleTitle"] = rule.Title; + + var eventType = !string.IsNullOrEmpty(rule.OutputEventType) + ? rule.OutputEventType + : "logparser_match"; + var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity); + + context.OnEventCollected(new EnrollmentEvent + { + SessionId = context.SessionId, + TenantId = context.TenantId, + Timestamp = entry.Timestamp, + EventType = eventType, + Severity = severity, + Source = "GatherRuleExecutor", + Message = $"Gather: {rule.Title}", + Data = data + }); + + matchCount++; + } + } + + // Capture the final stream position after reading + endPosition = stream.Position; + } + } + + if (trackPosition) + context.FilePositionTracker.SetPosition(filePath, endPosition); + + if (matchCount > 0) + context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}"); + } + catch (Exception ex) + { + context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}"); + } + } + + private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride) + { + // If the rule specifies a severity, use it + if (!string.IsNullOrEmpty(ruleOverride)) + { + switch (ruleOverride.ToLower()) + { + case "debug": return EventSeverity.Debug; + case "info": return EventSeverity.Info; + case "warning": return EventSeverity.Warning; + case "error": return EventSeverity.Error; + case "critical": return EventSeverity.Critical; + } + } + + // Otherwise derive from CMTrace log type + switch (cmTraceType) + { + case 2: return EventSeverity.Warning; + case 3: return EventSeverity.Error; + default: return EventSeverity.Info; + } + } + + private static string TruncateMessage(string message, int maxLength) + { + if (string.IsNullOrEmpty(message) || message.Length <= maxLength) + return message; + return message.Substring(0, maxLength) + "..."; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/RegistryCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/RegistryCollector.cs new file mode 100644 index 00000000..1f4a35f9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/RegistryCollector.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors +{ + public class RegistryCollector : IGatherRuleCollector + { + public string CollectorType => "registry"; + + public Dictionary Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + var path = rule.Target; + + if (string.IsNullOrEmpty(path)) + return data; + + // Determine hive + RegistryKey hive = null; + string subPath = path; + + if (path.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase)) + { + hive = Registry.LocalMachine; + subPath = path.Substring(path.IndexOf('\\') + 1); + } + else if (path.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase)) + { + hive = Registry.CurrentUser; + subPath = path.Substring(path.IndexOf('\\') + 1); + } + else + { + // Default to HKLM + hive = Registry.LocalMachine; + } + + // Guard: only allow enrollment-relevant registry paths + if (!GatherRuleGuards.IsRegistryPathAllowed(subPath, context.UnrestrictedMode)) + return context.EmitSecurityWarning(rule, "registry", path); + + // Determine explicit valueName from parameters + string explicitValueName = null; + rule.Parameters?.TryGetValue("valueName", out explicitValueName); + + // Check if subkey enumeration is requested + string listSubkeysStr = null; + rule.Parameters?.TryGetValue("listSubkeys", out listSubkeysStr); + bool listSubkeys = string.Equals(listSubkeysStr, "true", StringComparison.OrdinalIgnoreCase); + + // Optional conditional severity based on existence + string severityIfExists = null; + string severityIfNotExists = null; + rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists); + rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists); + + try + { + using (var key = hive.OpenSubKey(subPath, false)) + { + if (key == null) + { + // The key doesn't exist as a sub-key. If no explicit valueName was given, + // check whether the last path segment is a value name in the parent key. + // This handles the common case where the user specifies the full + // "HKLM\...\KeyName\ValueName" path directly in Target. + if (string.IsNullOrEmpty(explicitValueName) && !listSubkeys) + { + var lastBackslash = subPath.LastIndexOf('\\'); + if (lastBackslash > 0) + { + var parentSubPath = subPath.Substring(0, lastBackslash); + var inferredValueName = subPath.Substring(lastBackslash + 1); + + using (var parentKey = hive.OpenSubKey(parentSubPath, false)) + { + if (parentKey != null) + { + var valueNames = parentKey.GetValueNames(); + if (Array.Exists(valueNames, n => string.Equals(n, inferredValueName, StringComparison.OrdinalIgnoreCase))) + { + var value = parentKey.GetValue(inferredValueName); + data["exists"] = true; + data["path"] = path.Substring(0, path.LastIndexOf('\\')); + data[inferredValueName] = value?.ToString(); + return data; + } + } + } + } + } + + data["exists"] = false; + data["path"] = path; + return data; + } + + data["exists"] = true; + data["path"] = path; + + if (listSubkeys) + { + // Enumerate subkey names (max 100) + var subKeyNames = key.GetSubKeyNames().Take(100).ToArray(); + data["subkey_count"] = subKeyNames.Length; + for (int i = 0; i < subKeyNames.Length; i++) + { + data[$"subkey_{i + 1}"] = subKeyNames[i]; + } + } + else if (!string.IsNullOrEmpty(explicitValueName)) + { + // Read specific value if specified in parameters + var value = key.GetValue(explicitValueName); + data[explicitValueName] = value?.ToString(); + } + else + { + // Read all values + foreach (var name in key.GetValueNames().Take(50)) // Limit to 50 values + { + try + { + var value = key.GetValue(name); + var displayName = string.IsNullOrEmpty(name) ? "(Default)" : name; + data[displayName] = value?.ToString(); + } + catch { } + } + } + } + } + catch (Exception ex) + { + data["error"] = ex.Message; + } + + // Apply conditional severity based on existence + if (data.ContainsKey("exists")) + { + var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True"; + if (exists && !string.IsNullOrEmpty(severityIfExists)) + data["_severityOverride"] = severityIfExists; + else if (!exists && !string.IsNullOrEmpty(severityIfNotExists)) + data["_severityOverride"] = severityIfNotExists; + } + + return data; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/WmiCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/WmiCollector.cs new file mode 100644 index 00000000..d8a95f74 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/WmiCollector.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors +{ + public class WmiCollector : IGatherRuleCollector + { + public string CollectorType => "wmi"; + + public Dictionary Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + var query = rule.Target; + + if (string.IsNullOrEmpty(query)) + return data; + + // Guard: only allow known-safe WMI classes + if (!GatherRuleGuards.IsWmiQueryAllowed(query, context.UnrestrictedMode)) + return context.EmitSecurityWarning(rule, "wmi", query); + + try + { + using (var searcher = new ManagementObjectSearcher(query)) + { + int index = 0; + foreach (ManagementObject obj in searcher.Get()) + { + var item = new Dictionary(); + foreach (var prop in obj.Properties) + { + try + { + item[prop.Name] = prop.Value?.ToString(); + } + catch { } + } + + if (item.Count > 0) + { + // For single result, flatten; for multiple, use indexed keys + if (index == 0) + { + foreach (var kvp in item) + data[kvp.Key] = kvp.Value; + } + else + { + data[$"item_{index}"] = string.Join(", ", item.Select(k => $"{k.Key}={k.Value}")); + } + } + index++; + + if (index >= 20) break; // Limit results + } + } + } + catch (Exception ex) + { + data["error"] = ex.Message; + } + + return data; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/XmlCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/XmlCollector.cs new file mode 100644 index 00000000..e906523f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/XmlCollector.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using System.Xml.XPath; +using AutopilotMonitor.Shared.Models; + +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors +{ + public class XmlCollector : IGatherRuleCollector + { + public string CollectorType => "xml"; + + public Dictionary Execute(GatherRule rule, GatherRuleContext context) + { + var data = new Dictionary(); + var filePath = rule.Target; + + if (string.IsNullOrEmpty(filePath)) + return data; + + // Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables + var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath) + ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; + filePath = UserProfileResolver.ExpandCustomTokens(filePath); + if (filePath == null) + return data; // Token present but no user logged on — skip silently + + if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath)) + return context.EmitSecurityWarning(rule, "xml", filePath); + + string xpath; + if (rule.Parameters == null || !rule.Parameters.TryGetValue("xpath", out xpath) || + string.IsNullOrEmpty(xpath)) + { + context.Logger.Warning($"XML rule {rule.RuleId} has no 'xpath' parameter"); + data["error"] = "Missing required 'xpath' parameter"; + return data; + } + + int maxResults = 20; + string maxResultsStr; + if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr)) + int.TryParse(maxResultsStr, out maxResults); + maxResults = Math.Min(maxResults, 100); + + try + { + if (!File.Exists(filePath)) + { + data["exists"] = false; + data["path"] = filePath; + return data; + } + + var info = new FileInfo(filePath); + if (info.Length > 200 * 1024) // 200 KB limit + { + data["error"] = $"File too large ({info.Length} bytes, max 200 KB)"; + data["path"] = filePath; + return data; + } + + // Parse XML with DTD processing disabled (XXE prevention) + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }; + + XDocument doc; + using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var xmlReader = XmlReader.Create(stream, settings)) + { + doc = XDocument.Load(xmlReader); + } + + // Set up namespace resolver if namespaces are provided + XmlNamespaceManager nsManager = null; + string namespacesStr; + if (rule.Parameters.TryGetValue("namespaces", out namespacesStr) && + !string.IsNullOrEmpty(namespacesStr)) + { + nsManager = new XmlNamespaceManager(new NameTable()); + foreach (var ns in namespacesStr.Split(';')) + { + var parts = ns.Split(new[] { '=' }, 2); + if (parts.Length == 2 && !string.IsNullOrEmpty(parts[0]) && !string.IsNullOrEmpty(parts[1])) + { + nsManager.AddNamespace(parts[0].Trim(), parts[1].Trim()); + } + } + } + + // Execute XPath — try element selection first, fall back to evaluate for scalar/attribute results + var elements = nsManager != null + ? doc.XPathSelectElements(xpath, nsManager).Take(maxResults).ToList() + : doc.XPathSelectElements(xpath).Take(maxResults).ToList(); + + data["path"] = filePath; + data["query"] = xpath; + + if (elements.Count > 0) + { + data["matchCount"] = elements.Count; + if (elements.Count == 1) + { + data["value"] = TruncateMessage(elements[0].ToString(), 2000); + } + var matchStrings = elements.Select(e => TruncateMessage(e.ToString(), 500)).ToList(); + data["matches"] = string.Join("\n---\n", matchStrings); + } + else + { + // Try XPathEvaluate for scalar results (attributes, text(), count(), etc.) + var evalResult = nsManager != null + ? doc.XPathEvaluate(xpath, nsManager) + : doc.XPathEvaluate(xpath); + + if (evalResult is IEnumerable enumerable) + { + var items = enumerable.Take(maxResults).ToList(); + data["matchCount"] = items.Count; + if (items.Count == 1) + { + data["value"] = TruncateMessage(items[0].ToString(), 2000); + } + if (items.Count > 0) + { + data["matches"] = string.Join("\n---\n", + items.Select(i => TruncateMessage(i.ToString(), 500))); + } + } + else if (evalResult != null) + { + data["matchCount"] = 1; + data["value"] = TruncateMessage(evalResult.ToString(), 2000); + } + else + { + data["matchCount"] = 0; + } + } + } + catch (Exception ex) + { + data["error"] = ex.Message; + data["path"] = filePath; + data["query"] = xpath; + } + + return data; + } + + private static string TruncateMessage(string message, int maxLength) + { + if (string.IsNullOrEmpty(message) || message.Length <= maxLength) + return message; + return message.Substring(0, maxLength) + "..."; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs new file mode 100644 index 00000000..34e1b445 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather +{ + /// + /// Privacy and security guards for configurable diagnostics log paths. + /// + /// Restricts user-defined diagnostics paths to directories that contain only + /// enrollment- and device-management-relevant log files, preventing PII leakage. + /// + /// Rules: + /// - The path (after environment variable expansion and full-path normalization) + /// must start with one of the allowed prefixes. + /// - Segment-bounded matching: next character after the prefix must be '\' or end of string. + /// - Wildcards ('*', '?') are only permitted in the last path segment (filename part). + /// - Path traversal sequences are blocked via Path.GetFullPath normalization. + /// + public static class DiagnosticsPathGuards + { + // ----------------------------------------------------------------------- + // Allowed directory prefixes for diagnostics log paths + // (expanded, absolute paths — no environment variables) + // ----------------------------------------------------------------------- + public static readonly IReadOnlyList AllowedDiagnosticsPathPrefixes = new[] + { + // Autopilot Monitor agent logs + @"C:\ProgramData\AutopilotMonitor", + + // Intune Management Extension + @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs", + + // Windows Setup & OOBE + @"C:\Windows\Panther", + + // Windows general logs + @"C:\Windows\Logs", + + // Windows Setup Diagnostics + @"C:\Windows\SetupDiag", + + // Windows Update reporting + @"C:\Windows\SoftwareDistribution\ReportingEvents.log", + + // Windows Event Log files + @"C:\Windows\System32\winevt\Logs", + + // SCCM / ConfigMgr + @"C:\Windows\CCM\Logs", + + // Microsoft Diagnostic Log CSP + @"C:\ProgramData\Microsoft\DiagnosticLogCSP", + + // Windows Error Reporting + @"C:\ProgramData\Microsoft\Windows\WER", + + // Windows Component-Based Servicing + @"C:\Windows\Logs\CBS", + + // Custom install logs (e.g. app installers writing to C:\Install\Log\) + @"C:\Install\Log", + }; + + // Hard blocks: never allowed, even in unrestricted mode + private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users"); + private static readonly string[] AdditionalHardBlockedPrefixes = new[] + { + @"C:\Windows\System32\config", // SAM, SECURITY, SYSTEM hives + }; + + // Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token) + private static readonly string[] AllowedUserProfileSubdirs = new[] + { + @"AppData\Local", + @"AppData\Roaming", + }; + + /// + /// Returns true if the given path is allowed for diagnostics collection. + /// Expands environment variables and normalises to a full path before checking. + /// Wildcards in the last path segment are supported. + /// + public static bool IsDiagnosticsPathAllowed(string rawPath) + => IsDiagnosticsPathAllowed(rawPath, unrestrictedMode: false, userProfilePath: null); + + /// + /// Returns true if the given path is allowed for diagnostics collection. + /// When unrestrictedMode is true, all paths are allowed except C:\Users (privacy protection). + /// Path normalization and traversal protection always apply regardless of mode. + /// + public static bool IsDiagnosticsPathAllowed(string rawPath, bool unrestrictedMode) + => IsDiagnosticsPathAllowed(rawPath, unrestrictedMode, userProfilePath: null); + + /// + /// Returns true if the given path is allowed for diagnostics collection. + /// When userProfilePath is provided (from %LOGGED_ON_USER_PROFILE% token), paths under + /// the user's AppData\Local and AppData\Roaming are additionally allowed. + /// + public static bool IsDiagnosticsPathAllowed(string rawPath, bool unrestrictedMode, string userProfilePath) + { + if (string.IsNullOrWhiteSpace(rawPath)) + return false; + + try + { + // Split off a trailing wildcard segment before normalization + var expanded = Environment.ExpandEnvironmentVariables(rawPath); + var fileName = Path.GetFileName(expanded); + var hasWildcard = fileName.Contains('*') || fileName.Contains('?'); + + // Normalize the directory part (or full path if no wildcard) + string normalizedDir; + if (hasWildcard) + { + var dir = Path.GetDirectoryName(expanded); + if (string.IsNullOrEmpty(dir)) + return false; + normalizedDir = Path.GetFullPath(dir); + } + else + { + normalizedDir = Path.GetFullPath(expanded); + } + + // Symlink / junction / reparse point detection — fail-closed + if (IsSymlinkOrReparsePoint(normalizedDir)) + return false; + + // C:\Users block always applies (even in unrestricted mode) + // Exception: paths under \AppData\Local or AppData\Roaming + // are allowed when the %LOGGED_ON_USER_PROFILE% token was used. + if (normalizedDir.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) && + (normalizedDir.Length == BlockedUsersPrefix.Length || + normalizedDir[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar)) + { + if (!IsUserProfileSubpathAllowed(normalizedDir, userProfilePath)) + return false; + } + + // Additional hard-blocked paths (even in unrestricted mode) + foreach (var blocked in AdditionalHardBlockedPrefixes) + { + var normalizedBlocked = Path.GetFullPath(blocked); + if (normalizedDir.StartsWith(normalizedBlocked, StringComparison.OrdinalIgnoreCase) && + (normalizedDir.Length == normalizedBlocked.Length || + normalizedDir[normalizedBlocked.Length] == Path.DirectorySeparatorChar)) + { + return false; + } + } + + // In unrestricted mode, everything except hard-blocked paths is allowed + if (unrestrictedMode) + return true; + + foreach (var prefix in AllowedDiagnosticsPathPrefixes) + { + var normalizedPrefix = Path.GetFullPath(prefix); + if (normalizedDir.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase)) + { + // Segment-bounded: next char must be '\' or end-of-string + if (normalizedDir.Length == normalizedPrefix.Length || + normalizedDir[normalizedPrefix.Length] == Path.DirectorySeparatorChar) + { + return true; + } + } + } + + return false; + } + catch + { + // Any path normalization failure → deny + return false; + } + } + + /// + /// Returns true if the normalized path falls under one of the allowed subdirectories + /// of the detected user profile (AppData\Local, AppData\Roaming). + /// Only applies when %LOGGED_ON_USER_PROFILE% was used to resolve the path. + /// + internal static bool IsUserProfileSubpathAllowed(string normalizedPath, string userProfilePath) + { + if (string.IsNullOrEmpty(userProfilePath)) + return false; + + try + { + var normalizedProfile = Path.GetFullPath(userProfilePath); + foreach (var subdir in AllowedUserProfileSubdirs) + { + var allowedPrefix = Path.GetFullPath(Path.Combine(normalizedProfile, subdir)); + if (normalizedPath.StartsWith(allowedPrefix, StringComparison.OrdinalIgnoreCase) && + (normalizedPath.Length == allowedPrefix.Length || + normalizedPath[allowedPrefix.Length] == Path.DirectorySeparatorChar)) + { + return true; + } + } + } + catch + { + // Path normalization failure — deny + } + + return false; + } + + /// + /// Checks whether any component of the path (directories or file) is a symlink, + /// junction, or other reparse point. Returns true if a reparse point is detected. + /// Fail-closed: returns true on any error. + /// + private static bool IsSymlinkOrReparsePoint(string path) + { + try + { + // Walk up the directory tree checking each component + var current = path; + while (!string.IsNullOrEmpty(current)) + { + var dirInfo = new DirectoryInfo(current); + if (dirInfo.Exists && dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + return true; + + var parent = Path.GetDirectoryName(current); + if (parent == current) // root reached + break; + current = parent; + } + + // Check the target path itself (if it's a file) + if (File.Exists(path)) + { + var fileInfo = new FileInfo(path); + if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + return true; + } + + return false; + } + catch + { + // Fail-closed: treat errors as suspicious + return true; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs new file mode 100644 index 00000000..6411c094 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather +{ + /// + /// Shared dependencies passed to each gather rule collector. + /// + public class GatherRuleContext + { + public AgentLogger Logger { get; } + public string SessionId { get; } + public string TenantId { get; } + public Action OnEventCollected { get; } + public string ImeLogPathOverride { get; } + public LogFilePositionTracker FilePositionTracker { get; } + public bool UnrestrictedMode { get; set; } + + public GatherRuleContext( + AgentLogger logger, + string sessionId, + string tenantId, + Action onEventCollected, + string imeLogPathOverride, + LogFilePositionTracker filePositionTracker) + { + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + ImeLogPathOverride = imeLogPathOverride; + FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker)); + } + + /// + /// Emits a security_warning event and returns an empty result dictionary. + /// Called when a gather rule targets a path/query not on the allowlist. + /// + public Dictionary EmitSecurityWarning(GatherRule rule, string collectorType, string target) + { + Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})"); + + var data = new Dictionary + { + ["blocked"] = true, + ["reason"] = $"{collectorType} target not on allowlist", + ["target"] = target, + ["ruleId"] = rule.RuleId, + }; + + OnEventCollected(new EnrollmentEvent + { + SessionId = SessionId, + TenantId = TenantId, + Timestamp = DateTime.UtcNow, + EventType = "security_warning", + Severity = EventSeverity.Warning, + Source = "GatherRuleExecutor", + Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})", + Data = data + }); + + return new Dictionary(); + } + + /// + /// Parses a severity string into an EventSeverity enum value. + /// + public static EventSeverity ParseSeverity(string severity) + { + if (string.IsNullOrEmpty(severity)) + return EventSeverity.Info; + + switch (severity.ToLower()) + { + case "debug": return EventSeverity.Debug; + case "info": return EventSeverity.Info; + case "warning": return EventSeverity.Warning; + case "error": return EventSeverity.Error; + case "critical": return EventSeverity.Critical; + default: return EventSeverity.Info; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs new file mode 100644 index 00000000..b744b66c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather +{ + /// + /// Executes gather rules received from the backend API + /// Supports registry, eventlog, wmi, file, command_allowlisted, logparser, json, and xml collector types + /// + public class GatherRuleExecutor : IDisposable + { + private readonly AgentLogger _logger; + private readonly GatherRuleContext _context; + private readonly Dictionary _collectors; + + private List _activeRules = new List(); + private readonly Dictionary _intervalTimers = new Dictionary(); + private readonly HashSet _startupRulesExecuted = new HashSet(); + private readonly HashSet _phaseRulesExecuted = new HashSet(); + private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending + + /// + /// When true, guardrails are relaxed: all registry, WMI, and command targets are allowed. + /// File paths allow everything except C:\Users. Set from tenant configuration. + /// + public bool UnrestrictedMode + { + get { return _context.UnrestrictedMode; } + set { _context.UnrestrictedMode = value; } + } + + public GatherRuleExecutor(string sessionId, string tenantId, Action onEventCollected, + AgentLogger logger, string imeLogPathOverride = null) + { + if (sessionId == null) throw new ArgumentNullException(nameof(sessionId)); + if (tenantId == null) throw new ArgumentNullException(nameof(tenantId)); + if (onEventCollected == null) throw new ArgumentNullException(nameof(onEventCollected)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + _logger = logger; + + var filePositionTracker = new LogFilePositionTracker(); + _context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker); + + // Register all collector strategies + var collectorList = new IGatherRuleCollector[] + { + new RegistryCollector(), + new WmiCollector(), + new CommandCollector(), + new FileCollector(), + new EventLogCollector(), + new LogParserCollector(), + new JsonCollector(), + new XmlCollector(), + }; + + _collectors = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var collector in collectorList) + { + _collectors[collector.CollectorType] = collector; + } + + // Register legacy alias: "command" maps to the same collector as "command_allowlisted" + _collectors["command"] = _collectors["command_allowlisted"]; + } + + /// + /// Updates the active rules and starts/stops execution accordingly + /// + public void UpdateRules(List rules) + { + if (rules == null) + return; + + _logger.Info($"GatherRuleExecutor: updating with {rules.Count} active rules"); + + // Stop existing interval timers + StopAllTimers(); + + _activeRules = rules.Where(r => r.Enabled).ToList(); + + // Execute startup rules — track completion via CountdownEvent so callers can wait + var pendingStartup = _activeRules + .Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId)) + .ToList(); + + if (pendingStartup.Count > 0) + { + _startupRulesLatch?.Dispose(); + _startupRulesLatch = new CountdownEvent(pendingStartup.Count); + + foreach (var rule in pendingStartup) + { + _startupRulesExecuted.Add(rule.RuleId); + ThreadPool.QueueUserWorkItem(_ => + { + try { ExecuteRule(rule); } + finally { _startupRulesLatch?.Signal(); } + }); + } + } + + // Set up interval timers + foreach (var rule in _activeRules.Where(r => r.Trigger == "interval" && r.IntervalSeconds.HasValue)) + { + var interval = TimeSpan.FromSeconds(rule.IntervalSeconds.Value); + var timer = new Timer( + _ => ExecuteRule(rule), + null, + interval, // Initial delay = one interval + interval + ); + _intervalTimers[rule.RuleId] = timer; + _logger.Info($" Interval rule {rule.RuleId} scheduled every {rule.IntervalSeconds}s"); + } + + _logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " + + $"{_intervalTimers.Count} interval rules active"); + } + + /// + /// Called when a phase change event occurs - executes rules triggered by phase changes + /// + public void OnPhaseChanged(EnrollmentPhase newPhase) + { + var phaseName = newPhase.ToString(); + + foreach (var rule in _activeRules.Where(r => r.Trigger == "phase_change")) + { + if (string.IsNullOrEmpty(rule.TriggerPhase) || + string.Equals(rule.TriggerPhase, phaseName, StringComparison.OrdinalIgnoreCase)) + { + // Deduplicate: only fire once per (ruleId, phase) combination + var deduplicationKey = $"{rule.RuleId}|{phaseName}"; + if (!_phaseRulesExecuted.Add(deduplicationKey)) + { + _logger.Debug($"Phase rule {rule.RuleId} already executed for phase {phaseName}, skipping"); + continue; + } + + _logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})"); + ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule)); + } + } + } + + /// + /// Called when a specific event type is emitted - executes on_event rules + /// + public void OnEvent(string eventType) + { + foreach (var rule in _activeRules.Where(r => r.Trigger == "on_event")) + { + if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase)) + { + _logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})"); + ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule)); + } + } + } + + private void ExecuteRule(GatherRule rule) + { + try + { + _logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})"); + + var collectorType = rule.CollectorType?.ToLower(); + + IGatherRuleCollector collector; + if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector)) + { + _logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}"); + return; + } + + Dictionary result = collector.Execute(rule, _context); + + // LogParser (and potentially others) emit events directly and return null + if (result == null) + return; + + if (result.Count > 0) + { + result["ruleId"] = rule.RuleId; + result["ruleTitle"] = rule.Title; + + var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType : "gather_result"; + + // Allow collectors to override severity via _severityOverride in result + object severityOverride; + EventSeverity severity; + if (result.TryGetValue("_severityOverride", out severityOverride) && severityOverride is string sev) + { + severity = GatherRuleContext.ParseSeverity(sev); + result.Remove("_severityOverride"); + } + else + { + severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity); + } + + _context.OnEventCollected(new EnrollmentEvent + { + SessionId = _context.SessionId, + TenantId = _context.TenantId, + Timestamp = DateTime.UtcNow, + EventType = eventType, + Severity = severity, + Source = "GatherRuleExecutor", + Message = $"Gather: {rule.Title}", + Data = result + }); + } + } + catch (Exception ex) + { + _logger.Warning($"Gather rule {rule.RuleId} failed: {ex.Message}"); + } + } + + private EventSeverity ParseSeverity(string severity) + { + return GatherRuleContext.ParseSeverity(severity); + } + + private void StopAllTimers() + { + foreach (var timer in _intervalTimers.Values) + { + timer.Dispose(); + } + _intervalTimers.Clear(); + } + + /// + /// Blocks until all startup rules that were queued by the most recent + /// call have finished executing, or the timeout elapses. + /// Returns true if all rules completed within the timeout; false if timed out. + /// + public bool WaitForStartupRules(int timeoutSeconds = 120) + { + var latch = _startupRulesLatch; + if (latch == null) + return true; // no startup rules were queued + + _logger.Debug($"WaitForStartupRules: waiting up to {timeoutSeconds}s for startup rules to complete..."); + var completed = latch.Wait(TimeSpan.FromSeconds(timeoutSeconds)); + _logger.Debug($"WaitForStartupRules: {(completed ? "all rules completed" : "timed out")}"); + return completed; + } + + public void Dispose() + { + StopAllTimers(); + _startupRulesLatch?.Dispose(); + _startupRulesLatch = null; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs new file mode 100644 index 00000000..07dabf25 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather +{ + /// + /// Privacy and security guards for GatherRule collectors. + /// + /// All allowlists are loaded from the embedded guardrails.json resource + /// (source of truth: rules/guardrails.json). They restrict data collection + /// to enrollment-relevant information only. Rules targeting paths, queries, + /// or commands outside these lists are blocked and reported as security_warning events. + /// + /// To allow additional paths: edit rules/guardrails.json and run + /// node rules/scripts/combine.js to regenerate all derived files. + /// + /// Segment-bounded matching is used: the path must match the prefix exactly + /// up to a path separator or end of string, preventing prefix spoofing. + /// Registry paths are expected without the hive prefix (e.g. "SOFTWARE\\Microsoft\\..." not "HKLM\\..."). + /// File paths should be expanded (no environment variables). + /// Commands must match exactly (trimmed) — no prefix matching for security. + /// + public static class GatherRuleGuards + { + // ----------------------------------------------------------------------- + // Loaded from embedded guardrails.json + // ----------------------------------------------------------------------- + public static readonly IReadOnlyList AllowedRegistryPrefixes; + public static readonly IReadOnlyList AllowedFilePrefixes; + public static readonly IReadOnlyList AllowedWmiQueryPrefixes; + public static readonly IReadOnlyCollection AllowedCommands; + + // Hard blocks: never allowed, even in unrestricted mode + private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users"); + private static readonly string[] AdditionalHardBlockedPathPrefixes = new[] + { + Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives + }; + + // Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token) + private static readonly string[] AllowedUserProfileSubdirs = new[] + { + @"AppData\Local", + @"AppData\Roaming", + }; + + // Hard limit: maximum command length (even in unrestricted mode) + private const int MaxCommandLength = 2000; + + // Hard-blocked command patterns: never allowed, even in unrestricted mode. + // These prevent privilege escalation, persistence, and data exfiltration. + private static readonly string[] HardBlockedCommandPatterns = new[] + { + // Download / exfiltration + "Invoke-WebRequest", "Invoke-RestMethod", "Start-BitsTransfer", + "wget", "curl", "certutil -urlcache", + // User / group manipulation + "New-LocalUser", "Add-LocalGroupMember", "net user", "net localgroup", + // Boot configuration + "bcdedit", "bcdboot", + // Persistence mechanisms + "schtasks /create", "Register-ScheduledTask", + // Destructive operations + "Remove-Item -Recurse", "Format-Volume", + // Execution policy bypass + "Set-ExecutionPolicy", + }; + + static GatherRuleGuards() + { + try + { + var json = LoadEmbeddedGuardrails(); + if (json != null) + { + var obj = JObject.Parse(json); + + AllowedRegistryPrefixes = FlattenCategorized(obj, "registryPrefixes", "prefixes"); + AllowedFilePrefixes = obj["filePrefixes"]?.ToObject>() ?? new List(); + AllowedWmiQueryPrefixes = obj["wmiQueryPrefixes"]?.ToObject>() ?? new List(); + + var commands = FlattenCategorized(obj, "allowedCommands", "commands"); + AllowedCommands = new HashSet(commands, StringComparer.OrdinalIgnoreCase); + return; + } + } + catch + { + // Fallback to empty lists on parse error — agent will block everything + // and emit security_warning events, making the issue visible. + } + + AllowedRegistryPrefixes = new List(); + AllowedFilePrefixes = new List(); + AllowedWmiQueryPrefixes = new List(); + AllowedCommands = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + private static string LoadEmbeddedGuardrails() + { + var assembly = Assembly.GetExecutingAssembly(); + using (var stream = assembly.GetManifestResourceStream( + "AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json")) + { + if (stream == null) + return null; + using (var reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + } + + /// + /// Flattens a categorized array structure like + /// [{ "category": "...", "prefixes": ["a","b"] }, ...] into ["a","b",...]. + /// + private static List FlattenCategorized(JObject root, string arrayProp, string itemsProp) + { + var result = new List(); + var arr = root[arrayProp] as JArray; + if (arr == null) return result; + + foreach (var group in arr) + { + var items = group[itemsProp] as JArray; + if (items == null) continue; + foreach (var item in items) + { + var val = item.Value(); + if (!string.IsNullOrEmpty(val)) + result.Add(val); + } + } + return result; + } + + // ----------------------------------------------------------------------- + // Guard methods + // ----------------------------------------------------------------------- + + /// + /// Returns true if the registry subPath (hive stripped) matches an allowed prefix + /// with segment-bounded matching (next char must be '\' or end of string). + /// + public static bool IsRegistryPathAllowed(string subPath) + => IsRegistryPathAllowed(subPath, unrestrictedMode: false); + + /// + /// Returns true if the registry subPath is allowed. + /// When unrestrictedMode is true, all registry paths are allowed. + /// + public static bool IsRegistryPathAllowed(string subPath, bool unrestrictedMode) + { + if (string.IsNullOrEmpty(subPath)) + return false; + + if (unrestrictedMode) + return true; + + return AllowedRegistryPrefixes.Any(prefix => + subPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + (subPath.Length == prefix.Length || subPath[prefix.Length] == '\\')); + } + + /// + /// Returns true if the expanded file path matches an allowed prefix + /// with segment-bounded matching and path normalization to prevent traversal. + /// + public static bool IsFilePathAllowed(string expandedPath) + => IsFilePathAllowed(expandedPath, unrestrictedMode: false, userProfilePath: null); + + /// + /// Returns true if the expanded file path is allowed. + /// When unrestrictedMode is true, all paths are allowed except C:\Users (privacy protection). + /// Path normalization and traversal protection always apply regardless of mode. + /// + public static bool IsFilePathAllowed(string expandedPath, bool unrestrictedMode) + => IsFilePathAllowed(expandedPath, unrestrictedMode, userProfilePath: null); + + /// + /// Returns true if the expanded file path is allowed. + /// When userProfilePath is provided (from %LOGGED_ON_USER_PROFILE% token), paths under + /// the user's AppData\Local and AppData\Roaming are additionally allowed. + /// + public static bool IsFilePathAllowed(string expandedPath, bool unrestrictedMode, string userProfilePath) + { + if (string.IsNullOrEmpty(expandedPath)) + return false; + + string normalizedPath; + try + { + normalizedPath = Path.GetFullPath(expandedPath); + } + catch + { + return false; + } + + // C:\Users block always applies (even in unrestricted mode) + // Exception: paths under \AppData\Local or AppData\Roaming + // are allowed when the %LOGGED_ON_USER_PROFILE% token was used. + if (normalizedPath.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) && + (normalizedPath.Length == BlockedUsersPrefix.Length || + normalizedPath[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar)) + { + if (!DiagnosticsPathGuards.IsUserProfileSubpathAllowed(normalizedPath, userProfilePath)) + return false; + } + + // Additional hard-blocked paths (even in unrestricted mode) + foreach (var blocked in AdditionalHardBlockedPathPrefixes) + { + if (normalizedPath.StartsWith(blocked, StringComparison.OrdinalIgnoreCase) && + (normalizedPath.Length == blocked.Length || + normalizedPath[blocked.Length] == Path.DirectorySeparatorChar)) + { + return false; + } + } + + if (unrestrictedMode) + return true; + + return AllowedFilePrefixes.Any(prefix => + normalizedPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + (normalizedPath.Length == prefix.Length || normalizedPath[prefix.Length] == '\\')); + } + + /// + /// Returns true if the WMI query (trimmed) matches an allowed prefix + /// with boundary matching (next char must be whitespace or end of string). + /// + public static bool IsWmiQueryAllowed(string query) + => IsWmiQueryAllowed(query, unrestrictedMode: false); + + /// + /// Returns true if the WMI query is allowed. + /// When unrestrictedMode is true, all WMI queries are allowed. + /// + public static bool IsWmiQueryAllowed(string query, bool unrestrictedMode) + { + if (string.IsNullOrEmpty(query)) + return false; + + if (unrestrictedMode) + return true; + + var trimmed = query.Trim(); + return AllowedWmiQueryPrefixes.Any(prefix => + trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + (trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length]))); + } + + /// + /// Returns true if the command (trimmed) exactly matches an entry in the allowlist. + /// Exact matching is intentional — no prefix spoofing possible. + /// + public static bool IsCommandAllowed(string command) + => IsCommandAllowed(command, unrestrictedMode: false); + + /// + /// Returns true if the command is allowed. + /// When unrestrictedMode is true, all commands are allowed. + /// + public static bool IsCommandAllowed(string command, bool unrestrictedMode) + { + if (string.IsNullOrEmpty(command)) + return false; + + // Hard guards apply even in unrestricted mode + if (command.Length > MaxCommandLength) + return false; + + foreach (var blocked in HardBlockedCommandPatterns) + { + if (command.IndexOf(blocked, StringComparison.OrdinalIgnoreCase) >= 0) + return false; + } + + if (unrestrictedMode) + return true; + + return AllowedCommands.Contains(command.Trim()); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs new file mode 100644 index 00000000..60f29617 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather +{ + /// + /// Strategy interface for individual gather rule collector implementations. + /// + public interface IGatherRuleCollector + { + string CollectorType { get; } + Dictionary Execute(GatherRule rule, GatherRuleContext context); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs new file mode 100644 index 00000000..2f05d620 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic +{ + /// + /// Measures the agent's own resource footprint: process CPU, memory, threads, handles, + /// and HTTP network traffic. Emits agent_metrics_snapshot events via the standard event pipeline. + /// No WMI, no PerformanceCounters — only Process properties and Interlocked counters. + /// + public class AgentSelfMetricsCollector : CollectorBase + { + private readonly string _agentVersion; + private readonly NetworkMetrics _networkMetrics; + + // Previous sample for delta calculations + private TimeSpan _prevCpuTime; + private DateTime _prevWallTime; + private NetworkMetricsSnapshot _prevNetSnapshot; + + public AgentSelfMetricsCollector( + string sessionId, + string tenantId, + Action onEventCollected, + NetworkMetrics networkMetrics, + AgentLogger logger, + string agentVersion = "unknown", + int intervalSeconds = 60) + : base(sessionId, tenantId, onEventCollected, logger, intervalSeconds) + { + _networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics)); + _agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion; + } + + protected override void OnBeforeStart() + { + // Prime the baseline for delta calculations + try + { + var proc = Process.GetCurrentProcess(); + _prevCpuTime = proc.TotalProcessorTime; + _prevWallTime = DateTime.UtcNow; + } + catch (Exception ex) + { + Logger.Warning($"Failed to prime CPU baseline: {ex.Message}"); + _prevCpuTime = TimeSpan.Zero; + _prevWallTime = DateTime.UtcNow; + } + _prevNetSnapshot = _networkMetrics.GetSnapshot(); + } + + protected override void Collect() + { + var data = new Dictionary + { + { "agent_version", _agentVersion } + }; + var now = DateTime.UtcNow; + + // --- Process metrics (no WMI, no PerformanceCounter) --- + try + { + var proc = Process.GetCurrentProcess(); + proc.Refresh(); // ensure fresh values + + // CPU %: (delta CPU time) / (delta wall time) / cores * 100 + var currentCpuTime = proc.TotalProcessorTime; + var cpuDelta = currentCpuTime - _prevCpuTime; + var wallDelta = now - _prevWallTime; + + if (wallDelta.TotalMilliseconds > 0) + { + var cpuPercent = cpuDelta.TotalMilliseconds / wallDelta.TotalMilliseconds + / Environment.ProcessorCount * 100.0; + data["agent_cpu_percent"] = Math.Round(cpuPercent, 2); + } + + _prevCpuTime = currentCpuTime; + _prevWallTime = now; + + // Memory + data["agent_working_set_mb"] = Math.Round(proc.WorkingSet64 / (1024.0 * 1024), 1); + data["agent_private_bytes_mb"] = Math.Round(proc.PrivateMemorySize64 / (1024.0 * 1024), 1); + + // Threads & handles + data["agent_thread_count"] = proc.Threads.Count; + data["agent_handle_count"] = proc.HandleCount; + } + catch (Exception ex) + { + Logger.Debug($"Process metrics read failed: {ex.Message}"); + } + + // --- Spool queue depth --- + // M4.5.a: removed legacy EventSpool-based queue-depth metric. V2 equivalent + // (TelemetrySpool.LastAssignedItemId - LastUploadedItemId) would require Transport + // wiring that is out of scope for this collector; deferred to M4.5.b when + // DefaultComponentFactory introduces the new telemetry wiring. + + // --- Network delta --- + try + { + var currentNet = _networkMetrics.GetSnapshot(); + var delta = currentNet.DeltaFrom(_prevNetSnapshot); + _prevNetSnapshot = currentNet; + + data["net_requests"] = delta.Requests; + data["net_failures"] = delta.Failures; + data["net_bytes_up"] = delta.BytesUp; + data["net_bytes_down"] = delta.BytesDown; + data["net_avg_latency_ms"] = Math.Round(delta.AvgLatencyMs, 1); + + // Cumulative totals for easy "total cost of this session" view + data["net_total_bytes_up"] = currentNet.TotalBytesUp; + data["net_total_bytes_down"] = currentNet.TotalBytesDown; + data["net_total_requests"] = currentNet.RequestCount; + } + catch (Exception ex) + { + Logger.Debug($"Network metrics read failed: {ex.Message}"); + } + + if (data.Count > 0) + { + EmitEvent(new EnrollmentEvent + { + SessionId = SessionId, + TenantId = TenantId, + Timestamp = now, + EventType = "agent_metrics_snapshot", + Severity = EventSeverity.Debug, + Source = "AgentSelfMetricsCollector", + Phase = EnrollmentPhase.Unknown, + Message = $"Agent CPU: {(data.ContainsKey("agent_cpu_percent") ? data["agent_cpu_percent"] : "?")}%, " + + $"WS: {(data.ContainsKey("agent_working_set_mb") ? data["agent_working_set_mb"] : "?")} MB, " + + $"Net: {(data.ContainsKey("net_requests") ? data["net_requests"] : "?")} req, " + + $"\u2191{(data.ContainsKey("net_bytes_up") ? data["net_bytes_up"] : "?")} B, " + + $"\u2193{(data.ContainsKey("net_bytes_down") ? data["net_bytes_down"] : "?")} B", + Data = data + }); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs new file mode 100644 index 00000000..85a0c805 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs @@ -0,0 +1,488 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Management.Automation; +using System.Management.Automation.Runspaces; +using System.Text; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic +{ + /// + /// Polls OS-level Delivery Optimization status via a persistent PowerShell Runspace. + /// Matches DO entries to tracked IME app downloads using the intunewin-bin FileId format. + /// Emits download_progress events on every poll (for live UI) and do_telemetry once per app + /// when the download completes. + /// + /// Lifecycle: starts dormant, wakes up when ImeLogTracker detects a download, + /// goes dormant again when all downloads are enriched. + /// + public class DeliveryOptimizationCollector : CollectorBase + { + private const string LogFileName = "do-status.jsonl"; + private const int InvokeTimeoutMs = 5000; + + private readonly Func _getPackageStates; + private readonly Action _onDoTelemetryReceived; + private readonly string _logFilePath; + + private Runspace _runspace; + private bool _permanentlyDisabled; + private int _consecutiveErrors; + private volatile bool _dormant = true; + private int _collecting; // concurrency guard (0 = idle, 1 = collecting) + + // Track which apps we already sent final do_telemetry for (prevents duplicate callbacks) + private readonly HashSet _enrichedAppIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Change detection per app: only emit download_progress when bytes actually changed + private readonly Dictionary _lastBytesPerApp = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Change detection for JSONL log: only write when overall state changed + private long _lastSnapshotFingerprint; + + public DeliveryOptimizationCollector( + string sessionId, + string tenantId, + Action emitEvent, + AgentLogger logger, + int intervalSeconds, + Func getPackageStates, + Action onDoTelemetryReceived, + string logDirectory) + : base(sessionId, tenantId, emitEvent, logger, intervalSeconds) + { + _getPackageStates = getPackageStates ?? throw new ArgumentNullException(nameof(getPackageStates)); + _onDoTelemetryReceived = onDoTelemetryReceived; + _logFilePath = Path.Combine(logDirectory, LogFileName); + } + + /// Start dormant — timer does not fire until WakeUp() is called. + protected override TimeSpan GetInitialDelay() => Timeout.InfiniteTimeSpan; + + protected override void OnBeforeStart() + { + EnsureRunspace(); + } + + protected override void OnAfterStop() + { + DisposeRunspace(); + } + + /// + /// Wakes the collector from dormant state. Called by MonitoringService when + /// ImeLogTracker detects an app entering the Downloading state. + /// Safe to call multiple times / from any thread. + /// + public void WakeUp() + { + if (_permanentlyDisabled || !_dormant) return; + _dormant = false; + Logger.Info("[DeliveryOptimizationCollector] Waking up — download activity detected"); + ResumeTimer(); + } + + protected override void Collect() + { + if (_permanentlyDisabled || _dormant) return; + + // Concurrency guard: Runspace is not thread-safe. Skip if previous poll is still running. + if (Interlocked.CompareExchange(ref _collecting, 1, 0) != 0) + { + Logger.Debug("[DeliveryOptimizationCollector] Skipping poll — previous invocation still running"); + return; + } + try + { + CollectCore(); + } + finally + { + Interlocked.Exchange(ref _collecting, 0); + } + } + + private void CollectCore() + { + + // Guard: check if there's still work to do + var packageStates = _getPackageStates(); + if (packageStates == null || packageStates.Count == 0) return; + + var hasActiveDownloads = false; + var hasPendingEnrichment = false; + for (int i = 0; i < packageStates.Count; i++) + { + var p = packageStates[i]; + if (p.InstallationState == AppInstallationState.Downloading || + p.InstallationState == AppInstallationState.Installing) + hasActiveDownloads = true; + + if (!p.HasDoTelemetry && !_enrichedAppIds.Contains(p.Id) && + p.DownloadingOrInstallingSeen && + (p.InstallationState == AppInstallationState.Installed || + p.InstallationState == AppInstallationState.Error)) + hasPendingEnrichment = true; + } + + if (!hasActiveDownloads && !hasPendingEnrichment) + { + Logger.Info("[DeliveryOptimizationCollector] Going dormant — no active downloads or pending enrichment"); + _dormant = true; + PauseTimer(); + return; + } + + // Invoke PowerShell via persistent Runspace + var results = InvokeDoStatus(); + if (results == null) return; + + _consecutiveErrors = 0; + + // Process results: emit progress events and match completed downloads + ProcessResults(results, packageStates); + } + + // ----------------------------------------------------------------------- + // PowerShell Runspace management + // ----------------------------------------------------------------------- + + private void EnsureRunspace() + { + if (_runspace != null && _runspace.RunspaceStateInfo.State == RunspaceState.Opened) + return; + + DisposeRunspace(); + + try + { + _runspace = RunspaceFactory.CreateRunspace(); + _runspace.Open(); + Logger.Info("[DeliveryOptimizationCollector] PowerShell Runspace opened"); + } + catch (Exception ex) + { + Logger.Warning($"[DeliveryOptimizationCollector] Failed to open Runspace: {ex.Message}"); + _runspace = null; + } + } + + private void DisposeRunspace() + { + if (_runspace == null) return; + try + { + _runspace.Close(); + _runspace.Dispose(); + } + catch { /* best effort */ } + _runspace = null; + } + + /// + /// Invokes Get-DeliveryOptimizationStatus in the persistent Runspace. + /// Returns a list of PSObject results, or null on error. + /// Self-heals: recreates the Runspace on failure. + /// + private List InvokeDoStatus() + { + EnsureRunspace(); + if (_runspace == null) + { + HandleError("Runspace not available"); + return null; + } + + try + { + using (var ps = PowerShell.Create()) + { + ps.Runspace = _runspace; + ps.AddCommand("Get-DeliveryOptimizationStatus"); + + // Invoke with timeout protection + var asyncResult = ps.BeginInvoke(); + if (!asyncResult.AsyncWaitHandle.WaitOne(InvokeTimeoutMs)) + { + ps.Stop(); + Logger.Warning("[DeliveryOptimizationCollector] PS invoke timed out after 5s, stopping"); + HandleError("timeout"); + return null; + } + + var results = new List(ps.EndInvoke(asyncResult)); + + // Check for errors in the PS error stream + if (ps.HadErrors && ps.Streams.Error.Count > 0) + { + var firstError = ps.Streams.Error[0].ToString(); + if (firstError.Contains("is not recognized as the name of a cmdlet") || + firstError.Contains("CommandNotFoundException")) + { + Logger.Warning("[DeliveryOptimizationCollector] Get-DeliveryOptimizationStatus not available, disabling permanently"); + _permanentlyDisabled = true; + return null; + } + Logger.Debug($"[DeliveryOptimizationCollector] PS error stream: {firstError}"); + } + + return results; + } + } + catch (PSInvalidOperationException ex) + { + Logger.Warning($"[DeliveryOptimizationCollector] Runspace error, self-healing: {ex.Message}"); + DisposeRunspace(); + HandleError(ex.Message); + return null; + } + catch (RuntimeException ex) + { + // Catches command-not-found and other PS runtime errors + if (ex.ErrorRecord?.FullyQualifiedErrorId?.Contains("CommandNotFoundException") == true) + { + Logger.Warning("[DeliveryOptimizationCollector] Get-DeliveryOptimizationStatus not available, disabling permanently"); + _permanentlyDisabled = true; + return null; + } + Logger.Warning($"[DeliveryOptimizationCollector] PS runtime error: {ex.Message}"); + HandleError(ex.Message); + return null; + } + catch (Exception ex) + { + Logger.Warning($"[DeliveryOptimizationCollector] Invoke failed: {ex.Message}"); + DisposeRunspace(); + HandleError(ex.Message); + return null; + } + } + + // ----------------------------------------------------------------------- + // Result processing: progress events + final DO telemetry + // ----------------------------------------------------------------------- + + private void ProcessResults(List results, AppPackageStateList packageStates) + { + if (results.Count == 0) + { + Logger.Verbose("[DeliveryOptimizationCollector] No DO entries returned"); + return; + } + + int progressCount = 0; + int matchCount = 0; + + // Build JSONL snapshot for log (only if fingerprint changes) + var logEntries = new JArray(); + + foreach (var result in results) + { + var fileId = GetPropString(result, "FileId"); + if (string.IsNullOrEmpty(fileId)) continue; + + long fileSize = GetPropLong(result, "FileSize"); + long totalBytes = GetPropLong(result, "TotalBytesDownloaded"); + long bytesFromPeers = GetPropLong(result, "BytesFromPeers"); + int peerCachingPct = GetPropInt(result, "PercentPeerCaching"); + long bytesLanPeers = GetPropLong(result, "BytesFromLanPeers"); + long bytesGroupPeers = GetPropLong(result, "BytesFromGroupPeers"); + long bytesInternetPeers = GetPropLong(result, "BytesFromInternetPeers"); + int downloadMode = GetPropInt(result, "DownloadMode", -1); + long bytesFromHttp = GetPropLong(result, "BytesFromHttp"); + long bytesFromCacheServer = GetPropLong(result, "BytesFromCacheServer"); + var downloadDuration = GetPropTimeSpan(result, "DownloadDuration"); + var sourceUrl = GetPropString(result, "SourceURL"); + + // Build log entry for JSONL + logEntries.Add(new JObject + { + ["FileId"] = fileId, + ["FileSize"] = fileSize, + ["TotalBytesDownloaded"] = totalBytes, + ["PercentPeerCaching"] = peerCachingPct, + ["BytesFromPeers"] = bytesFromPeers, + ["BytesFromHttp"] = bytesFromHttp, + ["DownloadMode"] = downloadMode, + ["SourceURL"] = sourceUrl, + ["BytesFromCacheServer"] = bytesFromCacheServer + }); + + // Try to match to an IME-tracked app + var appId = ImeLogTracker.ExtractAppIdFromDoFileId(fileId); + if (string.IsNullOrEmpty(appId)) continue; + + var pkg = packageStates.GetPackage(appId); + if (pkg == null) continue; + + // --- Live download_progress (every poll where bytes changed) --- + long lastBytes; + _lastBytesPerApp.TryGetValue(appId, out lastBytes); + + if (totalBytes != lastBytes) + { + _lastBytesPerApp[appId] = totalBytes; + progressCount++; + + int percentComplete = fileSize > 0 ? (int)((totalBytes * 100) / fileSize) : 0; + + EmitEvent(new EnrollmentEvent + { + SessionId = SessionId, + TenantId = TenantId, + EventType = "download_progress", + Severity = EventSeverity.Debug, + Source = "DeliveryOptimizationCollector", + Phase = EnrollmentPhase.Unknown, + Message = $"{pkg.Name ?? appId}: {percentComplete}% ({totalBytes}/{fileSize}) peers={peerCachingPct}% mode={downloadMode}", + Data = new Dictionary + { + ["appId"] = appId, + ["appName"] = pkg.Name, + // UI-compatible fields (DownloadProgress.tsx reads these) + ["bytesDownloaded"] = totalBytes, + ["bytesTotal"] = fileSize, + ["progressPercent"] = percentComplete, + // DO-specific fields + ["doFileSize"] = fileSize, + ["doTotalBytesDownloaded"] = totalBytes, + ["doPercentComplete"] = percentComplete, + ["doBytesFromPeers"] = bytesFromPeers, + ["doBytesFromHttp"] = bytesFromHttp, + ["doPercentPeerCaching"] = peerCachingPct, + ["doDownloadMode"] = downloadMode, + ["doBytesFromCacheServer"] = bytesFromCacheServer, + ["doSource"] = "os_cmdlet" + }, + ImmediateUpload = true + }); + } + + // --- Final DO telemetry (once per app when download is complete) --- + if (!pkg.HasDoTelemetry && !_enrichedAppIds.Contains(appId) && + totalBytes >= fileSize && fileSize > 0) + { + var durationStr = downloadDuration?.ToString(@"hh\:mm\:ss\.fff"); + + pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct, + bytesLanPeers, bytesGroupPeers, bytesInternetPeers, + downloadMode, durationStr, bytesFromHttp); + + _enrichedAppIds.Add(appId); + matchCount++; + + Logger.Info($"[DeliveryOptimizationCollector] DO matched: {pkg.Name ?? appId} — " + + $"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " + + $"http={bytesFromHttp}, mode={downloadMode}, duration={durationStr}"); + + // Fire callback → EnrollmentTracker emits do_telemetry + download_progress events + _onDoTelemetryReceived?.Invoke(pkg); + } + } + + // Write JSONL log only when overall state changed + var fingerprint = ComputeFingerprint(logEntries); + if (fingerprint != _lastSnapshotFingerprint) + { + _lastSnapshotFingerprint = fingerprint; + WriteToLogFile(logEntries); + } + + Logger.Verbose($"[DeliveryOptimizationCollector] Poll: {results.Count} entries, " + + $"{progressCount} progress updates, {matchCount} new matches ({_enrichedAppIds.Count} total enriched)"); + } + + // ----------------------------------------------------------------------- + // PSObject property helpers (safe extraction, no exceptions on missing props) + // ----------------------------------------------------------------------- + + private static string GetPropString(PSObject obj, string name) + { + return obj.Properties[name]?.Value?.ToString(); + } + + private static long GetPropLong(PSObject obj, string name, long defaultValue = 0) + { + var val = obj.Properties[name]?.Value; + if (val == null) return defaultValue; + try { return Convert.ToInt64(val); } + catch { return defaultValue; } + } + + private static int GetPropInt(PSObject obj, string name, int defaultValue = 0) + { + var val = obj.Properties[name]?.Value; + if (val == null) return defaultValue; + try { return Convert.ToInt32(val); } + catch { return defaultValue; } + } + + private static TimeSpan? GetPropTimeSpan(PSObject obj, string name) + { + var val = obj.Properties[name]?.Value; + if (val is TimeSpan ts) return ts; + return null; + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private void HandleError(string error) + { + _consecutiveErrors++; + + if (_consecutiveErrors >= 5) + { + Logger.Warning($"[DeliveryOptimizationCollector] {_consecutiveErrors} consecutive errors, disabling permanently. Last: {error}"); + _permanentlyDisabled = true; + return; + } + + Logger.Warning($"[DeliveryOptimizationCollector] Error ({_consecutiveErrors}/5): {error}"); + } + + private static long ComputeFingerprint(JArray entries) + { + long sum = entries.Count; + foreach (var e in entries) + { + sum += e["TotalBytesDownloaded"]?.Value() ?? 0; + // Include FileId hash to detect entry additions/removals + var fileId = e["FileId"]?.ToString(); + if (fileId != null) + sum += fileId.GetHashCode(); + } + return sum; + } + + private void WriteToLogFile(JArray entries) + { + try + { + var snapshot = new JObject + { + ["timestamp"] = DateTime.UtcNow.ToString("o"), + ["entryCount"] = entries.Count, + ["entries"] = entries + }; + + var line = snapshot.ToString(Formatting.None) + Environment.NewLine; + File.AppendAllText(_logFilePath, line, Encoding.UTF8); + } + catch (Exception ex) + { + Logger.Warning($"[DeliveryOptimizationCollector] Failed to write DO log: {ex.Message}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs new file mode 100644 index 00000000..ec683c42 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs @@ -0,0 +1,598 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic +{ + /// + /// Detects network changes (SSID switches, wired↔wireless, IP changes) during provisioning + /// and runs MDM endpoint connectivity checks after each change. + /// Always-on collector — fires network_state_change and network_connectivity_check events. + /// + public class NetworkChangeDetector : IDisposable + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly AgentLogger _logger; + private readonly string _apiBaseUrl; + + private NetworkStateSnapshot _lastKnownState; + private Timer _debounceTimer; + private HttpClient _connectivityCheckClient; + private readonly object _stateLock = new object(); + private bool _disposed; + + private const int DebounceDelayMs = 3000; + private const int ConnectivityCheckDelayMs = 5000; + private const int ConnectivityCheckTimeoutMs = 8000; + private const int WifiCollectTimeoutMs = 3000; + private const string Source = "NetworkChangeDetector"; + + /// + /// Well-known MDM/Entra endpoints that must be reachable for enrollment to succeed. + /// + private static readonly (string Name, string Url)[] MdmEndpoints = + { + ("Entra ID", "https://login.microsoftonline.com"), + ("Device Registration", "https://enterpriseregistration.windows.net"), + ("Intune Portal", "https://portal.manage.microsoft.com"), + ("Microsoft Graph", "https://graph.microsoft.com"), + }; + + public NetworkChangeDetector( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger, + string apiBaseUrl = null) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _apiBaseUrl = apiBaseUrl; + } + + public void Start() + { + _logger.Info("NetworkChangeDetector: starting (monitoring network changes)"); + + _connectivityCheckClient = new HttpClient + { + Timeout = TimeSpan.FromMilliseconds(ConnectivityCheckTimeoutMs) + }; + + // Capture initial network state as baseline + _lastKnownState = CaptureCurrentState(); + _logger.Info($"NetworkChangeDetector: initial state — {FormatSnapshotSummary(_lastKnownState)}"); + + NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; + } + + public void Stop() + { + NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged; + + lock (_stateLock) + { + _debounceTimer?.Dispose(); + _debounceTimer = null; + } + + _connectivityCheckClient?.Dispose(); + _connectivityCheckClient = null; + + _logger.Info("NetworkChangeDetector: stopped"); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + } + + // ------------------------------------------------------------------- + // Event handling & debounce + // ------------------------------------------------------------------- + + private void OnNetworkAddressChanged(object sender, EventArgs e) + { + if (_disposed) return; + + lock (_stateLock) + { + // Reset debounce timer — collapses rapid OS events into one logical change + _debounceTimer?.Dispose(); + _debounceTimer = new Timer( + OnDebounceElapsed, + null, + DebounceDelayMs, + Timeout.Infinite); + } + } + + private void OnDebounceElapsed(object state) + { + if (_disposed) return; + + Task.Run(() => + { + try + { + ProcessNetworkChange(); + } + catch (Exception ex) + { + _logger.Warning($"NetworkChangeDetector: error processing network change: {ex.Message}"); + } + }); + } + + // ------------------------------------------------------------------- + // Core logic + // ------------------------------------------------------------------- + + private void ProcessNetworkChange() + { + if (_disposed) return; + + var newState = CaptureCurrentState(); + + NetworkStateSnapshot previousState; + lock (_stateLock) + { + previousState = _lastKnownState; + _lastKnownState = newState; + } + + // Suppress events when network hasn't actually changed (DHCP renewals, OS chatter) + if (previousState != null && previousState.IsSameNetwork(newState)) + { + _logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing"); + return; + } + + _logger.Info($"NetworkChangeDetector: network change detected — {FormatSnapshotSummary(previousState)} → {FormatSnapshotSummary(newState)}"); + + // Emit network_state_change event + var changeType = ClassifyChange(previousState, newState); + var message = BuildChangeMessage(previousState, newState); + + var data = new Dictionary + { + // Before state + { "before_connectionType", previousState?.ConnectionType ?? "None" }, + { "before_adapterName", previousState?.AdapterName ?? "None" }, + { "before_ipAddress", previousState?.IpAddress ?? "None" }, + { "before_gateway", previousState?.Gateway ?? "None" }, + { "before_wifiSsid", previousState?.WifiSsid ?? "n/a" }, + { "before_linkSpeedMbps", previousState?.LinkSpeedMbps ?? 0 }, + + // After state + { "after_connectionType", newState.ConnectionType ?? "None" }, + { "after_adapterName", newState.AdapterName ?? "None" }, + { "after_ipAddress", newState.IpAddress ?? "None" }, + { "after_gateway", newState.Gateway ?? "None" }, + { "after_wifiSsid", newState.WifiSsid ?? "n/a" }, + { "after_linkSpeedMbps", newState.LinkSpeedMbps }, + + // Change classification + { "changeType", changeType }, + { "hadNetwork", previousState?.HasNetwork ?? false }, + { "hasNetwork", newState.HasNetwork } + }; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.NetworkStateChange, + Severity = EventSeverity.Warning, + Source = Source, + Phase = EnrollmentPhase.Unknown, + Message = message, + Data = data + }); + + // Wait for new connection to stabilize, then check MDM endpoint connectivity + try + { + Task.Delay(ConnectivityCheckDelayMs).Wait(); + if (!_disposed) + { + RunConnectivityChecksAsync().Wait(); + } + } + catch (Exception ex) + { + _logger.Debug($"NetworkChangeDetector: connectivity check failed: {ex.Message}"); + } + } + + private async Task RunConnectivityChecksAsync() + { + if (_disposed || _connectivityCheckClient == null) return; + + // Build endpoint list: static MDM endpoints + dynamic backend URL + var endpoints = new List<(string Name, string Url)>(MdmEndpoints); + // Optionally add the Autopilot Monitor API endpoint, but we don't want to cause false alarms if it's down or unreachable, + // since it's not critical for enrollment success and may be available only after certain network changes (e.g. if it's behind a captive portal). + // For now, we can omit it from the critical checks. + // if (!string.IsNullOrEmpty(_apiBaseUrl)) + // { + // endpoints.Add(("Autopilot Monitor API", _apiBaseUrl)); + // } + + // Run all checks in parallel + var tasks = endpoints.Select(ep => CheckEndpointAsync(ep.Name, ep.Url)).ToArray(); + + Dictionary[] results; + try + { + results = await Task.WhenAll(tasks); + } + catch (Exception ex) + { + _logger.Debug($"NetworkChangeDetector: connectivity check tasks failed: {ex.Message}"); + return; + } + + if (_disposed) return; + + var reachableCount = results.Count(r => r.ContainsKey("reachable") && (bool)r["reachable"]); + var totalCount = results.Length; + var allReachable = reachableCount == totalCount; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.NetworkConnectivityCheck, + Severity = allReachable ? EventSeverity.Info : EventSeverity.Warning, + Source = Source, + Phase = EnrollmentPhase.Unknown, + Message = $"Connectivity check after network change: {reachableCount}/{totalCount} endpoints reachable", + Data = new Dictionary + { + { "reachableCount", reachableCount }, + { "totalCount", totalCount }, + { "allReachable", allReachable }, + { "checkDelayMs", ConnectivityCheckDelayMs }, + { "timeoutMs", ConnectivityCheckTimeoutMs }, + { "results", results.ToList() } + } + }); + + if (allReachable) + _logger.Info($"NetworkChangeDetector: connectivity check passed ({reachableCount}/{totalCount} reachable)"); + else + _logger.Warning($"NetworkChangeDetector: connectivity check partial — {reachableCount}/{totalCount} endpoints reachable"); + } + + private async Task> CheckEndpointAsync(string name, string url) + { + var sw = Stopwatch.StartNew(); + try + { + // Use GET — any HTTP response (even 401/403) means the network path works + var response = await _connectivityCheckClient.GetAsync(url); + sw.Stop(); + return new Dictionary + { + { "endpoint", name }, + { "url", url }, + { "reachable", true }, + { "httpStatus", (int)response.StatusCode }, + { "latencyMs", sw.ElapsedMilliseconds }, + { "error", (object)null } + }; + } + catch (Exception ex) + { + sw.Stop(); + // Unwrap AggregateException from async + var innerEx = ex is AggregateException agg ? agg.InnerException ?? ex : ex; + return new Dictionary + { + { "endpoint", name }, + { "url", url }, + { "reachable", false }, + { "httpStatus", (object)null }, + { "latencyMs", sw.ElapsedMilliseconds }, + { "error", $"{innerEx.GetType().Name}: {innerEx.Message}" } + }; + } + } + + // ------------------------------------------------------------------- + // State capture + // ------------------------------------------------------------------- + + private NetworkStateSnapshot CaptureCurrentState() + { + try + { + var activeNic = FindActiveNetworkInterface(); + if (activeNic == null) + { + return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow }; + } + + var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211; + var ipProps = activeNic.GetIPProperties(); + + // First IPv4 unicast address + string ipAddress = null; + foreach (var addr in ipProps.UnicastAddresses) + { + if (addr.Address.AddressFamily == AddressFamily.InterNetwork) + { + ipAddress = addr.Address.ToString(); + break; + } + } + + // First non-0.0.0.0 gateway + string gateway = null; + foreach (var gw in ipProps.GatewayAddresses) + { + if (gw.Address.ToString() != "0.0.0.0") + { + gateway = gw.Address.ToString(); + break; + } + } + + var snapshot = new NetworkStateSnapshot + { + HasNetwork = true, + AdapterName = activeNic.Name, + AdapterDescription = activeNic.Description, + InterfaceType = activeNic.NetworkInterfaceType.ToString(), + ConnectionType = isWifi ? "WiFi" : "Ethernet", + MacAddress = activeNic.GetPhysicalAddress().ToString(), + InterfaceId = activeNic.Id, + LinkSpeedMbps = activeNic.Speed / 1_000_000, + IpAddress = ipAddress, + Gateway = gateway, + CapturedAt = DateTime.UtcNow + }; + + // Collect WiFi SSID synchronously (needed for before/after comparison) + if (isWifi) + { + var wifiInfo = CollectWiFiInfo(); + snapshot.WifiSsid = wifiInfo.ssid; + snapshot.WifiSignalPercent = wifiInfo.signal; + snapshot.WifiRadioType = wifiInfo.radioType; + } + + return snapshot; + } + catch (Exception ex) + { + _logger.Debug($"NetworkChangeDetector: state capture failed: {ex.Message}"); + return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow }; + } + } + + /// + /// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces. + /// Synchronous with timeout — needed before emitting the event. + /// + private (string ssid, int? signal, string radioType) CollectWiFiInfo() + { + string ssid = null; + int? signal = null; + string radioType = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = SystemPaths.Netsh, + Arguments = "wlan show interfaces", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + string output; + using (var process = Process.Start(psi)) + { + output = process.StandardOutput.ReadToEnd(); + if (!process.WaitForExit(WifiCollectTimeoutMs)) + { + try { process.Kill(); } catch { } + _logger.Debug("NetworkChangeDetector: netsh timed out"); + return (null, null, null); + } + } + + if (string.IsNullOrEmpty(output)) + return (null, null, null); + + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = line.Trim(); + var colonIndex = trimmed.IndexOf(':'); + if (colonIndex < 0) continue; + + var key = trimmed.Substring(0, colonIndex).Trim(); + var value = trimmed.Substring(colonIndex + 1).Trim(); + + if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) && + !key.Equals("BSSID", StringComparison.OrdinalIgnoreCase)) + { + ssid = value; + } + else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(value.TrimEnd('%'), out var sig)) + signal = sig; + } + else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase)) + { + radioType = value; + } + } + } + catch (Exception ex) + { + _logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}"); + } + + return (ssid, signal, radioType); + } + + // ------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------- + + /// + /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. + /// + private static NetworkInterface FindActiveNetworkInterface() + { + try + { + var interfaces = NetworkInterface.GetAllNetworkInterfaces(); + foreach (var nic in interfaces) + { + if (nic.OperationalStatus != OperationalStatus.Up) + continue; + if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback || + nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + continue; + + var ipProps = nic.GetIPProperties(); + foreach (var gw in ipProps.GatewayAddresses) + { + if (gw.Address.ToString() != "0.0.0.0") + return nic; + } + } + } + catch + { + // Caller handles null + } + return null; + } + + private static string BuildChangeMessage(NetworkStateSnapshot before, NetworkStateSnapshot after) + { + var beforeDesc = FormatSnapshotBrief(before); + var afterDesc = FormatSnapshotBrief(after); + + if (before == null || !before.HasNetwork) + { + if (after.HasNetwork) + return $"Network connected: {afterDesc}"; + return "Network state changed (no connectivity)"; + } + + if (!after.HasNetwork) + return $"Network lost (was {beforeDesc})"; + + return $"Network changed: {beforeDesc} \u2192 {afterDesc}"; + } + + private static string FormatSnapshotBrief(NetworkStateSnapshot snapshot) + { + if (snapshot == null || !snapshot.HasNetwork) + return "None"; + + if (snapshot.ConnectionType == "WiFi" && !string.IsNullOrEmpty(snapshot.WifiSsid)) + return $"WiFi '{snapshot.WifiSsid}'"; + + return $"{snapshot.ConnectionType} ({snapshot.IpAddress ?? "no IP"})"; + } + + private static string FormatSnapshotSummary(NetworkStateSnapshot snapshot) + { + if (snapshot == null || !snapshot.HasNetwork) + return "no network"; + + var summary = $"{snapshot.ConnectionType}"; + if (!string.IsNullOrEmpty(snapshot.WifiSsid)) + summary += $" SSID='{snapshot.WifiSsid}'"; + if (!string.IsNullOrEmpty(snapshot.IpAddress)) + summary += $" IP={snapshot.IpAddress}"; + return summary; + } + + private static string ClassifyChange(NetworkStateSnapshot before, NetworkStateSnapshot after) + { + if (before == null || !before.HasNetwork) + return after.HasNetwork ? "network_restored" : "no_change"; + + if (!after.HasNetwork) + return "network_lost"; + + // Both have network — classify the change + if (before.ConnectionType != after.ConnectionType) + return "type_change"; + + if (before.ConnectionType == "WiFi" && after.ConnectionType == "WiFi" && + before.WifiSsid != after.WifiSsid) + return "ssid_change"; + + if (before.InterfaceId != after.InterfaceId) + return "adapter_change"; + + return "ip_change"; + } + + // ------------------------------------------------------------------- + // Inner type: network state snapshot + // ------------------------------------------------------------------- + + private class NetworkStateSnapshot + { + public bool HasNetwork { get; set; } + public string AdapterName { get; set; } + public string AdapterDescription { get; set; } + public string InterfaceType { get; set; } + public string ConnectionType { get; set; } + public string MacAddress { get; set; } + public string InterfaceId { get; set; } + public long LinkSpeedMbps { get; set; } + public string IpAddress { get; set; } + public string Gateway { get; set; } + public string WifiSsid { get; set; } + public int? WifiSignalPercent { get; set; } + public string WifiRadioType { get; set; } + public DateTime CapturedAt { get; set; } + + /// + /// Returns true if both snapshots represent the same logical network connection. + /// Suppresses events for DHCP renewals and other OS chatter. + /// + public bool IsSameNetwork(NetworkStateSnapshot other) + { + if (other == null) return false; + if (HasNetwork != other.HasNetwork) return false; + if (!HasNetwork && !other.HasNetwork) return true; + + // Same adapter + same IP + same SSID = same logical network + return InterfaceId == other.InterfaceId && + IpAddress == other.IpAddress && + WifiSsid == other.WifiSsid; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/PerformanceCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/PerformanceCollector.cs new file mode 100644 index 00000000..6f286104 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/PerformanceCollector.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.NetworkInformation; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic +{ + /// + /// Collects system performance metrics (CPU, memory, disk) on a configurable interval. + /// Optional collector - toggled on/off via remote config. + /// + public class PerformanceCollector : CollectorBase + { + private PerformanceCounter _cpuCounter; + private PerformanceCounter _diskQueueCounter; + + // Network throughput tracking + private string _activeNicId; + private string _activeNicName; + private long _prevBytesSent; + private long _prevBytesReceived; + private DateTime _prevNetSampleTime; + private bool _networkInitialized; + + public PerformanceCollector(string sessionId, string tenantId, Action onEventCollected, + AgentLogger logger, int intervalSeconds = 60) + : base(sessionId, tenantId, onEventCollected, logger, intervalSeconds) + { + } + + /// Use a 5-second warm-up delay so counters are primed before first read. + protected override TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(5); + + protected override void OnBeforeStart() + { + try + { + _cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total"); + _diskQueueCounter = new PerformanceCounter("PhysicalDisk", "Current Disk Queue Length", "_Total"); + + // Initial read to prime the counters (first reading is always 0) + _cpuCounter.NextValue(); + } + catch (Exception ex) + { + Logger.Warning($"Failed to initialize performance counters: {ex.Message}"); + } + + // Initialize network throughput baseline + try + { + var activeNic = FindActiveNetworkInterface(); + if (activeNic != null) + { + _activeNicId = activeNic.Id; + _activeNicName = activeNic.Description; + var stats = activeNic.GetIPStatistics(); + _prevBytesSent = stats.BytesSent; + _prevBytesReceived = stats.BytesReceived; + _prevNetSampleTime = DateTime.UtcNow; + _networkInitialized = true; + Logger.Debug($"Network throughput tracking initialized on: {_activeNicName}"); + } + else + { + Logger.Debug("No active network interface found for throughput tracking"); + } + } + catch (Exception ex) + { + Logger.Debug($"Failed to initialize network throughput baseline: {ex.Message}"); + } + } + + protected override void OnAfterStop() + { + _cpuCounter?.Dispose(); + _cpuCounter = null; + _diskQueueCounter?.Dispose(); + _diskQueueCounter = null; + } + + protected override void Collect() + { + var data = new Dictionary(); + + // CPU usage + try + { + var cpuPercent = _cpuCounter?.NextValue() ?? 0; + data["cpu_percent"] = Math.Round(cpuPercent, 1); + } + catch (Exception ex) + { + Logger.Debug($"CPU counter read failed: {ex.Message}"); + } + + // Memory info via GlobalMemoryStatusEx (kernel32) + try + { + if (MemoryNativeMethods.TryGetMemoryInfo(out var availBytes, out var totalBytes, out var loadPercent) + && totalBytes > 0) + { + data["memory_available_mb"] = Math.Round(availBytes / (1024.0 * 1024.0), 0); + data["memory_total_mb"] = Math.Round(totalBytes / (1024.0 * 1024.0), 0); + data["memory_used_percent"] = Math.Round((double)loadPercent, 1); + } + else + { + Logger.Debug("GlobalMemoryStatusEx returned no data"); + } + } + catch (Exception ex) + { + Logger.Debug($"Memory query failed: {ex.Message}"); + } + + // Disk queue length + try + { + var diskQueue = _diskQueueCounter?.NextValue() ?? 0; + data["disk_queue_length"] = Math.Round(diskQueue, 1); + } + catch (Exception ex) + { + Logger.Debug($"Disk queue counter read failed: {ex.Message}"); + } + + // Disk free space on system drive + try + { + var systemDrive = Path.GetPathRoot(Environment.SystemDirectory); + var driveInfo = new DriveInfo(systemDrive); + data["disk_free_gb"] = Math.Round(driveInfo.AvailableFreeSpace / (1024.0 * 1024 * 1024), 1); + data["disk_total_gb"] = Math.Round(driveInfo.TotalSize / (1024.0 * 1024 * 1024), 1); + } + catch (Exception ex) + { + Logger.Debug($"Disk space query failed: {ex.Message}"); + } + + // Network throughput (bytes sent/received delta + rate) + try + { + if (_networkInitialized) + { + // Re-find the NIC by cached ID + NetworkInterface currentNic = null; + try + { + foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.Id == _activeNicId) + { + currentNic = nic; + break; + } + } + } + catch { /* swallow */ } + + // If cached NIC is gone, try to find a new active one + if (currentNic == null || currentNic.OperationalStatus != OperationalStatus.Up) + { + currentNic = FindActiveNetworkInterface(); + if (currentNic != null) + { + _activeNicId = currentNic.Id; + _activeNicName = currentNic.Description; + var freshStats = currentNic.GetIPStatistics(); + _prevBytesSent = freshStats.BytesSent; + _prevBytesReceived = freshStats.BytesReceived; + _prevNetSampleTime = DateTime.UtcNow; + Logger.Debug($"Network throughput tracking switched to: {_activeNicName}"); + } + else + { + _networkInitialized = false; + Logger.Debug("Active network interface lost, throughput tracking paused"); + } + } + + if (currentNic != null && currentNic.OperationalStatus == OperationalStatus.Up) + { + var stats = currentNic.GetIPStatistics(); + var now = DateTime.UtcNow; + var elapsedSeconds = (now - _prevNetSampleTime).TotalSeconds; + + if (elapsedSeconds > 0) + { + var deltaSent = stats.BytesSent - _prevBytesSent; + var deltaReceived = stats.BytesReceived - _prevBytesReceived; + + // Guard against counter reset (adapter reconnect) + if (deltaSent < 0) deltaSent = 0; + if (deltaReceived < 0) deltaReceived = 0; + + data["net_adapter"] = _activeNicName; + data["net_bytes_sent_delta"] = deltaSent; + data["net_bytes_received_delta"] = deltaReceived; + data["net_send_rate_kbps"] = Math.Round(deltaSent * 8.0 / 1000.0 / elapsedSeconds, 1); + data["net_receive_rate_kbps"] = Math.Round(deltaReceived * 8.0 / 1000.0 / elapsedSeconds, 1); + } + + _prevBytesSent = stats.BytesSent; + _prevBytesReceived = stats.BytesReceived; + _prevNetSampleTime = now; + } + } + else + { + // Try to re-initialize (e.g., network came up after boot) + var activeNic = FindActiveNetworkInterface(); + if (activeNic != null) + { + _activeNicId = activeNic.Id; + _activeNicName = activeNic.Description; + var stats = activeNic.GetIPStatistics(); + _prevBytesSent = stats.BytesSent; + _prevBytesReceived = stats.BytesReceived; + _prevNetSampleTime = DateTime.UtcNow; + _networkInitialized = true; + Logger.Debug($"Network throughput tracking re-initialized on: {_activeNicName}"); + } + } + } + catch (Exception ex) + { + Logger.Debug($"Network throughput collection failed: {ex.Message}"); + } + + if (data.Count > 0) + { + EmitEvent(new EnrollmentEvent + { + SessionId = SessionId, + TenantId = TenantId, + Timestamp = DateTime.UtcNow, + EventType = "performance_snapshot", + Severity = EventSeverity.Debug, + Source = "PerformanceCollector", + Phase = EnrollmentPhase.Unknown, + Message = $"CPU: {(data.ContainsKey("cpu_percent") ? data["cpu_percent"] : "?")}%, " + + $"Memory: {(data.ContainsKey("memory_used_percent") ? data["memory_used_percent"] : "?")}%, " + + $"Disk Free: {(data.ContainsKey("disk_free_gb") ? data["disk_free_gb"] : "?")} GB" + + (data.ContainsKey("net_receive_rate_kbps") + ? $", Net: \u2193{data["net_receive_rate_kbps"]} / \u2191{data["net_send_rate_kbps"]} kbps" + : ""), + Data = data + }); + } + } + + /// + /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. + /// + private static NetworkInterface FindActiveNetworkInterface() + { + try + { + var interfaces = NetworkInterface.GetAllNetworkInterfaces(); + foreach (var nic in interfaces) + { + if (nic.OperationalStatus != OperationalStatus.Up) + continue; + if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback || + nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + continue; + + var ipProps = nic.GetIPProperties(); + foreach (var gw in ipProps.GatewayAddresses) + { + if (gw.Address.ToString() != "0.0.0.0") + return nic; + } + } + } + catch + { + // Caller handles null + } + return null; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs new file mode 100644 index 00000000..b971054a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs @@ -0,0 +1,499 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport +{ + /// + /// Client for communicating with the backend API + /// + public class BackendApiClient : IDisposable + { + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + private readonly X509Certificate2 _clientCertificate; + private readonly string _manufacturer; + private readonly string _model; + private readonly string _serialNumber; + private readonly Logging.AgentLogger _logger; + private readonly NetworkMetrics _networkMetrics = new NetworkMetrics(); + private readonly string _bootstrapToken; + private readonly bool _useBootstrapTokenAuth; + private readonly string _agentVersion; + + /// + /// Exposes the network metrics counters for AgentSelfMetricsCollector to read. + /// + public NetworkMetrics NetworkMetrics => _networkMetrics; + + /// + /// The MDM client certificate loaded at construction time, or null if not found. + /// Used by DistressReporter to detect cert-missing scenarios. + /// + public X509Certificate2 ClientCertificate => _clientCertificate; + + /// + /// Protected constructor for testability (Moq proxy creation). + /// + protected BackendApiClient() { } + + public BackendApiClient(string baseUrl, AgentConfiguration configuration = null, Logging.AgentLogger logger = null, string agentVersion = null) + { + _baseUrl = baseUrl.TrimEnd('/'); + _logger = logger; + _bootstrapToken = configuration?.BootstrapToken; + _useBootstrapTokenAuth = configuration?.UseBootstrapTokenAuth ?? false; + _agentVersion = agentVersion; + + // Find MDM certificate for client authentication if enabled + if (configuration?.UseClientCertAuth == true) + { + logger?.Debug("Client certificate authentication enabled - searching for certificate..."); + + _clientCertificate = CertificateHelper.FindMdmCertificate( + logger: logger + ); + + if (_clientCertificate != null) + { + logger?.Info($"Client certificate loaded successfully"); + logger?.Debug($" Thumbprint: {_clientCertificate.Thumbprint}"); + logger?.Debug($" Subject: {_clientCertificate.Subject}"); + logger?.Debug($" Issuer: {_clientCertificate.Issuer}"); + logger?.Debug($" Valid: {_clientCertificate.NotBefore:yyyy-MM-dd} to {_clientCertificate.NotAfter:yyyy-MM-dd}"); + } + else + { + logger?.Warning("Client certificate authentication enabled but no certificate found"); + logger?.Warning(" Request will be sent WITHOUT certificate (will likely fail security validation)"); + } + } + else + { + logger?.Debug("Client certificate authentication disabled"); + } + + // Get hardware information for whitelist validation and Autopilot device verification + var hardwareInfo = HardwareInfo.GetHardwareInfo(logger); + _manufacturer = hardwareInfo.Manufacturer; + _model = hardwareInfo.Model; + _serialNumber = hardwareInfo.SerialNumber; + + // Use HttpClientHandler with client certificate for TLS-level mTLS negotiation. + // Azure App Service extracts the cert from the TLS handshake and forwards it + // in the X-ARR-ClientCert header to the backend function. + var handler = new HttpClientHandler(); + // AutomaticDecompression sends Accept-Encoding and transparently decompresses + // gzip/deflate responses. Bandwidth-sensitive links benefit a lot on JSON-heavy + // endpoints like GetAgentConfig. + if (handler.SupportsAutomaticDecompression) + { + handler.AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate; + } + if (_clientCertificate != null) + { + handler.ClientCertificates.Add(_clientCertificate); + logger?.Info("Client certificate attached to TLS handler for mTLS negotiation"); + } + + _httpClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(30) + }; + + // Set User-Agent for firewall allowlisting and server-side logging + var ua = string.IsNullOrEmpty(_agentVersion) + ? "AutopilotMonitor.Agent" + : $"AutopilotMonitor.Agent/{_agentVersion}"; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(ua); + } + + /// + /// Registers a new enrollment session + /// + public async Task RegisterSessionAsync(SessionRegistration registration) + { + var request = new RegisterSessionRequest { Registration = registration }; + var endpoint = _useBootstrapTokenAuth + ? Constants.ApiEndpoints.BootstrapRegisterSession + : Constants.ApiEndpoints.RegisterSession; + var url = $"{_baseUrl}{endpoint}"; + + var response = await PostAsync(url, request); + return response; + } + + /// + /// Ingests a batch of events using NDJSON + gzip compression for bandwidth optimization + /// + public virtual async Task IngestEventsAsync(IngestEventsRequest request) + { + var ingestEndpoint = _useBootstrapTokenAuth + ? Constants.ApiEndpoints.BootstrapIngestEvents + : Constants.ApiEndpoints.IngestEvents; + var url = $"{_baseUrl}{ingestEndpoint}"; + + // Use NDJSON (newline-delimited JSON) + gzip for efficient event upload + // This reduces bandwidth significantly (70-80% compression typical for JSON) + var ndjson = CreateNdjson(request); + var ndjsonLength = System.Text.Encoding.UTF8.GetByteCount(ndjson); + var compressedContent = CompressWithGzip(ndjson); + + _logger?.Debug($"IngestEventsAsync: POST {url} — {request.Events.Count} events, {compressedContent.Length} bytes compressed"); + _logger?.Verbose($"IngestEventsAsync: NDJSON {ndjsonLength} bytes → gzip {compressedContent.Length} bytes ({(ndjsonLength > 0 ? (100 - compressedContent.Length * 100 / ndjsonLength) : 0)}% reduction)"); + + using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new ByteArrayContent(compressedContent) + }) + { + httpRequest.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-ndjson"); + httpRequest.Content.Headers.Add("Content-Encoding", "gzip"); + + // Add TenantId header so the backend can run security checks before parsing the body + httpRequest.Headers.Add("X-Tenant-Id", request.TenantId); + + // Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification) + AddSecurityHeaders(httpRequest); + + var sw = Stopwatch.StartNew(); + var failed = false; + long bytesDown = 0; + try + { + using (var httpResponse = await _httpClient.SendAsync(httpRequest)) + { + ThrowOnAuthFailure(httpResponse); + httpResponse.EnsureSuccessStatusCode(); + + var responseJson = await httpResponse.Content.ReadAsStringAsync(); + bytesDown = Encoding.UTF8.GetByteCount(responseJson); + var response = JsonConvert.DeserializeObject(responseJson); + + return response; + } + } + catch + { + failed = true; + throw; + } + finally + { + sw.Stop(); + _networkMetrics.RecordRequest(compressedContent.Length, bytesDown, sw.ElapsedMilliseconds, failed); + } + } + } + + /// + /// Fetches the agent configuration (collector toggles + gather rules) from the backend + /// + public async Task GetAgentConfigAsync(string tenantId) + { + var configEndpoint = _useBootstrapTokenAuth + ? Constants.ApiEndpoints.BootstrapGetAgentConfig + : Constants.ApiEndpoints.GetAgentConfig; + var url = $"{_baseUrl}{configEndpoint}?tenantId={Uri.EscapeDataString(tenantId)}"; + + // Retry on 503 (backend signals transient device validation failure) + const int maxAttempts = 3; + const int maxRetryAfterSeconds = 120; // Cap Retry-After to prevent indefinite blocking + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, url)) + { + AddSecurityHeaders(httpRequest); + + _logger?.Debug($"GetAgentConfigAsync: GET {url} (attempt {attempt}/{maxAttempts})"); + + var sw = Stopwatch.StartNew(); + var failed = false; + long bytesDown = 0; + try + { + using (var response = await _httpClient.SendAsync(httpRequest)) + { + if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable && attempt < maxAttempts) + { + // Backend says "try again" — parse Retry-After or default to 30s + var retryAfterSeconds = 30; + if (response.Headers.TryGetValues("Retry-After", out var retryValues) && + int.TryParse(System.Linq.Enumerable.FirstOrDefault(retryValues), out var parsedRetry)) + { + retryAfterSeconds = Math.Min(parsedRetry, maxRetryAfterSeconds); + } + + _logger?.Warning($"GetAgentConfigAsync: 503 Service Unavailable (attempt {attempt}/{maxAttempts}). Retrying in {retryAfterSeconds}s..."); + sw.Stop(); + _networkMetrics.RecordRequest(0, 0, sw.ElapsedMilliseconds, true); + await System.Threading.Tasks.Task.Delay(retryAfterSeconds * 1000); + continue; + } + + ThrowOnAuthFailure(response); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(); + bytesDown = Encoding.UTF8.GetByteCount(responseJson); + var result = JsonConvert.DeserializeObject(responseJson); + return result; + } + } + catch + { + failed = true; + throw; + } + finally + { + sw.Stop(); + _networkMetrics.RecordRequest(0, bytesDown, sw.ElapsedMilliseconds, failed); + } + } + } + + // Final 503 after all retries — throw so caller falls back to cached/default config + throw new Exception("GetAgentConfigAsync: backend returned 503 after all retry attempts"); + } + + /// + /// Requests a short-lived SAS URL for diagnostics package upload. + /// Called just before upload so the URL is never stored in config or on disk. + /// Returns null if the request fails (non-fatal — diagnostics upload is best-effort). + /// + public async Task GetDiagnosticsUploadUrlAsync( + string tenantId, string sessionId, string fileName) + { + var url = $"{_baseUrl}{Constants.ApiEndpoints.GetDiagnosticsUploadUrl}"; + var request = new GetDiagnosticsUploadUrlRequest + { + TenantId = tenantId, + SessionId = sessionId, + FileName = fileName + }; + var response = await PostAsync(url, request); + return response; + } + + /// + /// Sends a critical error report to the emergency channel endpoint. + /// Fire-and-forget: swallows all exceptions so a failure here never cascades. + /// Uses a 5-second per-request timeout so it cannot block the upload loop. + /// + public async Task ReportAgentErrorAsync(AgentErrorReport report) + { + try + { + var errorEndpoint = _useBootstrapTokenAuth + ? Constants.ApiEndpoints.BootstrapReportError + : Constants.ApiEndpoints.ReportAgentError; + var url = $"{_baseUrl}{errorEndpoint}"; + var json = JsonConvert.SerializeObject(report); + + using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }) + { + // Security validation on the backend requires tenant ID before parsing the body + httpRequest.Headers.Add("X-Tenant-Id", report.TenantId); + + // Hardware headers are required for full security validation + AddSecurityHeaders(httpRequest); + + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) + using (var response = await _httpClient.SendAsync(httpRequest, cts.Token)) + { + // Response status is deliberately ignored — endpoint always returns 200 + } + } + } + catch (Exception ex) + { + // Swallow all exceptions — the emergency channel must never cascade failures + _logger?.Debug($"ReportAgentErrorAsync: emergency channel failed: {ex.Message}"); + } + } + + private async Task PostAsync(string url, TRequest data) + { + var json = JsonConvert.SerializeObject(data); + var bytesUp = Encoding.UTF8.GetByteCount(json); + + using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }) + { + // Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification) + AddSecurityHeaders(httpRequest); + + var sw = Stopwatch.StartNew(); + var failed = false; + long bytesDown = 0; + try + { + using (var response = await _httpClient.SendAsync(httpRequest)) + { + ThrowOnAuthFailure(response); + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(); + bytesDown = Encoding.UTF8.GetByteCount(responseJson); + var result = JsonConvert.DeserializeObject(responseJson); + + return result; + } + } + catch + { + failed = true; + throw; + } + finally + { + sw.Stop(); + _networkMetrics.RecordRequest(bytesUp, bytesDown, sw.ElapsedMilliseconds, failed); + } + } + } + + /// + /// Throws BackendAuthException for 401/403 responses so callers can distinguish + /// authentication failures from transient server errors. + /// + private static void ThrowOnAuthFailure(HttpResponseMessage response) + { + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || + response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new BackendAuthException( + $"Backend returned {(int)response.StatusCode} {response.StatusCode}. " + + "The device is not authorized. Check client certificate and Autopilot device validation.", + (int)response.StatusCode); + } + } + + /// + /// Creates NDJSON (newline-delimited JSON) from IngestEventsRequest + /// Format: metadata line + one event per line + /// + private string CreateNdjson(IngestEventsRequest request) + { + var lines = new System.Collections.Generic.List(); + + // First line: metadata (sessionId, tenantId) + var metadata = new + { + sessionId = request.SessionId, + tenantId = request.TenantId + }; + lines.Add(JsonConvert.SerializeObject(metadata)); + + // Subsequent lines: one event per line + foreach (var evt in request.Events) + { + lines.Add(JsonConvert.SerializeObject(evt)); + } + + return string.Join("\n", lines); + } + + /// + /// Compresses a string using gzip + /// + private byte[] CompressWithGzip(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + + using (var output = new MemoryStream()) + { + using (var gzip = new GZipStream(output, CompressionMode.Compress)) + { + gzip.Write(bytes, 0, bytes.Length); + } + return output.ToArray(); + } + } + + /// + /// Adds additional security headers for device authorization. + /// Client certificate is sent at TLS layer via HttpClientHandler.ClientCertificates. + /// + private void AddSecurityHeaders(HttpRequestMessage request) + { + _logger?.Trace("Adding security headers to request..."); + + // Bootstrap token auth (pre-MDM, OOBE bootstrapped agents) + if (_useBootstrapTokenAuth && !string.IsNullOrEmpty(_bootstrapToken)) + { + request.Headers.Add("X-Bootstrap-Token", _bootstrapToken); + _logger?.Trace(" X-Bootstrap-Token: [set]"); + } + + // Client certificate is sent at TLS layer via HttpClientHandler.ClientCertificates (mTLS) + + // Add hardware information for whitelist validation + if (!string.IsNullOrEmpty(_manufacturer)) + { + request.Headers.Add("X-Device-Manufacturer", _manufacturer); + _logger?.Trace($" X-Device-Manufacturer: {_manufacturer}"); + } + + if (!string.IsNullOrEmpty(_model)) + { + request.Headers.Add("X-Device-Model", _model); + _logger?.Trace($" X-Device-Model: {_model}"); + } + + // Add serial number for Autopilot device validation against Intune registration + if (!string.IsNullOrEmpty(_serialNumber)) + { + request.Headers.Add("X-Device-SerialNumber", _serialNumber); + _logger?.Trace($" X-Device-SerialNumber: {_serialNumber}"); + } + + // Add agent version for version-based block/kill management + if (!string.IsNullOrEmpty(_agentVersion)) + { + request.Headers.Add("X-Agent-Version", _agentVersion); + _logger?.Trace($" X-Agent-Version: {_agentVersion}"); + } + } + + public void Dispose() + { + _httpClient?.Dispose(); + } + } + + /// + /// Thrown when the backend returns 401 or 403, indicating the device is not authorized. + /// + public class BackendAuthException : Exception + { + /// + /// HTTP status code returned by the backend (401 or 403). + /// Used by the DistressReporter to classify the failure type. + /// + public int StatusCode { get; } + + public BackendAuthException(string message, int statusCode = 0) : base(message) + { + StatusCode = statusCode; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs new file mode 100644 index 00000000..5f2ca4d4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport +{ + /// + /// Sends pre-auth distress signals to the backend when the authenticated error channel + /// is unreachable (cert missing, hardware blocked, device not registered, etc.). + /// + /// Uses its own plain HttpClient (no client certificate, no BackendApiClient dependency) + /// so it works even when BackendApiClient cannot be constructed. + /// + /// Anti-flood guarantees (per session): + /// - Same error key (ErrorType + HTTP status code) is only ever sent once per session + /// - At most distress reports total per session + /// - At most one report every minutes + /// + /// A failure in this reporter is silently swallowed — it must never cascade into the main loop. + /// + public class DistressReporter : IDisposable + { + private const int MaxReportsPerSession = 3; + private const int MinIntervalMinutes = 30; + + private readonly HttpClient _httpClient; + private readonly string _distressUrl; + private readonly string _tenantId; + private readonly string _manufacturer; + private readonly string _model; + private readonly string _serialNumber; + private readonly string _agentVersion; + private readonly AgentLogger _logger; + + private readonly HashSet _reportedKeys = new HashSet(); + private readonly object _antiFloodLock = new object(); + private DateTime? _lastReportTime; + private int _reportCount; + + public DistressReporter( + string baseUrl, + string tenantId, + string manufacturer, + string model, + string serialNumber, + string agentVersion, + AgentLogger logger) + { + _tenantId = tenantId; + _manufacturer = manufacturer; + _model = model; + _serialNumber = serialNumber; + _agentVersion = agentVersion; + _logger = logger; + + var cleanBaseUrl = baseUrl?.TrimEnd('/') ?? Constants.ApiBaseUrl; + _distressUrl = $"{cleanBaseUrl}{Constants.ApiEndpoints.ReportDistress}"; + + // Plain HttpClient — no client cert, no mTLS. + // Deliberately isolated from BackendApiClient. + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(5) + }; + + var ua = string.IsNullOrEmpty(agentVersion) + ? "AutopilotMonitor.Agent" + : $"AutopilotMonitor.Agent/{agentVersion}"; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(ua); + } + + /// + /// Attempts to send a distress report. Returns immediately without awaiting + /// (intended to be called as fire-and-forget via _ = reporter.TrySendAsync(...)). + /// + /// All anti-flood checks are applied synchronously before the async HTTP call, + /// so repeated calls are cheap when suppressed. + /// + public virtual async Task TrySendAsync( + DistressErrorType errorType, + string message, + int? httpStatusCode = null) + { + var key = $"{errorType}:{httpStatusCode}"; + int currentReportCount; + + lock (_antiFloodLock) + { + if (_reportCount >= MaxReportsPerSession) + { + _logger?.Debug($"[DistressChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}"); + return; + } + + if (_reportedKeys.Contains(key)) + { + _logger?.Debug($"[DistressChannel] Suppressed (already reported): {key}"); + return; + } + + if (_lastReportTime != null && + (DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes) + { + _logger?.Debug($"[DistressChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}"); + return; + } + + _reportedKeys.Add(key); + _reportCount++; + _lastReportTime = DateTime.UtcNow; + currentReportCount = _reportCount; + } + + var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty; + _logger?.Warning($"[DistressChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}"); + + var report = new DistressReport + { + TenantId = _tenantId, + ErrorType = errorType, + Manufacturer = _manufacturer, + Model = _model, + SerialNumber = _serialNumber, + AgentVersion = _agentVersion, + HttpStatusCode = httpStatusCode, + Message = Truncate(message, 256), + Timestamp = DateTime.UtcNow + }; + + try + { + var json = JsonConvert.SerializeObject(report); + + using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, _distressUrl) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }) + { + // Only header needed: TenantId for the backend's first gate + httpRequest.Headers.Add("X-Tenant-Id", _tenantId); + + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) + using (var response = await _httpClient.SendAsync(httpRequest, cts.Token)) + { + // Response status is deliberately ignored — endpoint always returns 200 + } + } + } + catch (Exception ex) + { + // Swallow — distress channel must never cascade failures into the caller + _logger?.Debug($"[DistressChannel] Failed to send report: {ex.Message}"); + } + } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value)) return value; + return value.Length <= maxLength ? value : value.Substring(0, maxLength); + } + + public void Dispose() + { + _httpClient?.Dispose(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs new file mode 100644 index 00000000..2cd57209 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/EmergencyReporter.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport +{ + /// + /// Sends critical agent-side errors to the backend emergency channel endpoint. + /// + /// Anti-flood guarantees (per session): + /// - Only fires after consecutive upload failures + /// - Same error key (ErrorType + HTTP status code) is only ever sent once per session + /// - At most emergency reports total per session + /// - At most one report every minutes regardless of error type + /// + /// A failure in this reporter is silently swallowed — it must never cascade into the main loop. + /// + public class EmergencyReporter + { + /// + /// Number of consecutive upload failures required before the first emergency report is sent. + /// + public const int ConsecutiveFailureThreshold = 3; + + private const int MaxReportsPerSession = 5; + private const int MinIntervalMinutes = 10; + + private readonly BackendApiClient _apiClient; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly string _agentVersion; + private readonly AgentLogger _logger; + + private readonly HashSet _reportedKeys = new HashSet(); + private readonly object _antiFloodLock = new object(); + private DateTime? _lastReportTime; + private int _reportCount; + + public EmergencyReporter( + BackendApiClient apiClient, + string sessionId, + string tenantId, + string agentVersion, + AgentLogger logger) + { + _apiClient = apiClient; + _sessionId = sessionId; + _tenantId = tenantId; + _agentVersion = agentVersion; + _logger = logger; + } + + /// + /// Attempts to send an emergency report. Returns immediately without awaiting + /// (intended to be called as fire-and-forget via _ = reporter.TrySendAsync(...)). + /// + /// All anti-flood checks are applied synchronously before the async HTTP call, + /// so repeated calls are cheap when suppressed. + /// + public virtual async Task TrySendAsync( + AgentErrorType errorType, + string message, + int? httpStatusCode = null, + long? sequenceNumber = null) + { + // Deduplicate by error type + status code: same failure category sent only once per session. + // All anti-flood checks are protected by a lock to prevent race conditions when + // TrySendAsync is called concurrently (fire-and-forget from multiple threads). + var key = $"{errorType}:{httpStatusCode}"; + int currentReportCount; + + lock (_antiFloodLock) + { + if (_reportCount >= MaxReportsPerSession) + { + _logger?.Debug($"[EmergencyChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}"); + return; + } + + if (_reportedKeys.Contains(key)) + { + _logger?.Debug($"[EmergencyChannel] Suppressed (already reported): {key}"); + return; + } + + if (_lastReportTime != null && + (DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes) + { + _logger?.Debug($"[EmergencyChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}"); + return; + } + + _reportedKeys.Add(key); + _reportCount++; + _lastReportTime = DateTime.UtcNow; + currentReportCount = _reportCount; + } + + var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty; + _logger?.Warning($"[EmergencyChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}"); + + var report = new AgentErrorReport + { + SessionId = _sessionId, + TenantId = _tenantId, + ErrorType = errorType, + Message = message, + HttpStatusCode = httpStatusCode, + SequenceNumber = sequenceNumber, + AgentVersion = _agentVersion, + Timestamp = DateTime.UtcNow + }; + + try + { + await _apiClient.ReportAgentErrorAsync(report); + } + catch (Exception ex) + { + // Swallow — emergency channel must never cascade failures into the caller + _logger?.Debug($"[EmergencyChannel] Failed to send report: {ex.Message}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs new file mode 100644 index 00000000..410434e2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport +{ + public class GeoLocationResult + { + public string Country { get; set; } + public string Region { get; set; } + public string City { get; set; } + public string Loc { get; set; } + public string Timezone { get; set; } + public string Source { get; set; } + + public Dictionary ToDictionary() + { + return new Dictionary + { + { "country", Country }, + { "region", Region }, + { "city", City }, + { "loc", Loc }, + { "timezone", Timezone }, + { "source", Source } + }; + } + } + + public class GeoLocationAttemptResult + { + public GeoLocationResult Location { get; set; } + public string PrimaryError { get; set; } + public string PrimaryRetryError { get; set; } + public string FallbackError { get; set; } + } + + public static class GeoLocationService + { + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2); + + // Static HttpClient to avoid socket exhaustion from repeated short-lived instances. + // HttpClient is designed to be reused across requests. + private static readonly HttpClient SharedHttpClient = CreateSharedHttpClient(); + + private static HttpClient CreateSharedHttpClient() + { + var client = new HttpClient { Timeout = RequestTimeout }; + client.DefaultRequestHeaders.Add("Accept", "application/json"); + return client; + } + + public static async Task GetLocationAsync(AgentLogger logger) + { + var attempt = new GeoLocationAttemptResult(); + + // Try ipinfo.io first + var (result, error) = await TryIpInfo(logger); + if (result != null) + { + attempt.Location = result; + return attempt; + } + attempt.PrimaryError = error; + + // Retry ipinfo.io once after a short delay (network may still be initializing during Autopilot) + logger?.Info($"GeoLocation: Retrying ipinfo.io after {RetryDelay.TotalSeconds}s..."); + await Task.Delay(RetryDelay); + + (result, error) = await TryIpInfo(logger); + if (result != null) + { + attempt.Location = result; + return attempt; + } + attempt.PrimaryRetryError = error; + + // Fallback to ifconfig.co + (result, error) = await TryIfConfigCo(logger); + if (result != null) + { + attempt.Location = result; + return attempt; + } + attempt.FallbackError = error; + + logger?.Warning("GeoLocation: All providers failed, skipping location event"); + return attempt; + } + + private static async Task<(GeoLocationResult result, string error)> TryIpInfo(AgentLogger logger) + { + try + { + logger?.Info("GeoLocation: Querying ipinfo.io..."); + + using (var httpResponse = await SharedHttpClient.GetAsync("https://ipinfo.io/json")) + { + if (!httpResponse.IsSuccessStatusCode) + { + var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})"; + logger?.Warning($"GeoLocation: ipinfo.io failed: {error}"); + return (null, error); + } + + var response = await httpResponse.Content.ReadAsStringAsync(); + var json = JObject.Parse(response); + + var result = new GeoLocationResult + { + Country = json.Value("country"), + Region = json.Value("region"), + City = json.Value("city"), + Loc = json.Value("loc"), + Timezone = json.Value("timezone"), + Source = "ipinfo" + }; + + logger?.Info($"GeoLocation: ipinfo.io returned {result.City}, {result.Region}, {result.Country}"); + return (result, null); + } + } + catch (TaskCanceledException) + { + var error = "Timeout (5s)"; + logger?.Warning($"GeoLocation: ipinfo.io failed: {error}"); + return (null, error); + } + catch (Exception ex) + { + var error = ex.Message; + logger?.Warning($"GeoLocation: ipinfo.io failed: {error}"); + return (null, error); + } + } + + private static async Task<(GeoLocationResult result, string error)> TryIfConfigCo(AgentLogger logger) + { + try + { + logger?.Info("GeoLocation: Falling back to ifconfig.co..."); + + using (var httpResponse = await SharedHttpClient.GetAsync("https://ifconfig.co/json")) + { + if (!httpResponse.IsSuccessStatusCode) + { + var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})"; + logger?.Warning($"GeoLocation: ifconfig.co failed: {error}"); + return (null, error); + } + + var response = await httpResponse.Content.ReadAsStringAsync(); + var json = JObject.Parse(response); + + var latitude = json.Value("latitude") ?? ""; + var longitude = json.Value("longitude") ?? ""; + var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude) + ? $"{latitude},{longitude}" + : ""; + + var result = new GeoLocationResult + { + Country = json.Value("country_iso"), + Region = json.Value("region_name"), + City = json.Value("city"), + Loc = loc, + Timezone = json.Value("time_zone"), + Source = "ifconfig.co" + }; + + logger?.Info($"GeoLocation: ifconfig.co returned {result.City}, {result.Region}, {result.Country}"); + return (result, null); + } + } + catch (TaskCanceledException) + { + var error = "Timeout (5s)"; + logger?.Warning($"GeoLocation: ifconfig.co failed: {error}"); + return (null, error); + } + catch (Exception ex) + { + var error = ex.Message; + logger?.Warning($"GeoLocation: ifconfig.co failed: {error}"); + return (null, error); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetrics.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetrics.cs new file mode 100644 index 00000000..978558b0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NetworkMetrics.cs @@ -0,0 +1,102 @@ +using System.Threading; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport +{ + /// + /// Thread-safe counters for HTTP request/response tracking. + /// Incremented by BackendApiClient on every request, read by AgentSelfMetricsCollector. + /// All operations are lock-free (Interlocked). + /// + public class NetworkMetrics + { + private long _requestCount; + private long _failureCount; + private long _totalBytesUp; + private long _totalBytesDown; + private long _totalLatencyMs; + + public void RecordRequest(long bytesUp, long bytesDown, long latencyMs, bool failed) + { + Interlocked.Increment(ref _requestCount); + Interlocked.Add(ref _totalBytesUp, bytesUp); + Interlocked.Add(ref _totalBytesDown, bytesDown); + Interlocked.Add(ref _totalLatencyMs, latencyMs); + if (failed) + Interlocked.Increment(ref _failureCount); + } + + /// + /// Returns an immutable snapshot of all counters for delta calculation. + /// + public NetworkMetricsSnapshot GetSnapshot() + { + return new NetworkMetricsSnapshot( + Interlocked.Read(ref _requestCount), + Interlocked.Read(ref _failureCount), + Interlocked.Read(ref _totalBytesUp), + Interlocked.Read(ref _totalBytesDown), + Interlocked.Read(ref _totalLatencyMs) + ); + } + } + + /// + /// Immutable snapshot of network counters at a point in time. + /// Used for delta calculation between two collection intervals. + /// + public class NetworkMetricsSnapshot + { + public long RequestCount { get; } + public long FailureCount { get; } + public long TotalBytesUp { get; } + public long TotalBytesDown { get; } + public long TotalLatencyMs { get; } + + public NetworkMetricsSnapshot(long requestCount, long failureCount, + long totalBytesUp, long totalBytesDown, long totalLatencyMs) + { + RequestCount = requestCount; + FailureCount = failureCount; + TotalBytesUp = totalBytesUp; + TotalBytesDown = totalBytesDown; + TotalLatencyMs = totalLatencyMs; + } + + /// + /// Computes the delta between this snapshot and a previous one. + /// + public NetworkMetricsDelta DeltaFrom(NetworkMetricsSnapshot previous) + { + var requests = RequestCount - previous.RequestCount; + var failures = FailureCount - previous.FailureCount; + var bytesUp = TotalBytesUp - previous.TotalBytesUp; + var bytesDown = TotalBytesDown - previous.TotalBytesDown; + var latencyDelta = TotalLatencyMs - previous.TotalLatencyMs; + var avgLatency = requests > 0 ? (double)latencyDelta / requests : 0; + + return new NetworkMetricsDelta(requests, failures, bytesUp, bytesDown, avgLatency); + } + } + + /// + /// Computed delta values between two snapshots — ready for emission as event data. + /// + public class NetworkMetricsDelta + { + public long Requests { get; } + public long Failures { get; } + public long BytesUp { get; } + public long BytesDown { get; } + public double AvgLatencyMs { get; } + + public NetworkMetricsDelta(long requests, long failures, + long bytesUp, long bytesDown, double avgLatencyMs) + { + Requests = requests; + Failures = failures; + BytesUp = bytesUp; + BytesDown = bytesDown; + AvgLatencyMs = avgLatencyMs; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NtpTimeCheckService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NtpTimeCheckService.cs new file mode 100644 index 00000000..af7b0cd4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/NtpTimeCheckService.cs @@ -0,0 +1,105 @@ +using System; +using System.Net; +using System.Net.Sockets; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport +{ + public class NtpCheckResult + { + public bool Success { get; set; } + public double OffsetSeconds { get; set; } + public string NtpServer { get; set; } + public string Error { get; set; } + public DateTime? NtpTime { get; set; } + public DateTime? LocalTime { get; set; } + } + + public static class NtpTimeCheckService + { + private static readonly int ReceiveTimeoutMs = 3000; + + /// + /// Queries an NTP server and returns the time offset between local clock and NTP time. + /// Based on NTP v3 client mode (LI=0, VN=3, Mode=3). + /// + public static NtpCheckResult CheckTime(string ntpServer, AgentLogger logger) + { + var result = new NtpCheckResult { NtpServer = ntpServer }; + + try + { + logger.Info($"NTP time check: querying {ntpServer}"); + + // NTP request packet: 48 bytes, first byte = 0x1B (LI=0, VN=3, Mode=3) + var ntpData = new byte[48]; + ntpData[0] = 0x1B; + + var addresses = Dns.GetHostEntry(ntpServer).AddressList; + if (addresses.Length == 0) + { + result.Error = $"DNS resolution returned no addresses for {ntpServer}"; + logger.Warning($"NTP time check failed: {result.Error}"); + return result; + } + + var ipEndPoint = new IPEndPoint(addresses[0], 123); + using (var socket = new Socket(addresses[0].AddressFamily, SocketType.Dgram, ProtocolType.Udp)) + { + socket.ReceiveTimeout = ReceiveTimeoutMs; + socket.SendTimeout = ReceiveTimeoutMs; + socket.Connect(ipEndPoint); + socket.Send(ntpData); + var received = socket.Receive(ntpData); + + // Validate NTP response: must be at least 48 bytes + if (received < 48) + { + result.Error = $"Invalid NTP response: received {received} bytes (expected 48)"; + logger.Warning($"NTP time check failed: {result.Error}"); + return result; + } + + // Check Leap Indicator (bits 6-7 of first byte): value 3 means unsynchronized + var leapIndicator = (ntpData[0] >> 6) & 3; + if (leapIndicator == 3) + { + result.Error = "NTP server is unsynchronized (Leap Indicator = 3)"; + logger.Warning($"NTP time check failed: {result.Error}"); + return result; + } + } + + // Parse transmit timestamp from bytes 40-47 (NTP epoch: 1900-01-01) + ulong intPart = (ulong)ntpData[40] << 24 + | (ulong)ntpData[41] << 16 + | (ulong)ntpData[42] << 8 + | (ulong)ntpData[43]; + + ulong fractPart = (ulong)ntpData[44] << 24 + | (ulong)ntpData[45] << 16 + | (ulong)ntpData[46] << 8 + | (ulong)ntpData[47]; + + var milliseconds = (intPart * 1000) + ((fractPart * 1000) / 0x100000000L); + var ntpTime = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(milliseconds); + var localTime = DateTime.UtcNow; + var offset = ntpTime.Subtract(localTime); + + result.Success = true; + result.NtpTime = ntpTime; + result.LocalTime = localTime; + result.OffsetSeconds = offset.TotalSeconds; + + logger.Info($"NTP time check: offset={offset.TotalSeconds:F2}s (NTP={ntpTime:o}, Local={localTime:o})"); + return result; + } + catch (Exception ex) + { + result.Error = ex.Message; + logger.Warning($"NTP time check failed for {ntpServer}: {ex.Message}"); + return result; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/TimezoneService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/TimezoneService.cs new file mode 100644 index 00000000..ffe71dff --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/TimezoneService.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Security; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport +{ + public class TimezoneSetResult + { + public bool Success { get; set; } + public string IanaTimezone { get; set; } + public string WindowsTimezoneId { get; set; } + public string PreviousTimezone { get; set; } + public string Error { get; set; } + } + + public static class TimezoneService + { + /// + /// Attempts to set the Windows timezone based on an IANA timezone identifier. + /// Uses tzutil /s to apply the timezone (works from SYSTEM context). + /// + public static TimezoneSetResult TrySetTimezone(string ianaTimezone, AgentLogger logger) + { + var result = new TimezoneSetResult { IanaTimezone = ianaTimezone }; + + try + { + // Map IANA timezone to Windows timezone ID + if (!IanaToWindowsMap.TryGetValue(ianaTimezone, out var windowsId)) + { + result.Error = $"No Windows timezone mapping found for IANA timezone '{ianaTimezone}'"; + logger.Warning($"Timezone auto-set: {result.Error}"); + return result; + } + + result.WindowsTimezoneId = windowsId; + result.PreviousTimezone = TimeZoneInfo.Local.Id; + + // Skip if already set to the target timezone + if (string.Equals(result.PreviousTimezone, windowsId, StringComparison.OrdinalIgnoreCase)) + { + result.Success = true; + logger.Info($"Timezone auto-set: already set to {windowsId} (IANA: {ianaTimezone})"); + return result; + } + + // Execute tzutil /s to set the timezone + logger.Info($"Timezone auto-set: setting timezone to {windowsId} (IANA: {ianaTimezone}, previous: {result.PreviousTimezone})"); + + var psi = new ProcessStartInfo + { + FileName = SystemPaths.TzUtil, + Arguments = $"/s \"{windowsId}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using (var process = Process.Start(psi)) + { + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(10000); // 10s timeout + + if (process.ExitCode != 0) + { + result.Error = $"tzutil exited with code {process.ExitCode}: {stderr.Trim()}"; + logger.Warning($"Timezone auto-set failed: {result.Error}"); + return result; + } + } + + result.Success = true; + logger.Info($"Timezone auto-set: successfully set to {windowsId}"); + return result; + } + catch (Exception ex) + { + result.Error = ex.Message; + logger.Warning($"Timezone auto-set failed: {ex.Message}"); + return result; + } + } + + /// + /// IANA timezone to Windows timezone ID mapping. + /// Sourced from Unicode CLDR / IANA timezone database. + /// Covers all major regions and practical timezones. + /// + private static readonly Dictionary IanaToWindowsMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // UTC + { "Etc/UTC", "UTC" }, + { "Etc/GMT", "UTC" }, + { "UTC", "UTC" }, + + // Europe + { "Europe/London", "GMT Standard Time" }, + { "Europe/Dublin", "GMT Standard Time" }, + { "Europe/Lisbon", "GMT Standard Time" }, + { "Europe/Berlin", "W. Europe Standard Time" }, + { "Europe/Amsterdam", "W. Europe Standard Time" }, + { "Europe/Zurich", "W. Europe Standard Time" }, + { "Europe/Vienna", "W. Europe Standard Time" }, + { "Europe/Rome", "W. Europe Standard Time" }, + { "Europe/Stockholm", "W. Europe Standard Time" }, + { "Europe/Oslo", "W. Europe Standard Time" }, + { "Europe/Copenhagen", "Romance Standard Time" }, + { "Europe/Paris", "Romance Standard Time" }, + { "Europe/Brussels", "Romance Standard Time" }, + { "Europe/Madrid", "Romance Standard Time" }, + { "Europe/Warsaw", "Central European Standard Time" }, + { "Europe/Prague", "Central Europe Standard Time" }, + { "Europe/Budapest", "Central Europe Standard Time" }, + { "Europe/Belgrade", "Central Europe Standard Time" }, + { "Europe/Bratislava", "Central Europe Standard Time" }, + { "Europe/Ljubljana", "Central Europe Standard Time" }, + { "Europe/Zagreb", "Central Europe Standard Time" }, + { "Europe/Athens", "GTB Standard Time" }, + { "Europe/Bucharest", "GTB Standard Time" }, + { "Europe/Helsinki", "FLE Standard Time" }, + { "Europe/Kiev", "FLE Standard Time" }, + { "Europe/Kyiv", "FLE Standard Time" }, + { "Europe/Riga", "FLE Standard Time" }, + { "Europe/Sofia", "FLE Standard Time" }, + { "Europe/Tallinn", "FLE Standard Time" }, + { "Europe/Vilnius", "FLE Standard Time" }, + { "Europe/Istanbul", "Turkey Standard Time" }, + { "Europe/Moscow", "Russian Standard Time" }, + { "Europe/Minsk", "Belarus Standard Time" }, + { "Europe/Samara", "Russia Time Zone 3" }, + + // Americas + { "America/New_York", "Eastern Standard Time" }, + { "America/Detroit", "Eastern Standard Time" }, + { "America/Toronto", "Eastern Standard Time" }, + { "America/Chicago", "Central Standard Time" }, + { "America/Winnipeg", "Central Standard Time" }, + { "America/Denver", "Mountain Standard Time" }, + { "America/Edmonton", "Mountain Standard Time" }, + { "America/Phoenix", "US Mountain Standard Time" }, + { "America/Los_Angeles", "Pacific Standard Time" }, + { "America/Vancouver", "Pacific Standard Time" }, + { "America/Anchorage", "Alaskan Standard Time" }, + { "Pacific/Honolulu", "Hawaiian Standard Time" }, + { "America/Halifax", "Atlantic Standard Time" }, + { "America/St_Johns", "Newfoundland Standard Time" }, + { "America/Mexico_City", "Central Standard Time (Mexico)" }, + { "America/Tijuana", "Pacific Standard Time (Mexico)" }, + { "America/Bogota", "SA Pacific Standard Time" }, + { "America/Lima", "SA Pacific Standard Time" }, + { "America/Santiago", "Pacific SA Standard Time" }, + { "America/Buenos_Aires", "Argentina Standard Time" }, + { "America/Argentina/Buenos_Aires", "Argentina Standard Time" }, + { "America/Sao_Paulo", "E. South America Standard Time" }, + { "America/Caracas", "Venezuela Standard Time" }, + + // Asia + { "Asia/Tokyo", "Tokyo Standard Time" }, + { "Asia/Seoul", "Korea Standard Time" }, + { "Asia/Shanghai", "China Standard Time" }, + { "Asia/Hong_Kong", "China Standard Time" }, + { "Asia/Taipei", "Taipei Standard Time" }, + { "Asia/Singapore", "Singapore Standard Time" }, + { "Asia/Kuala_Lumpur", "Singapore Standard Time" }, + { "Asia/Bangkok", "SE Asia Standard Time" }, + { "Asia/Jakarta", "SE Asia Standard Time" }, + { "Asia/Ho_Chi_Minh", "SE Asia Standard Time" }, + { "Asia/Kolkata", "India Standard Time" }, + { "Asia/Calcutta", "India Standard Time" }, + { "Asia/Colombo", "Sri Lanka Standard Time" }, + { "Asia/Karachi", "Pakistan Standard Time" }, + { "Asia/Dhaka", "Bangladesh Standard Time" }, + { "Asia/Kathmandu", "Nepal Standard Time" }, + { "Asia/Almaty", "Central Asia Standard Time" }, + { "Asia/Tashkent", "West Asia Standard Time" }, + { "Asia/Dubai", "Arabian Standard Time" }, + { "Asia/Muscat", "Arabian Standard Time" }, + { "Asia/Riyadh", "Arab Standard Time" }, + { "Asia/Kuwait", "Arab Standard Time" }, + { "Asia/Baghdad", "Arabic Standard Time" }, + { "Asia/Tehran", "Iran Standard Time" }, + { "Asia/Kabul", "Afghanistan Standard Time" }, + { "Asia/Baku", "Azerbaijan Standard Time" }, + { "Asia/Tbilisi", "Georgian Standard Time" }, + { "Asia/Yerevan", "Caucasus Standard Time" }, + { "Asia/Jerusalem", "Israel Standard Time" }, + { "Asia/Beirut", "Middle East Standard Time" }, + { "Asia/Yangon", "Myanmar Standard Time" }, + { "Asia/Vladivostok", "Vladivostok Standard Time" }, + { "Asia/Yakutsk", "Yakutsk Standard Time" }, + { "Asia/Krasnoyarsk", "North Asia Standard Time" }, + { "Asia/Novosibirsk", "N. Central Asia Standard Time" }, + { "Asia/Irkutsk", "North Asia East Standard Time" }, + + // Oceania + { "Australia/Sydney", "AUS Eastern Standard Time" }, + { "Australia/Melbourne", "AUS Eastern Standard Time" }, + { "Australia/Brisbane", "E. Australia Standard Time" }, + { "Australia/Perth", "W. Australia Standard Time" }, + { "Australia/Adelaide", "Cen. Australia Standard Time" }, + { "Australia/Darwin", "AUS Central Standard Time" }, + { "Australia/Hobart", "Tasmania Standard Time" }, + { "Pacific/Auckland", "New Zealand Standard Time" }, + { "Pacific/Fiji", "Fiji Standard Time" }, + { "Pacific/Guam", "West Pacific Standard Time" }, + { "Pacific/Port_Moresby", "West Pacific Standard Time" }, + + // Africa + { "Africa/Cairo", "Egypt Standard Time" }, + { "Africa/Johannesburg", "South Africa Standard Time" }, + { "Africa/Lagos", "W. Central Africa Standard Time" }, + { "Africa/Nairobi", "E. Africa Standard Time" }, + { "Africa/Casablanca", "Morocco Standard Time" }, + { "Africa/Windhoek", "Namibia Standard Time" }, + }; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ClassifierRegistry.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ClassifierRegistry.cs new file mode 100644 index 00000000..598f96c2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ClassifierRegistry.cs @@ -0,0 +1,53 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Default dictionary-backed . Plan §2.4. + /// + public sealed class ClassifierRegistry : IClassifierRegistry + { + private readonly Dictionary _byId; + + public ClassifierRegistry(IEnumerable classifiers) + { + if (classifiers == null) throw new ArgumentNullException(nameof(classifiers)); + + _byId = new Dictionary(StringComparer.Ordinal); + foreach (var c in classifiers) + { + if (c == null) throw new ArgumentException("Classifier enumerable must not contain null.", nameof(classifiers)); + if (string.IsNullOrEmpty(c.Id)) + { + throw new ArgumentException($"Classifier {c.GetType().FullName} has empty Id.", nameof(classifiers)); + } + if (_byId.ContainsKey(c.Id)) + { + throw new ArgumentException($"Duplicate classifier id: '{c.Id}'.", nameof(classifiers)); + } + _byId[c.Id] = c; + } + } + + public bool TryGet(string classifierId, out IClassifier? classifier) + { + if (string.IsNullOrEmpty(classifierId)) + { + classifier = null; + return false; + } + + if (_byId.TryGetValue(classifierId, out var found)) + { + classifier = found; + return true; + } + + classifier = null; + return false; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineFiredEventArgs.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineFiredEventArgs.cs new file mode 100644 index 00000000..a4e24dfe --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineFiredEventArgs.cs @@ -0,0 +1,30 @@ +#nullable enable +using System; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Payload for . Plan §2.6. + /// + /// The orchestrator consumes this event and translates it into a synthetic + /// DeadlineFired- + /// whose OccurredAtUtc equals the deadline's , + /// not the wall-clock firing time — so replay stays deterministic even if the agent + /// restarts long after the deadline has passed. + /// + /// + public sealed class DeadlineFiredEventArgs : EventArgs + { + public DeadlineFiredEventArgs(ActiveDeadline deadline, DateTime firedAtUtc) + { + Deadline = deadline ?? throw new ArgumentNullException(nameof(deadline)); + FiredAtUtc = firedAtUtc; + } + + public ActiveDeadline Deadline { get; } + + /// Wall-clock time the event was raised. For observability; does NOT influence the signal's OccurredAtUtc. + public DateTime FiredAtUtc { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineScheduler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineScheduler.cs new file mode 100644 index 00000000..8e7eb46b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DeadlineScheduler.cs @@ -0,0 +1,195 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// backed by . + /// Plan §2.6 / L.7 — the sole owner of decision-relevant timers in V2. + /// + /// Uses to compute remaining = DueAtUtc - UtcNow. + /// The firing itself is wall-clock driven (), not virtual — virtual-clock + /// tests must use past-due deadlines (fired synchronously on ) or + /// short real delays to assert behavior. + /// + /// + public sealed class DeadlineScheduler : IDeadlineScheduler + { + private readonly IClock _clock; + private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); + private readonly object _lock = new object(); + private bool _disposed; + + public DeadlineScheduler(IClock clock) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public event EventHandler? Fired; + + public IReadOnlyList ActiveDeadlines + { + get + { + lock (_lock) + { + var list = new List(_entries.Count); + foreach (var e in _entries.Values) + { + list.Add(e.Deadline); + } + return list; + } + } + } + + public bool IsScheduled(string name) + { + if (string.IsNullOrEmpty(name)) return false; + lock (_lock) + { + return _entries.ContainsKey(name); + } + } + + public void Schedule(ActiveDeadline deadline) + { + if (deadline == null) throw new ArgumentNullException(nameof(deadline)); + + Entry? newEntry = null; + Entry? oldEntry = null; + + lock (_lock) + { + if (_disposed) throw new ObjectDisposedException(nameof(DeadlineScheduler)); + + if (_entries.TryGetValue(deadline.Name, out var existing)) + { + oldEntry = existing; + _entries.Remove(deadline.Name); + } + + newEntry = new Entry(deadline); + _entries[deadline.Name] = newEntry; + } + + // Dispose old timer outside the lock — disposal can block while a pending + // callback unwinds, and we don't want to hold _lock during that. + oldEntry?.DisposeTimer(); + + var remaining = deadline.DueAtUtc - _clock.UtcNow; + if (remaining <= TimeSpan.Zero) + { + // Past-due — fire on the ThreadPool. Don't fire inline: re-entrant Schedule + // calls from subscribers would deadlock on _lock otherwise. + ThreadPool.QueueUserWorkItem(_ => FireIfStillCurrent(newEntry)); + } + else + { + var timer = new Timer( + static state => ((DeadlineScheduler)((TimerState)state!).Scheduler) + .FireIfStillCurrent(((TimerState)state).Entry), + new TimerState(this, newEntry), + remaining, + Timeout.InfiniteTimeSpan); + newEntry.Timer = timer; + } + } + + public void Cancel(string name) + { + if (string.IsNullOrEmpty(name)) return; + + Entry? entry = null; + lock (_lock) + { + if (_disposed) return; + if (_entries.TryGetValue(name, out var existing)) + { + entry = existing; + _entries.Remove(name); + } + } + + entry?.DisposeTimer(); + } + + public void RehydrateFromSnapshot(IEnumerable deadlines) + { + if (deadlines == null) throw new ArgumentNullException(nameof(deadlines)); + foreach (var d in deadlines) + { + Schedule(d); + } + } + + public void Dispose() + { + List? entriesToDispose = null; + + lock (_lock) + { + if (_disposed) return; + _disposed = true; + entriesToDispose = new List(_entries.Values); + _entries.Clear(); + } + + if (entriesToDispose == null) return; + foreach (var e in entriesToDispose) + { + e.DisposeTimer(); + } + } + + private void FireIfStillCurrent(Entry entry) + { + // Remove atomically; if Cancel or a newer Schedule already replaced us, skip. + lock (_lock) + { + if (_disposed) return; + if (!_entries.TryGetValue(entry.Deadline.Name, out var current)) return; + if (!ReferenceEquals(current, entry)) return; + _entries.Remove(entry.Deadline.Name); + } + + entry.DisposeTimer(); + Fired?.Invoke(this, new DeadlineFiredEventArgs(entry.Deadline, _clock.UtcNow)); + } + + private sealed class Entry + { + public Entry(ActiveDeadline deadline) + { + Deadline = deadline; + } + + public ActiveDeadline Deadline { get; } + public Timer? Timer { get; set; } + + public void DisposeTimer() + { + var t = Timer; + Timer = null; + try { t?.Dispose(); } + catch { /* Timer.Dispose may race with callback; we've already removed the entry. */ } + } + } + + private sealed class TimerState + { + public TimerState(DeadlineScheduler scheduler, Entry entry) + { + Scheduler = scheduler; + Entry = entry; + } + + public DeadlineScheduler Scheduler { get; } + public Entry Entry { get; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs new file mode 100644 index 00000000..47ec88ea --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs @@ -0,0 +1,220 @@ +#nullable enable +using System; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Produktions-. Plan §2.5 / §2.7 / L.1. + /// + /// Sequence pro : + /// + /// (Sofort-Flush, L.12) — wenn das wirft, + /// ist der Step nicht committed und der Failure-Counter zählt hoch. + /// synchron (sync-over-async auf + /// Ingress-Worker-Thread — kein SynchronizationContext, kein Deadlock-Risiko). + /// und Failures werden geloggt; + /// kein Throw, da EffectRunner bereits alle Failure-Klassen sauber mapped. + /// best-effort — Exception wird + /// geloggt, aber der Step ist trotzdem committed (Journal ist die Wahrheit). + /// wird auf + /// fortgesetzt; Failure-Counter reset. + /// + /// + /// + /// Quarantine-Eskalation: Journal-Failures werden gezählt. Nach + /// aufeinanderfolgenden Failures (default 3) ruft der + /// Processor und wirft weiter. Der + /// Ingress-Worker schluckt die Exception (SignalIngress.cs:300-307), aber die + /// Quarantine-Senke hat dann den Alarm gespeichert → nächstes Agent-Start räumt auf. + /// + /// + /// Thread-Modell: Einzelner Schreiber — Ingress-Worker-Thread. Keine Locks nötig. + /// Lesen von erfolgt aus demselben Thread direkt vor dem + /// nächsten Reduce, ebenfalls unlockbar sicher. + /// + /// + public sealed class DecisionStepProcessor : IDecisionStepProcessor + { + /// Default — 3 Failures hintereinander → Quarantine. + public const int DefaultQuarantineThreshold = 3; + + private readonly IJournalWriter _journal; + private readonly IEffectRunner _effectRunner; + private readonly ISnapshotPersistence _snapshot; + private readonly IQuarantineSink _quarantineSink; + private readonly AgentLogger _logger; + private readonly int _quarantineThreshold; + private readonly Action? _onTerminalStageReached; + private readonly TelemetryTransitionEmitter? _transitionEmitter; + + private DecisionState _currentState; + private int _consecutiveJournalFailures; + private bool _quarantineTriggered; + private bool _terminalNotified; + + public DecisionStepProcessor( + DecisionState initialState, + IJournalWriter journal, + IEffectRunner effectRunner, + ISnapshotPersistence snapshot, + IQuarantineSink quarantineSink, + AgentLogger logger, + int quarantineThreshold = DefaultQuarantineThreshold, + Action? onTerminalStageReached = null, + TelemetryTransitionEmitter? transitionEmitter = null) + { + if (quarantineThreshold <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(quarantineThreshold), + "Threshold must be > 0."); + } + + _currentState = initialState ?? throw new ArgumentNullException(nameof(initialState)); + _journal = journal ?? throw new ArgumentNullException(nameof(journal)); + _effectRunner = effectRunner ?? throw new ArgumentNullException(nameof(effectRunner)); + _snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); + _quarantineSink = quarantineSink ?? throw new ArgumentNullException(nameof(quarantineSink)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _quarantineThreshold = quarantineThreshold; + _onTerminalStageReached = onTerminalStageReached; + _transitionEmitter = transitionEmitter; + + // If recovery loaded a state that already sits on a terminal stage (e.g. a crash + // after a success-path step but before Stop()), treat it as already-notified so we + // do not re-fire the hook. + _terminalNotified = initialState.Stage.IsTerminal(); + } + + public DecisionState CurrentState => _currentState; + + /// Test-Observability — anzahl aufeinanderfolgender Journal-Failures. + public int ConsecutiveJournalFailureCount => _consecutiveJournalFailures; + + public void ApplyStep(DecisionStep step, DecisionSignal signal) + { + if (step == null) throw new ArgumentNullException(nameof(step)); + if (signal == null) throw new ArgumentNullException(nameof(signal)); + + // 1) Journal-Append — einziger Fehler-Pfad der hart wirft + Quarantine eskaliert. + try + { + _journal.Append(step.Transition); + } + catch (Exception ex) + { + _consecutiveJournalFailures++; + _logger.Error( + $"DecisionStepProcessor: journal append failed (consecutive={_consecutiveJournalFailures}/{_quarantineThreshold}) " + + $"for signal ordinal={signal.SessionSignalOrdinal} kind={signal.Kind}.", + ex); + + if (_consecutiveJournalFailures >= _quarantineThreshold && !_quarantineTriggered) + { + _quarantineTriggered = true; + TryTriggerQuarantine( + $"journal append failed {_consecutiveJournalFailures}x consecutively; " + + $"last signal ordinal={signal.SessionSignalOrdinal} kind={signal.Kind}"); + } + + throw; + } + + // 1a) Project the transition onto the telemetry transport for backend upload. Journal + // is authoritative (§2.7c / L.1); a transport enqueue failure here must NOT abort the + // step — the local journal already committed and effects below must still run. + if (_transitionEmitter != null) + { + try { _transitionEmitter.Emit(step.Transition); } + catch { /* best-effort upload; local state already consistent */ } + } + + // 2) EffectRunner — Async-Methode vom Ingress-Worker-Thread sync ausführen. + // Kein SynchronizationContext auf einem reinen Background-Thread, deshalb + // GetAwaiter().GetResult() hier deadlock-frei (L.1 Ingress-Worker-Thread). + EffectRunResult effectResult; + try + { + effectResult = _effectRunner + .RunAsync(step.Effects, step.NewState, signal.OccurredAtUtc, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + // EffectRunner.RunAsync ist vertraglich exception-frei (Plan §2.7b); ein + // unerwartetes Throw loggen wir hart, lassen den Step aber committed (Journal + // ist bereits persistiert). Weiterwerfen würde den Ingress-Worker stoppen. + _logger.Error( + $"DecisionStepProcessor: effect runner threw unexpectedly for signal ordinal={signal.SessionSignalOrdinal}.", + ex); + effectResult = EffectRunResult.Empty(); + } + + if (effectResult.SessionMustAbort) + { + _logger.Error( + $"DecisionStepProcessor: effect run signaled session abort " + + $"(reason='{effectResult.AbortReason}') for signal ordinal={signal.SessionSignalOrdinal}."); + } + else if (effectResult.Failures.Count > 0) + { + _logger.Warning( + $"DecisionStepProcessor: effect run completed with {effectResult.Failures.Count} non-fatal failure(s) " + + $"for signal ordinal={signal.SessionSignalOrdinal}."); + } + + // 3) Snapshot — best-effort. Journal ist die Wahrheit, Snapshot ist Cache. + try + { + _snapshot.Save(step.NewState); + } + catch (Exception ex) + { + _logger.Warning( + $"DecisionStepProcessor: snapshot save failed (transient, not escalated) " + + $"for signal ordinal={signal.SessionSignalOrdinal}: {ex.Message}"); + } + + // 4) State-Forward + Counter-Reset. + _currentState = step.NewState; + _consecutiveJournalFailures = 0; + + // 5) Terminal-stage detection (M4.6.β). Fires exactly once per agent run when the + // DecisionEngine transitions the session into a terminal SessionStage — the + // orchestrator turns this into the public EnrollmentTerminated event so peripheral + // consumers (CleanupService, SummaryDialog, DiagnosticsPackageService) can react + // without touching the kernel state machine. + if (!_terminalNotified && _currentState.Stage.IsTerminal()) + { + _terminalNotified = true; + try { _onTerminalStageReached?.Invoke(_currentState); } + catch (Exception ex) + { + _logger.Error( + $"DecisionStepProcessor: onTerminalStageReached handler threw for stage {_currentState.Stage}.", + ex); + } + } + } + + private void TryTriggerQuarantine(string reason) + { + try + { + _quarantineSink.TriggerQuarantine(reason); + } + catch (Exception ex) + { + _logger.Error("DecisionStepProcessor: quarantine sink threw unexpectedly; continuing.", ex); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs new file mode 100644 index 00000000..fbd80b40 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -0,0 +1,642 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Production . Plan §4.x M4.5.b. + /// + /// Builds the real Collector-Hosts for the V2 agent runtime: + /// + /// EspAndHelloHost — coordinator + /// (internally aggregates HelloTracker + ShellCoreTracker + ProvisioningStatusTracker + /// + ModernDeploymentTracker) wired via . + /// This is the single production entry for the ESP+Hello signal surface — avoids + /// double emission that would happen if the sub-tracker adapters were also wired + /// in parallel (§4.x M4.3 tech-debt note about adapter duplication). + /// DesktopArrivalHost — + . + /// AadJoinHost — + . + /// ImeLogHost — + + /// + . + /// StallProbeHost — + + /// . Owns its 60-s idle-check timer (the + /// collector itself has no timer — it's a pure probe invoked from outside). + /// + /// Optional peripheral hosts (driven by toggles): + /// + /// PerformanceHost — CPU/memory/disk samples. + /// AgentSelfMetricsHost — process CPU, memory and HTTP traffic counters. + /// Wires into the instance created by the Program.cs + /// (the V2 + /// has no metrics — accepted M4.5.b tech-debt). + /// + /// + /// + /// ModernDeployment Events-Bridge: the ModernDeploymentTracker lives inside + /// . Its diagnostic log events reach the telemetry spool + /// via onEnrollmentEvent, exactly like any other EspAndHello event. No separate + /// adapter exists (documented in + verified by the + /// Modern_deployment_host_emits_event_bridge_only_no_decision_signal test). + /// + /// + public sealed class DefaultComponentFactory : IComponentFactory + { + private readonly AgentConfiguration _agentConfig; + private readonly AgentConfigResponse _remoteConfig; + private readonly NetworkMetrics? _networkMetrics; + private readonly string _agentVersion; + private readonly string _stateDirectory; + + private ImeLogHost? _imeLogHost; + + /// + /// Exposes the IME tracker's package-state list to peripheral consumers such as the + /// FinalStatusBuilder in M4.6.β. Returns null before + /// has been called (Orchestrator start order). + /// + public Monitoring.Enrollment.Ime.AppPackageStateList? ImePackageStates => _imeLogHost?.PackageStates; + + public DefaultComponentFactory( + AgentConfiguration agentConfig, + AgentConfigResponse remoteConfig, + NetworkMetrics? networkMetrics, + string agentVersion, + string stateDirectory) + { + _agentConfig = agentConfig ?? throw new ArgumentNullException(nameof(agentConfig)); + _remoteConfig = remoteConfig ?? throw new ArgumentNullException(nameof(remoteConfig)); + _networkMetrics = networkMetrics; + _agentVersion = string.IsNullOrEmpty(agentVersion) ? "unknown" : agentVersion; + _stateDirectory = stateDirectory ?? throw new ArgumentNullException(nameof(stateDirectory)); + } + + public IReadOnlyList CreateCollectorHosts( + string sessionId, + string tenantId, + AgentLogger logger, + Action onEnrollmentEvent, + IReadOnlyCollection whiteGloveSealingPatternIds, + ISignalIngressSink ingress, + IClock clock) + { + if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId)); + if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + if (onEnrollmentEvent == null) throw new ArgumentNullException(nameof(onEnrollmentEvent)); + if (ingress == null) throw new ArgumentNullException(nameof(ingress)); + if (clock == null) throw new ArgumentNullException(nameof(clock)); + + var hosts = new List(); + var collectors = _remoteConfig.Collectors ?? CollectorConfiguration.CreateDefault(); + + // ----- Kernel hosts (always-on; they produce decision signals) -------------------- + + hosts.Add(new EspAndHelloHost( + sessionId: sessionId, + tenantId: tenantId, + onEnrollmentEvent: onEnrollmentEvent, + logger: logger, + ingress: ingress, + clock: clock, + helloWaitTimeoutSeconds: collectors.HelloWaitTimeoutSeconds, + modernDeploymentWatcherEnabled: collectors.ModernDeploymentWatcherEnabled, + modernDeploymentLogLevelMax: collectors.ModernDeploymentLogLevelMax, + modernDeploymentBackfillEnabled: collectors.ModernDeploymentBackfillEnabled, + modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes, + modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds, + stateDirectory: _stateDirectory)); + + hosts.Add(new DesktopArrivalHost(logger, ingress, clock)); + + hosts.Add(new AadJoinHost(logger, ingress, clock)); + + _imeLogHost = new ImeLogHost( + sessionId: sessionId, + tenantId: tenantId, + onEnrollmentEvent: onEnrollmentEvent, + logger: logger, + ingress: ingress, + clock: clock, + imeLogPathOverride: _agentConfig.ImeLogPathOverride, + imeMatchLogPath: _agentConfig.ImeMatchLogPath, + imePatterns: _remoteConfig.ImeLogPatterns, + stateDirectory: _stateDirectory, + whiteGloveSealingPatternIds: whiteGloveSealingPatternIds); + hosts.Add(_imeLogHost); + + if (collectors.StallProbeEnabled) + { + hosts.Add(new StallProbeHost( + sessionId: sessionId, + tenantId: tenantId, + onEnrollmentEvent: onEnrollmentEvent, + logger: logger, + ingress: ingress, + clock: clock, + thresholdsMinutes: collectors.StallProbeThresholdsMinutes, + traceIndices: collectors.StallProbeTraceIndices, + sources: collectors.StallProbeSources, + sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex, + harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds)); + } + + // ----- Peripheral hosts (event-only; driven by remote-config toggles) -------------- + + if (collectors.EnablePerformanceCollector) + { + hosts.Add(new PerformanceHost(sessionId, tenantId, onEnrollmentEvent, logger, + collectors.PerformanceIntervalSeconds)); + } + + if (collectors.EnableAgentSelfMetrics && _networkMetrics != null) + { + hosts.Add(new AgentSelfMetricsHost(sessionId, tenantId, onEnrollmentEvent, logger, + _networkMetrics, _agentVersion, collectors.AgentSelfMetricsIntervalSeconds)); + } + + // M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: only polls when the + // IME log tracker reports an app entering Downloading/Installing (see AppStateChanged + // chain below). Needs the IME tracker's PackageStates + OnDoTelemetryReceived hook. + if (collectors.EnableDeliveryOptimizationCollector && _imeLogHost != null) + { + var doHost = new DeliveryOptimizationHost( + sessionId: sessionId, + tenantId: tenantId, + onEnrollmentEvent: onEnrollmentEvent, + logger: logger, + intervalSeconds: collectors.DeliveryOptimizationIntervalSeconds, + imeHost: _imeLogHost); + hosts.Add(doHost); + } + + // M4.6.δ — Gather-rules runtime executor. Runs the backend-defined rules whose + // Trigger is "startup" once the agent is up; signal / event / periodic triggers + // remain supported inside the executor itself. + if (_remoteConfig.GatherRules != null && _remoteConfig.GatherRules.Count > 0) + { + hosts.Add(new GatherRuleExecutorHost( + sessionId: sessionId, + tenantId: tenantId, + onEnrollmentEvent: onEnrollmentEvent, + logger: logger, + rules: _remoteConfig.GatherRules, + imeLogPathOverride: _agentConfig.ImeLogPathOverride)); + } + + return hosts; + } + + // ===================================================================================== + // Hosts + // ===================================================================================== + + private sealed class GatherRuleExecutorHost : ICollectorHost + { + public string Name => "GatherRuleExecutor"; + + private readonly Monitoring.Telemetry.Gather.GatherRuleExecutor _executor; + private readonly System.Collections.Generic.List _rules; + private readonly AgentLogger _logger; + private int _disposed; + + public GatherRuleExecutorHost( + string sessionId, + string tenantId, + Action onEnrollmentEvent, + AgentLogger logger, + System.Collections.Generic.List rules, + string? imeLogPathOverride) + { + _logger = logger; + _rules = rules ?? new System.Collections.Generic.List(); + _executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor( + sessionId, tenantId, onEnrollmentEvent, logger, imeLogPathOverride); + } + + public void Start() + { + _executor.UpdateRules(_rules); + _logger.Info($"GatherRuleExecutorHost: started with {_rules.Count} rule(s)."); + } + + public void Stop() + { + // GatherRuleExecutor is IDisposable; no explicit Stop. Rely on Dispose. + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _executor.Dispose(); } catch { } + } + } + + private sealed class EspAndHelloHost : ICollectorHost + { + public string Name => "EspAndHelloTracker"; + + private readonly EspAndHelloTracker _tracker; + private readonly EspAndHelloTrackerAdapter _adapter; + private int _disposed; + + public EspAndHelloHost( + string sessionId, + string tenantId, + Action onEnrollmentEvent, + AgentLogger logger, + ISignalIngressSink ingress, + IClock clock, + int helloWaitTimeoutSeconds, + bool modernDeploymentWatcherEnabled, + int modernDeploymentLogLevelMax, + bool modernDeploymentBackfillEnabled, + int modernDeploymentBackfillLookbackMinutes, + int[]? modernDeploymentHarmlessEventIds, + string stateDirectory) + { + _tracker = new EspAndHelloTracker( + sessionId: sessionId, + tenantId: tenantId, + onEventCollected: onEnrollmentEvent, + logger: logger, + helloWaitTimeoutSeconds: helloWaitTimeoutSeconds, + modernDeploymentWatcherEnabled: modernDeploymentWatcherEnabled, + modernDeploymentLogLevelMax: modernDeploymentLogLevelMax, + modernDeploymentBackfillEnabled: modernDeploymentBackfillEnabled, + modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes, + stateDirectory: stateDirectory, + modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds); + + _adapter = new EspAndHelloTrackerAdapter(_tracker, ingress, clock); + } + + public void Start() => _tracker.Start(); + public void Stop() => _tracker.Stop(); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _adapter.Dispose(); } catch { } + try { _tracker.Dispose(); } catch { } + } + } + + private sealed class DesktopArrivalHost : ICollectorHost + { + public string Name => "DesktopArrivalDetector"; + + private readonly DesktopArrivalDetector _detector; + private readonly DesktopArrivalDetectorAdapter _adapter; + private int _disposed; + + public DesktopArrivalHost(AgentLogger logger, ISignalIngressSink ingress, IClock clock) + { + _detector = new DesktopArrivalDetector(logger); + _adapter = new DesktopArrivalDetectorAdapter(_detector, ingress, clock); + } + + public void Start() => _detector.Start(); + public void Stop() => _detector.Stop(); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _adapter.Dispose(); } catch { } + try { _detector.Dispose(); } catch { } + } + } + + private sealed class AadJoinHost : ICollectorHost + { + public string Name => "AadJoinWatcher"; + + private readonly AadJoinWatcher _watcher; + private readonly AadJoinWatcherAdapter _adapter; + private int _disposed; + + public AadJoinHost(AgentLogger logger, ISignalIngressSink ingress, IClock clock) + { + _watcher = new AadJoinWatcher(logger); + _adapter = new AadJoinWatcherAdapter(_watcher, ingress, clock); + } + + public void Start() => _watcher.Start(); + public void Stop() => _watcher.Stop(); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _adapter.Dispose(); } catch { } + try { _watcher.Dispose(); } catch { } + } + } + + private sealed class ImeLogHost : ICollectorHost + { + public string Name => "ImeLogTracker"; + + private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs"; + + private readonly ImeLogTracker _tracker; + private readonly ImeProcessWatcher _processWatcher; + private readonly ImeLogTrackerAdapter _adapter; + private readonly AgentLogger _logger; + private int _disposed; + + /// + /// Exposes the tracker's live package-state list for peripheral consumers + /// (M4.6.β FinalStatusBuilder, M4.6.γ DeliveryOptimizationHost). + /// + public Monitoring.Enrollment.Ime.AppPackageStateList PackageStates => _tracker.PackageStates; + + /// + /// Reference to the wrapped IME tracker for co-collector wiring. Used by + /// DeliveryOptimizationHost to set OnDoTelemetryReceived and to chain + /// OnAppStateChanged for dormant/wake-up transitions. + /// + internal Monitoring.Enrollment.Ime.ImeLogTracker Tracker => _tracker; + + public ImeLogHost( + string sessionId, + string tenantId, + Action onEnrollmentEvent, + AgentLogger logger, + ISignalIngressSink ingress, + IClock clock, + string? imeLogPathOverride, + string? imeMatchLogPath, + List? imePatterns, + string stateDirectory, + IReadOnlyCollection? whiteGloveSealingPatternIds) + { + _logger = logger; + var logFolder = string.IsNullOrEmpty(imeLogPathOverride) ? DefaultImeLogFolder : imeLogPathOverride!; + var expandedMatchLogPath = string.IsNullOrEmpty(imeMatchLogPath) + ? null + : Environment.ExpandEnvironmentVariables(imeMatchLogPath); + var patterns = imePatterns ?? new List(); + + _tracker = new ImeLogTracker( + logFolder: logFolder, + patterns: patterns, + logger: logger, + matchLogPath: expandedMatchLogPath, + stateDirectory: stateDirectory); + + _adapter = new ImeLogTrackerAdapter(_tracker, ingress, clock, whiteGloveSealingPatternIds); + + _processWatcher = new ImeProcessWatcher(sessionId, tenantId, onEnrollmentEvent, logger); + } + + public void Start() + { + _tracker.Start(); + _processWatcher.Start(); + } + + public void Stop() + { + try { _processWatcher.Dispose(); } + catch (Exception ex) { _logger.Warning($"ImeLogHost: processWatcher dispose failed: {ex.Message}"); } + try { _tracker.Stop(); } + catch (Exception ex) { _logger.Warning($"ImeLogHost: tracker stop failed: {ex.Message}"); } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _adapter.Dispose(); } catch { } + try { _processWatcher.Dispose(); } catch { } + try { _tracker.Stop(); } catch { } + } + } + + private sealed class StallProbeHost : ICollectorHost + { + public string Name => "StallProbeCollector"; + + private static readonly TimeSpan IdleTickInterval = TimeSpan.FromSeconds(60); + + private readonly StallProbeCollector _collector; + private readonly StallProbeCollectorAdapter _adapter; + private readonly AgentLogger _logger; + private Timer? _tickTimer; + private readonly DateTime _startedAtUtc; + private int _disposed; + + public StallProbeHost( + string sessionId, + string tenantId, + Action onEnrollmentEvent, + AgentLogger logger, + ISignalIngressSink ingress, + IClock clock, + int[]? thresholdsMinutes, + int[]? traceIndices, + string[]? sources, + int sessionStalledAfterProbeIndex, + int[]? harmlessModernDeploymentEventIds) + { + _logger = logger; + _collector = new StallProbeCollector( + sessionId: sessionId, + tenantId: tenantId, + onEventCollected: onEnrollmentEvent, + logger: logger, + thresholdsMinutes: thresholdsMinutes ?? new[] { 2, 15, 30, 60, 180 }, + traceIndices: traceIndices ?? new[] { 2 }, + sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" }, + sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex, + harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds); + + _adapter = new StallProbeCollectorAdapter(_collector, ingress, clock); + _startedAtUtc = DateTime.UtcNow; + } + + public void Start() + { + // The collector has no timer of its own — we drive it with a 60-s idle tick. + _tickTimer = new Timer( + _ => SafeTick(), + state: null, + dueTime: IdleTickInterval, + period: IdleTickInterval); + _logger.Info($"StallProbeHost: started (tick every {IdleTickInterval.TotalSeconds}s)."); + } + + public void Stop() + { + try + { + _tickTimer?.Dispose(); + _tickTimer = null; + } + catch (Exception ex) { _logger.Warning($"StallProbeHost: timer dispose failed: {ex.Message}"); } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + Stop(); + try { _adapter.Dispose(); } catch { } + } + + private void SafeTick() + { + try + { + var idleMinutes = (DateTime.UtcNow - _startedAtUtc).TotalMinutes; + _collector.CheckAndRunProbes(idleMinutes); + } + catch (Exception ex) + { + _logger.Error("StallProbeHost: tick failed.", ex); + } + } + } + + private sealed class PerformanceHost : ICollectorHost + { + public string Name => "PerformanceCollector"; + + private readonly PerformanceCollector _collector; + private int _disposed; + + public PerformanceHost( + string sessionId, + string tenantId, + Action onEnrollmentEvent, + AgentLogger logger, + int intervalSeconds) + { + _collector = new PerformanceCollector(sessionId, tenantId, onEnrollmentEvent, logger, intervalSeconds); + } + + public void Start() => _collector.Start(); + public void Stop() => _collector.Stop(); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _collector.Dispose(); } catch { } + } + } + + private sealed class DeliveryOptimizationHost : ICollectorHost + { + public string Name => "DeliveryOptimizationCollector"; + + private readonly DeliveryOptimizationCollector _collector; + private readonly ImeLogHost _imeHost; + private readonly AgentLogger _logger; + private Action? _prevStateChanged; + private int _disposed; + + public DeliveryOptimizationHost( + string sessionId, + string tenantId, + Action onEnrollmentEvent, + AgentLogger logger, + int intervalSeconds, + ImeLogHost imeHost) + { + _imeHost = imeHost; + _logger = logger; + + _collector = new DeliveryOptimizationCollector( + sessionId: sessionId, + tenantId: tenantId, + emitEvent: onEnrollmentEvent, + logger: logger, + intervalSeconds: intervalSeconds, + getPackageStates: () => imeHost.PackageStates, + onDoTelemetryReceived: pkg => + { + try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); } + catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); } + }, + logDirectory: Environment.ExpandEnvironmentVariables(Shared.Constants.LogDirectory)); + } + + public void Start() + { + // Chain ourselves into the tracker's OnAppStateChanged so the DO collector wakes + // up on the first Downloading/Installing transition (Legacy parity). We preserve + // any existing handler — the IME adapter's own listener must keep running. + _prevStateChanged = _imeHost.Tracker.OnAppStateChanged; + _imeHost.Tracker.OnAppStateChanged = (pkg, oldState, newState) => + { + try { _prevStateChanged?.Invoke(pkg, oldState, newState); } + catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: previous OnAppStateChanged handler threw: {ex.Message}"); } + + if (newState >= Monitoring.Enrollment.Ime.AppInstallationState.Downloading && + newState <= Monitoring.Enrollment.Ime.AppInstallationState.Installing) + { + try { _collector.WakeUp(); } + catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: WakeUp threw: {ex.Message}"); } + } + }; + + _collector.Start(); + _logger.Info($"DeliveryOptimizationHost: started dormant (interval={_collector}, wakes on Downloading/Installing)."); + } + + public void Stop() + { + // Restore the previous handler only if we're still the current one — otherwise + // someone else replaced us and owns the slot now. + if (object.ReferenceEquals(_imeHost.Tracker.OnAppStateChanged, _imeHost.Tracker.OnAppStateChanged)) + _imeHost.Tracker.OnAppStateChanged = _prevStateChanged; + _collector.Stop(); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _collector.Dispose(); } catch { } + } + } + + private sealed class AgentSelfMetricsHost : ICollectorHost + { + public string Name => "AgentSelfMetricsCollector"; + + private readonly AgentSelfMetricsCollector _collector; + private int _disposed; + + public AgentSelfMetricsHost( + string sessionId, + string tenantId, + Action onEnrollmentEvent, + AgentLogger logger, + NetworkMetrics networkMetrics, + string agentVersion, + int intervalSeconds) + { + _collector = new AgentSelfMetricsCollector( + sessionId, tenantId, onEnrollmentEvent, networkMetrics, logger, agentVersion, intervalSeconds); + } + + public void Start() => _collector.Start(); + public void Stop() => _collector.Stop(); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _collector.Dispose(); } catch { } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EffectFailure.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EffectFailure.cs new file mode 100644 index 00000000..fc96f768 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EffectFailure.cs @@ -0,0 +1,31 @@ +#nullable enable +using System; +using AutopilotMonitor.DecisionCore.Engine; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Ein einzelner Effect-Fehler. Plan §2.7b — wird vom Orchestrator (M4.4) als + /// DecisionTransition mit Trigger="effect_failure" ins Journal geschrieben. + /// + public sealed class EffectFailure + { + public EffectFailure(DecisionEffectKind effectKind, string errorReason, bool isTransient, bool exhaustedRetries) + { + if (string.IsNullOrEmpty(errorReason)) + { + throw new ArgumentException("ErrorReason is mandatory.", nameof(errorReason)); + } + + EffectKind = effectKind; + ErrorReason = errorReason; + IsTransient = isTransient; + ExhaustedRetries = exhaustedRetries; + } + + public DecisionEffectKind EffectKind { get; } + public string ErrorReason { get; } + public bool IsTransient { get; } + public bool ExhaustedRetries { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EffectRunResult.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EffectRunResult.cs new file mode 100644 index 00000000..aec63901 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EffectRunResult.cs @@ -0,0 +1,45 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Ergebnis eines -Laufs. Plan §2.7b. + /// + /// Der Orchestrator (M4.4) liest: + /// + /// → emittiert enrollment_failed(reason=<AbortReason>) + /// → schreibt pro Fehler eine effect_failure-Transition ins Journal + /// / → Observability + /// + /// + /// + public sealed class EffectRunResult + { + public EffectRunResult( + bool sessionMustAbort, + string? abortReason, + IReadOnlyList failures, + int classifierInvocations, + int classifierSkippedByAntiLoop) + { + SessionMustAbort = sessionMustAbort; + AbortReason = abortReason; + Failures = failures ?? Array.Empty(); + ClassifierInvocations = classifierInvocations; + ClassifierSkippedByAntiLoop = classifierSkippedByAntiLoop; + } + + public bool SessionMustAbort { get; } + public string? AbortReason { get; } + public IReadOnlyList Failures { get; } + public int ClassifierInvocations { get; } + public int ClassifierSkippedByAntiLoop { get; } + + public bool Success => !SessionMustAbort && Failures.Count == 0; + + public static EffectRunResult Empty() => + new EffectRunResult(false, null, Array.Empty(), 0, 0); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EffectRunner.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EffectRunner.cs new file mode 100644 index 00000000..48c9aee0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EffectRunner.cs @@ -0,0 +1,294 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Produktions-. Plan §2.5 / §2.7b / L.17. + /// + /// Dispatcht Effekte an ihre jeweiligen I/O-Kollaborateure; implementiert die drei + /// Fehlerklassen (Transient / Kritisch / Optional) streng nach Plan-Vorgabe. + /// + /// + public sealed class EffectRunner : IEffectRunner + { + /// Plan L.17 — Transient-Retry-Backoffs (100/400/1600 ms). + public static readonly IReadOnlyList DefaultRetryBackoffs = new[] + { + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(400), + TimeSpan.FromMilliseconds(1600), + }; + + private readonly IDeadlineScheduler _scheduler; + private readonly IClassifierRegistry _classifiers; + private readonly ISignalIngressSink _ingress; + private readonly IEventTimelineEmitter _emitter; + private readonly ISnapshotPersistence _snapshot; + private readonly IClock _clock; + private readonly IReadOnlyList _retryBackoffs; + + public EffectRunner( + IDeadlineScheduler scheduler, + IClassifierRegistry classifiers, + ISignalIngressSink ingress, + IEventTimelineEmitter emitter, + ISnapshotPersistence snapshot, + IClock clock, + IReadOnlyList? retryBackoffs = null) + { + _scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler)); + _classifiers = classifiers ?? throw new ArgumentNullException(nameof(classifiers)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _emitter = emitter ?? throw new ArgumentNullException(nameof(emitter)); + _snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _retryBackoffs = retryBackoffs ?? DefaultRetryBackoffs; + } + + public async Task RunAsync( + IReadOnlyList effects, + DecisionState stateAfterReduce, + DateTime stepOccurredAtUtc, + CancellationToken cancellationToken = default) + { + if (effects == null) throw new ArgumentNullException(nameof(effects)); + if (stateAfterReduce == null) throw new ArgumentNullException(nameof(stateAfterReduce)); + + var failures = new List(); + int invocations = 0; + int skipped = 0; + + foreach (var effect in effects) + { + cancellationToken.ThrowIfCancellationRequested(); + + switch (effect.Kind) + { + case DecisionEffectKind.ScheduleDeadline: + case DecisionEffectKind.CancelDeadline: + var criticalFail = TryRunCriticalDeadlineEffect(effect); + if (criticalFail != null) + { + failures.Add(criticalFail); + return new EffectRunResult( + sessionMustAbort: true, + abortReason: $"timer_infrastructure_failure: {criticalFail.ErrorReason}", + failures: failures, + classifierInvocations: invocations, + classifierSkippedByAntiLoop: skipped); + } + break; + + case DecisionEffectKind.RunClassifier: + var classifierOutcome = RunOptionalClassifierEffect(effect, stateAfterReduce, stepOccurredAtUtc); + invocations += classifierOutcome.Invoked ? 1 : 0; + skipped += classifierOutcome.SkippedByAntiLoop ? 1 : 0; + if (classifierOutcome.Failure != null) failures.Add(classifierOutcome.Failure); + break; + + case DecisionEffectKind.EmitEventTimelineEntry: + await RunTransientAsync( + effect, + () => _emitter.Emit(effect.Parameters, stateAfterReduce, stepOccurredAtUtc), + failures, + cancellationToken).ConfigureAwait(false); + break; + + case DecisionEffectKind.PersistSnapshot: + await RunTransientAsync( + effect, + () => _snapshot.Save(stateAfterReduce), + failures, + cancellationToken).ConfigureAwait(false); + break; + + default: + throw new InvalidOperationException($"Unhandled DecisionEffectKind: {effect.Kind}"); + } + } + + return new EffectRunResult( + sessionMustAbort: false, + abortReason: null, + failures: failures, + classifierInvocations: invocations, + classifierSkippedByAntiLoop: skipped); + } + + private EffectFailure? TryRunCriticalDeadlineEffect(DecisionEffect effect) + { + try + { + if (effect.Kind == DecisionEffectKind.ScheduleDeadline) + { + if (effect.Deadline == null) + { + throw new InvalidOperationException("ScheduleDeadline effect missing Deadline payload."); + } + _scheduler.Schedule(effect.Deadline); + } + else + { + if (string.IsNullOrEmpty(effect.CancelDeadlineName)) + { + throw new InvalidOperationException("CancelDeadline effect missing CancelDeadlineName."); + } + _scheduler.Cancel(effect.CancelDeadlineName!); + } + return null; + } + catch (Exception ex) + { + return new EffectFailure( + effectKind: effect.Kind, + errorReason: $"{ex.GetType().Name}: {ex.Message}", + isTransient: false, + exhaustedRetries: false); + } + } + + private ClassifierEffectOutcome RunOptionalClassifierEffect( + DecisionEffect effect, + DecisionState stateAfterReduce, + DateTime stepOccurredAtUtc) + { + if (string.IsNullOrEmpty(effect.ClassifierId)) + { + return new ClassifierEffectOutcome( + false, false, + new EffectFailure(effect.Kind, "RunClassifier effect missing ClassifierId.", isTransient: false, exhaustedRetries: false)); + } + + if (!_classifiers.TryGet(effect.ClassifierId!, out var classifier) || classifier == null) + { + return new ClassifierEffectOutcome( + false, false, + new EffectFailure(effect.Kind, $"Classifier '{effect.ClassifierId}' not registered.", isTransient: false, exhaustedRetries: false)); + } + + var snapshot = effect.ClassifierSnapshot; + if (snapshot == null) + { + return new ClassifierEffectOutcome( + false, false, + new EffectFailure(effect.Kind, $"RunClassifier '{effect.ClassifierId}' missing snapshot.", isTransient: false, exhaustedRetries: false)); + } + + // Anti-loop (§2.4): skip if snapshot-hash matches the last verdict for this classifier. + var hashable = snapshot as IClassifierSnapshot; + var snapshotHash = hashable?.ComputeInputHash() ?? string.Empty; + var lastVerdictId = ClassifierVerdictLookup.LookupLastVerdictId(stateAfterReduce, effect.ClassifierId!); + if (!string.IsNullOrEmpty(snapshotHash) && + !string.IsNullOrEmpty(lastVerdictId) && + snapshotHash == lastVerdictId) + { + return new ClassifierEffectOutcome(invoked: false, skippedByAntiLoop: true, failure: null); + } + + ClassifierVerdict verdict; + try + { + verdict = classifier.Classify(snapshot); + } + catch (Exception ex) + { + // Optional-class failure: emit an Inconclusive verdict instead of aborting. + var inconclusive = new ClassifierVerdict( + classifierId: effect.ClassifierId!, + level: HypothesisLevel.Inconclusive, + score: 0, + contributingFactors: Array.Empty(), + reason: $"exception: {ex.GetType().Name}: {ex.Message}", + inputHash: string.IsNullOrEmpty(snapshotHash) ? $"inconclusive:{Guid.NewGuid():N}" : snapshotHash); + PostClassifierVerdictSignal(inconclusive, effect.ClassifierId!, stepOccurredAtUtc); + return new ClassifierEffectOutcome( + invoked: true, + skippedByAntiLoop: false, + failure: new EffectFailure(effect.Kind, $"{ex.GetType().Name}: {ex.Message}", isTransient: false, exhaustedRetries: false)); + } + + PostClassifierVerdictSignal(verdict, effect.ClassifierId!, stepOccurredAtUtc); + return new ClassifierEffectOutcome(invoked: true, skippedByAntiLoop: false, failure: null); + } + + private void PostClassifierVerdictSignal(ClassifierVerdict verdict, string classifierId, DateTime stepOccurredAtUtc) + { + _ingress.Post( + kind: DecisionSignalKind.ClassifierVerdictIssued, + occurredAtUtc: stepOccurredAtUtc, + sourceOrigin: $"effectrunner:classifier:{classifierId}", + evidence: new Evidence( + kind: EvidenceKind.Synthetic, + identifier: $"classifier:{classifierId}:{verdict.InputHash}", + summary: $"{classifierId}={verdict.Level}/{verdict.Score}"), + payload: new Dictionary(StringComparer.Ordinal) + { + ["classifier"] = verdict.ClassifierId, + ["level"] = verdict.Level.ToString(), + ["score"] = verdict.Score.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["reason"] = verdict.Reason, + ["inputHash"] = verdict.InputHash, + }); + } + + private async Task RunTransientAsync( + DecisionEffect effect, + Action action, + List failures, + CancellationToken cancellationToken) + { + string? lastErr = null; + + for (int attempt = 0; attempt <= _retryBackoffs.Count; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + action(); + return; // success + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + lastErr = $"{ex.GetType().Name}: {ex.Message}"; + if (attempt < _retryBackoffs.Count) + { + await _clock.Delay(_retryBackoffs[attempt], cancellationToken).ConfigureAwait(false); + } + } + } + + failures.Add(new EffectFailure( + effect.Kind, + lastErr ?? "unknown transient error", + isTransient: true, + exhaustedRetries: true)); + } + + private readonly struct ClassifierEffectOutcome + { + public ClassifierEffectOutcome(bool invoked, bool skippedByAntiLoop, EffectFailure? failure) + { + Invoked = invoked; + SkippedByAntiLoop = skippedByAntiLoop; + Failure = failure; + } + + public bool Invoked { get; } + public bool SkippedByAntiLoop { get; } + public EffectFailure? Failure { get; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs new file mode 100644 index 00000000..b02eada8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs @@ -0,0 +1,762 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Events; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Signals; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Top-Level-Lifecycle-Owner der V2-Agent-Runtime. Plan §2.1a / §2.7 / §4.x M4.4.5. + /// + /// Scope M4.4.5.b: verdrahtet die Kern-Pipeline — Persistenz (SignalLog + Journal + + /// Snapshot), Telemetry-Transport (Spool + Uploader), Event-Emitter-Kette, + /// DeadlineScheduler → Ingress-Bridge, ClassifierRegistry, EffectRunner, + /// DecisionStepProcessor, SignalIngress. Sub-c erweitert um Collectors + SignalAdapters. + /// + /// + /// Startup-Reihenfolge (ctor → ): + /// + /// Persistenz-Writer instanziieren (Sofort-Flush aktiv ab hier) + /// snapshot.Load() → initialState; fallback + /// TelemetrySpool + BackendUploader + TelemetryUploadOrchestrator + /// EventSequenceCounter → TelemetryEventEmitter → EventTimelineEmitter + BackPressureObserver + /// DeadlineScheduler + ClassifierRegistry + /// LazyIngressSinkRelay (für die EffectRunner-↔-SignalIngress-Zirkel-Dep) + /// EffectRunner (sink = Relay) + /// DecisionStepProcessor (initial state) + /// SessionTraceOrdinalProvider (seeded from max across SignalLog + Journal + Spool) + /// SignalIngress — Relay.Target = ingress + /// Scheduler.Fired → Ingress.Post-Bridge subscriben + /// signalIngress.Start() — Worker-Thread läuft + /// Periodischer Drain-Loop starten (Fire-and-forget Task) + /// + /// + /// + /// Shutdown-Reihenfolge (, idempotent): + /// + /// Scheduler.Fired-Handler abmelden (keine neuen DeadlineFired-Signals) + /// DeadlineScheduler disposen (stoppt alle Timer) + /// SignalIngress.Stop — drainiert verbleibende Items + /// Drain-Loop Token cancellen, Task joinen + /// Terminaler Drain — finale Batches ans Backend + /// Snapshot.Save — letzter konsistenter State + /// Dispose aller Disposables + /// + /// + /// + public sealed class EnrollmentOrchestrator : IQuarantineSink, IDisposable + { + /// Default-Intervall zwischen periodischen Drain-Versuchen. + public static readonly TimeSpan DefaultDrainInterval = TimeSpan.FromSeconds(30); + + /// Default Timeout für den Terminal-Drain beim Stop. + public static readonly TimeSpan DefaultTerminalDrainTimeout = TimeSpan.FromSeconds(30); + + private readonly string _sessionId; + private readonly string _tenantId; + private readonly string _stateDirectory; + private readonly string _transportDirectory; + private readonly IClock _clock; + private readonly AgentLogger _logger; + private readonly IBackendTelemetryUploader _uploader; + private readonly IReadOnlyList _classifiers; + private readonly IComponentFactory? _componentFactory; + private readonly IReadOnlyCollection _whiteGloveSealingPatternIds; + private readonly int _channelCapacity; + private readonly int _quarantineThreshold; + private readonly TimeSpan _drainInterval; + private readonly TimeSpan _terminalDrainTimeout; + private readonly TimeSpan? _agentMaxLifetime; + + // Max-lifetime watchdog (M4.6.α). Timer is armed in Start() when _agentMaxLifetime != null. + private System.Threading.Timer? _maxLifetimeTimer; + private int _terminatedFired; + + // Built in Start() + private DecisionEngine? _engine; + private SignalLogWriter? _signalLog; + private JournalWriter? _journal; + private SnapshotPersistence? _snapshot; + private EventSequencePersistence? _eventSequencePersistence; + private EventSequenceCounter? _eventSequenceCounter; + private TelemetrySpool? _spool; + private TelemetryUploadOrchestrator? _transport; + private TelemetryEventEmitter? _eventEmitter; + private TelemetrySignalEmitter? _signalEmitter; + private TelemetryTransitionEmitter? _transitionEmitter; + private EventTimelineEmitter? _timelineEmitter; + private BackPressureEventObserver? _backPressureObserver; + private DeadlineScheduler? _scheduler; + private ClassifierRegistry? _classifierRegistry; + private LazyIngressSinkRelay? _sinkRelay; + private EffectRunner? _effectRunner; + private DecisionStepProcessor? _processor; + private SessionTraceOrdinalProvider? _traceCounter; + private SignalIngress? _ingress; + private IReadOnlyList? _collectorHosts; + + private EventHandler? _deadlineBridge; + + // Drain-Loop + private CancellationTokenSource? _drainCts; + private Task? _drainTask; + + // Lifecycle + private int _started; + private int _stopRequested; + private int _disposed; + + // Quarantine flag — set mid-run, read on next start. + private bool _quarantineRequested; + private string? _quarantineReason; + + // Recovery flags (populated during Start()). + private bool _isWhiteGlovePart2Resume; + private bool _wasStartupQuarantine; + + public EnrollmentOrchestrator( + string sessionId, + string tenantId, + string stateDirectory, + string transportDirectory, + IClock clock, + AgentLogger logger, + IBackendTelemetryUploader uploader, + IEnumerable classifiers, + IComponentFactory? componentFactory = null, + IReadOnlyCollection? whiteGloveSealingPatternIds = null, + int channelCapacity = SignalIngress.DefaultChannelCapacity, + int quarantineThreshold = DecisionStepProcessor.DefaultQuarantineThreshold, + TimeSpan? drainInterval = null, + TimeSpan? terminalDrainTimeout = null, + TimeSpan? agentMaxLifetime = null) + { + if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId)); + if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId)); + if (string.IsNullOrEmpty(stateDirectory)) throw new ArgumentException("StateDirectory is mandatory.", nameof(stateDirectory)); + if (string.IsNullOrEmpty(transportDirectory)) throw new ArgumentException("TransportDirectory is mandatory.", nameof(transportDirectory)); + + _sessionId = sessionId; + _tenantId = tenantId; + _stateDirectory = stateDirectory; + _transportDirectory = transportDirectory; + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _uploader = uploader ?? throw new ArgumentNullException(nameof(uploader)); + + if (classifiers == null) throw new ArgumentNullException(nameof(classifiers)); + var list = new List(); + foreach (var c in classifiers) + { + if (c == null) throw new ArgumentException("Classifier enumerable must not contain null.", nameof(classifiers)); + list.Add(c); + } + _classifiers = list; + _componentFactory = componentFactory; + _whiteGloveSealingPatternIds = whiteGloveSealingPatternIds ?? Array.Empty(); + + _channelCapacity = channelCapacity; + _quarantineThreshold = quarantineThreshold; + _drainInterval = drainInterval ?? DefaultDrainInterval; + _terminalDrainTimeout = terminalDrainTimeout ?? DefaultTerminalDrainTimeout; + + if (agentMaxLifetime.HasValue && agentMaxLifetime.Value <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(agentMaxLifetime), "AgentMaxLifetime must be positive when set."); + _agentMaxLifetime = agentMaxLifetime; + } + + /// + /// Terminal event — fires once when the orchestrator declares the session done. Plan §4.x M4.6.α. + /// + /// M4.6.α fires only ; the + /// path is wired in M4.6.β + /// together with CleanupService self-destruct + SummaryDialog launch. + /// + /// + /// Handlers may run on a ThreadPool thread (Timer callback). They must NOT call + /// directly (re-entrant) — raise a shutdown + /// from the handler and let the main thread call . + /// + /// + public event EventHandler? Terminated; + + // ---------------------------------------------------------------- Observability + + /// Aktueller — nur nach verfügbar. + public DecisionState CurrentState => + _processor?.CurrentState ?? throw new InvalidOperationException("Orchestrator not started."); + + /// True wenn ein Processor-Callback Quarantine-Eskalation ausgelöst hat. M4.4.5.f liest das. + public bool IsQuarantineRequested => _quarantineRequested; + + /// Letzter Quarantine-Reason oder null. + public string? QuarantineReason => _quarantineReason; + + /// + /// true wenn der Start mit einem persistierten WhiteGloveSealed-State + /// gelaufen ist und die Part-2-Bridge via SessionRecovered-Signal gezündet wurde. + /// Plan §2.7 Sonderfall 1. + /// + public bool IsWhiteGlovePart2Resume => _isWhiteGlovePart2Resume; + + /// + /// true wenn der Start auf einen korrupten State-Segment traf und + /// Snapshot + Log-Segmente nach .quarantine/{ts}/ bewegt wurden. + /// Plan §2.7 Sonderfall 2. + /// + public bool WasStartupQuarantine => _wasStartupQuarantine; + + /// Exposed für Sub-c-Wiring (SignalAdapters + Collector-Callbacks). + public ISignalIngressSink IngressSink => + (ISignalIngressSink?)_ingress ?? throw new InvalidOperationException("Orchestrator not started."); + + /// Exposed für Sub-c-Wiring (Collector onEventCollected-Bridge). + public TelemetryEventEmitter EventEmitter => + _eventEmitter ?? throw new InvalidOperationException("Orchestrator not started."); + + /// + /// Exposed so Program.cs can subscribe to + /// for M4.6.ε DeviceBlocked / DeviceKillSignal / AdminAction / Actions plumbing. + /// + public TelemetryUploadOrchestrator Transport => + _transport ?? throw new InvalidOperationException("Orchestrator not started."); + + // ---------------------------------------------------------------- Lifecycle + + /// + /// Wires all components and starts the Ingress-Worker + periodischen Drain-Loop. + /// Idempotent per — zweiter Aufruf wirft. + /// + public void Start() + { + if (Volatile.Read(ref _disposed) == 1) throw new ObjectDisposedException(nameof(EnrollmentOrchestrator)); + if (Interlocked.Exchange(ref _started, 1) == 1) + { + throw new InvalidOperationException("EnrollmentOrchestrator already started."); + } + + EnsureDirectories(); + + // 1) Persistenz. Writer scannen bestehende Files im Ctor (LastOrdinal / LastStepIndex). + var snapshotPath = Path.Combine(_stateDirectory, "snapshot.json"); + _signalLog = new SignalLogWriter(Path.Combine(_stateDirectory, "signal-log.jsonl")); + _journal = new JournalWriter(Path.Combine(_stateDirectory, "journal.jsonl")); + _snapshot = new SnapshotPersistence(snapshotPath, () => _clock.UtcNow); + _eventSequencePersistence = new EventSequencePersistence(Path.Combine(_stateDirectory, "event-sequence.json")); + + // 2) Recovery (Plan §2.7 Sonderfälle 1+2). + var snapshotFileExistsPreLoad = File.Exists(snapshotPath); + var loadedState = _snapshot.Load(); + DecisionState initialState; + + if (loadedState == null && snapshotFileExistsPreLoad) + { + // Sonderfall 2: Snapshot-File existiert, Load lieferte null → Checksum-Mismatch + // oder Deserialize-Failure. Plan §2.7c: Snapshot ist Cache, nicht Wahrheit — + // Snapshot + Log-Segmente in Quarantäne, frisch starten. + _logger.Error( + "EnrollmentOrchestrator: snapshot present but Load returned null (checksum mismatch or parse error) — quarantining state."); + const string reason = "checksum-mismatch-on-startup"; + _snapshot.Quarantine(reason); + SegmentQuarantine.QuarantineAll(_stateDirectory, reason, () => _clock.UtcNow); + + // Writer halten Pfade, nicht Handles — aber ihre in-memory Counter (LastOrdinal, + // LastStepIndex) sind jetzt stale gegenüber den entfernten Files. Recreate, damit + // Counter bei -1 starten. + _signalLog = new SignalLogWriter(Path.Combine(_stateDirectory, "signal-log.jsonl")); + _journal = new JournalWriter(Path.Combine(_stateDirectory, "journal.jsonl")); + _eventSequencePersistence = new EventSequencePersistence(Path.Combine(_stateDirectory, "event-sequence.json")); + + initialState = DecisionState.CreateInitial(_sessionId, _tenantId); + _wasStartupQuarantine = true; + } + else if (loadedState != null) + { + initialState = loadedState; + _logger.Info( + $"EnrollmentOrchestrator: recovered state from snapshot (stage={loadedState.Stage}, stepIndex={loadedState.StepIndex})."); + } + else + { + initialState = DecisionState.CreateInitial(_sessionId, _tenantId); + _logger.Info($"EnrollmentOrchestrator: fresh initial state for session {_sessionId}."); + } + + // Sonderfall 1 Detection: WG Part 1 -> Reboot -> Part 2 Resume. + // Self-destruct bleibt AUS (Caller ist zuständig — die Info wird via + // IsWhiteGlovePart2Resume exponiert); Session-Recovered-Signal wird gepostet + // nachdem Ingress gestartet ist. + if (initialState.Stage == SessionStage.WhiteGloveSealed) + { + _isWhiteGlovePart2Resume = true; + _logger.Info( + "EnrollmentOrchestrator: White-Glove Part-1 resume detected — SessionRecovered signal " + + "will be posted after ingress start; self-destruct handling is caller-owned."); + } + + // 3) Telemetry-Transport. + _spool = new TelemetrySpool(_transportDirectory, _clock); + _transport = new TelemetryUploadOrchestrator(_spool, _uploader, _clock); + + // 4) Event-Emitter-Kette. + _eventSequenceCounter = new EventSequenceCounter(_eventSequencePersistence); + _eventEmitter = new TelemetryEventEmitter(_transport, _eventSequenceCounter, _sessionId, _tenantId); + _signalEmitter = new TelemetrySignalEmitter(_transport, _sessionId, _tenantId); + _transitionEmitter = new TelemetryTransitionEmitter(_transport, _sessionId, _tenantId); + _timelineEmitter = new EventTimelineEmitter(_eventEmitter); + _backPressureObserver = new BackPressureEventObserver(_eventEmitter, _clock); + + // 5) Deadlines + Classifiers. + _scheduler = new DeadlineScheduler(_clock); + _classifierRegistry = new ClassifierRegistry(_classifiers); + + // 6) Relay — EffectRunner braucht ISignalIngressSink, aber Ingress wird erst später + // gebaut. Relay löst den Zirkel über einen setzbaren Target-Pointer. + _sinkRelay = new LazyIngressSinkRelay(); + + // 7) EffectRunner. + _effectRunner = new EffectRunner( + scheduler: _scheduler, + classifiers: _classifierRegistry, + ingress: _sinkRelay, + emitter: _timelineEmitter, + snapshot: _snapshot, + clock: _clock); + + // 8) Processor — owns the initial state + journal + snapshot + quarantine hook + + // M4.6.β terminal-stage hook (fires Terminated event when the engine reaches a + // terminal SessionStage). + _processor = new DecisionStepProcessor( + initialState: initialState, + journal: _journal, + effectRunner: _effectRunner, + snapshot: _snapshot, + quarantineSink: this, + logger: _logger, + quarantineThreshold: _quarantineThreshold, + onTerminalStageReached: OnDecisionTerminalStage, + transitionEmitter: _transitionEmitter); + + // 9) Trace-Ordinal. Recovery-seed = max(SignalLog, Journal, Spool) so restart after a + // crash never re-uses a SessionTraceOrdinal already persisted by a prior session. + // Every signal + transition goes through the Spool in M5+, but we still consult all + // three sources because: + // - SignalLog may contain signals whose spool-enqueue later failed (emit swallows + // transport exceptions per SignalIngress.cs §2.7c). + // - Journal likewise for transitions with failed spool-enqueue. + // - Spool.LastAssignedItemId is the single-source-of-truth for everything that + // reached the transport (including Events that bypass the provider entirely). + var traceSeed = System.Math.Max( + System.Math.Max(_signalLog.LastTraceOrdinal, _journal.LastTraceOrdinal), + _spool.LastAssignedItemId); + _traceCounter = new SessionTraceOrdinalProvider(seedLastAssigned: traceSeed); + + // 10) DecisionEngine + SignalIngress. + _engine = new DecisionEngine(); + _ingress = new SignalIngress( + engine: _engine, + signalLog: _signalLog, + traceCounter: _traceCounter, + processor: _processor, + clock: _clock, + backPressureObserver: _backPressureObserver, + signalEmitter: _signalEmitter, + channelCapacity: _channelCapacity); + + // 11) Relay auf den echten Ingress umbiegen. + _sinkRelay.Target = _ingress; + + // 12) Scheduler.Fired → synthetic DeadlineFired-Signal. + _deadlineBridge = OnDeadlineFired; + _scheduler.Fired += _deadlineBridge; + + // 13) Ingress-Worker starten (vor Collectors — sonst race-prone). + _ingress.Start(); + + // 13a) Recovery Sonderfall 1: WG Part-1 → Part-2 bridge zünden. Das Signal muss + // nach _ingress.Start laufen, aber VOR Collectors/Adapters — so sieht der + // Reducer-Worker zuerst die Bridge-Transition (WhiteGloveSealed → AwaitingUserSignIn) + // und erst danach kommende Real-Signals. + if (_isWhiteGlovePart2Resume) + { + try + { + _ingress.Post( + kind: DecisionSignalKind.SessionRecovered, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "EnrollmentOrchestrator", + evidence: new Evidence( + kind: EvidenceKind.Synthetic, + identifier: "wg_part1_resume", + summary: "White-Glove Part-1 state recovered from snapshot; Part-2 bridge triggered."), + payload: new Dictionary(StringComparer.Ordinal) + { + ["from"] = SessionStage.WhiteGloveSealed.ToString(), + ["to"] = SessionStage.WhiteGloveAwaitingUserSignIn.ToString(), + }); + } + catch (Exception ex) + { + _logger.Error("EnrollmentOrchestrator: failed to post SessionRecovered for WG Part-2 resume.", ex); + } + } + + // 14) Collector-Hosts via Factory — onEnrollmentEvent bridged Telemetry-Events + // an den zentralen TelemetryEventEmitter. Exception-Swallow damit ein + // Collector-Thread nicht am Emitter-Fehler stirbt (Plan §2.7a). + if (_componentFactory != null) + { + var safeSink = BuildTelemetryEventSink(); + _collectorHosts = _componentFactory.CreateCollectorHosts( + _sessionId, _tenantId, _logger, safeSink, _whiteGloveSealingPatternIds, + ingress: _ingress, clock: _clock); + foreach (var host in _collectorHosts) + { + try + { + host.Start(); + _logger.Debug($"EnrollmentOrchestrator: started collector host '{host.Name}'."); + } + catch (Exception ex) + { + _logger.Error($"EnrollmentOrchestrator: failed to start collector host '{host.Name}'.", ex); + } + } + } + + // 15) Periodischer Drain-Loop (Fire-and-forget). + _drainCts = new CancellationTokenSource(); + _drainTask = Task.Run(() => DrainLoopAsync(_drainCts.Token)); + + // 16) Max-lifetime watchdog (M4.6.α). Fires once after the configured duration when + // no real terminal stage has been reached. Timer is a best-effort System.Threading.Timer + // because VirtualClock-driven delay would not map to an OS-level wall-clock wait. + if (_agentMaxLifetime.HasValue) + { + _maxLifetimeTimer = new System.Threading.Timer( + state: null, + dueTime: _agentMaxLifetime.Value, + period: System.Threading.Timeout.InfiniteTimeSpan, + callback: _ => RaiseMaxLifetimeExceeded()); + _logger.Info($"EnrollmentOrchestrator: max-lifetime watchdog armed ({_agentMaxLifetime.Value.TotalMinutes:F0}min)."); + } + + _logger.Info("EnrollmentOrchestrator: started."); + } + + private void RaiseMaxLifetimeExceeded() + { + if (Volatile.Read(ref _stopRequested) == 1) return; + if (Interlocked.Exchange(ref _terminatedFired, 1) == 1) return; + + _logger.Warning( + $"EnrollmentOrchestrator: max-lifetime ({_agentMaxLifetime?.TotalMinutes:F0}min) exceeded — firing Terminated(MaxLifetimeExceeded)."); + + var currentStage = _processor?.CurrentState?.Stage.ToString(); + + var terminatedArgs = new EnrollmentTerminatedEventArgs( + reason: EnrollmentTerminationReason.MaxLifetimeExceeded, + outcome: EnrollmentTerminationOutcome.TimedOut, + stageName: currentStage, + terminatedAtUtc: _clock.UtcNow, + details: $"Agent exceeded AgentMaxLifetimeMinutes cap ({_agentMaxLifetime?.TotalMinutes:F0}min) without reaching a terminal stage."); + + try { Terminated?.Invoke(this, terminatedArgs); } + catch (Exception ex) { _logger.Error("EnrollmentOrchestrator: Terminated handler threw.", ex); } + } + + /// + /// M4.6.β — DecisionStepProcessor callback: the engine has transitioned the session + /// into a terminal . Fires the public + /// event with and an + /// outcome derived from the stage (Completed/Part2→Succeeded, Failed→Failed, + /// WhiteGloveSealed→Succeeded but with + /// signalled to callers via the stage name — callers decide whether to self-destruct). + /// + private void OnDecisionTerminalStage(DecisionState terminalState) + { + if (Volatile.Read(ref _stopRequested) == 1) return; + if (Interlocked.Exchange(ref _terminatedFired, 1) == 1) return; + + // Stop the max-lifetime watchdog — the real terminal arrived before it could trip. + try { _maxLifetimeTimer?.Dispose(); _maxLifetimeTimer = null; } catch { } + + var outcome = terminalState.Stage switch + { + SessionStage.Completed or SessionStage.WhiteGloveCompletedPart2 or SessionStage.WhiteGloveSealed + => EnrollmentTerminationOutcome.Succeeded, + SessionStage.Failed => EnrollmentTerminationOutcome.Failed, + _ => EnrollmentTerminationOutcome.TimedOut, + }; + + _logger.Info( + $"EnrollmentOrchestrator: decision terminal stage reached (stage={terminalState.Stage}, outcome={outcome}) — firing Terminated(DecisionTerminalStage)."); + + var terminatedArgs = new EnrollmentTerminatedEventArgs( + reason: EnrollmentTerminationReason.DecisionTerminalStage, + outcome: outcome, + stageName: terminalState.Stage.ToString(), + terminatedAtUtc: _clock.UtcNow, + details: terminalState.Stage.IsPauseBeforePart2() + ? "WhiteGlove Part 1 sealed — session will resume on Part 2 post-reboot; self-destruct suppressed." + : null); + + try { Terminated?.Invoke(this, terminatedArgs); } + catch (Exception ex) { _logger.Error("EnrollmentOrchestrator: Terminated handler threw.", ex); } + } + + /// + /// Stop the pipeline. Idempotent; calls from as well. + /// + public void Stop() + { + if (Volatile.Read(ref _started) == 0) return; + if (Interlocked.Exchange(ref _stopRequested, 1) == 1) return; + + _logger.Info("EnrollmentOrchestrator: stopping."); + + // -1) Max-lifetime watchdog stoppen (M4.6.α). Idempotent — safe even if never armed. + try { _maxLifetimeTimer?.Dispose(); _maxLifetimeTimer = null; } + catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: max-lifetime timer dispose failed: {ex.Message}"); } + + // 0) Collector-Hosts stoppen — keine neuen Events / DecisionSignals aus dem Feld. + if (_collectorHosts != null) + { + foreach (var host in _collectorHosts) + { + try { host.Stop(); } + catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: host '{host.Name}' stop failed: {ex.Message}"); } + } + } + + // 1) Scheduler.Fired-Handler abmelden — keine neuen DeadlineFired-Signals mehr. + try + { + if (_scheduler != null && _deadlineBridge != null) + { + _scheduler.Fired -= _deadlineBridge; + } + } + catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: unsubscribe scheduler failed: {ex.Message}"); } + + // 2) Scheduler disposen — stoppt alle aktiven Timer. + try { _scheduler?.Dispose(); } + catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: scheduler dispose failed: {ex.Message}"); } + + // 3) SignalIngress stoppen — drainiert verbleibende Items, wartet auf Worker-Join. + try { _ingress?.Stop(); } + catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: ingress stop failed: {ex.Message}"); } + + // 4) Drain-Loop Token cancellen + warten. + try + { + _drainCts?.Cancel(); + _drainTask?.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) { /* wrapped OperationCanceledException */ } + catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: drain loop join failed: {ex.Message}"); } + + // 5) Terminaler Drain — finale Batches. + try + { + using (var timeoutCts = new CancellationTokenSource(_terminalDrainTimeout)) + { + _transport?.DrainAllAsync(timeoutCts.Token).GetAwaiter().GetResult(); + } + } + catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: terminal drain failed: {ex.Message}"); } + + // 6) Finales Snapshot. + try + { + if (_processor != null && _snapshot != null) + { + _snapshot.Save(_processor.CurrentState); + } + } + catch (Exception ex) { _logger.Warning($"EnrollmentOrchestrator: final snapshot save failed: {ex.Message}"); } + + // 7) Dispose aller Disposables. + if (_collectorHosts != null) + { + foreach (var host in _collectorHosts) + { + try { host.Dispose(); } catch { /* best-effort */ } + } + } + try { _transport?.Dispose(); } catch { /* best-effort */ } + try { _ingress?.Dispose(); } catch { /* best-effort */ } + try { _drainCts?.Dispose(); } catch { /* best-effort */ } + + _logger.Info("EnrollmentOrchestrator: stopped."); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { Stop(); } + catch { /* Dispose darf nicht werfen. */ } + } + + // ---------------------------------------------------------------- IQuarantineSink + + public void TriggerQuarantine(string reason) + { + _quarantineRequested = true; + _quarantineReason = reason ?? string.Empty; + _logger.Error($"EnrollmentOrchestrator: quarantine requested — {reason}"); + // Actual segment-quarantine happens on next Start (M4.4.5.f), not mid-run. + } + + // ---------------------------------------------------------------- Test helpers + + /// + /// Explicit drain for tests. Returns the of one flush cycle. + /// + internal Task DrainAsync(CancellationToken cancellationToken = default) + { + if (_transport == null) throw new InvalidOperationException("Orchestrator not started."); + return _transport.DrainAllAsync(cancellationToken); + } + + // ---------------------------------------------------------------- Private + + private void EnsureDirectories() + { + if (!Directory.Exists(_stateDirectory)) Directory.CreateDirectory(_stateDirectory); + if (!Directory.Exists(_transportDirectory)) Directory.CreateDirectory(_transportDirectory); + } + + /// + /// Shared callback that collector hosts use for onEventCollected. Exception-safe: + /// an emitter failure (disk hiccup, transport closed) must never kill a collector thread. + /// + private Action BuildTelemetryEventSink() + { + return evt => + { + if (evt == null || _eventEmitter == null) return; + try + { + _eventEmitter.Emit(evt); + } + catch (Exception ex) + { + _logger.Error( + $"EnrollmentOrchestrator: telemetry emit failed for event '{evt.EventType}'.", ex); + } + }; + } + + private void OnDeadlineFired(object? sender, DeadlineFiredEventArgs e) + { + if (Volatile.Read(ref _stopRequested) == 1 || _ingress == null) return; + + try + { + var evidence = new Evidence( + kind: EvidenceKind.Synthetic, + identifier: e.Deadline.Name, + summary: $"deadline '{e.Deadline.Name}' fired at {e.Deadline.DueAtUtc:O}"); + + var payload = new Dictionary(StringComparer.Ordinal) + { + ["deadlineName"] = e.Deadline.Name, + }; + + // OccurredAtUtc = DueAtUtc (not firedAt) — replay-determinism per DeadlineFiredEventArgs doc. + _ingress.Post( + kind: DecisionSignalKind.DeadlineFired, + occurredAtUtc: e.Deadline.DueAtUtc, + sourceOrigin: "DeadlineScheduler", + evidence: evidence, + payload: payload); + } + catch (Exception ex) + { + // Deadline-Bridge darf den Scheduler-Thread nicht killen. + _logger.Error($"EnrollmentOrchestrator: failed to post DeadlineFired for '{e.Deadline.Name}'.", ex); + } + } + + private async Task DrainLoopAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + try + { + // Wall-clock delay, NOT _clock.Delay: drain is a real network cadence + // independent of the decision-engine's logical time. VirtualClock.Delay + // returns immediately and would cause a tight loop in unit tests. + await Task.Delay(_drainInterval, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { return; } + + if (ct.IsCancellationRequested) return; + + try + { + var result = await _transport!.DrainAllAsync(ct).ConfigureAwait(false); + if (result.FailedBatches > 0) + { + _logger.Warning( + $"EnrollmentOrchestrator: periodic drain had {result.FailedBatches} failed batch(es); " + + $"last error: {result.LastErrorReason}"); + } + } + catch (OperationCanceledException) { return; } + catch (Exception ex) + { + _logger.Error("EnrollmentOrchestrator: periodic drain threw.", ex); + } + } + } + catch (Exception ex) + { + _logger.Error("EnrollmentOrchestrator: drain loop faulted.", ex); + } + } + + /// + /// Pass-through to break the Effect-↔-Ingress ctor + /// cycle: EffectRunner requires a sink, but the real Ingress wants the processor + + /// effect-runner to already exist. Relay is constructed first, wired to the real + /// Ingress last. + /// + private sealed class LazyIngressSinkRelay : ISignalIngressSink + { + internal ISignalIngressSink? Target { get; set; } + + public void Post( + DecisionSignalKind kind, + DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + IReadOnlyDictionary? payload = null, + int kindSchemaVersion = 1) + { + var t = Target; + if (t == null) + { + throw new InvalidOperationException( + "LazyIngressSinkRelay.Target is null — SignalIngress was not wired yet."); + } + t.Post(kind, occurredAtUtc, sourceOrigin, evidence, payload, kindSchemaVersion); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentTerminatedEventArgs.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentTerminatedEventArgs.cs new file mode 100644 index 00000000..744ee338 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentTerminatedEventArgs.cs @@ -0,0 +1,77 @@ +#nullable enable +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Reasons for which fires. Plan §4.x M4.6.α. + /// + public enum EnrollmentTerminationReason + { + /// DecisionEngine reached a terminal stage via real signals (success or failure path). + DecisionTerminalStage, + + /// + /// AgentMaxLifetimeMinutes safety watchdog fired — orchestrator has been running + /// longer than the configured cap without reaching a terminal stage. + /// + MaxLifetimeExceeded, + } + + /// + /// Outcome descriptor emitted alongside . + /// + /// M4.6.β will consume this to drive CleanupService.ExecuteSelfDestruct, + /// SummaryDialog launch and DiagnosticsPackageService upload — the orchestrator + /// stays kernel-pure and only declares "we are done, here is the outcome". + /// + /// + public enum EnrollmentTerminationOutcome + { + /// + /// Reached a success stage (Completed / WhiteGloveSealed for Part 1 exit). + /// + Succeeded, + + /// Reached an explicit failure stage. + Failed, + + /// + /// Not yet terminal (used when the watchdog fires mid-flight — no classifier verdict, + /// stage is still an in-progress state). + /// + TimedOut, + } + + /// + /// Payload for . Plan §4.x M4.6.α. + /// + /// Carries the decision-relevant fields needed by downstream peripheral capabilities + /// (M4.6.β: CleanupService + SummaryDialog + DiagnosticsPackageService) without forcing + /// them to touch the DecisionEngine state directly — that coupling would leak kernel + /// invariants into peripheral code. + /// + /// + public sealed class EnrollmentTerminatedEventArgs : EventArgs + { + public EnrollmentTerminationReason Reason { get; } + public EnrollmentTerminationOutcome Outcome { get; } + public string? StageName { get; } + public DateTime TerminatedAtUtc { get; } + public string? Details { get; } + + public EnrollmentTerminatedEventArgs( + EnrollmentTerminationReason reason, + EnrollmentTerminationOutcome outcome, + string? stageName, + DateTime terminatedAtUtc, + string? details = null) + { + Reason = reason; + Outcome = outcome; + StageName = stageName; + TerminatedAtUtc = terminatedAtUtc; + Details = details; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IBackPressureObserver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IBackPressureObserver.cs new file mode 100644 index 00000000..52f2bba6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IBackPressureObserver.cs @@ -0,0 +1,32 @@ +#nullable enable +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Beobachter für Back-Pressure am -Channel. Plan §2.1a. + /// + /// SignalIngress ruft diesen Observer auf, wenn ein Post-Call blockieren musste, + /// weil der Channel voll war. Die Throttle-Logik (max. 1×/min/origin) passiert in + /// ; der Observer bekommt nur die bereits gefilterten + /// Ereignisse. + /// + /// + /// Konkrete Impl in M4.4.1: routet als agent_trace-Event mit + /// EventType="ingress_backpressure" über den Events-Bridge-Pfad + /// ( Kind=Event), nicht + /// durch den Ingress-Channel selbst (würde deadlocken — §2.1a). + /// + /// + public interface IBackPressureObserver + { + /// + /// Meldet ein Back-Pressure-Ereignis. + /// + /// Collector-Origin des blockierten Producers (DecisionSignal.SourceOrigin). + /// Konfigurierte Channel-Kapazität. + /// Warteschlangen-Länge beim Detektionspunkt. + /// Wie lange der Producer tatsächlich geblockt hat. + void OnBackPressure(string origin, int channelCapacity, int queueLength, TimeSpan blockDuration); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IClassifierRegistry.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IClassifierRegistry.cs new file mode 100644 index 00000000..cd6bcc97 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IClassifierRegistry.cs @@ -0,0 +1,18 @@ +#nullable enable +using AutopilotMonitor.DecisionCore.Classifiers; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Lookup-by-id für registrierte -Instanzen. Plan §2.4. + /// + /// Der EffectRunner konsultiert die Registry bei RunClassifier-Effekten. + /// Registry ist read-only nach Konstruktion — Classifiers werden beim Orchestrator-Start + /// einmalig registriert (§2.4 „Kernel-Service"). + /// + /// + public interface IClassifierRegistry + { + bool TryGet(string classifierId, out IClassifier? classifier); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ICollectorHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ICollectorHost.cs new file mode 100644 index 00000000..2f8cbdd8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ICollectorHost.cs @@ -0,0 +1,34 @@ +#nullable enable +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Lifecycle-Abstraktion für einen einzelnen Collector im V2-Runtime. + /// Plan §2.1 / §4.x M4.4.5.c. + /// + /// Ein Host wrappt einen konkreten Collector (z.B. HelloTracker, + /// ShellCoreTracker, ModernDeploymentTracker) und kapselt Start/Stop. + /// Die Action<EnrollmentEvent>-Callback für onEventCollected wird bei + /// der Host-Erstellung über die injiziert — der + /// Orchestrator übergibt dort + /// als Sink. + /// + /// + /// SignalAdapter-Wiring: separat — kann die + /// SignalAdapter-Instanzen mit-konstruieren und selbst an ihre Collector-Events + /// subscriben. Der Orchestrator muss die Adapter-Seam nicht kennen. + /// + /// + public interface ICollectorHost : IDisposable + { + /// Stabiler Name für Observability/Logs (z.B. "HelloTracker"). + string Name { get; } + + /// Startet Event-Subscriptions + Background-Worker des gewrappten Collectors. + void Start(); + + /// Stoppt und gibt Ressourcen frei. Idempotent. + void Stop(); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs new file mode 100644 index 00000000..fd60fd64 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs @@ -0,0 +1,55 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Factory-Seam für Collector-Hosts. Plan §4.x M4.4.5.c + M4.5.b. + /// + /// Der delegiert an diese Factory, weil die konkreten + /// Collector-Ctoren heavy Production-Deps (Event-Log-Watcher, WMI, Registry) haben — Tests + /// liefern eine Fake-Factory, Prod liefert eine Default-Implementation, die HelloTracker, + /// ShellCoreTracker, ProvisioningStatusTracker, StallProbeCollector und + /// ModernDeploymentTracker baut und deren onEventCollected-Callbacks an den + /// übergebenen bindet. + /// + /// + /// ModernDeploymentTracker-Spezifikum: der einzige Kollektor ohne SignalAdapter — + /// bridged nur Events, keine DecisionSignals. Default-Factory-Impls dürfen ihn aufbauen, + /// Tests brauchen ihn nicht zwingend. + /// + /// + public interface IComponentFactory + { + /// + /// Baut alle Collector-Hosts und bindet deren Event-Emission an + /// (typischerweise + /// ). + /// Exceptions im Callback darf die Factory NICHT weiterreichen — der Orchestrator + /// wrappt das mit einem Exception-Swallow, damit ein Collector-Thread nicht stirbt. + /// + /// wird an den ImeLogTrackerAdapter + /// durchgereicht (Sealing-Emission fire-once nur für diese Pattern-IDs). Leer / null + /// = Feature off, M3-kompatibel. Plan §4.x M4.4.5.e. + /// + /// + /// M4.5.b-Extension: und werden + /// durchgereicht, damit die Default-Factory SignalAdapter direkt mitwiren kann (Adapters + /// brauchen einen + , die erst im + /// Orchestrator gebaut werden). + /// + /// + IReadOnlyList CreateCollectorHosts( + string sessionId, + string tenantId, + AgentLogger logger, + Action onEnrollmentEvent, + IReadOnlyCollection whiteGloveSealingPatternIds, + ISignalIngressSink ingress, + IClock clock); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDeadlineScheduler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDeadlineScheduler.cs new file mode 100644 index 00000000..4d7d558e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDeadlineScheduler.cs @@ -0,0 +1,55 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Einzige Komponente mit Entscheidungs-relevanten Timern. Plan §2.6 / L.7 (Timer-Monopol). + /// + /// Collectors / SignalAdapter / Orchestrator dürfen keine eigenen + /// System.Threading.Timer-Instanzen für Session-Entscheidungen halten. + /// und sind die einzige API. + /// + /// + public interface IDeadlineScheduler : IDisposable + { + /// + /// Plant ein Deadline ein. Ist bereits eines mit gleichem + /// aktiv, wird es ersetzt (alter Timer gecancelt, neuer gestartet). + /// + void Schedule(ActiveDeadline deadline); + + /// + /// Cancelt ein aktives Deadline. Unbekannter Name ist no-op. + /// + void Cancel(string name); + + /// + /// True wenn Deadline aktuell eingeplant ist. + /// + bool IsScheduled(string name); + + /// + /// Momentaufnahme aller aktiven Deadlines. + /// + IReadOnlyList ActiveDeadlines { get; } + + /// + /// Wird vom Timer-Thread geraised, wenn ein Deadline fällig wird. Subscriber + /// ist typischerweise der Orchestrator, der daraus ein synthetisches + /// DeadlineFired-Signal mit OccurredAtUtc = deadline.DueAtUtc erzeugt. + /// + event EventHandler? Fired; + + /// + /// Bulk-Rehydration nach Restart (Plan §2.6 Restart-Recovery). + /// remaining = DueAtUtc - clock.UtcNow; bei remaining ≤ 0 wird das Deadline + /// sofort gefeuert, sonst ein neuer Timer gestartet. Alle Deadlines werden in einem + /// einzigen Durchgang verarbeitet, Reihenfolge über Queue an ThreadPool — kein Dispatch + /// aus dem aufrufenden Thread. + /// + void RehydrateFromSnapshot(IEnumerable deadlines); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDecisionStepProcessor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDecisionStepProcessor.cs new file mode 100644 index 00000000..f1e34c35 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IDecisionStepProcessor.cs @@ -0,0 +1,43 @@ +#nullable enable +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Senke für nach einem erfolgreichen + /// -Call. Plan §2.5 / §2.7 / L.1. + /// + /// Der Enrollment-Orchestrator (M4.4.5) implementiert diese Abstraktion und übernimmt + /// Journal-Append, -Invocation und State-Update in einer + /// Transaktion. SignalIngress kennt diese Schichten nicht. + /// + /// + /// Thread-Modell: wird ausschließlich vom Ingress-Worker + /// aufgerufen (Single-Thread). Implementierungen dürfen das voraussetzen — kein eigenes + /// Locking nötig. wird vom selben Worker vor jedem Reduce + /// gelesen; das ApplyStep-Callback setzt ihn fort. + /// + /// + public interface IDecisionStepProcessor + { + /// + /// Aktueller , den der Worker als Input für den nächsten + /// Reduce-Call verwendet. Initial typischerweise + /// oder ein wiederhergestellter Snapshot. + /// + DecisionState CurrentState { get; } + + /// + /// Angewandten Reducer-Step persistieren. Implementierer: + /// + /// Transition ins Journal appenden (Sofort-Flush) + /// Effekte mittels ausführen + /// auf fortsetzen + /// + /// Darf werfen — Ingress fängt ab und loggt, verstummt aber nicht. + /// + void ApplyStep(DecisionStep step, DecisionSignal signal); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IEffectRunner.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IEffectRunner.cs new file mode 100644 index 00000000..9ff55fd0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IEffectRunner.cs @@ -0,0 +1,43 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Führt die von einem Reducer-Schritt erzeugten s aus. Plan §2.5 / §2.7b. + /// + /// Single side-channel: Reducer bleibt pur, I/O läuft ausschließlich hier. + /// Fehlerklassen (§2.7b): + /// + /// Transient (, ): + /// Retry 100/400/1600ms; nach Exhaust Warning, kein Abort. + /// Kritisch (, ): + /// jede Exception → . + /// Optional (): Exception → Inconclusive-Verdict, + /// kein Abort. + /// + /// + /// + public interface IEffectRunner + { + /// + /// Führt alle der Reihe nach aus. + /// ist der neue nach dem + /// Reduce-Schritt, der die Effekte erzeugt hat (wird für Anti-Loop-Lookup und + /// Event-Emitter-Kontext gebraucht). + /// wird als OccurredAtUtc der synthetischen + /// Signals (z.B. ClassifierVerdictIssued) verwendet — so bleibt die Zeitreihenfolge + /// deterministisch beim Replay. + /// + Task RunAsync( + IReadOnlyList effects, + DecisionState stateAfterReduce, + DateTime stepOccurredAtUtc, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IEventTimelineEmitter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IEventTimelineEmitter.cs new file mode 100644 index 00000000..c52c6ba0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IEventTimelineEmitter.cs @@ -0,0 +1,33 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Abstraktion für -Effekte. + /// Plan §2.5 / §2.13. + /// + /// Die konkrete Implementierung (M4.4.1 + /// ) baut aus Effect-Parametern + aktuellem + /// einen TelemetryItemDraft mit Kind=Event, + /// vergibt das Events-Table-Schema (EventType, Sequence, DataJson, …) und enqueued via + /// . + /// + /// + public interface IEventTimelineEmitter + { + /// + /// Emittiert einen Event-Timeline-Eintrag. enthält + /// typischerweise eventType und optional reason (vom Reducer gesetzt). + /// ist der deterministische Schritt-Zeitstempel + /// (DecisionSignal.OccurredAtUtc des auslösenden Signals) — nicht + /// clock.UtcNow. Damit bleibt die Event-Timeline replay-deterministisch. + /// + void Emit( + IReadOnlyDictionary? parameters, + DecisionState currentState, + DateTime occurredAtUtc); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IQuarantineSink.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IQuarantineSink.cs new file mode 100644 index 00000000..866bf99d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IQuarantineSink.cs @@ -0,0 +1,24 @@ +#nullable enable +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Escalation-Hook für den bei wiederholten + /// Persistenz-Failures. Plan §2.7 Sonderfall 2. + /// + /// Wenn das Journal n-mal hintereinander wirft (z.B. Disk-Full, File-Lock), + /// meldet der Processor das an diese Senke. Der + /// (M4.4.5.b) implementiert das und koordiniert die eigentliche Segment-Quarantäne beim + /// nächsten Start (M4.4.5.f) — ein laufender Prozess zieht sich die State-Dateien + /// nicht selbst unter den Füßen weg. + /// + /// + public interface IQuarantineSink + { + /// + /// Signalisiert, dass die Session nicht mehr konsistent persistiert werden kann und + /// beim nächsten Start neu aufgesetzt werden muss. Darf nicht werfen — Ingress-Worker + /// soll den Alarm absetzen und weiterlaufen. + /// + void TriggerQuarantine(string reason); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ISessionTraceOrdinalProvider.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ISessionTraceOrdinalProvider.cs new file mode 100644 index 00000000..0da60672 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ISessionTraceOrdinalProvider.cs @@ -0,0 +1,25 @@ +#nullable enable + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Session-weiter, monotoner Trace-Counter für SessionTraceOrdinal. Plan §2.2. + /// + /// Wird in M4.4.0 nur von konsumiert (Signals); in M4.4.1 + /// kommt der zweite Konsument dazu (Events + Transitions via + /// ). Der Provider ist dann die + /// zentrale Instanz, die das „alle drei Typen"-Versprechen aus §2.2 realisiert. + /// + /// + /// Thread-safe. Impl vergibt monotone Werte via Interlocked. + /// + /// + public interface ISessionTraceOrdinalProvider + { + /// Vergibt den nächsten Ordinal. Strikt monoton steigend pro Session. + long Next(); + + /// Zuletzt vergebener Wert; -1 wenn noch keiner vergeben ist. + long LastAssigned { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ISignalIngressSink.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ISignalIngressSink.cs new file mode 100644 index 00000000..7167377b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ISignalIngressSink.cs @@ -0,0 +1,32 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Senke für synthetische Signals, die der EffectRunner in den Ingress zurückspeist + /// (z.B. ClassifierVerdictIssued). Plan §2.1a / §2.4. + /// + /// Ingress übernimmt Ordinal-Vergabe, SignalLog-Append und die anschließende Reducer- + /// Reingestion in derselben Single-Writer-Serialisierung wie Collector-Signals. + /// EffectRunner kennt die Ordinal-Logik nicht. + /// + /// + /// M4.2 liefert nur die Abstraktion + Test-Fake; die reale Implementierung wird in M4.4 + /// mit dem Enrollment-Orchestrator gewired. + /// + /// + public interface ISignalIngressSink + { + /// Post ein synthetisches Signal an den Ingress. + void Post( + DecisionSignalKind kind, + DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + IReadOnlyDictionary? payload = null, + int kindSchemaVersion = 1); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/MtlsHttpClientFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/MtlsHttpClientFactory.cs new file mode 100644 index 00000000..504bdbd7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/MtlsHttpClientFactory.cs @@ -0,0 +1,87 @@ +#nullable enable +using System; +using System.Net; +using System.Net.Http; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Security; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Baut einen mit mTLS-Client-Cert für den + /// . Plan §4.x M4.4.5.d. + /// + /// Design: Static Factory + -Seam statt direkte + /// Abhängigkeit an . Tests injizieren einen + /// Fake-Resolver mit einem self-signed Cert; Prod nutzt + /// , der den Windows-Cert-Store durchsucht. + /// + /// + /// Konfiguration (analog Legacy BackendApiClient): + /// + /// + + /// Default-Timeout 30s (override via Parameter) + /// Keine CookieContainer, keine Default-Credentials + /// + /// + /// + public static class MtlsHttpClientFactory + { + /// Plan §4.x M4.4.5.d — Default-Timeout (analog Legacy). + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + + /// + /// Baut einen mit attached Client-Cert. Wirft + /// wenn kein + /// Zertifikat liefert — der Agent darf ohne Cert nicht starten (mTLS ist Pflicht). + /// + /// Separat exponiert, damit Tests direkt auf dem Handler asserten können + /// ('s _handler-Feld ist internal und in net48 + /// reflection-unzuverlässig). + /// + /// + public static HttpClientHandler CreateHandler( + ICertificateResolver resolver, + AgentLogger logger) + { + if (resolver == null) throw new ArgumentNullException(nameof(resolver)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + var cert = resolver.FindClientCertificate(logger); + if (cert == null) + { + throw new InvalidOperationException( + "MtlsHttpClientFactory: no MDM client certificate found. " + + "Agent cannot start without a client cert (mTLS is mandatory). " + + "Check cert enrollment status in HKLM\\SOFTWARE\\Microsoft\\Enrollments."); + } + + var handler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }; + handler.ClientCertificates.Add(cert); + + logger.Info( + $"MtlsHttpClientFactory: handler ready (cert thumbprint={cert.Thumbprint})."); + + return handler; + } + + /// + /// Baut einen fertig konfigurierten — Handler via + /// , Timeout via Parameter (default ). + /// + public static HttpClient Create( + ICertificateResolver resolver, + AgentLogger logger, + TimeSpan? timeout = null) + { + var handler = CreateHandler(resolver, logger); + return new HttpClient(handler) + { + Timeout = timeout ?? DefaultTimeout, + }; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SessionTraceOrdinalProvider.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SessionTraceOrdinalProvider.cs new file mode 100644 index 00000000..4d0940a4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SessionTraceOrdinalProvider.cs @@ -0,0 +1,28 @@ +#nullable enable +using System.Threading; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// In-memory via + /// . Plan §2.2. + /// + /// Recovery: beim Konstruktor seedLastAssigned aus dem höchsten + /// persistierten Ordinal (max aus SignalLog + Journal + Spool) setzen; + /// liefert dann seed + 1, seed + 2, …. + /// + /// + public sealed class SessionTraceOrdinalProvider : ISessionTraceOrdinalProvider + { + private long _lastAssigned; + + public SessionTraceOrdinalProvider(long seedLastAssigned = -1) + { + _lastAssigned = seedLastAssigned; + } + + public long LastAssigned => Interlocked.Read(ref _lastAssigned); + + public long Next() => Interlocked.Increment(ref _lastAssigned); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SignalIngress.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SignalIngress.cs new file mode 100644 index 00000000..7c8a64d0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/SignalIngress.cs @@ -0,0 +1,350 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Signals; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Single-writer Signal-Ingress. Plan §2.1a / §2.7c / L.1. + /// + /// Alle Collector-Threads (und der via + /// ) rufen . Ein dedizierter Worker-Thread + /// liest die Items sequenziell, vergibt SessionSignalOrdinal + + /// SessionTraceOrdinal, appendet ins SignalLog (Sofort-Flush) und delegiert den + /// Reduce+Apply-Schritt an . + /// + /// + /// Back-Pressure: bounded (default 256) — + /// volle Channel blockiert den Producer beim Add. Der Ingress misst die Block-Dauer + /// und meldet sie throttled (1×/min/origin) an . + /// Die Back-Pressure-Emission läuft NICHT durch den Signal-Channel (würde deadlocken). + /// + /// + /// Shutdown: ruft , + /// wartet auf das Drain-Ende via Thread.Join. Nach Stop wirft jeder + /// weitere . + /// + /// + public sealed class SignalIngress : ISignalIngressSink, IDisposable + { + /// Plan §2.1a — Default-Channel-Kapazität. + public const int DefaultChannelCapacity = 256; + + /// Plan §2.1a — max. 1 Back-Pressure-Event pro Minute pro Origin. + public static readonly TimeSpan DefaultBackPressureThrottle = TimeSpan.FromMinutes(1); + + private readonly IDecisionEngine _engine; + private readonly ISignalLogWriter _signalLog; + private readonly ISessionTraceOrdinalProvider _traceCounter; + private readonly IDecisionStepProcessor _processor; + private readonly IBackPressureObserver? _observer; + private readonly TelemetrySignalEmitter? _signalEmitter; + private readonly IClock _clock; + private readonly TimeSpan _backPressureThrottle; + private readonly int _channelCapacity; + + private readonly BlockingCollection _channel; + private readonly Dictionary _lastBackPressureEmittedUtc = + new Dictionary(StringComparer.Ordinal); + private readonly object _backPressureLock = new object(); + + private Thread? _worker; + private long _nextSignalOrdinal; // worker-thread-only after Start; seeded from SignalLog + private int _started; // 0 = not started, 1 = running + private int _stopRequested; // 0 = live, 1 = Stop called + private Exception? _workerFault; // captured if worker dies unexpectedly + + public SignalIngress( + IDecisionEngine engine, + ISignalLogWriter signalLog, + ISessionTraceOrdinalProvider traceCounter, + IDecisionStepProcessor processor, + IClock clock, + IBackPressureObserver? backPressureObserver = null, + TelemetrySignalEmitter? signalEmitter = null, + int channelCapacity = DefaultChannelCapacity, + TimeSpan? backPressureThrottle = null) + { + if (channelCapacity <= 0) + { + throw new ArgumentOutOfRangeException(nameof(channelCapacity), "Channel capacity must be > 0."); + } + + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _signalLog = signalLog ?? throw new ArgumentNullException(nameof(signalLog)); + _traceCounter = traceCounter ?? throw new ArgumentNullException(nameof(traceCounter)); + _processor = processor ?? throw new ArgumentNullException(nameof(processor)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _observer = backPressureObserver; + _signalEmitter = signalEmitter; + _channelCapacity = channelCapacity; + _backPressureThrottle = backPressureThrottle ?? DefaultBackPressureThrottle; + + _channel = new BlockingCollection(boundedCapacity: channelCapacity); + _nextSignalOrdinal = signalLog.LastOrdinal + 1; + } + + /// Zuletzt vergebener SessionSignalOrdinal; -1 bevor das erste Signal verarbeitet wurde. + public long LastAssignedSignalOrdinal => Interlocked.Read(ref _nextSignalOrdinal) - 1; + + /// Konfigurierte Channel-Kapazität. + public int ChannelCapacity => _channelCapacity; + + /// Aktuelle Queue-Länge. Nur für Observability/Tests — enthält Race-Fenster. + public int ApproximateQueueLength => _channel.Count; + + /// + /// Startet den Worker-Thread. Muss vor dem ersten aufgerufen werden. + /// Mehrfach-Aufruf wirft. + /// + public void Start() + { + if (Interlocked.Exchange(ref _started, 1) == 1) + { + throw new InvalidOperationException("SignalIngress already started."); + } + + _worker = new Thread(WorkerLoop) + { + IsBackground = true, + Name = "SignalIngress.Worker", + }; + _worker.Start(); + } + + /// + /// Signalisiert Shutdown, drained verbleibende Items und joined den Worker-Thread. + /// Idempotent — zweiter Aufruf ist ein No-Op. Wirft, wenn der Worker-Thread eine + /// unerwartete Exception gefangen hat. + /// + public void Stop() + { + if (Interlocked.Exchange(ref _stopRequested, 1) == 1) + { + return; + } + + // CompleteAdding unblockt GetConsumingEnumerable sobald die Queue leer ist. + _channel.CompleteAdding(); + + var worker = _worker; + if (worker != null && worker.IsAlive) + { + worker.Join(); + } + + if (_workerFault != null) + { + throw new InvalidOperationException( + "SignalIngress worker faulted unexpectedly.", _workerFault); + } + } + + public void Dispose() + { + try { Stop(); } + catch { /* Dispose darf nicht werfen. */ } + _channel.Dispose(); + } + + /// + /// Reicht ein Raw-/Derived-/Synthetic-Signal in den Ingress ein. Thread-safe; darf von + /// beliebig vielen Collector-Threads (und vom ) parallel + /// gerufen werden. Blockiert, wenn der Channel voll ist — Plan §2.1a Back-Pressure. + /// + public void Post( + DecisionSignalKind kind, + DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + IReadOnlyDictionary? payload = null, + int kindSchemaVersion = 1) + { + if (Volatile.Read(ref _stopRequested) == 1 || _channel.IsAddingCompleted) + { + throw new InvalidOperationException("SignalIngress has been stopped."); + } + if (evidence == null) + { + throw new ArgumentNullException(nameof(evidence)); + } + if (string.IsNullOrEmpty(sourceOrigin)) + { + throw new ArgumentException("SourceOrigin is mandatory.", nameof(sourceOrigin)); + } + + var item = new IngressItem(kind, kindSchemaVersion, occurredAtUtc, sourceOrigin, evidence, payload); + + // Fast path: non-blocking enqueue. + if (_channel.TryAdd(item)) + { + return; + } + + // Slow path: bounded channel full → block, measure duration, notify observer throttled. + var blockStartedUtc = _clock.UtcNow; + int queueLenAtBlock = _channel.Count; + try + { + _channel.Add(item); + } + catch (InvalidOperationException) + { + // CompleteAdding wurde zwischen TryAdd und Add gerufen. + throw new InvalidOperationException("SignalIngress has been stopped."); + } + + var blockedFor = _clock.UtcNow - blockStartedUtc; + if (blockedFor < TimeSpan.Zero) + { + blockedFor = TimeSpan.Zero; + } + + NotifyBackPressureThrottled(sourceOrigin, queueLenAtBlock, blockedFor); + } + + private void NotifyBackPressureThrottled(string origin, int queueLengthAtBlock, TimeSpan blockedFor) + { + if (_observer == null) + { + return; + } + + var now = _clock.UtcNow; + lock (_backPressureLock) + { + if (_lastBackPressureEmittedUtc.TryGetValue(origin, out var last) && + (now - last) < _backPressureThrottle) + { + return; + } + _lastBackPressureEmittedUtc[origin] = now; + } + + try + { + _observer.OnBackPressure(origin, _channelCapacity, queueLengthAtBlock, blockedFor); + } + catch + { + // Observer-Fehler dürfen den Produktions-Pfad nicht aufhalten. Weglucken. + } + } + + private void WorkerLoop() + { + try + { + foreach (var item in _channel.GetConsumingEnumerable()) + { + ProcessItem(item); + } + } + catch (Exception ex) + { + // Darf nicht passieren — ProcessItem fängt alles ab. Falls doch, für Stop() + // aufheben, damit die Regression sichtbar ist. + _workerFault = ex; + } + } + + private void ProcessItem(IngressItem item) + { + // Single-writer: _nextSignalOrdinal wird ausschließlich hier verändert. + long signalOrdinal = _nextSignalOrdinal; + long traceOrdinal = _traceCounter.Next(); + + DecisionSignal signal; + try + { + signal = new DecisionSignal( + sessionSignalOrdinal: signalOrdinal, + sessionTraceOrdinal: traceOrdinal, + kind: item.Kind, + kindSchemaVersion: item.KindSchemaVersion, + occurredAtUtc: item.OccurredAtUtc, + sourceOrigin: item.SourceOrigin, + evidence: item.Evidence, + payload: item.Payload); + } + catch + { + // DecisionSignal-Ctor validiert Pflichtfelder. Invalid input → überspringen, + // Ordinal NICHT verbrauchen (sonst entsteht eine Lücke im SignalLog). + return; + } + + try + { + _signalLog.Append(signal); + } + catch + { + // Append fehlgeschlagen (z.B. Disk-Full). Ohne on-disk-Persistenz darf der + // Reducer nicht laufen (L.1 Signal-Log-Determinismus). Ordinal bleibt „geplant", + // wird beim nächsten Versuch neu vergeben — monoton, da _nextSignalOrdinal + // noch nicht inkrementiert wurde. + return; + } + + _nextSignalOrdinal = signalOrdinal + 1; + + // Project the signal onto the telemetry transport for backend upload. Local + // SignalLog is authoritative (§2.7c / L.1); a transport enqueue failure must NOT + // break the ingress worker. Spool overflow / serialization faults get swallowed + // here so the reducer path below still runs on the just-committed local signal. + if (_signalEmitter != null) + { + try { _signalEmitter.Emit(signal); } + catch { /* best-effort upload; local state already consistent */ } + } + + var state = _processor.CurrentState; + // IDecisionEngine.Reduce ist fail-safe (DecisionEngine.cs:39-47): wirft nicht. + var step = _engine.Reduce(state, signal); + + try + { + _processor.ApplyStep(step, signal); + } + catch + { + // Processor-Fehler (z.B. Journal-Write, Effect-Transient-Exhaust) werden in + // M4.4.5 vom Orchestrator geloggt + ggf. Quarantine ausgelöst. M4.4.0 + // verschluckt bewusst, damit der Ingress-Worker nicht verstummt. + } + } + + private readonly struct IngressItem + { + public IngressItem( + DecisionSignalKind kind, + int kindSchemaVersion, + DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + IReadOnlyDictionary? payload) + { + Kind = kind; + KindSchemaVersion = kindSchemaVersion; + OccurredAtUtc = occurredAtUtc; + SourceOrigin = sourceOrigin; + Evidence = evidence; + Payload = payload; + } + + public DecisionSignalKind Kind { get; } + public int KindSchemaVersion { get; } + public DateTime OccurredAtUtc { get; } + public string SourceOrigin { get; } + public Evidence Evidence { get; } + public IReadOnlyDictionary? Payload { get; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/IJournalWriter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/IJournalWriter.cs new file mode 100644 index 00000000..b28bebc1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/IJournalWriter.cs @@ -0,0 +1,45 @@ +#nullable enable +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Engine; + +namespace AutopilotMonitor.Agent.V2.Core.Persistence +{ + /// + /// Append-only writer for the Decision-Journal. Plan §2.7 / L.2. + /// + /// Invariants analog : + /// + /// Single-writer (Reducer-Loop) + /// Sofort-Flush per Append (L.12) + /// StepIndex ist monoton steigend — der Aufrufer stellt das sicher, der Writer validiert + /// + /// + /// + public interface IJournalWriter + { + /// + /// Persist . Blockiert bis Flush-to-Disk. + /// Wirft, wenn transition.StepIndex <= LastStepIndex. + /// + void Append(DecisionTransition transition); + + /// + /// Alle persisted Transitions in StepIndex-Reihenfolge. Bei korrupter Tail-Zeile + /// wird bis zur letzten parsbaren Zeile gelesen. + /// + IReadOnlyList ReadAll(); + + /// + /// Höchster bisher erfolgreich appendeter StepIndex; -1 bei leerem Journal. + /// + int LastStepIndex { get; } + + /// + /// Höchster in irgendeiner persisted Transition vorkommender SessionTraceOrdinal; + /// -1 bei leerem Journal. Analog ISignalLogWriter.LastTraceOrdinal — wird vom + /// Orchestrator für das Seeding von SessionTraceOrdinalProvider auf Recovery + /// verwendet. + /// + long LastTraceOrdinal { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/ISignalLogWriter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/ISignalLogWriter.cs new file mode 100644 index 00000000..78f2ee92 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/ISignalLogWriter.cs @@ -0,0 +1,54 @@ +#nullable enable +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.Persistence +{ + /// + /// Append-only writer for the session SignalLog. Plan §2.7 / L.1. + /// + /// Invariants: + /// + /// Single-writer (SignalIngress worker thread per §2.1a) + /// Sofort-Flush per Append (FileOptions.WriteThrough + Flush(flushToDisk:true)) + /// — jeder erfolgreiche Append steht auf Disk bevor die Methode zurückkehrt (L.12) + /// Ordinal-Reihenfolge wird vom Aufrufer garantiert (SignalIngress vergibt monoton); + /// der Writer validiert sie gegen + /// + /// + /// + public interface ISignalLogWriter + { + /// + /// Persist . Blockiert bis zum Flush-to-Disk. + /// Wirft, wenn signal.SessionSignalOrdinal <= LastOrdinal (Monotonie verletzt). + /// + void Append(DecisionSignal signal); + + /// + /// Alle persisted Signals in Ordinal-Reihenfolge. Bei korrupter Tail-Zeile wird + /// bis zur letzten parsbaren Zeile gelesen (Recovery-Regel §2.7 — SignalLog-Tail + /// kann nach Crash ohne Flush inkonsistent sein; höchster validierbarer Ordinal + /// ist Wahrheit). + /// + IReadOnlyList ReadAll(); + + /// + /// Höchster bisher erfolgreich appendete SessionSignalOrdinal; -1 bei leerem Log. + /// + long LastOrdinal { get; } + + /// + /// Höchster in irgendeinem persisted Signal vorkommender SessionTraceOrdinal; -1 bei + /// leerem Log. Wird von EnrollmentOrchestrator zum Seed des + /// SessionTraceOrdinalProvider auf Recovery benutzt — zusammen mit + /// IJournalWriter.LastTraceOrdinal und TelemetrySpool.LastAssignedItemId. + /// + /// Unterschied zu : SessionSignalOrdinal ist der + /// Signal-spezifische Monotone-Counter, SessionTraceOrdinal der session-weite + /// Korrelations-Counter (Event + Signal + Transition teilen sich eine Nummerierung). + /// + /// + long LastTraceOrdinal { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/ISnapshotPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/ISnapshotPersistence.cs new file mode 100644 index 00000000..8a8485e3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/ISnapshotPersistence.cs @@ -0,0 +1,37 @@ +#nullable enable +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Persistence +{ + /// + /// Snapshot cache for fast start. Plan §2.7 / L.3. + /// + /// Snapshot ist Cache, nicht Wahrheit — bei Inkonsistenz oder Korruption wird er + /// verworfen, nicht repariert (L.3, §2.7c). Der Orchestrator ruft bei Mismatch + /// auf und rekonstruiert via Reducer.Replay(signals) + /// aus dem SignalLog. + /// + /// + public interface ISnapshotPersistence + { + /// + /// Persist . Atomar (write-temp + rename). Sofort-Flush. + /// + void Save(DecisionState state); + + /// + /// Lädt das persistierte Snapshot oder null wenn: + /// (a) keine Datei vorhanden, + /// (b) Checksum-Mismatch (Snapshot wird dabei nicht gelöscht — Caller entscheidet + /// via ). + /// + DecisionState? Load(); + + /// + /// Verschiebt das aktuelle Snapshot nach .quarantine/{timestamp}/ (Plan §2.7 + /// Sonderfall 2). Nach Quarantine ist das Snapshot weg; beim nächsten + /// kommt null. wird als Sidecar-Textfile mitgeschrieben. + /// + void Quarantine(string reason); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/JournalWriter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/JournalWriter.cs new file mode 100644 index 00000000..52829cee --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/JournalWriter.cs @@ -0,0 +1,157 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Serialization; + +namespace AutopilotMonitor.Agent.V2.Core.Persistence +{ + /// + /// JSONL append-only backed by a single file on disk. + /// Plan §2.7 / L.12. + /// + public sealed class JournalWriter : IJournalWriter + { + private readonly string _path; + private readonly object _lock = new object(); + private int _lastStepIndex = -1; + private long _lastTraceOrdinal = -1; + + public JournalWriter(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException("Path is mandatory.", nameof(path)); + } + + _path = path; + + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + ScanRecoveryState(); + } + + public int LastStepIndex + { + get + { + lock (_lock) + { + return _lastStepIndex; + } + } + } + + public long LastTraceOrdinal + { + get + { + lock (_lock) + { + return _lastTraceOrdinal; + } + } + } + + public void Append(DecisionTransition transition) + { + if (transition == null) throw new ArgumentNullException(nameof(transition)); + + lock (_lock) + { + if (transition.StepIndex <= _lastStepIndex) + { + throw new InvalidOperationException( + $"Journal monotonicity violated: incoming stepIndex {transition.StepIndex} <= lastStepIndex {_lastStepIndex}."); + } + + var line = TransitionSerializer.Serialize(transition); + var bytes = Encoding.UTF8.GetBytes(line + "\n"); + + using (var fs = new FileStream( + _path, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + options: FileOptions.WriteThrough)) + { + fs.Write(bytes, 0, bytes.Length); + fs.Flush(flushToDisk: true); + } + + _lastStepIndex = transition.StepIndex; + if (transition.SessionTraceOrdinal > _lastTraceOrdinal) + { + _lastTraceOrdinal = transition.SessionTraceOrdinal; + } + } + } + + public IReadOnlyList ReadAll() + { + lock (_lock) + { + var transitions = new List(); + if (!File.Exists(_path)) return transitions; + + foreach (var rawLine in File.ReadAllLines(_path, Encoding.UTF8)) + { + if (string.IsNullOrWhiteSpace(rawLine)) continue; + DecisionTransition parsed; + try + { + parsed = TransitionSerializer.Deserialize(rawLine); + } + catch + { + break; + } + transitions.Add(parsed); + } + + return transitions; + } + } + + /// + /// Single-pass scan that populates both _lastStepIndex and + /// _lastTraceOrdinal from the persisted JSONL. Stops at the first unparsable + /// line (recovery §2.7). + /// + private void ScanRecoveryState() + { + _lastStepIndex = -1; + _lastTraceOrdinal = -1; + + if (!File.Exists(_path)) return; + + foreach (var line in File.ReadAllLines(_path, Encoding.UTF8)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + var t = TransitionSerializer.Deserialize(line); + if (t.StepIndex > _lastStepIndex) + { + _lastStepIndex = t.StepIndex; + } + if (t.SessionTraceOrdinal > _lastTraceOrdinal) + { + _lastTraceOrdinal = t.SessionTraceOrdinal; + } + } + catch + { + break; + } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/SegmentQuarantine.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/SegmentQuarantine.cs new file mode 100644 index 00000000..7a8b4142 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/SegmentQuarantine.cs @@ -0,0 +1,78 @@ +#nullable enable +using System; +using System.IO; +using System.Text; + +namespace AutopilotMonitor.Agent.V2.Core.Persistence +{ + /// + /// Verschiebt beschädigte Persistenz-Segmente (SignalLog, Journal, EventSequence) in einen + /// timestamped .quarantine/{ts}/-Bucket. Plan §2.7 Sonderfall 2 / §4.x M4.4.5.f. + /// + /// Parallel zu — der Caller + /// () bewegt den Snapshot via + /// SnapshotPersistence.Quarantine und die Log-Segmente via diesem Helper. + /// Beide Aufrufe schreiben in <stateDir>/.quarantine/<timestamp>/. + /// + /// + /// Invariante: wenn die Methode erfolgreich zurückkehrt, sind die bekannten + /// Log-Files aus verschwunden — der nächste Append + /// legt frische Dateien an, der Reducer startet im Initial-State. + /// + /// + public static class SegmentQuarantine + { + /// Die bekannten Segment-Dateien im State-Verzeichnis (neben snapshot.json). + private static readonly string[] KnownSegmentFiles = new[] + { + "signal-log.jsonl", + "journal.jsonl", + "event-sequence.json", + }; + + /// + /// Bewegt alle existierenden Segment-Files in einen timestamped Bucket. Kein-Op + /// wenn das State-Verzeichnis nicht existiert. + /// + public static void QuarantineAll(string stateDirectory, string reason, Func utcNow) + { + if (string.IsNullOrEmpty(stateDirectory)) throw new ArgumentException("stateDirectory is mandatory.", nameof(stateDirectory)); + if (utcNow == null) throw new ArgumentNullException(nameof(utcNow)); + if (!Directory.Exists(stateDirectory)) return; + + var quarantineRoot = Path.Combine(stateDirectory, ".quarantine"); + if (!Directory.Exists(quarantineRoot)) + { + Directory.CreateDirectory(quarantineRoot); + } + + var bucket = Path.Combine( + quarantineRoot, + utcNow().ToString("yyyyMMdd'T'HHmmssfff'Z'")); + Directory.CreateDirectory(bucket); + + var movedAny = false; + foreach (var name in KnownSegmentFiles) + { + var src = Path.Combine(stateDirectory, name); + if (File.Exists(src)) + { + File.Move(src, Path.Combine(bucket, name)); + movedAny = true; + } + } + + // Reason-Sidecar immer schreiben, auch wenn keine Datei bewegt wurde — macht die + // Quarantäne-Historie nachvollziehbar. + File.WriteAllText(Path.Combine(bucket, "reason.txt"), reason ?? string.Empty, Encoding.UTF8); + + // Wenn nichts zu bewegen war → leeren Bucket entfernen, damit kein leeres + // Quarantine-Verzeichnis bleibt. + if (!movedAny) + { + try { File.Delete(Path.Combine(bucket, "reason.txt")); } catch { /* best-effort */ } + try { Directory.Delete(bucket); } catch { /* best-effort */ } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/SignalLogWriter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/SignalLogWriter.cs new file mode 100644 index 00000000..bbff7740 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/SignalLogWriter.cs @@ -0,0 +1,167 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.Persistence +{ + /// + /// JSONL append-only backed by a single file on disk. + /// Plan §2.7 / L.12 (Sofort-Flush). + /// + public sealed class SignalLogWriter : ISignalLogWriter + { + private readonly string _path; + private readonly object _lock = new object(); + private long _lastOrdinal = -1; + private long _lastTraceOrdinal = -1; + + public SignalLogWriter(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException("Path is mandatory.", nameof(path)); + } + + _path = path; + + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + ScanRecoveryState(); + } + + public long LastOrdinal + { + get + { + lock (_lock) + { + return _lastOrdinal; + } + } + } + + public long LastTraceOrdinal + { + get + { + lock (_lock) + { + return _lastTraceOrdinal; + } + } + } + + public void Append(DecisionSignal signal) + { + if (signal == null) throw new ArgumentNullException(nameof(signal)); + + lock (_lock) + { + if (signal.SessionSignalOrdinal <= _lastOrdinal) + { + throw new InvalidOperationException( + $"SignalLog monotonicity violated: incoming ordinal {signal.SessionSignalOrdinal} <= lastOrdinal {_lastOrdinal}."); + } + + var line = SignalSerializer.Serialize(signal); + var bytes = Encoding.UTF8.GetBytes(line + "\n"); + + // WriteThrough + Flush(true) — belt-and-suspenders per L.12: OS cache + // bypass plus explicit disk flush. Any return from Append means on-disk. + using (var fs = new FileStream( + _path, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + options: FileOptions.WriteThrough)) + { + fs.Write(bytes, 0, bytes.Length); + fs.Flush(flushToDisk: true); + } + + _lastOrdinal = signal.SessionSignalOrdinal; + if (signal.SessionTraceOrdinal > _lastTraceOrdinal) + { + _lastTraceOrdinal = signal.SessionTraceOrdinal; + } + } + } + + public IReadOnlyList ReadAll() + { + lock (_lock) + { + var signals = new List(); + if (!File.Exists(_path)) + { + return signals; + } + + foreach (var rawLine in File.ReadAllLines(_path, Encoding.UTF8)) + { + if (string.IsNullOrWhiteSpace(rawLine)) continue; + + DecisionSignal parsed; + try + { + parsed = SignalSerializer.Deserialize(rawLine); + } + catch + { + // Recovery rule §2.7: corrupt tail (crash mid-append) → stop at last + // parsable line. Ordinals are monotonic, so everything before this is + // still valid. Logging is the Orchestrator's concern in M4.4. + break; + } + + signals.Add(parsed); + } + + return signals; + } + } + + /// + /// Single-pass scan that populates both _lastOrdinal and _lastTraceOrdinal + /// from the persisted JSONL. Stops at the first unparsable line (recovery §2.7). + /// + private void ScanRecoveryState() + { + _lastOrdinal = -1; + _lastTraceOrdinal = -1; + + if (!File.Exists(_path)) return; + + foreach (var line in File.ReadAllLines(_path, Encoding.UTF8)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + var sig = SignalSerializer.Deserialize(line); + if (sig.SessionSignalOrdinal > _lastOrdinal) + { + _lastOrdinal = sig.SessionSignalOrdinal; + } + if (sig.SessionTraceOrdinal > _lastTraceOrdinal) + { + _lastTraceOrdinal = sig.SessionTraceOrdinal; + } + } + catch + { + // Stop at first unparsable line — recovery §2.7. + break; + } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/SnapshotPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/SnapshotPersistence.cs new file mode 100644 index 00000000..c24fc417 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Persistence/SnapshotPersistence.cs @@ -0,0 +1,241 @@ +#nullable enable +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Persistence +{ + /// + /// File-backed . Plan §2.7. + /// + /// On-disk format: a single JSON object with two properties: + /// { "Checksum": "<sha256-hex>", "State": { … DecisionState … } } + /// + /// + /// Save is atomic: serialize → write snapshot.json.tmp → flush-to-disk → rename + /// to snapshot.json. A crash mid-save leaves either the old snapshot intact or + /// produces an orphan .tmp that's ignored on . + /// + /// + public sealed class SnapshotPersistence : ISnapshotPersistence + { + private readonly string _path; + private readonly string _tempPath; + private readonly string _quarantineRoot; + private readonly object _lock = new object(); + private readonly Func _utcNow; + + public SnapshotPersistence(string path) + : this(path, () => DateTime.UtcNow) + { + } + + /// Overload for deterministic tests — stamps quarantine dirs. + public SnapshotPersistence(string path, Func utcNow) + { + if (string.IsNullOrEmpty(path)) throw new ArgumentException("Path is mandatory.", nameof(path)); + _path = path; + _tempPath = path + ".tmp"; + _utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow)); + + var dir = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(dir)) + { + throw new ArgumentException("Snapshot path must have a directory component.", nameof(path)); + } + + _quarantineRoot = Path.Combine(dir, ".quarantine"); + + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + } + + public void Save(DecisionState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + + lock (_lock) + { + var stateJson = StateSerializer.Serialize(state); + var checksum = ComputeSha256Hex(stateJson); + var envelopeJson = BuildEnvelope(checksum, stateJson); + var bytes = Encoding.UTF8.GetBytes(envelopeJson); + + // Write to temp + flush, then atomic rename. + using (var fs = new FileStream( + _tempPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.WriteThrough)) + { + fs.Write(bytes, 0, bytes.Length); + fs.Flush(flushToDisk: true); + } + + // File.Replace is the atomic rename on Windows (preserves attributes); + // fall back to Move if no target exists yet. + if (File.Exists(_path)) + { + File.Replace(_tempPath, _path, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + else + { + File.Move(_tempPath, _path); + } + } + } + + public DecisionState? Load() + { + lock (_lock) + { + if (!File.Exists(_path)) return null; + + string envelopeJson; + try + { + envelopeJson = File.ReadAllText(_path, Encoding.UTF8); + } + catch + { + return null; + } + + if (!TryParseEnvelope(envelopeJson, out var declaredChecksum, out var stateJson)) + { + return null; + } + + var actualChecksum = ComputeSha256Hex(stateJson); + if (!string.Equals(declaredChecksum, actualChecksum, StringComparison.OrdinalIgnoreCase)) + { + // §2.7 Sonderfall 2: Checksum-Mismatch → null zurück. Caller entscheidet + // ob Quarantine + Replay. + return null; + } + + try + { + return StateSerializer.Deserialize(stateJson); + } + catch + { + return null; + } + } + } + + public void Quarantine(string reason) + { + if (reason == null) reason = string.Empty; + + lock (_lock) + { + if (!File.Exists(_path)) return; + + if (!Directory.Exists(_quarantineRoot)) + { + Directory.CreateDirectory(_quarantineRoot); + } + + var stamp = _utcNow().ToString("yyyyMMdd'T'HHmmssfff'Z'"); + var bucket = Path.Combine(_quarantineRoot, stamp); + Directory.CreateDirectory(bucket); + + var snapshotName = Path.GetFileName(_path); + File.Move(_path, Path.Combine(bucket, snapshotName)); + File.WriteAllText(Path.Combine(bucket, "reason.txt"), reason, Encoding.UTF8); + } + } + + private static string ComputeSha256Hex(string payload) + { + using (var sha = SHA256.Create()) + { + var bytes = Encoding.UTF8.GetBytes(payload); + var hash = sha.ComputeHash(bytes); + var sb = new StringBuilder(hash.Length * 2); + foreach (var b in hash) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(); + } + } + + private static string BuildEnvelope(string checksum, string stateJson) + { + // Minimal hand-rolled envelope — keeps the inner stateJson byte-identical to + // what the checksum was computed over. Round-tripping through Newtonsoft at + // this layer would reformat and break the checksum. + var escapedChecksum = EscapeJsonString(checksum); + return "{\"Checksum\":\"" + escapedChecksum + "\",\"State\":" + stateJson + "}"; + } + + private static bool TryParseEnvelope(string envelopeJson, out string checksum, out string stateJson) + { + checksum = string.Empty; + stateJson = string.Empty; + if (string.IsNullOrWhiteSpace(envelopeJson)) return false; + + const string checksumKey = "\"Checksum\":\""; + var cIdx = envelopeJson.IndexOf(checksumKey, StringComparison.Ordinal); + if (cIdx < 0) return false; + var cStart = cIdx + checksumKey.Length; + var cEnd = envelopeJson.IndexOf('"', cStart); + if (cEnd < 0) return false; + checksum = envelopeJson.Substring(cStart, cEnd - cStart); + + const string stateKey = "\"State\":"; + var sIdx = envelopeJson.IndexOf(stateKey, cEnd, StringComparison.Ordinal); + if (sIdx < 0) return false; + var sStart = sIdx + stateKey.Length; + + // Scan to the matching closing brace of the State object. + while (sStart < envelopeJson.Length && char.IsWhiteSpace(envelopeJson[sStart])) sStart++; + if (sStart >= envelopeJson.Length || envelopeJson[sStart] != '{') return false; + + var depth = 0; + var inString = false; + var escape = false; + var sEnd = -1; + for (var i = sStart; i < envelopeJson.Length; i++) + { + var ch = envelopeJson[i]; + if (escape) { escape = false; continue; } + if (ch == '\\' && inString) { escape = true; continue; } + if (ch == '"') { inString = !inString; continue; } + if (inString) continue; + if (ch == '{') depth++; + else if (ch == '}') + { + depth--; + if (depth == 0) { sEnd = i; break; } + } + } + + if (sEnd < 0) return false; + stateJson = envelopeJson.Substring(sStart, sEnd - sStart + 1); + return true; + } + + private static string EscapeJsonString(string s) + { + var sb = new StringBuilder(s.Length); + foreach (var ch in s) + { + if (ch == '"' || ch == '\\') sb.Append('\\').Append(ch); + else if (ch < 0x20) sb.AppendFormat("\\u{0:x4}", (int)ch); + else sb.Append(ch); + } + return sb.ToString(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/README.md b/src/Agent/AutopilotMonitor.Agent.V2.Core/README.md new file mode 100644 index 00000000..0035d078 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/README.md @@ -0,0 +1,68 @@ +# AutopilotMonitor.Agent.V2.Core + +**Owner:** V2-Agent clean-slate library. Plan [REFACTOR_AGENT_V2.md](../../../plans/REFACTOR_AGENT_V2.md) §2.16. + +## Strict Separation Rule (plan §2.16 — merge block if violated) + +This project references **only**: +- `AutopilotMonitor.Shared` +- `AutopilotMonitor.DecisionCore` + +It must **never** reference `AutopilotMonitor.Agent.Core` or `AutopilotMonitor.Agent`. +Required legacy code is **copied**, not referenced — see copy list below. Once copied, +a file belongs to V2.Core and is maintained here independently. + +Verification (CI gate): +``` +grep -r "AutopilotMonitor\.Agent\.Core" src/Agent/AutopilotMonitor.Agent.V2* +``` +Must return zero matches. + +## Guiding Principle — V2 is Kernel-Only + +The V2 refactor replaces **only** the decision/completion kernel. Every other capability +(gather rules, analyzers, diagnostics upload, config polling, timezone, server-driven actions, +summary dialog launch, env-var resolution, version checks, registry-based signal tracking, +network / DO / performance telemetry, self-update, bootstrap→cert-auth handover, self-destruct +cleanup) is preserved verbatim. See user feedback memory +`feedback_v2_kernel_only_refactor.md` for the policy. + +SKIP list is closed to plan §2.11 items: +- `CompletionStateMachine` + partials +- `CompletionGuards` +- `EnrollmentTracker.CompletionLogic` +- `IEnrollmentFlowHandler` + `ClassicAutopilotFlow` / `DevicePreparationFlow` / `EnrollmentFlowFactory` +- `WhiteGloveClassifier` (scoring ported to `DecisionCore.Classifiers.WhiteGloveSealingClassifier` in M3) +- `WhiteGloveSignals` +- `EnrollmentStatePersistence` +- Shadow infrastructure (`ShadowProcessTrigger`, `EmitShadowDiscrepancyEvent`, `shadow_discrepancy`) +- `CheckSignalCorrelatedWhiteGlove()` as direct completion invoker +- `TryEmitEnrollmentComplete` / related direct completion paths +- EnrollmentType string checks in guard code +- Event emissions: `decision_process_completion`, `completion_check`, `shadow_discrepancy`, + decision-trace parts of `agent_trace` + +Everything else from `Agent.Core` → **COPY** into V2.Core (this project). + +## Copy List (source of truth: [tasks/m2-copy-list.md](../../../tasks/m2-copy-list.md)) + +After M2 copy phase: this README is updated with the full per-file inventory + adjustments +applied (namespace renames, V2-specific path constants, etc.). Until then, +`tasks/m2-copy-list.md` is the working document. + +## Folder Structure + +Target layout mirrors plan §7 R7 guidance: + +``` +Signals/ — V2-specific signal adapters (wrap copied collectors; M4) +State/ — V2 local state glue (not the pure DecisionState — that lives in DecisionCore) +Engine/ — (empty here; decision engine lives in AutopilotMonitor.DecisionCore) +Classifiers/ — (empty here; classifiers live in AutopilotMonitor.DecisionCore) +Transport/ — TelemetrySpool, TelemetryUploadOrchestrator, BackendApiClient (M4) +Persistence/ — SignalLogWriter, JournalWriter, SnapshotPersistence (M4) +SignalAdapters/ — one adapter per collector (M4) +Orchestration/ — V2-Orchestrator (replaces legacy MonitoringService in M4) +Collectors/ — Copies of the 8 decision-relevant + 4 telemetry-only collectors +Infrastructure/ — Logger, Config, Security, BackendApiClient baseline, CleanupService, … +``` diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs new file mode 100644 index 00000000..c8d587bf --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/AgentAnalyzerManager.cs @@ -0,0 +1,151 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Runtime +{ + /// + /// Manages the three agent analyzers: initialisation from remote config, startup execution + /// on a background thread, and shutdown execution with optional WhiteGlove part discriminator. + /// Plan §4.x M4.6.δ. + /// + /// Ported from Legacy Monitoring/Runtime/AnalyzerManager.cs with the sole change that + /// V2 reads the directly from + /// rather than through a RemoteConfigService indirection — the service-level cache is + /// already resolved by Program.cs before it instantiates this manager. + /// + /// + public sealed class AgentAnalyzerManager + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly Action _emitEvent; + private readonly AnalyzerConfiguration _analyzerConfig; + + private readonly List _analyzers = new List(); + private bool _initialised; + + public AgentAnalyzerManager( + AgentConfiguration configuration, + AgentLogger logger, + Action emitEvent, + AnalyzerConfiguration? analyzerConfig) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); + _analyzerConfig = analyzerConfig ?? new AnalyzerConfiguration(); + } + + /// Exposed for tests / observability. + public IReadOnlyList Analyzers => _analyzers; + + public void Initialize() + { + if (_initialised) return; + _initialised = true; + _analyzers.Clear(); + + if (_analyzerConfig.EnableLocalAdminAnalyzer) + { + _analyzers.Add(new LocalAdminAnalyzer( + _configuration.SessionId, + _configuration.TenantId, + _emitEvent, + _logger, + _analyzerConfig.LocalAdminAllowedAccounts)); + _logger.Info("LocalAdminAnalyzer registered"); + } + else + { + _logger.Info("LocalAdminAnalyzer disabled by remote config"); + } + + if (_analyzerConfig.EnableSoftwareInventoryAnalyzer) + { + _analyzers.Add(new SoftwareInventoryAnalyzer( + _configuration.SessionId, + _configuration.TenantId, + _emitEvent, + _logger)); + _logger.Info("SoftwareInventoryAnalyzer registered"); + } + else + { + _logger.Info("SoftwareInventoryAnalyzer disabled by remote config"); + } + + if (_analyzerConfig.EnableIntegrityBypassAnalyzer) + { + _analyzers.Add(new IntegrityBypassAnalyzer( + _configuration.SessionId, + _configuration.TenantId, + _emitEvent, + _logger)); + _logger.Info("IntegrityBypassAnalyzer registered"); + } + else + { + _logger.Info("IntegrityBypassAnalyzer disabled by remote config"); + } + + _logger.Info($"Analyzers initialized: {_analyzers.Count} active"); + } + + /// + /// Runs on a background thread for every + /// registered analyzer. Fire-and-forget: one analyzer throwing must not affect the others + /// or the caller's critical path. + /// + public void RunStartup() + { + if (!_initialised) Initialize(); + if (_analyzers.Count == 0) return; + + var snapshot = new List(_analyzers); + _logger.Info($"Scheduling {snapshot.Count} startup analyzer(s) on background thread"); + + Task.Run(() => + { + foreach (var analyzer in snapshot) + { + try { analyzer.AnalyzeAtStartup(); } + catch (Exception ex) { _logger.Error($"Analyzer {analyzer.Name} threw during startup", ex); } + } + }); + } + + /// + /// Runs synchronously so the caller can + /// sequence it before CleanupService.ExecuteSelfDestruct. + /// receives the optional WhiteGlove-part discriminator via its overload. + /// + public void RunShutdown(int? whiteGlovePart = null) + { + if (!_initialised) Initialize(); + if (_analyzers.Count == 0) return; + + _logger.Info($"Running {_analyzers.Count} shutdown analyzer(s) (whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})"); + + foreach (var analyzer in _analyzers) + { + try + { + if (analyzer is SoftwareInventoryAnalyzer softwareAnalyzer) + softwareAnalyzer.AnalyzeAtShutdown(whiteGlovePart); + else + analyzer.AnalyzeAtShutdown(); + } + catch (Exception ex) + { + _logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex); + } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs new file mode 100644 index 00000000..5814367f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs @@ -0,0 +1,229 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Telemetry.Events; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Runtime +{ + /// + /// Fire-and-forget startup probes ported from the Legacy MonitoringService. + /// Plan §4.x M4.6.γ. + /// + /// Emits three diagnostic events when the orchestrator is live: + /// + /// + /// device_location (or agent_trace on failure) — IP-based geo via + /// . Skipped when EnableGeoLocation=false. + /// timezone_auto_set — only when EnableTimezoneAutoSet=true AND the + /// geo lookup returned an IANA timezone. Uses (tzutil). + /// ntp_time_check — NTP offset from NtpServer (default time.windows.com). + /// Always emitted — offset >60s is Warning, smaller is Info, failure is Warning. + /// + /// + /// Each probe runs best-effort: failures are logged + optionally emitted as a Warning event, + /// never thrown. Designed to run on a background task so they never block the critical path. + /// + /// + public static class StartupEnvironmentProbes + { + /// + /// Runs all three probes sequentially and emits the resulting events via + /// . Returns a that completes when all + /// probes have finished (typical runtime <10s; longer on slow/blocked networks). + /// + public static async Task RunAsync( + AgentConfiguration configuration, + AgentLogger logger, + TelemetryEventEmitter emitter) + { + if (configuration == null) throw new ArgumentNullException(nameof(configuration)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + if (emitter == null) throw new ArgumentNullException(nameof(emitter)); + + if (configuration.EnableGeoLocation) + await RunGeoAndTimezone(configuration, logger, emitter).ConfigureAwait(false); + else + logger.Debug("StartupEnvironmentProbes: EnableGeoLocation=false — skipping geo + timezone probes."); + + await RunNtpCheck(configuration, logger, emitter).ConfigureAwait(false); + } + + private static async Task RunGeoAndTimezone( + AgentConfiguration configuration, + AgentLogger logger, + TelemetryEventEmitter emitter) + { + GeoLocationAttemptResult? attempt = null; + try + { + attempt = await GeoLocationService.GetLocationAsync(logger).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.Warning($"StartupEnvironmentProbes: geo-location lookup threw: {ex.Message}"); + } + + if (attempt?.Location != null) + { + SafeEmit(emitter, logger, BuildGeoEvent(configuration, attempt.Location)); + } + else + { + SafeEmit(emitter, logger, BuildGeoFailureEvent(configuration, attempt)); + } + + if (configuration.EnableTimezoneAutoSet && !string.IsNullOrEmpty(attempt?.Location?.Timezone)) + { + try + { + var tzResult = TimezoneService.TrySetTimezone(attempt!.Location!.Timezone, logger); + SafeEmit(emitter, logger, BuildTimezoneEvent(configuration, tzResult)); + } + catch (Exception tzEx) + { + logger.Warning($"StartupEnvironmentProbes: timezone auto-set threw: {tzEx.Message}"); + } + } + } + + private static async Task RunNtpCheck( + AgentConfiguration configuration, + AgentLogger logger, + TelemetryEventEmitter emitter) + { + try + { + var ntpServer = string.IsNullOrEmpty(configuration.NtpServer) ? "time.windows.com" : configuration.NtpServer; + + // NtpTimeCheckService.CheckTime is synchronous — wrap on a background thread so the + // probe runner stays async-friendly (avoids blocking the continuation thread when + // Program.cs awaits RunAsync with ConfigureAwait(false)). + var result = await Task.Run(() => NtpTimeCheckService.CheckTime(ntpServer, logger)) + .ConfigureAwait(false); + + SafeEmit(emitter, logger, BuildNtpEvent(configuration, ntpServer, result)); + } + catch (Exception ex) + { + logger.Warning($"StartupEnvironmentProbes: NTP check threw: {ex.Message}"); + } + } + + // --------------------------------------------------------------- Event builders (internal for tests) + + internal static EnrollmentEvent BuildGeoEvent(AgentConfiguration configuration, GeoLocationResult location) => + new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "device_location", + Severity = EventSeverity.Info, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = $"Device location: {location.City}, {location.Region}, {location.Country} (via {location.Source})", + Data = location.ToDictionary(), + ImmediateUpload = true, + }; + + internal static EnrollmentEvent BuildGeoFailureEvent(AgentConfiguration configuration, GeoLocationAttemptResult? attempt) => + new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "agent_trace", + Severity = EventSeverity.Warning, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = "Geo-location lookup failed: all providers unreachable", + Data = new Dictionary + { + { "decision", "geo_location_failed" }, + { "reason", "All geo-location providers failed after retry" }, + { "primaryError", attempt?.PrimaryError ?? "unknown" }, + { "primaryRetryError", attempt?.PrimaryRetryError ?? "unknown" }, + { "fallbackError", attempt?.FallbackError ?? "unknown" }, + { "primaryProvider", "ipinfo.io" }, + { "fallbackProvider", "ifconfig.co" }, + }, + }; + + internal static EnrollmentEvent BuildTimezoneEvent(AgentConfiguration configuration, TimezoneSetResult tz) => + new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "timezone_auto_set", + Severity = tz.Success ? EventSeverity.Info : EventSeverity.Warning, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = tz.Success + ? $"Timezone set to {tz.WindowsTimezoneId} (from {tz.IanaTimezone})" + : $"Timezone auto-set failed: {tz.Error}", + Data = new Dictionary + { + { "ianaTimezone", tz.IanaTimezone ?? "" }, + { "windowsTimezoneId", tz.WindowsTimezoneId ?? "unknown" }, + { "previousTimezone", tz.PreviousTimezone ?? "unknown" }, + { "success", tz.Success }, + { "error", tz.Error ?? "" }, + }, + }; + + internal static EnrollmentEvent BuildNtpEvent(AgentConfiguration configuration, string ntpServer, NtpCheckResult result) + { + if (result.Success) + { + var severity = Math.Abs(result.OffsetSeconds) > 60 ? EventSeverity.Warning : EventSeverity.Info; + return new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "ntp_time_check", + Severity = severity, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = $"NTP time check: offset {result.OffsetSeconds:F2}s from {ntpServer}", + Data = new Dictionary + { + { "ntpServer", ntpServer }, + { "offsetSeconds", result.OffsetSeconds }, + { "ntpTimeUtc", result.NtpTime?.ToString("o") ?? "" }, + { "localTimeUtc", result.LocalTime?.ToString("o") ?? "" }, + }, + }; + } + + return new EnrollmentEvent + { + SessionId = configuration.SessionId, + TenantId = configuration.TenantId, + EventType = "ntp_time_check", + Severity = EventSeverity.Warning, + Source = "Network", + Phase = EnrollmentPhase.Unknown, + Timestamp = DateTime.UtcNow, + Message = $"NTP time check failed: {result.Error}", + Data = new Dictionary + { + { "ntpServer", ntpServer }, + { "error", result.Error ?? "unknown" }, + }, + }; + } + + private static void SafeEmit(TelemetryEventEmitter emitter, AgentLogger logger, EnrollmentEvent evt) + { + try { emitter.Emit(evt); } + catch (Exception ex) { logger.Warning($"StartupEnvironmentProbes: emit failed for '{evt.EventType}': {ex.Message}"); } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BootstrapConfigCleanup.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BootstrapConfigCleanup.cs new file mode 100644 index 00000000..9d969f3b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BootstrapConfigCleanup.cs @@ -0,0 +1,152 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// H-2 mitigation: removes the persisted bootstrap token (bootstrap-config.json) + /// once the agent has proven it can authenticate with its MDM client certificate. + /// + /// The bootstrap token is only needed during OOBE (before the MDM cert is issued). Leaving + /// it on disk after enrollment creates an unnecessary secret-at-rest window: %ProgramData% + /// grants BUILTIN\Users read access by default, so any post-OOBE interactive user + /// could exfiltrate the token and replay it against the bootstrap endpoints until expiry. + /// + /// The cleanup runs a single-shot, cert-authenticated probe against /api/agent/config + /// using a dedicated . If the probe succeeds, the file is deleted. + /// Any failure (cert missing, network error, non-2xx response) is logged and the file is + /// left in place — the next run will try again. This path is strictly additive: a failed + /// probe never disrupts the in-progress bootstrap session. + /// + public static class BootstrapConfigCleanup + { + private const string BootstrapConfigFileName = "bootstrap-config.json"; + + /// + /// Attempts to remove the persisted bootstrap-config.json after verifying the MDM client + /// certificate authenticates successfully against the backend. Safe no-op if the agent + /// is not in bootstrap-token mode, the file is absent, the cert is missing, or the + /// probe call fails. + /// + public static async Task TryDeleteIfCertReadyAsync( + AgentConfiguration configuration, + AgentLogger logger, + string agentVersion) + { + if (configuration == null || !configuration.UseBootstrapTokenAuth) + return; + + var bootstrapPath = Path.Combine( + Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), + BootstrapConfigFileName); + + if (!File.Exists(bootstrapPath)) + return; + + X509Certificate2 cert; + try + { + cert = CertificateHelper.FindMdmCertificate(logger: logger); + } + catch (Exception ex) + { + logger?.Warning($"Bootstrap cleanup: MDM cert lookup threw ({ex.Message}), keeping {BootstrapConfigFileName}"); + return; + } + + if (cert == null) + { + logger?.Info($"Bootstrap cleanup: MDM cert not yet available, keeping {BootstrapConfigFileName}"); + return; + } + + var handler = new HttpClientHandler(); + handler.ClientCertificates.Add(cert); + + using (handler) + using (var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) }) + { + var request = BuildProbeRequest(configuration, agentVersion); + await ProbeAndDeleteCoreAsync(bootstrapPath, http, request, logger).ConfigureAwait(false); + } + } + + /// + /// Builds the probe request: GET /api/agent/config with the standard agent security + /// headers, but without the X-Bootstrap-Token header — authentication must come + /// from the TLS-layer client certificate alone. + /// + private static HttpRequestMessage BuildProbeRequest(AgentConfiguration configuration, string agentVersion) + { + var url = $"{configuration.ApiBaseUrl.TrimEnd('/')}{Constants.ApiEndpoints.GetAgentConfig}"; + var request = new HttpRequestMessage(HttpMethod.Get, url); + + if (!string.IsNullOrEmpty(configuration.TenantId)) + request.Headers.Add("X-Tenant-Id", configuration.TenantId); + + var hw = HardwareInfo.GetHardwareInfo(null); + if (!string.IsNullOrEmpty(hw.Manufacturer)) + request.Headers.Add("X-Device-Manufacturer", hw.Manufacturer); + if (!string.IsNullOrEmpty(hw.Model)) + request.Headers.Add("X-Device-Model", hw.Model); + if (!string.IsNullOrEmpty(hw.SerialNumber)) + request.Headers.Add("X-Device-SerialNumber", hw.SerialNumber); + if (!string.IsNullOrEmpty(agentVersion)) + request.Headers.Add("X-Agent-Version", agentVersion); + + var userAgent = string.IsNullOrEmpty(agentVersion) + ? "AutopilotMonitor.Agent" + : $"AutopilotMonitor.Agent/{agentVersion}"; + request.Headers.UserAgent.ParseAdd(userAgent); + + return request; + } + + /// + /// Pure-IO core: sends the probe, deletes the file on 2xx, logs + keeps on anything + /// else. Exposed internal for unit tests that inject a fake + /// . + /// + internal static async Task ProbeAndDeleteCoreAsync( + string bootstrapConfigPath, + HttpClient httpClient, + HttpRequestMessage request, + AgentLogger logger) + { + try + { + using (request) + using (var response = await httpClient.SendAsync(request).ConfigureAwait(false)) + { + if (response.IsSuccessStatusCode) + { + try + { + if (File.Exists(bootstrapConfigPath)) + File.Delete(bootstrapConfigPath); + logger?.Info("Bootstrap cleanup: cert probe succeeded — bootstrap-config.json removed, cert auth is now operational"); + } + catch (Exception delEx) + { + logger?.Warning($"Bootstrap cleanup: cert probe succeeded but delete failed ({delEx.Message}); will retry on next run"); + } + } + else + { + logger?.Warning($"Bootstrap cleanup: cert probe returned HTTP {(int)response.StatusCode}, keeping {BootstrapConfigFileName}"); + } + } + } + catch (Exception ex) + { + logger?.Warning($"Bootstrap cleanup: cert probe failed ({ex.Message}), keeping {BootstrapConfigFileName}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CertificateHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CertificateHelper.cs new file mode 100644 index 00000000..3ff934f4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CertificateHelper.cs @@ -0,0 +1,109 @@ +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Helper for finding and validating MDM client certificates + /// + public static class CertificateHelper + { + /// + /// Finds the MDM device certificate for client authentication + /// + /// Optional specific thumbprint to search for + /// Logger for diagnostic output + /// The MDM certificate, or null if not found + public static X509Certificate2 FindMdmCertificate(string thumbprint = null, AgentLogger logger = null) + { + try + { + // Search locations for MDM certificate + var stores = new[] + { + new { Location = StoreLocation.LocalMachine, Name = StoreName.My }, + new { Location = StoreLocation.CurrentUser, Name = StoreName.My } + }; + + foreach (var storeInfo in stores) + { + using (var store = new X509Store(storeInfo.Name, storeInfo.Location)) + { + store.Open(OpenFlags.ReadOnly); + + logger?.Debug($"Searching for MDM certificate in {storeInfo.Location}\\{storeInfo.Name}"); + + // If thumbprint specified, search by thumbprint + if (!string.IsNullOrEmpty(thumbprint)) + { + var cert = store.Certificates + .Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) + .OfType() + .FirstOrDefault(); + + if (cert != null) + { + logger?.Info($"Found certificate by thumbprint: {thumbprint}"); + return cert; + } + } + else + { + // Auto-detect MDM certificate by issuer and EKU + var mdmCerts = store.Certificates + .OfType() + .Where(c => + { + // Check issuer (Microsoft Intune MDM Device CA or similar) + var issuerContainsMdm = c.Issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) || + c.Issuer.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase) || + c.Issuer.Contains("Microsoft Device CA", StringComparison.OrdinalIgnoreCase); + + // Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2) + var hasClientAuth = c.Extensions + .OfType() + .Any(ext => ext.EnhancedKeyUsages + .OfType() + .Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2")); + + return issuerContainsMdm && hasClientAuth; + }) + .OrderByDescending(c => c.NotAfter) // Prefer certs with longer validity + .ToList(); + + if (mdmCerts.Any()) + { + var cert = mdmCerts.First(); + logger?.Info($"Found MDM certificate: Issuer={cert.Issuer}, Subject={cert.Subject}, Thumbprint={cert.Thumbprint}"); + return cert; + } + } + } + } + + logger?.Warning("No MDM certificate found"); + return null; + } + catch (Exception ex) + { + logger?.Error("Error finding MDM certificate", ex); + return null; + } + } + + /// + /// Validates that a certificate is still valid + /// + public static bool IsCertificateValid(X509Certificate2 certificate) + { + if (certificate == null) + return false; + + var now = DateTime.UtcNow; + return now >= certificate.NotBefore && now <= certificate.NotAfter; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentAwaiter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentAwaiter.cs new file mode 100644 index 00000000..84f5db84 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentAwaiter.cs @@ -0,0 +1,166 @@ +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Waits for an Intune MDM certificate to appear in the certificate store. + /// Used in await-enrollment mode when the agent is deployed before MDM enrollment completes. + /// Polls CertificateHelper.FindMdmCertificate() at a fixed interval (Timer-based, no busy-wait). + /// + public static class EnrollmentAwaiter + { + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan LogInterval = TimeSpan.FromMinutes(1); + + /// + /// Polls the certificate store until a valid MDM certificate is found or the timeout expires. + /// + public static async Task WaitForMdmCertificateAsync( + string thumbprint = null, + int timeoutMinutes = 480, + AgentLogger logger = null, + CancellationToken cancellationToken = default) + { + var timeoutDisplay = timeoutMinutes > 0 ? $"{timeoutMinutes}min" : "unlimited"; + logger?.Info($"Await-enrollment: Waiting for MDM certificate (timeout: {timeoutDisplay}, poll interval: {PollInterval.TotalSeconds}s)"); + + var startTime = DateTime.UtcNow; + var timeout = timeoutMinutes > 0 ? TimeSpan.FromMinutes(timeoutMinutes) : TimeSpan.MaxValue; + var lastLogTime = DateTime.MinValue; + + while (!cancellationToken.IsCancellationRequested) + { + var cert = CertificateHelper.FindMdmCertificate(thumbprint, logger); + + if (cert != null) + { + // No IsCertificateValid check here: freshly provisioned SCEP certs can have + // a NotBefore slightly in the future due to clock skew between device and CA. + // The cert existing in the store with correct issuer+EKU is sufficient. + var elapsed = DateTime.UtcNow - startTime; + logger?.Info($"Await-enrollment: MDM certificate found after {FormatElapsed(elapsed)} — " + + $"Issuer={cert.Issuer}, Thumbprint={cert.Thumbprint}, " + + $"Valid={cert.NotBefore:yyyy-MM-dd HH:mm:ss}..{cert.NotAfter:yyyy-MM-dd HH:mm:ss}"); + return cert; + } + + var totalElapsed = DateTime.UtcNow - startTime; + + if (totalElapsed >= timeout) + { + logger?.Warning($"Await-enrollment: Timeout after {FormatElapsed(totalElapsed)} — no MDM certificate found"); + LogCertificateStoreDetails(logger); + return null; + } + + // Periodic status with cert counts (every 1 minute) + if (DateTime.UtcNow - lastLogTime >= LogInterval) + { + var remaining = timeout - totalElapsed; + var storeSummary = GetCertificateStoreSummary(); + logger?.Info($"Await-enrollment: Waiting for MDM certificate... " + + $"(elapsed: {FormatElapsed(totalElapsed)}, remaining: {FormatElapsed(remaining)}, {storeSummary})"); + lastLogTime = DateTime.UtcNow; + } + + try + { + await Task.Delay(PollInterval, cancellationToken); + } + catch (TaskCanceledException) + { + break; + } + } + + logger?.Info("Await-enrollment: Cancelled"); + return null; + } + + /// + /// Returns a short summary of cert counts per store, e.g. "LocalMachine=2, CurrentUser=0". + /// + private static string GetCertificateStoreSummary() + { + try + { + int lmCount = 0, cuCount = 0; + + using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) + { + store.Open(OpenFlags.ReadOnly); + lmCount = store.Certificates.Count; + } + + using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadOnly); + cuCount = store.Certificates.Count; + } + + return $"certs in store: LM={lmCount}, CU={cuCount}"; + } + catch + { + return "certs in store: unknown"; + } + } + + /// + /// Full cert store dump — only on timeout to help diagnose why no MDM cert was matched. + /// + private static void LogCertificateStoreDetails(AgentLogger logger) + { + if (logger == null) return; + + try + { + var stores = new[] + { + new { Location = StoreLocation.LocalMachine, Name = StoreName.My }, + new { Location = StoreLocation.CurrentUser, Name = StoreName.My } + }; + + foreach (var storeInfo in stores) + { + using (var store = new X509Store(storeInfo.Name, storeInfo.Location)) + { + store.Open(OpenFlags.ReadOnly); + var certs = store.Certificates.OfType().ToList(); + + logger.Info($"Await-enrollment: {storeInfo.Location}\\{storeInfo.Name} — {certs.Count} certificate(s):"); + + foreach (var cert in certs) + { + var hasClientAuth = cert.Extensions + .OfType() + .Any(ext => ext.EnhancedKeyUsages + .OfType() + .Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2")); + + logger.Info($" - Issuer={cert.Issuer}, Thumbprint={cert.Thumbprint}, " + + $"ClientAuth={hasClientAuth}, Valid={cert.NotBefore:yyyy-MM-dd}..{cert.NotAfter:yyyy-MM-dd}"); + } + } + } + } + catch (Exception ex) + { + logger.Warning($"Await-enrollment: Could not read certificate store: {ex.Message}"); + } + } + + private static string FormatElapsed(TimeSpan elapsed) + { + if (elapsed.TotalHours >= 1) + return $"{(int)elapsed.TotalHours}h{elapsed.Minutes:D2}m"; + return $"{(int)elapsed.TotalMinutes}m{elapsed.Seconds:D2}s"; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs new file mode 100644 index 00000000..4e3ba815 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/HardwareInfo.cs @@ -0,0 +1,82 @@ +using System; +using System.Management; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Helper for retrieving hardware information + /// + public static class HardwareInfo + { + /// + /// Gets the device manufacturer and model from WMI + /// + /// Logger for diagnostic output + /// Tuple of (Manufacturer, Model, SerialNumber) + public static (string Manufacturer, string Model, string SerialNumber) GetHardwareInfo(AgentLogger logger = null) + { + string manufacturer = "Unknown"; + string model = "Unknown"; + string serialNumber = "Unknown"; + + try + { + // Get Manufacturer and Model from Win32_ComputerSystem + // Lenovo reports the marketing model name in Win32_ComputerSystemProduct.Version + // instead of Win32_ComputerSystem.Model (which contains a generic platform string) + using (var searcher = new ManagementObjectSearcher("SELECT Manufacturer, Model FROM Win32_ComputerSystem")) + using (var collection = searcher.Get()) + { + foreach (var obj in collection) + { + using (obj) + { + manufacturer = obj["Manufacturer"]?.ToString() ?? "Unknown"; + if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0) + { + using (var lenovoSearcher = new ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct")) + using (var lenovoCollection = lenovoSearcher.Get()) + { + foreach (var lenovoObj in lenovoCollection) + { + using (lenovoObj) + { + model = lenovoObj["Version"]?.ToString() ?? "Unknown"; + } + break; + } + } + } + else + { + model = obj["Model"]?.ToString() ?? "Unknown"; + } + } + } + } + + // Get Serial Number from Win32_BIOS + using (var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS")) + using (var collection = searcher.Get()) + { + foreach (var obj in collection) + { + using (obj) + { + serialNumber = obj["SerialNumber"]?.ToString() ?? "Unknown"; + } + } + } + + logger?.Debug($"Hardware detected: Manufacturer={manufacturer}, Model={model}, SerialNumber={serialNumber}"); + return (manufacturer, model, serialNumber); + } + catch (Exception ex) + { + logger?.Error("Error retrieving hardware information", ex); + return ("Unknown", "Unknown", "Unknown"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/ICertificateResolver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/ICertificateResolver.cs new file mode 100644 index 00000000..46f7b981 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/ICertificateResolver.cs @@ -0,0 +1,38 @@ +#nullable enable +using System.Security.Cryptography.X509Certificates; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Abstraktion über , damit Tests die + /// Zert-Auflösung ohne echten Windows-Cert-Store ersetzen können. Plan §4.x M4.4.5.d. + /// + public interface ICertificateResolver + { + /// + /// Sucht das MDM-Client-Zertifikat. null wenn nichts gefunden — der Caller + /// (typischerweise ) entscheidet dann + /// über Fehlerbehandlung (wirft in Prod, Test-Resolver liefert null für die negative + /// Test-Variante). + /// + X509Certificate2? FindClientCertificate(AgentLogger logger); + } + + /// + /// Produktiver . Delegiert an + /// . + /// + public sealed class DefaultCertificateResolver : ICertificateResolver + { + private readonly string? _thumbprintOverride; + + public DefaultCertificateResolver(string? thumbprintOverride = null) + { + _thumbprintOverride = thumbprintOverride; + } + + public X509Certificate2? FindClientCertificate(AgentLogger logger) => + CertificateHelper.FindMdmCertificate(_thumbprintOverride, logger); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs new file mode 100644 index 00000000..e9fb5325 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs @@ -0,0 +1,184 @@ +using System; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Persists the current enrollment SessionId across agent restarts. Plan §4.x M4.5.b. + /// + /// V2 uses a single session.id text file in the agent data directory — simpler than + /// the Legacy SessionPersistence (no .seq, no white-glove marker, no session-age + /// tracking; those live in the new DecisionState snapshot/journal). + /// + /// + /// Write semantics are atomic (write-to-temp + ) so a + /// power-cut mid-write cannot leave a zero-byte file that later reads as "no session" and + /// trips a fresh SessionId on recovery. + /// + /// + public sealed class SessionIdPersistence + { + private readonly string _sessionFilePath; + private readonly string _sessionCreatedFilePath; + private readonly string _whiteGloveMarkerPath; + private readonly object _lockObject = new object(); + + public SessionIdPersistence(string dataDirectory) + { + if (string.IsNullOrWhiteSpace(dataDirectory)) + throw new ArgumentNullException(nameof(dataDirectory)); + + if (!Directory.Exists(dataDirectory)) + Directory.CreateDirectory(dataDirectory); + + _sessionFilePath = Path.Combine(dataDirectory, "session.id"); + _sessionCreatedFilePath = Path.Combine(dataDirectory, "session.created"); + _whiteGloveMarkerPath = Path.Combine(dataDirectory, "whiteglove.complete"); + } + + /// + /// Returns the persisted SessionId or creates, writes and returns a fresh one. + /// Thread-safe — concurrent callers see the same SessionId. + /// + /// Also initialises session.created on first creation (and on recovery when it is + /// missing) so the emergency-break watchdog can compare the session's wall-clock age. + /// + /// + public string GetOrCreate(AgentLogger logger = null) + { + lock (_lockObject) + { + try + { + if (File.Exists(_sessionFilePath)) + { + var existing = File.ReadAllText(_sessionFilePath).Trim(); + if (Guid.TryParse(existing, out _)) + { + if (!File.Exists(_sessionCreatedFilePath)) + { + // Recover session.created when session.id exists but the companion + // timestamp was lost (crash between the two writes, upgrade from an + // older SessionIdPersistence without session.created). + SaveSessionCreatedAtInternal(DateTime.UtcNow); + logger?.Info("SessionIdPersistence: initialised missing session.created for existing SessionId."); + } + logger?.Debug($"SessionIdPersistence: resumed existing SessionId={existing}."); + return existing; + } + logger?.Warning($"SessionIdPersistence: session.id contained non-GUID content — regenerating."); + } + } + catch (Exception ex) + { + logger?.Warning($"SessionIdPersistence: failed to read session.id ({ex.Message}) — regenerating."); + } + + var fresh = Guid.NewGuid().ToString(); + WriteAtomic(fresh, logger); + SaveSessionCreatedAtInternal(DateTime.UtcNow); + logger?.Info($"SessionIdPersistence: created new SessionId={fresh}."); + return fresh; + } + } + + /// True when a persisted SessionId is present on disk. + public bool SessionExists() + { + lock (_lockObject) return File.Exists(_sessionFilePath); + } + + /// + /// Returns the persisted session creation timestamp (UTC) or null when unavailable / + /// unparseable. Used by the M4.6.α emergency-break watchdog. + /// + public DateTime? LoadSessionCreatedAt() + { + lock (_lockObject) + { + try + { + if (!File.Exists(_sessionCreatedFilePath)) return null; + var raw = File.ReadAllText(_sessionCreatedFilePath).Trim(); + if (DateTime.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) + return parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime(); + } + catch + { + // Fall through — treat corrupt file as missing so the watchdog does not trip on bogus data. + } + return null; + } + } + + /// + /// Persists the session creation timestamp (UTC). Used by the watchdog to establish a + /// known-good baseline if the file is missing on recovery (see ). + /// + public void SaveSessionCreatedAt(DateTime createdAtUtc) + { + lock (_lockObject) SaveSessionCreatedAtInternal(createdAtUtc); + } + + /// + /// true when a whiteglove.complete marker was persisted by the Part-1 exit path. + /// The watchdog treats these sessions as paused (device is powered off between Part 1 and + /// Part 2) and does NOT trip the emergency break on their age — the age clock restarts on + /// Part-2 resume. + /// + public bool IsWhiteGloveResume() + { + lock (_lockObject) return File.Exists(_whiteGloveMarkerPath); + } + + /// Deletes the persisted SessionId. Next starts a fresh session. + public void Delete(AgentLogger logger = null) + { + lock (_lockObject) + { + TryDelete(_sessionFilePath, logger); + TryDelete(_sessionCreatedFilePath, logger); + TryDelete(_whiteGloveMarkerPath, logger); + } + } + + private static void TryDelete(string path, AgentLogger logger) + { + try { if (File.Exists(path)) File.Delete(path); } + catch (Exception ex) { logger?.Warning($"SessionIdPersistence: failed to delete {Path.GetFileName(path)}: {ex.Message}"); } + } + + private void SaveSessionCreatedAtInternal(DateTime createdAtUtc) + { + if (createdAtUtc.Kind != DateTimeKind.Utc) createdAtUtc = createdAtUtc.ToUniversalTime(); + try { File.WriteAllText(_sessionCreatedFilePath, createdAtUtc.ToString("O")); } + catch { /* best-effort — watchdog will fall back to initialising it on next start */ } + } + + private void WriteAtomic(string sessionId, AgentLogger logger) + { + var tempPath = _sessionFilePath + ".tmp"; + try + { + File.WriteAllText(tempPath, sessionId); + + if (File.Exists(_sessionFilePath)) + { + // File.Replace preserves ACLs + is atomic on NTFS. + File.Replace(tempPath, _sessionFilePath, destinationBackupFileName: null); + } + else + { + File.Move(tempPath, _sessionFilePath); + } + } + catch (Exception ex) + { + logger?.Error($"SessionIdPersistence: atomic write failed ({ex.Message}) — falling back to direct write.", ex); + File.WriteAllText(_sessionFilePath, sessionId); + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SystemPaths.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SystemPaths.cs new file mode 100644 index 00000000..ebf4ed33 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SystemPaths.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Resolved absolute paths to Windows system binaries. + /// + /// Rationale: launching processes by bare name (e.g. "cmd.exe", "tzutil") lets + /// Windows walk the PATH to find them. As SYSTEM this is a DLL/binary-hijacking + /// vector — an attacker able to plant a file in any PATH entry ahead of System32 + /// gets code execution with SYSTEM privileges. Always use these resolved paths. + /// + /// respects WOW64 redirection + /// automatically: + /// - 64-bit process on any Windows → C:\Windows\System32 + /// - 32-bit process on 64-bit Windows → C:\Windows\SysWOW64 + /// The agent ships as AnyCPU/net48 and runs 64-bit on 64-bit Windows, so this + /// resolves to the real System32. It also honors non-default %SystemRoot% + /// (rare, but seen in custom OEM images on drives other than C:). + /// + /// All listed binaries are in-box on every supported Windows version + /// (Windows 7 / 8.1 / 10 / 11 / Server 2012R2 – 2025). + /// + public static class SystemPaths + { + private static readonly string System32 = Environment.SystemDirectory; + + /// C:\Windows\System32\cmd.exe + public static readonly string Cmd = Path.Combine(System32, "cmd.exe"); + + /// C:\Windows\System32\shutdown.exe + public static readonly string Shutdown = Path.Combine(System32, "shutdown.exe"); + + /// C:\Windows\System32\tzutil.exe + public static readonly string TzUtil = Path.Combine(System32, "tzutil.exe"); + + /// C:\Windows\System32\netsh.exe + public static readonly string Netsh = Path.Combine(System32, "netsh.exe"); + + /// + /// C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe + /// Note: the "v1.0" folder is historical — it hosts every in-box PowerShell + /// version including 5.1 on Windows 10/11. Do not "fix" it to v5.1. + /// + public static readonly string PowerShell = Path.Combine(System32, "WindowsPowerShell", "v1.0", "powershell.exe"); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs new file mode 100644 index 00000000..eb4b5198 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/TenantIdResolver.cs @@ -0,0 +1,75 @@ +using System; +using AutopilotMonitor.Agent.V2.Core.Logging; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Resolves the Autopilot device's AAD TenantId from the MDM enrollment registry. + /// Plan §4.x M4.5.b. + /// + /// The MDM enrollment writes one sub-key per enrollment under + /// HKLM\SOFTWARE\Microsoft\Enrollments. The entry with EnrollmentType==6 + /// (MDM Intune enrollment) carries AADTenantID — the only value that maps the + /// device back to its Entra ID tenant without a backend round-trip. + /// + /// + public static class TenantIdResolver + { + private const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; + private const int IntuneMdmEnrollmentType = 6; + + /// + /// Walks the enrollments subtree and returns the first AADTenantID tagged with + /// the Intune MDM enrollment type. Returns null when the device is not MDM-enrolled + /// or the registry key is inaccessible. Never throws. + /// + public static string ResolveFromEnrollmentRegistry(AgentLogger logger = null) + { + try + { + using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(EnrollmentsKeyPath)) + { + if (enrollmentsKey == null) + { + logger?.Warning($"TenantIdResolver: {EnrollmentsKeyPath} not found — device likely not MDM-enrolled."); + return null; + } + + foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames()) + { + using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid)) + { + if (enrollmentKey == null) continue; + + var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); + if (enrollmentType == null) continue; + + int typeValue; + try { typeValue = Convert.ToInt32(enrollmentType); } + catch { continue; } + + if (typeValue != IntuneMdmEnrollmentType) continue; + + var tenantId = enrollmentKey.GetValue("AADTenantID"); + var resolved = tenantId?.ToString(); + if (!string.IsNullOrEmpty(resolved)) + { + logger?.Debug($"TenantIdResolver: resolved AADTenantID={resolved} from enrollment {enrollmentGuid}."); + return resolved; + } + } + } + } + + logger?.Warning("TenantIdResolver: no Intune MDM enrollment (EnrollmentType=6) with AADTenantID found."); + return null; + } + catch (Exception ex) + { + logger?.Error("TenantIdResolver: registry probe failed.", ex); + return null; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs new file mode 100644 index 00000000..9516c78e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/AadJoinWatcherAdapter.cs @@ -0,0 +1,91 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for → + /// (Part 1) or (Part 2). Plan §2.1a / §2.2. + /// + /// Only the AadUserJoined-Event (real user, non-placeholder) ist Decision-relevant + /// und wird als Signal emittiert. PlaceholderUserDetected ist diagnostisch + /// (transienter Provisioning-Account) und wird aktuell nicht als Decision-Signal + /// weitergereicht — bei Bedarf später als Event-Timeline-Entry emittierbar. + /// + /// + internal sealed class AadJoinWatcherAdapter : IDisposable + { + private readonly AadJoinWatcher _watcher; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + private readonly bool _part2Mode; + private bool _fired; + + public AadJoinWatcherAdapter( + AadJoinWatcher watcher, + ISignalIngressSink ingress, + IClock clock, + bool part2Mode = false) + { + _watcher = watcher ?? throw new ArgumentNullException(nameof(watcher)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _part2Mode = part2Mode; + + _watcher.AadUserJoined += OnAadUserJoined; + } + + public void Dispose() + { + _watcher.AadUserJoined -= OnAadUserJoined; + } + + private void OnAadUserJoined(object sender, AadUserJoinedEventArgs e) => EmitInternal(e.UserEmail, e.Thumbprint); + + internal void TriggerFromTest(string userEmail, string thumbprint) => EmitInternal(userEmail, thumbprint); + + private void EmitInternal(string userEmail, string thumbprint) + { + if (_fired) return; + _fired = true; + + var kind = _part2Mode ? DecisionSignalKind.UserAadSignInComplete : DecisionSignalKind.AadUserJoinedLate; + + var payload = new Dictionary(StringComparer.Ordinal) + { + // Don't log the full email in decision signals — user PII. Keep domain only + // for audit trail; full email lives only in local agent logs if at all. + ["userDomain"] = ExtractDomain(userEmail), + ["hasThumbprint"] = string.IsNullOrEmpty(thumbprint) ? "false" : "true", + }; + + _ingress.Post( + kind: kind, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "AadJoinWatcher", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "aad-join-watcher-v1", + summary: _part2Mode + ? "Post-reboot AAD user sign-in detected (JoinInfo registry key)" + : "Late AAD user join detected (JoinInfo registry key)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["registryKey"] = @"HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo", + }), + payload: payload); + } + + private static string ExtractDomain(string email) + { + if (string.IsNullOrEmpty(email)) return "unknown"; + var at = email.IndexOf('@'); + return at >= 0 && at + 1 < email.Length ? email.Substring(at + 1) : "unknown"; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/DesktopArrivalDetectorAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/DesktopArrivalDetectorAdapter.cs new file mode 100644 index 00000000..a3aca2dc --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/DesktopArrivalDetectorAdapter.cs @@ -0,0 +1,75 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for → + /// (or in post-reboot WhiteGlove-Part-2 mode). + /// Plan §2.1a / §2.2. + /// + /// Fire-once by design (the detector itself guards against duplicate fires; the adapter + /// also guards defensively in case the detector is restarted). + /// + /// + internal sealed class DesktopArrivalDetectorAdapter : IDisposable + { + private readonly DesktopArrivalDetector _detector; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + private readonly bool _part2Mode; + private bool _fired; + + public DesktopArrivalDetectorAdapter( + DesktopArrivalDetector detector, + ISignalIngressSink ingress, + IClock clock, + bool part2Mode = false) + { + _detector = detector ?? throw new ArgumentNullException(nameof(detector)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _part2Mode = part2Mode; + + _detector.DesktopArrived += OnDesktopArrived; + } + + public void Dispose() + { + _detector.DesktopArrived -= OnDesktopArrived; + } + + private void OnDesktopArrived(object sender, EventArgs e) => EmitInternal(); + + /// Test hook — triggers the same emit-logic bypassing the event plumbing. + internal void TriggerFromTest() => EmitInternal(); + + private void EmitInternal() + { + if (_fired) return; + _fired = true; + + var kind = _part2Mode ? DecisionSignalKind.DesktopArrivedPart2 : DecisionSignalKind.DesktopArrived; + _ingress.Post( + kind: kind, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "DesktopArrivalDetector", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "desktop-arrival-detector-v1", + summary: _part2Mode + ? "Desktop arrival observed (Part 2 — post-reboot user sign-in)" + : "Desktop arrival observed (explorer.exe under real user)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["detectionSource"] = "explorer.exe process poll", + ["part2Mode"] = _part2Mode ? "true" : "false", + })); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs new file mode 100644 index 00000000..4946c462 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs @@ -0,0 +1,233 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for — der Coordinator, den der V2-Orchestrator + /// in Produktion tatsächlich aufsetzt. Plan §2.1a / §2.2. + /// + /// EspAndHelloTracker re-raised Events aus den privaten Sub-Trackern (HelloTracker, + /// ShellCoreTracker, ProvisioningStatusTracker, ModernDeploymentTracker). + /// In Produktion sind die Sub-Tracker nicht direkt zugänglich, daher kommt das Mapping + /// hier im Coordinator-Adapter an. + /// + /// + /// Signal-Mapping identisch zu den Sub-Tracker-Adaptern (Single-Tracker-Wiring-Szenarien): + /// + /// HelloCompleted → / + /// FinalizingSetupPhaseTriggered → (phase=FinalizingSetup) + /// WhiteGloveCompleted → + /// EspFailureDetected → (merged aus ShellCore + Provisioning) + /// DeviceSetupProvisioningComplete → + /// + /// + /// + /// Tech-Debt (M4.5-Cleanup-Kandidat): Signal-Mapping-Logik ist dupliziert zu den + /// Sub-Tracker-Adaptern. Refactoring-Idee: SignalAdapterEmitters-Static-Helper oder + /// gemeinsame Mapping-Interfaces auf Tracker-Seite. Pragmatisch erst in M4.5 oder wenn ein + /// dritter Konsument dazu kommt. + /// + /// + internal sealed class EspAndHelloTrackerAdapter : IDisposable + { + private const string SourceOrigin = "EspAndHelloTracker"; + private const string DetectorId = "esp-and-hello-tracker-v1"; + + private readonly EspAndHelloTracker _coordinator; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + private readonly bool _part2Mode; + + private bool _helloPosted; + private bool _finalizingPosted; + private bool _whiteGloveSuccessPosted; + private bool _espFailurePosted; + private bool _deviceSetupCompletePosted; + + /// Tracked HelloOutcome (read via event; coordinator exposes property). + public EspAndHelloTrackerAdapter( + EspAndHelloTracker coordinator, + ISignalIngressSink ingress, + IClock clock, + bool part2Mode = false) + { + _coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _part2Mode = part2Mode; + + _coordinator.HelloCompleted += OnHelloCompleted; + _coordinator.FinalizingSetupPhaseTriggered += OnFinalizing; + _coordinator.WhiteGloveCompleted += OnWhiteGloveCompleted; + _coordinator.EspFailureDetected += OnEspFailure; + _coordinator.DeviceSetupProvisioningComplete += OnDeviceSetupComplete; + } + + public void Dispose() + { + _coordinator.HelloCompleted -= OnHelloCompleted; + _coordinator.FinalizingSetupPhaseTriggered -= OnFinalizing; + _coordinator.WhiteGloveCompleted -= OnWhiteGloveCompleted; + _coordinator.EspFailureDetected -= OnEspFailure; + _coordinator.DeviceSetupProvisioningComplete -= OnDeviceSetupComplete; + } + + private void OnHelloCompleted(object sender, EventArgs e) => EmitHello(ReadHelloOutcome()); + private void OnFinalizing(object sender, string reason) => EmitFinalizing(reason); + private void OnWhiteGloveCompleted(object sender, EventArgs e) => EmitWhiteGlove(); + private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType); + private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete(); + + internal void TriggerHelloFromTest(string? helloOutcome) => EmitHello(helloOutcome); + internal void TriggerFinalizingFromTest(string reason) => EmitFinalizing(reason); + internal void TriggerWhiteGloveFromTest() => EmitWhiteGlove(); + internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType); + internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete(); + + /// + /// Reads HelloOutcome from the coordinator's forwarded property + /// ( delegates to the inner + /// HelloTracker.HelloOutcome). Plan §4.x M4.4.4. Falls back to + /// "unknown" only if the coordinator hasn't resolved Hello yet (race between + /// completion event and property read — should not happen in practice, but defensive). + /// + private string ReadHelloOutcome() => _coordinator.HelloOutcome ?? "unknown"; + + /// Test-only hook to exercise + the full event-handler emit path without driving the inner HelloTracker's live event sources. + internal void TriggerHelloFromCoordinatorPropertyForTest() => EmitHello(ReadHelloOutcome()); + + private void EmitHello(string? helloOutcome) + { + if (_helloPosted) return; + _helloPosted = true; + + var outcome = string.IsNullOrEmpty(helloOutcome) ? "unknown" : helloOutcome!; + var kind = _part2Mode ? DecisionSignalKind.HelloResolvedPart2 : DecisionSignalKind.HelloResolved; + + _ingress.Post( + kind: kind, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: SourceOrigin, + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: DetectorId, + summary: $"Hello resolved via coordinator (outcome={outcome})", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["subSource"] = "HelloTracker", + [SignalPayloadKeys.HelloOutcome] = outcome, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.HelloOutcome] = outcome, + }); + } + + private void EmitFinalizing(string reason) + { + if (_finalizingPosted) return; + _finalizingPosted = true; + + _ingress.Post( + kind: DecisionSignalKind.EspPhaseChanged, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: SourceOrigin, + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: DetectorId, + summary: "ESP Finalizing phase triggered (coordinator-forwarded)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["subSource"] = "ShellCoreTracker", + ["phaseReason"] = reason ?? string.Empty, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.EspPhase] = EnrollmentPhase.FinalizingSetup.ToString(), + ["reason"] = reason ?? string.Empty, + }); + } + + private void EmitWhiteGlove() + { + if (_whiteGloveSuccessPosted) return; + _whiteGloveSuccessPosted = true; + + _ingress.Post( + kind: DecisionSignalKind.WhiteGloveShellCoreSuccess, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: SourceOrigin, + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: DetectorId, + summary: "WhiteGlove sealing success (coordinator-forwarded)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["subSource"] = "ShellCoreTracker", + })); + } + + private void EmitEspFailure(string failureType) + { + if (_espFailurePosted) return; + _espFailurePosted = true; + + var safeType = string.IsNullOrEmpty(failureType) ? "unknown" : failureType!; + + _ingress.Post( + kind: DecisionSignalKind.EspTerminalFailure, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: SourceOrigin, + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: DetectorId, + summary: $"ESP terminal failure (coordinator-merged, type={safeType})", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["subSource"] = "ShellCoreTracker+ProvisioningStatusTracker (merged)", + ["failureType"] = safeType, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["failureType"] = safeType, + }); + } + + private void EmitDeviceSetupComplete() + { + if (_deviceSetupCompletePosted) return; + _deviceSetupCompletePosted = true; + + var snapshot = _coordinator.GetProvisioningCategorySnapshot(); + var deviceSetupResolved = + snapshot.TryGetValue("DeviceSetup", out var ds) && ds.HasValue + ? ds.Value.ToString().ToLowerInvariant() + : "unknown"; + + _ingress.Post( + kind: DecisionSignalKind.DeviceSetupProvisioningComplete, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: SourceOrigin, + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: DetectorId, + summary: "DeviceSetupCategory provisioning completed (coordinator-forwarded)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["subSource"] = "ProvisioningStatusTracker", + ["deviceSetupResolved"] = deviceSetupResolved, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["deviceSetupResolved"] = deviceSetupResolved, + }); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/HelloTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/HelloTrackerAdapter.cs new file mode 100644 index 00000000..7dd155ba --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/HelloTrackerAdapter.cs @@ -0,0 +1,77 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for → + /// (Part 1) or (Part 2). Plan §2.1a / §2.2. + /// + /// Fires genau einmal, wenn der Tracker Hello-Abschluss feststellt. Payload enthält den + /// HelloOutcome-String (completed | skipped | timeout | not_configured | wizard_not_started), + /// der vom Reducer für die SignalFact<string> HelloOutcome-Fakt-Update verwendet wird. + /// + /// + internal sealed class HelloTrackerAdapter : IDisposable + { + private readonly HelloTracker _tracker; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + private readonly bool _part2Mode; + private bool _fired; + + public HelloTrackerAdapter( + HelloTracker tracker, + ISignalIngressSink ingress, + IClock clock, + bool part2Mode = false) + { + _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _part2Mode = part2Mode; + + _tracker.HelloCompleted += OnHelloCompleted; + } + + public void Dispose() + { + _tracker.HelloCompleted -= OnHelloCompleted; + } + + private void OnHelloCompleted(object sender, EventArgs e) => EmitInternal(_tracker.HelloOutcome); + + internal void TriggerFromTest(string helloOutcome) => EmitInternal(helloOutcome); + + private void EmitInternal(string? helloOutcome) + { + if (_fired) return; + _fired = true; + + var outcome = string.IsNullOrEmpty(helloOutcome) ? "unknown" : helloOutcome!; + + var kind = _part2Mode ? DecisionSignalKind.HelloResolvedPart2 : DecisionSignalKind.HelloResolved; + _ingress.Post( + kind: kind, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "HelloTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "hello-tracker-v1", + summary: $"Hello resolved (outcome={outcome})", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.HelloOutcome] = outcome, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.HelloOutcome] = outcome, + }); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs new file mode 100644 index 00000000..019480a9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs @@ -0,0 +1,282 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for → mehrere DecisionSignalKinds. + /// Plan §2.1a / §2.2. + /// + /// ImeLogTracker nutzt Action-Property-Callbacks (nicht Events). Adapter ersetzt + /// diese Action-Props — Orchestrator darf die nicht parallel belegen. Dispose restauriert + /// die vorherigen Action-Werte (nur für Clean-Shutdown relevant; normalerweise ist der + /// Tracker selbst kurz vor Dispose). + /// + /// + /// Mapping: + /// + /// OnEspPhaseChanged(phase) → + /// (dedup per distinct phase value — Reducer Plan §2.1a idempotenz-Anforderung). + /// OnUserSessionCompleted() → (fire-once). + /// OnAppStateChanged(app, old, new) → + /// (state=Installed/Skipped/Postponed) oder (state=Error). + /// Dedup pro (AppId, terminal-state-tuple). + /// + /// + /// + /// WhiteGloveSealingPatternDetected (M4.4.4, Plan §4.x): fired via the newly added + /// ImeLogTracker.OnPatternMatched hook. Orchestrator passes the configured set of + /// WG-sealing-Pattern-IDs; this adapter fires the signal at most once per session when any + /// of those IDs matches. Default empty collection = no emission (backwards-compatible with + /// the pre-M4.4.4 M3 behavior). + /// + /// + internal sealed class ImeLogTrackerAdapter : IDisposable + { + private readonly ImeLogTracker _tracker; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + private readonly HashSet _whiteGloveSealingPatternIds; + private readonly object _lock = new object(); + + // Previous action-handlers — restored on Dispose so we don't leave a dead callback + // hanging on the tracker. + private readonly Action? _prevOnEspPhaseChanged; + private readonly Action? _prevOnUserSessionCompleted; + private readonly Action? _prevOnAppStateChanged; + private readonly Action? _prevOnPatternMatched; + + // Our own delegate instances — stored once so Dispose can compare by reference. + private readonly Action _ourOnEspPhaseChanged; + private readonly Action _ourOnUserSessionCompleted; + private readonly Action _ourOnAppStateChanged; + private readonly Action _ourOnPatternMatched; + + // Dedup state. + private string? _lastEspPhase; + private bool _userSessionCompletedPosted; + private bool _sealingPatternPosted; + private readonly HashSet _appsAlreadyPostedTerminal = new HashSet(StringComparer.Ordinal); + + public ImeLogTrackerAdapter( + ImeLogTracker tracker, + ISignalIngressSink ingress, + IClock clock, + IReadOnlyCollection? whiteGloveSealingPatternIds = null) + { + _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + + _whiteGloveSealingPatternIds = whiteGloveSealingPatternIds == null + ? new HashSet(StringComparer.Ordinal) + : new HashSet(whiteGloveSealingPatternIds, StringComparer.Ordinal); + + // Chain-preserve: save any previously-wired handlers and invoke them from our + // dispatcher. Lets the orchestrator co-wire diagnostic callbacks (e.g. logging) + // without being blocked by the adapter. + _prevOnEspPhaseChanged = _tracker.OnEspPhaseChanged; + _prevOnUserSessionCompleted = _tracker.OnUserSessionCompleted; + _prevOnAppStateChanged = _tracker.OnAppStateChanged; + _prevOnPatternMatched = _tracker.OnPatternMatched; + + // Store our delegate instances once — implicit method-group conversions + // create a new delegate each time, which would break Dispose's reference check. + _ourOnEspPhaseChanged = OnEspPhaseChanged; + _ourOnUserSessionCompleted = OnUserSessionCompleted; + _ourOnAppStateChanged = OnAppStateChanged; + _ourOnPatternMatched = OnPatternMatched; + + _tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged; + _tracker.OnUserSessionCompleted = _ourOnUserSessionCompleted; + _tracker.OnAppStateChanged = _ourOnAppStateChanged; + _tracker.OnPatternMatched = _ourOnPatternMatched; + } + + public void Dispose() + { + // Restore only if we're still the current handler; otherwise leave it alone + // (someone else re-wired after us and owns it now). + if (ReferenceEquals(_tracker.OnEspPhaseChanged, _ourOnEspPhaseChanged)) + _tracker.OnEspPhaseChanged = _prevOnEspPhaseChanged; + if (ReferenceEquals(_tracker.OnUserSessionCompleted, _ourOnUserSessionCompleted)) + _tracker.OnUserSessionCompleted = _prevOnUserSessionCompleted; + if (ReferenceEquals(_tracker.OnAppStateChanged, _ourOnAppStateChanged)) + _tracker.OnAppStateChanged = _prevOnAppStateChanged; + if (ReferenceEquals(_tracker.OnPatternMatched, _ourOnPatternMatched)) + _tracker.OnPatternMatched = _prevOnPatternMatched; + } + + private void OnEspPhaseChanged(string phase) + { + _prevOnEspPhaseChanged?.Invoke(phase); + EmitEspPhase(phase); + } + + private void OnUserSessionCompleted() + { + _prevOnUserSessionCompleted?.Invoke(); + EmitUserSessionCompleted(); + } + + private void OnAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) + { + _prevOnAppStateChanged?.Invoke(app, oldState, newState); + EmitAppState(app, oldState, newState); + } + + private void OnPatternMatched(string patternId) + { + _prevOnPatternMatched?.Invoke(patternId); + MaybeEmitWhiteGloveSealingPattern(patternId); + } + + internal void TriggerEspPhaseFromTest(string phase) => EmitEspPhase(phase); + internal void TriggerUserSessionCompletedFromTest() => EmitUserSessionCompleted(); + internal void TriggerAppStateFromTest(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) => + EmitAppState(app, oldState, newState); + internal void TriggerPatternMatchedFromTest(string patternId) => MaybeEmitWhiteGloveSealingPattern(patternId); + + private void EmitEspPhase(string phase) + { + if (string.IsNullOrEmpty(phase)) return; + + lock (_lock) + { + if (string.Equals(_lastEspPhase, phase, StringComparison.Ordinal)) + { + return; // Idempotent — phase unchanged. + } + _lastEspPhase = phase; + } + + _ingress.Post( + kind: DecisionSignalKind.EspPhaseChanged, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ImeLogTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "ime-log-tracker-v1", + summary: $"IME log phase transition → {phase}", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["detectionSource"] = "IME log regex pattern", + ["phase"] = phase, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.EspPhase] = phase, + }); + } + + private void EmitUserSessionCompleted() + { + lock (_lock) + { + if (_userSessionCompletedPosted) return; + _userSessionCompletedPosted = true; + } + + _ingress.Post( + kind: DecisionSignalKind.ImeUserSessionCompleted, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ImeLogTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "ime-log-tracker-v1", + summary: "IME user session completed (all user-scope apps finished)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["detectionSource"] = "IME log pattern (UserSessionCompleted)", + })); + } + + private void EmitAppState(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) + { + if (app == null || string.IsNullOrEmpty(app.Id)) return; + + var terminalKind = ClassifyTerminalState(newState); + if (terminalKind == null) return; // Not a terminal state — skip. + + lock (_lock) + { + if (!_appsAlreadyPostedTerminal.Add(app.Id)) + { + return; // Already posted terminal state for this app. + } + } + + _ingress.Post( + kind: terminalKind.Value, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ImeLogTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "ime-log-tracker-v1", + summary: $"App {app.Id} → {newState}", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["appId"] = app.Id, + ["appName"] = app.Name ?? string.Empty, + ["previousState"] = oldState.ToString(), + ["newState"] = newState.ToString(), + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["appId"] = app.Id, + ["newState"] = newState.ToString(), + }); + } + + private void MaybeEmitWhiteGloveSealingPattern(string patternId) + { + if (string.IsNullOrEmpty(patternId)) return; + if (_whiteGloveSealingPatternIds.Count == 0) return; // No configured IDs — no emit. + if (!_whiteGloveSealingPatternIds.Contains(patternId)) return; + + lock (_lock) + { + if (_sealingPatternPosted) return; + _sealingPatternPosted = true; + } + + _ingress.Post( + kind: DecisionSignalKind.WhiteGloveSealingPatternDetected, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ImeLogTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "ime-log-tracker-v1", + summary: $"WG sealing pattern match → {patternId}", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["detectionSource"] = "IME log regex pattern (WG-sealing set)", + [SignalPayloadKeys.ImePatternId] = patternId, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.ImePatternId] = patternId, + }); + } + + private static DecisionSignalKind? ClassifyTerminalState(AppInstallationState state) + { + switch (state) + { + case AppInstallationState.Installed: + case AppInstallationState.Skipped: + case AppInstallationState.Postponed: + return DecisionSignalKind.AppInstallCompleted; + case AppInstallationState.Error: + return DecisionSignalKind.AppInstallFailed; + default: + return null; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs new file mode 100644 index 00000000..3430d14b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs @@ -0,0 +1,117 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for → 2 DecisionSignalKinds. + /// Plan §2.1a / §2.2. + /// + /// Event mapping: + /// + /// DeviceSetupProvisioningComplete → + /// EspFailureDetected → + /// + /// + /// + /// Duplicate EspTerminalFailure-Signals aus ShellCoreTracker + ProvisioningStatusTracker + /// sind erwartbar (zwei Detection-Quellen für dasselbe Outcome). Der Reducer handled das + /// idempotent — erste Failure gewinnt und setzt Stage auf Failed; weitere sind dann Dead-End. + /// Adapter führt nur lokale per-kind Dedup (innerhalb einer Tracker-Instanz). + /// + /// + internal sealed class ProvisioningStatusTrackerAdapter : IDisposable + { + private readonly ProvisioningStatusTracker _tracker; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + + private bool _deviceSetupCompletePosted; + private bool _espFailurePosted; + + public ProvisioningStatusTrackerAdapter( + ProvisioningStatusTracker tracker, + ISignalIngressSink ingress, + IClock clock) + { + _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + + _tracker.DeviceSetupProvisioningComplete += OnDeviceSetupComplete; + _tracker.EspFailureDetected += OnEspFailure; + } + + public void Dispose() + { + _tracker.DeviceSetupProvisioningComplete -= OnDeviceSetupComplete; + _tracker.EspFailureDetected -= OnEspFailure; + } + + private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete(); + private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType); + + internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete(); + internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType); + + private void EmitDeviceSetupComplete() + { + if (_deviceSetupCompletePosted) return; + _deviceSetupCompletePosted = true; + + var snapshot = _tracker.GetProvisioningCategorySnapshot(); + var deviceSetupResolved = snapshot.TryGetValue("DeviceSetup", out var dsState) && dsState.HasValue + ? dsState.Value.ToString().ToLowerInvariant() + : "unknown"; + + _ingress.Post( + kind: DecisionSignalKind.DeviceSetupProvisioningComplete, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ProvisioningStatusTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "provisioning-status-tracker-v1", + summary: "DeviceSetupCategory provisioning completed", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\DeviceSetupCategory.Status", + ["deviceSetupResolved"] = deviceSetupResolved, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["deviceSetupResolved"] = deviceSetupResolved, + }); + } + + private void EmitEspFailure(string failureType) + { + if (_espFailurePosted) return; + _espFailurePosted = true; + + var safeFailureType = string.IsNullOrEmpty(failureType) ? "unknown" : failureType!; + + _ingress.Post( + kind: DecisionSignalKind.EspTerminalFailure, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ProvisioningStatusTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "provisioning-status-tracker-v1", + summary: $"ESP terminal failure from provisioning registry (type={safeFailureType})", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\*.Status", + ["failureType"] = safeFailureType, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["failureType"] = safeFailureType, + }); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ShellCoreTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ShellCoreTrackerAdapter.cs new file mode 100644 index 00000000..36107626 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ShellCoreTrackerAdapter.cs @@ -0,0 +1,139 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for — translates its 3 events into Decision-Signals. + /// Plan §2.1a / §2.2. + /// + /// Event mapping (Plan §2.2 DecisionSignalKind): + /// + /// FinalizingSetupPhaseTriggered → (phase=Finalizing) + /// WhiteGloveCompleted → + /// EspFailureDetected → + /// + /// + /// + /// Jeder Signaltyp ist fire-once (ShellCoreTracker selbst hat Dedup-Guards; der Adapter + /// führt zusätzlich pro-Kind-Flag-Dedup, damit doppelte Subscribe-Calls oder externe + /// Re-Entry-Szenarien nicht zweimal posten). + /// + /// + internal sealed class ShellCoreTrackerAdapter : IDisposable + { + private readonly ShellCoreTracker _tracker; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + + private bool _finalizingPosted; + private bool _whiteGloveSuccessPosted; + private bool _espFailurePosted; + + public ShellCoreTrackerAdapter( + ShellCoreTracker tracker, + ISignalIngressSink ingress, + IClock clock) + { + _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + + _tracker.FinalizingSetupPhaseTriggered += OnFinalizing; + _tracker.WhiteGloveCompleted += OnWhiteGloveCompleted; + _tracker.EspFailureDetected += OnEspFailure; + } + + public void Dispose() + { + _tracker.FinalizingSetupPhaseTriggered -= OnFinalizing; + _tracker.WhiteGloveCompleted -= OnWhiteGloveCompleted; + _tracker.EspFailureDetected -= OnEspFailure; + } + + private void OnFinalizing(object sender, string reason) => EmitFinalizing(reason); + private void OnWhiteGloveCompleted(object sender, EventArgs e) => EmitWhiteGloveSuccess(); + private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType); + + internal void TriggerFinalizingFromTest(string reason) => EmitFinalizing(reason); + internal void TriggerWhiteGloveCompletedFromTest() => EmitWhiteGloveSuccess(); + internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType); + + private void EmitFinalizing(string reason) + { + if (_finalizingPosted) return; + _finalizingPosted = true; + + _ingress.Post( + kind: DecisionSignalKind.EspPhaseChanged, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ShellCoreTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "shell-core-tracker-v1", + summary: "ESP Finalizing phase triggered (Shell-Core 62404/62407)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["eventSource"] = "Microsoft-Windows-Shell-Core", + ["phaseReason"] = reason ?? string.Empty, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.EspPhase] = EnrollmentPhase.FinalizingSetup.ToString(), + ["reason"] = reason ?? string.Empty, + }); + } + + private void EmitWhiteGloveSuccess() + { + if (_whiteGloveSuccessPosted) return; + _whiteGloveSuccessPosted = true; + + _ingress.Post( + kind: DecisionSignalKind.WhiteGloveShellCoreSuccess, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ShellCoreTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "shell-core-tracker-v1", + summary: "WhiteGlove sealing success observed (Shell-Core)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["eventSource"] = "Microsoft-Windows-Shell-Core", + ["detection"] = "WhiteGloveCompleted event", + })); + } + + private void EmitEspFailure(string failureType) + { + if (_espFailurePosted) return; + _espFailurePosted = true; + + var safeFailureType = string.IsNullOrEmpty(failureType) ? "unknown" : failureType!; + + _ingress.Post( + kind: DecisionSignalKind.EspTerminalFailure, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ShellCoreTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "shell-core-tracker-v1", + summary: $"ESP terminal failure detected (type={safeFailureType})", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["eventSource"] = "Microsoft-Windows-Shell-Core", + ["failureType"] = safeFailureType, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["failureType"] = safeFailureType, + }); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/StallProbeCollectorAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/StallProbeCollectorAdapter.cs new file mode 100644 index 00000000..e2e9ab32 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/StallProbeCollectorAdapter.cs @@ -0,0 +1,78 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for → . + /// Plan §2.1a / §2.2. + /// + /// StallProbe ist eine 60-Sekunden-Tick-basierte Anomalie-Erkennung mit 4 parallelen + /// Scan-Quellen (Provisioning-Registry, Diagnostics-Registry, Event-Logs, AppWorkload.log). + /// Die EspFailureDetected-Event ist der Terminal-Failure-Pfad — nur wenn alle + /// Quellen konvergieren auf eine Terminal-Situation (isTerminal=true in + /// ProbeResult). Adapter führt fire-once-Dedup; Source-Origin unterscheidet vom + /// ShellCore / Provisioning ESP-Failure-Pfaden. + /// + /// + internal sealed class StallProbeCollectorAdapter : IDisposable + { + private readonly StallProbeCollector _collector; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + private bool _failurePosted; + + public StallProbeCollectorAdapter( + StallProbeCollector collector, + ISignalIngressSink ingress, + IClock clock) + { + _collector = collector ?? throw new ArgumentNullException(nameof(collector)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + + _collector.EspFailureDetected += OnEspFailure; + } + + public void Dispose() + { + _collector.EspFailureDetected -= OnEspFailure; + } + + private void OnEspFailure(object sender, string terminalReason) => EmitFailure(terminalReason); + + internal void TriggerEspFailureFromTest(string terminalReason) => EmitFailure(terminalReason); + + private void EmitFailure(string terminalReason) + { + if (_failurePosted) return; + _failurePosted = true; + + var safeReason = string.IsNullOrEmpty(terminalReason) ? "unknown" : terminalReason!; + + _ingress.Post( + kind: DecisionSignalKind.EspTerminalFailure, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "StallProbeCollector", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "stall-probe-collector-v1", + summary: $"Stall-probe classified session as terminal (reason={safeReason})", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["detectionSource"] = "stall-probe 60s idle classifier (4 scan sources)", + ["terminalReason"] = safeReason, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["failureType"] = safeReason, + ["detector"] = "stall-probe", + }); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/BackPressureEventObserver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/BackPressureEventObserver.cs new file mode 100644 index 00000000..d457aadf --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/BackPressureEventObserver.cs @@ -0,0 +1,56 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events +{ + /// + /// Konkrete -Implementierung. Plan §2.1a. + /// + /// Mapping eines Back-Pressure-Ereignisses auf ein agent_trace-Event + /// mit EventType="ingress_backpressure" (). Delegiert an + /// . Die Throttling-Logik (1×/min/origin) liegt in + /// — hier wird jedes reingereichte Ereignis emittiert. + /// + /// + public sealed class BackPressureEventObserver : IBackPressureObserver + { + internal const string EventType = "ingress_backpressure"; + internal const string SourceId = "signal_ingress"; + + private readonly TelemetryEventEmitter _emitter; + private readonly IClock _clock; + + public BackPressureEventObserver(TelemetryEventEmitter emitter, IClock clock) + { + _emitter = emitter ?? throw new ArgumentNullException(nameof(emitter)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public void OnBackPressure(string origin, int channelCapacity, int queueLength, TimeSpan blockDuration) + { + var evt = new EnrollmentEvent + { + EventType = EventType, + Severity = EventSeverity.Warning, + Source = SourceId, + Phase = EnrollmentPhase.Unknown, + Message = $"Signal ingress back-pressure on origin '{origin}' (queue {queueLength}/{channelCapacity}, blocked {blockDuration.TotalMilliseconds:F0}ms)", + Timestamp = _clock.UtcNow, + ImmediateUpload = false, + Data = new Dictionary(StringComparer.Ordinal) + { + ["origin"] = origin, + ["channelCapacity"] = channelCapacity, + ["queueLength"] = queueLength, + ["blockDurationMs"] = (long)blockDuration.TotalMilliseconds, + }, + }; + + _emitter.Emit(evt); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequenceCounter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequenceCounter.cs new file mode 100644 index 00000000..8b6f17dd --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequenceCounter.cs @@ -0,0 +1,48 @@ +#nullable enable +using System; +using System.Threading; + +namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events +{ + /// + /// Thread-safe, persistenter Counter für EnrollmentEvent.Sequence. Plan §2.2 / §4.x M4.4.1. + /// + /// Sequences beginnen bei 1 (kompatibel mit Legacy-Wire-Format). Jeder -Call + /// inkrementiert atomar + persistiert sofort über . + /// + /// + /// Warum sofort-persist? Event-Raten sind niedrig (<1/s typical); die Cost für Disk-Flush + /// pro Event ist irrelevant gegenüber der Crash-Safety-Garantie: nach einem Crash darf keine + /// Sequence-Nummer doppelt vergeben werden (sonst Backend-RowKey-Kollision). + /// + /// + public sealed class EventSequenceCounter + { + private readonly EventSequencePersistence _persistence; + private readonly object _lock = new object(); + private long _lastAssigned; + + public EventSequenceCounter(EventSequencePersistence persistence) + { + _persistence = persistence ?? throw new ArgumentNullException(nameof(persistence)); + _lastAssigned = persistence.Load(); + } + + /// Zuletzt vergebener Wert. 0 bei leerem File. + public long LastAssigned + { + get { lock (_lock) return _lastAssigned; } + } + + /// Vergibt die nächste Sequence, persistiert vor Rückgabe. + public long Next() + { + lock (_lock) + { + _lastAssigned++; + _persistence.Save(_lastAssigned); + return _lastAssigned; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequencePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequencePersistence.cs new file mode 100644 index 00000000..dd2ac235 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequencePersistence.cs @@ -0,0 +1,99 @@ +#nullable enable +using System; +using System.IO; +using System.Text; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events +{ + /// + /// Atomarer Writer für event-sequence.json (enthält nur LastAssignedSequence). + /// Plan §2.2 / §4.x M4.4-Adjustments („analog "). + /// + /// Keine Checksum: bei Parse-Fehler wird der Counter auf 0 zurückgesetzt. + /// Konsequenz: im pathologischen Fall (komplett korruptes File) kann dieselbe + /// Sequence-Nummer nach Crash erneut vergeben werden. Backend-Ingest dedupliziert via + /// (PartitionKey, RowKey) — Doppel-Upload ist no-op (§2.7a Drain-Semantik). Die + /// RowKey-Dimension enthält Event.Sequence, daher bleibt der Effekt auf „doppeltes + /// Event wird einmal persistiert" begrenzt. + /// + /// + public sealed class EventSequencePersistence + { + private readonly string _path; + private readonly string _tempPath; + private readonly object _lock = new object(); + + public EventSequencePersistence(string path) + { + if (string.IsNullOrEmpty(path)) throw new ArgumentException("Path is mandatory.", nameof(path)); + _path = path; + _tempPath = path + ".tmp"; + + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + } + + /// Lädt den letzten persisted Sequence-Wert. 0 bei leerem / korruptem File. + public long Load() + { + lock (_lock) + { + if (!File.Exists(_path)) return 0; + try + { + var json = File.ReadAllText(_path, Encoding.UTF8); + var dto = JsonConvert.DeserializeObject(json); + return dto?.LastAssignedSequence ?? 0; + } + catch + { + return 0; + } + } + } + + /// + /// Atomarer Write: temp-File → . + /// + explicit Flush-to-disk (§2.7 L.12). + /// + public void Save(long lastAssignedSequence) + { + lock (_lock) + { + var dto = new SequenceDto { LastAssignedSequence = lastAssignedSequence }; + var json = JsonConvert.SerializeObject(dto); + var bytes = Encoding.UTF8.GetBytes(json); + + using (var fs = new FileStream( + _tempPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 512, + options: FileOptions.WriteThrough)) + { + fs.Write(bytes, 0, bytes.Length); + fs.Flush(flushToDisk: true); + } + + if (File.Exists(_path)) + { + File.Replace(_tempPath, _path, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + else + { + File.Move(_tempPath, _path); + } + } + } + + private sealed class SequenceDto + { + public long LastAssignedSequence { get; set; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs new file mode 100644 index 00000000..34803986 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs @@ -0,0 +1,94 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events +{ + /// + /// Konkrete -Implementierung. Plan §2.5 / §2.13 / L.10. + /// + /// Verwandelt EmitEventTimelineEntry-Effekte (Parameter-Dict mit eventType, + /// optional reason + scenario-spezifische Kontext-Keys) in einen + /// und delegiert an . + /// + /// + /// Phase-Invariante (feedback_phase_strategy): Terminal-Events deklarieren KEINEN + /// Phase-Übergang — bleibt Unknown, damit die + /// UI-Timeline die Events chronologisch in die aktive Phase einsortiert. + /// + /// + public sealed class EventTimelineEmitter : IEventTimelineEmitter + { + internal const string SourceId = "decision_engine"; + internal const string EventTypeParamKey = "eventType"; + + private readonly TelemetryEventEmitter _emitter; + + public EventTimelineEmitter(TelemetryEventEmitter emitter) + { + _emitter = emitter ?? throw new ArgumentNullException(nameof(emitter)); + } + + public void Emit( + IReadOnlyDictionary? parameters, + DecisionState currentState, + DateTime occurredAtUtc) + { + if (currentState == null) throw new ArgumentNullException(nameof(currentState)); + + if (parameters == null || !parameters.TryGetValue(EventTypeParamKey, out var eventType) || string.IsNullOrEmpty(eventType)) + { + throw new ArgumentException( + "EmitEventTimelineEntry effect must provide a non-empty 'eventType' parameter.", + nameof(parameters)); + } + + var evt = new EnrollmentEvent + { + SessionId = currentState.SessionId, + TenantId = currentState.TenantId, + EventType = eventType, + Severity = DeriveSeverity(eventType), + Source = SourceId, + Phase = EnrollmentPhase.Unknown, + Message = BuildMessage(eventType, parameters), + Timestamp = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), + ImmediateUpload = true, // terminal + classification events are always immediate + Data = BuildDataDict(parameters), + }; + + _emitter.Emit(evt); + } + + private static EventSeverity DeriveSeverity(string eventType) + { + if (eventType.EndsWith("_failed", StringComparison.Ordinal)) return EventSeverity.Error; + if (eventType.EndsWith("_aborted", StringComparison.Ordinal)) return EventSeverity.Warning; + return EventSeverity.Info; + } + + private static string BuildMessage(string eventType, IReadOnlyDictionary parameters) + { + if (parameters.TryGetValue("reason", out var reason) && !string.IsNullOrEmpty(reason)) + { + return $"{eventType}: {reason}"; + } + return eventType; + } + + private static Dictionary BuildDataDict(IReadOnlyDictionary parameters) + { + var data = new Dictionary(parameters.Count, StringComparer.Ordinal); + foreach (var kv in parameters) + { + // eventType is the top-level EnrollmentEvent.EventType, not a data key — don't duplicate. + if (kv.Key == EventTypeParamKey) continue; + data[kv.Key] = kv.Value; + } + return data; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs new file mode 100644 index 00000000..ebdabef1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs @@ -0,0 +1,80 @@ +#nullable enable +using System; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events +{ + /// + /// Zentrale Senke für alle -Emissionen im V2-Agent. + /// Plan §2.7a / L.10. + /// + /// Drei Aufrufer: (Reducer-Effekte), + /// Collector-Callbacks (via Orchestrator-Wiring in M4.4.5) und + /// . Alle nutzen denselben Pfad: + /// Sequence-Vergabe → Wire-Format-Serialisierung (Newtonsoft, Legacy-kompatibel) → + /// Kind=Event → . + /// + /// + /// Mutation: setzt , , + /// , falls leer. + /// Caller darf das Event nach nicht weiter verwenden. + /// + /// + public sealed class TelemetryEventEmitter + { + private readonly ITelemetryTransport _transport; + private readonly EventSequenceCounter _sequenceCounter; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly string _partitionKey; + + public TelemetryEventEmitter( + ITelemetryTransport transport, + EventSequenceCounter sequenceCounter, + string sessionId, + string tenantId) + { + if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId)); + if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId)); + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _sequenceCounter = sequenceCounter ?? throw new ArgumentNullException(nameof(sequenceCounter)); + _sessionId = sessionId; + _tenantId = tenantId; + _partitionKey = $"{tenantId}_{sessionId}"; + } + + /// + /// Weist Sequence zu, serialisiert im Legacy-Wire-Format und enqueued via Transport. + /// Thread-safe (EventSequenceCounter + ITelemetryTransport.Enqueue beide unter Lock). + /// + public TelemetryItem Emit(EnrollmentEvent evt) + { + if (evt == null) throw new ArgumentNullException(nameof(evt)); + if (string.IsNullOrEmpty(evt.EventType)) + { + throw new ArgumentException("EnrollmentEvent.EventType is mandatory.", nameof(evt)); + } + + evt.Sequence = _sequenceCounter.Next(); + if (string.IsNullOrEmpty(evt.SessionId)) evt.SessionId = _sessionId; + if (string.IsNullOrEmpty(evt.TenantId)) evt.TenantId = _tenantId; + + var rowKey = $"{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence:D10}"; + evt.RowKey = rowKey; + + var payloadJson = JsonConvert.SerializeObject(evt, Formatting.None); + + var draft = new TelemetryItemDraft( + kind: TelemetryItemKind.Event, + partitionKey: _partitionKey, + rowKey: rowKey, + payloadJson: payloadJson, + isSessionScoped: true, + requiresImmediateFlush: evt.ImmediateUpload); + + return _transport.Enqueue(draft); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Signals/TelemetrySignalEmitter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Signals/TelemetrySignalEmitter.cs new file mode 100644 index 00000000..c9739692 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Signals/TelemetrySignalEmitter.cs @@ -0,0 +1,71 @@ +#nullable enable +using System; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Signals; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Signals +{ + /// + /// Emitter that projects s onto the generic + /// for upload to the backend /api/agent/telemetry + /// endpoint (Plan §2.7a, M5.a Signals table). Sibling of + /// . + /// + /// Called by after SignalLog.Append has + /// committed locally — the local persistence is authoritative (§2.7c / L.1), the upload + /// is a best-effort projection. Enqueue failures are the caller's to swallow. + /// + /// + /// Wire format: Newtonsoft PascalCase with for + /// . Matches the backend-side shape + /// TelemetryPayloadParser.ParseSignal expects (SessionSignalOrdinal, + /// Kind, KindSchemaVersion, OccurredAtUtc, SourceOrigin, + /// SessionTraceOrdinal). + /// + /// + public sealed class TelemetrySignalEmitter + { + private static readonly JsonSerializerSettings WireFormatSettings = new JsonSerializerSettings + { + Converters = { new StringEnumConverter() }, + NullValueHandling = NullValueHandling.Ignore, + }; + + private readonly ITelemetryTransport _transport; + private readonly string _partitionKey; + + public TelemetrySignalEmitter(ITelemetryTransport transport, string sessionId, string tenantId) + { + if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId)); + if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId)); + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _partitionKey = $"{tenantId}_{sessionId}"; + } + + /// + /// Serialises the signal and enqueues it as a + /// draft. RowKey is the 19-digit zero-padded + /// — matches TableSignalRepository.BuildRowKey on the backend so Upsert collapses + /// duplicates. + /// + public TelemetryItem Emit(DecisionSignal signal) + { + if (signal == null) throw new ArgumentNullException(nameof(signal)); + + var payloadJson = JsonConvert.SerializeObject(signal, Formatting.None, WireFormatSettings); + var rowKey = signal.SessionSignalOrdinal.ToString("D19"); + + var draft = new TelemetryItemDraft( + kind: TelemetryItemKind.Signal, + partitionKey: _partitionKey, + rowKey: rowKey, + payloadJson: payloadJson, + isSessionScoped: true, + requiresImmediateFlush: false); + + return _transport.Enqueue(draft); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Transitions/TelemetryTransitionEmitter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Transitions/TelemetryTransitionEmitter.cs new file mode 100644 index 00000000..69ee6fa7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Transitions/TelemetryTransitionEmitter.cs @@ -0,0 +1,72 @@ +#nullable enable +using System; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Engine; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions +{ + /// + /// Emitter that projects s onto the generic + /// for upload to the backend /api/agent/telemetry + /// endpoint (Plan §2.7a, M5.a DecisionTransitions table). Sibling of + /// / . + /// + /// Called by after + /// Journal.Append(transition) has committed locally — Journal is the authoritative + /// store (§2.7c / L.1), upload is a best-effort projection. + /// + /// + /// Wire format: Newtonsoft PascalCase with for + /// SessionStage, HypothesisLevel, etc. Matches the backend shape + /// TelemetryPayloadParser.ParseTransition expects (StepIndex, + /// SessionTraceOrdinal, SignalOrdinalRef, Trigger, FromStage, + /// ToStage, Taken, DeadEndReason, ReducerVersion, optional + /// nested ClassifierVerdict { ClassifierId, Level }). + /// + /// + public sealed class TelemetryTransitionEmitter + { + private static readonly JsonSerializerSettings WireFormatSettings = new JsonSerializerSettings + { + Converters = { new StringEnumConverter() }, + NullValueHandling = NullValueHandling.Ignore, + }; + + private readonly ITelemetryTransport _transport; + private readonly string _partitionKey; + + public TelemetryTransitionEmitter(ITelemetryTransport transport, string sessionId, string tenantId) + { + if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId)); + if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId)); + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _partitionKey = $"{tenantId}_{sessionId}"; + } + + /// + /// Serialises the transition and enqueues it as a + /// draft. RowKey is the 10-digit + /// zero-padded — matches + /// TableDecisionTransitionRepository.BuildRowKey on the backend. + /// + public TelemetryItem Emit(DecisionTransition transition) + { + if (transition == null) throw new ArgumentNullException(nameof(transition)); + + var payloadJson = JsonConvert.SerializeObject(transition, Formatting.None, WireFormatSettings); + var rowKey = transition.StepIndex.ToString("D10"); + + var draft = new TelemetryItemDraft( + kind: TelemetryItemKind.DecisionTransition, + partitionKey: _partitionKey, + rowKey: rowKey, + payloadJson: payloadJson, + isSessionScoped: true, + requiresImmediateFlush: false); + + return _transport.Enqueue(draft); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs new file mode 100644 index 00000000..9edff8da --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs @@ -0,0 +1,257 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Agent.V2.Core.Termination +{ + /// + /// Orchestrates peripheral termination work in response to + /// . Plan §4.x M4.6.β. + /// + /// Sequence (Legacy parity — single-writer, best-effort each step, never throws): + /// + /// + /// Compose via . + /// Write final-status.json + launch SummaryDialog via . + /// Upload diagnostics via (respects + /// DiagnosticsUploadMode: Off / Always / OnFailure). + /// Write enrollment-complete.marker (so ghost-restart detection on next boot + /// exits cleanly even if failed). + /// Run — UNLESS the stage is + /// (Part 1 exit, session resumes Part 2). + /// Signal the caller-owned shutdown so Program.cs' + /// main loop exits the shutdown.Wait() and calls orchestrator.Stop(). + /// + /// + /// Each step logs + continues on failure — nothing in here is allowed to prevent the agent + /// from shutting down. CleanupService is fire-and-forget (spawns a PowerShell cleanup script + /// that waits for process exit); this handler does not block waiting for the script. + /// + /// + public sealed class EnrollmentTerminationHandler + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + private readonly string _stateDirectory; + private readonly DateTime _agentStartTimeUtc; + private readonly Func _currentStateAccessor; + private readonly Func _packageStatesAccessor; + private readonly Func _cleanupServiceFactory; + private readonly Func> _uploadDiagnosticsAsync; + private readonly Action _signalShutdown; + private readonly string _dialogExePathOverride; + private readonly AgentAnalyzerManager _analyzerManager; + + private int _handled; + + public EnrollmentTerminationHandler( + AgentConfiguration configuration, + AgentLogger logger, + string stateDirectory, + DateTime agentStartTimeUtc, + Func currentStateAccessor, + Func packageStatesAccessor, + Func cleanupServiceFactory, + Func> uploadDiagnosticsAsync, + Action signalShutdown, + string dialogExePathOverride = null, + AgentAnalyzerManager analyzerManager = null) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _stateDirectory = string.IsNullOrEmpty(stateDirectory) ? throw new ArgumentNullException(nameof(stateDirectory)) : stateDirectory; + _agentStartTimeUtc = agentStartTimeUtc; + _currentStateAccessor = currentStateAccessor ?? throw new ArgumentNullException(nameof(currentStateAccessor)); + _packageStatesAccessor = packageStatesAccessor ?? throw new ArgumentNullException(nameof(packageStatesAccessor)); + _cleanupServiceFactory = cleanupServiceFactory ?? throw new ArgumentNullException(nameof(cleanupServiceFactory)); + _uploadDiagnosticsAsync = uploadDiagnosticsAsync ?? throw new ArgumentNullException(nameof(uploadDiagnosticsAsync)); + _signalShutdown = signalShutdown ?? throw new ArgumentNullException(nameof(signalShutdown)); + _dialogExePathOverride = dialogExePathOverride; + _analyzerManager = analyzerManager; + } + + /// + /// Handler for . Idempotent — runs at most once. + /// + public void Handle(object sender, EnrollmentTerminatedEventArgs args) + { + if (Interlocked.Exchange(ref _handled, 1) == 1) return; + + try + { + _logger.Info( + $"EnrollmentTerminationHandler: handling Terminated (reason={args.Reason}, outcome={args.Outcome}, stage={args.StageName})."); + + var state = TryGetCurrentState(); + + // M4.6.δ — shutdown analyzers run BEFORE the dialog / diagnostics upload so + // their delta events make it into the final diagnostics ZIP. The + // AnalyzerManager is optional (null in tests where analyzers are out-of-scope). + RunShutdownAnalyzers(args); + + if (state == null) + { + _logger.Warning("EnrollmentTerminationHandler: current state unavailable — skipping FinalStatus + SummaryDialog."); + } + else + { + RunBuildAndLaunchDialog(state, args); + } + + RunUploadDiagnostics(args); + WriteEnrollmentCompleteMarker(args); + RunSelfDestructIfAppropriate(args); + } + catch (Exception ex) + { + _logger.Error("EnrollmentTerminationHandler: unhandled exception during termination sequence.", ex); + } + finally + { + try { _signalShutdown(); } + catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: signalShutdown threw: {ex.Message}"); } + } + } + + private DecisionState TryGetCurrentState() + { + try { return _currentStateAccessor(); } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: current state accessor threw: {ex.Message}"); + return null; + } + } + + private AppPackageStateList TryGetPackageStates() + { + try { return _packageStatesAccessor(); } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: package states accessor threw: {ex.Message}"); + return null; + } + } + + private void RunShutdownAnalyzers(EnrollmentTerminatedEventArgs args) + { + if (_analyzerManager == null) return; + try + { + // WhiteGlove Part 1 exit passes whiteGlovePart=1 so SoftwareInventoryAnalyzer + // takes a baseline snapshot rather than computing a pre/post delta (the user + // sign-in phase has not run yet; the real delta computes on Part-2 completion). + int? wgPart = args.StageName == SessionStage.WhiteGloveSealed.ToString() ? 1 : (int?)null; + _analyzerManager.RunShutdown(wgPart); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: analyzer shutdown threw: {ex.Message}"); + } + } + + private void RunBuildAndLaunchDialog(DecisionState state, EnrollmentTerminatedEventArgs args) + { + try + { + var packages = TryGetPackageStates(); + var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc); + SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: FinalStatus/SummaryDialog step failed: {ex.Message}"); + } + } + + private void RunUploadDiagnostics(EnrollmentTerminatedEventArgs args) + { + var mode = _configuration.DiagnosticsUploadMode ?? "Off"; + if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) + { + _logger.Debug("EnrollmentTerminationHandler: diagnostics upload skipped (disabled or mode=Off)."); + return; + } + + var enrollmentSucceeded = args.Outcome == EnrollmentTerminationOutcome.Succeeded; + if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded) + { + _logger.Debug("EnrollmentTerminationHandler: diagnostics upload skipped (mode=OnFailure + enrollment succeeded)."); + return; + } + + try + { + var suffix = enrollmentSucceeded ? "success" : "failure"; + var result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult(); + if (result != null && result.Success) + _logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName})."); + else + _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload returned failure: {result?.ErrorCode ?? "null-result"}."); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload threw: {ex.Message}"); + } + } + + private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args) + { + // On WhiteGlove Part 1 exit we keep the session alive for Part 2 — DO NOT write the + // marker (ghost-restart detection would fire + destroy the in-flight session state). + if (args.StageName == SessionStage.WhiteGloveSealed.ToString()) + { + _logger.Info("EnrollmentTerminationHandler: WhiteGlove Part 1 exit — enrollment-complete marker NOT written."); + return; + } + + try + { + Directory.CreateDirectory(_stateDirectory); + var markerPath = Path.Combine(_stateDirectory, "enrollment-complete.marker"); + File.WriteAllText(markerPath, + $"Terminated at {args.TerminatedAtUtc:O} (reason={args.Reason}, outcome={args.Outcome}, stage={args.StageName})."); + _logger.Info($"EnrollmentTerminationHandler: enrollment-complete.marker written to {markerPath}."); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTerminationHandler: enrollment-complete.marker write failed: {ex.Message}"); + } + } + + private void RunSelfDestructIfAppropriate(EnrollmentTerminatedEventArgs args) + { + if (!_configuration.SelfDestructOnComplete) + { + _logger.Info("EnrollmentTerminationHandler: SelfDestructOnComplete=false — cleanup skipped."); + return; + } + + if (args.StageName == SessionStage.WhiteGloveSealed.ToString()) + { + _logger.Info("EnrollmentTerminationHandler: WhiteGlove Part 1 exit — cleanup skipped (session resumes on reboot)."); + return; + } + + try + { + var service = _cleanupServiceFactory(); + service.ExecuteSelfDestruct(); + _logger.Info("EnrollmentTerminationHandler: CleanupService.ExecuteSelfDestruct() invoked (fire-and-forget)."); + } + catch (Exception ex) + { + _logger.Error("EnrollmentTerminationHandler: cleanup service invocation threw.", ex); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs new file mode 100644 index 00000000..bac6025b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Termination +{ + /// + /// Wire-format DTO consumed by AutopilotMonitor.SummaryDialog.exe. Plan §4.x M4.6.β. + /// + /// Mirrors AutopilotMonitor.SummaryDialog.Models.FinalStatus — the dialog deserializes + /// this JSON on launch and renders the summary. We cannot reference the dialog project + /// directly (WPF net48 WinExe), so the shape is duplicated here. Keep the JSON property names + /// in lockstep with the dialog — changes here require a corresponding update in the dialog. + /// + /// + public sealed class FinalStatus + { + [JsonProperty("timestamp")] + public string Timestamp { get; set; } + + [JsonProperty("outcome")] + public string Outcome { get; set; } + + [JsonProperty("completionSource")] + public string CompletionSource { get; set; } + + [JsonProperty("helloOutcome")] + public string HelloOutcome { get; set; } + + [JsonProperty("enrollmentType")] + public string EnrollmentType { get; set; } + + [JsonProperty("agentUptimeSeconds")] + public double AgentUptimeSeconds { get; set; } + + [JsonProperty("signalsSeen")] + public List SignalsSeen { get; set; } = new List(); + + [JsonProperty("appSummary")] + public FinalStatusAppSummary AppSummary { get; set; } = new FinalStatusAppSummary(); + + [JsonProperty("packageStatesByPhase")] + public Dictionary> PackageStatesByPhase { get; set; } = + new Dictionary>(); + } + + public sealed class FinalStatusAppSummary + { + [JsonProperty("totalApps")] + public int TotalApps { get; set; } + + [JsonProperty("completedApps")] + public int CompletedApps { get; set; } + + [JsonProperty("errorCount")] + public int ErrorCount { get; set; } + + [JsonProperty("deviceErrors")] + public int DeviceErrors { get; set; } + + [JsonProperty("userErrors")] + public int UserErrors { get; set; } + + [JsonProperty("appsByPhase")] + public Dictionary AppsByPhase { get; set; } = new Dictionary(); + } + + public sealed class FinalStatusPackageInfo + { + [JsonProperty("appName")] + public string AppName { get; set; } + + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("isError")] + public bool IsError { get; set; } + + [JsonProperty("isCompleted")] + public bool IsCompleted { get; set; } + + [JsonProperty("targeted")] + public string Targeted { get; set; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs new file mode 100644 index 00000000..6f76262d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs @@ -0,0 +1,145 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.Agent.V2.Core.Termination +{ + /// + /// Composes a snapshot from the kernel + /// plus the IME tracker's live . Plan §4.x M4.6.β. + /// + /// Pure function: no I/O, no side effects. The writer () + /// takes the built DTO and serialises it to JSON at the path the dialog reads. Splitting + /// builder from writer keeps this testable without touching the file system. + /// + /// + public static class FinalStatusBuilder + { + /// + /// Constructs a from the orchestrator outcome plus a live snapshot + /// of the IME package state list (may be null when the IME host was not started — + /// the summary then reports an empty app section). + /// + public static FinalStatus Build( + DecisionState state, + EnrollmentTerminatedEventArgs terminated, + AppPackageStateList? packageStates, + DateTime agentStartTimeUtc) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + if (terminated == null) throw new ArgumentNullException(nameof(terminated)); + + var uptimeSeconds = Math.Max(0, (terminated.TerminatedAtUtc - agentStartTimeUtc).TotalSeconds); + + var status = new FinalStatus + { + Timestamp = terminated.TerminatedAtUtc.ToString("O"), + Outcome = MapOutcome(terminated.Outcome, state.Stage), + CompletionSource = terminated.Reason.ToString(), + HelloOutcome = state.HelloOutcome?.Value ?? "unknown", + EnrollmentType = state.EnrollmentType?.Reason ?? state.EnrollmentType?.Level.ToString() ?? "unknown", + AgentUptimeSeconds = uptimeSeconds, + SignalsSeen = BuildSignalsSeen(state), + AppSummary = BuildAppSummary(packageStates), + PackageStatesByPhase = BuildPackageStatesByPhase(packageStates), + }; + + return status; + } + + private static string MapOutcome(EnrollmentTerminationOutcome outcome, SessionStage stage) + { + if (stage == SessionStage.WhiteGloveSealed) return "whiteglove_part1"; + if (stage == SessionStage.WhiteGloveCompletedPart2) return "whiteglove_part2"; + switch (outcome) + { + case EnrollmentTerminationOutcome.Succeeded: return "succeeded"; + case EnrollmentTerminationOutcome.Failed: return "failed"; + case EnrollmentTerminationOutcome.TimedOut: return "timed_out"; + default: return "unknown"; + } + } + + private static List BuildSignalsSeen(DecisionState state) + { + var signals = new List(); + if (state.EspFinalExitUtc != null) signals.Add("esp_final_exit"); + if (state.HelloResolvedUtc != null) signals.Add("hello_resolved"); + if (state.DesktopArrivedUtc != null) signals.Add("desktop_arrived"); + if (state.SystemRebootUtc != null) signals.Add("system_reboot"); + if (state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) signals.Add("aad_user_joined"); + if (state.ShellCoreWhiteGloveSuccessSeen != null && state.ShellCoreWhiteGloveSuccessSeen.Value) + signals.Add("whiteglove_shellcore_success"); + if (state.WhiteGloveSealingPatternSeen != null && state.WhiteGloveSealingPatternSeen.Value) + signals.Add("whiteglove_sealing_pattern"); + if (state.ImeMatchedPatternId != null) signals.Add($"ime_pattern:{state.ImeMatchedPatternId.Value}"); + if (state.UserAadSignInCompleteUtc != null) signals.Add("part2_user_aad_signin"); + if (state.HelloResolvedPart2Utc != null) signals.Add("part2_hello_resolved"); + if (state.DesktopArrivedPart2Utc != null) signals.Add("part2_desktop_arrived"); + if (state.AccountSetupCompletedPart2Utc != null) signals.Add("part2_account_setup_completed"); + return signals; + } + + private static FinalStatusAppSummary BuildAppSummary(AppPackageStateList? packageStates) + { + var summary = new FinalStatusAppSummary(); + if (packageStates == null || packageStates.Count == 0) return summary; + + foreach (var pkg in packageStates) + { + summary.TotalApps++; + if (IsCompleted(pkg.InstallationState)) summary.CompletedApps++; + if (pkg.InstallationState == AppInstallationState.Error) + { + summary.ErrorCount++; + if (pkg.Targeted == AppTargeted.Device) summary.DeviceErrors++; + else if (pkg.Targeted == AppTargeted.User) summary.UserErrors++; + } + + var phaseKey = pkg.Targeted.ToString(); + if (!summary.AppsByPhase.TryGetValue(phaseKey, out var cnt)) cnt = 0; + summary.AppsByPhase[phaseKey] = cnt + 1; + } + + return summary; + } + + private static Dictionary> BuildPackageStatesByPhase( + AppPackageStateList? packageStates) + { + var result = new Dictionary>(); + if (packageStates == null) return result; + + foreach (var pkg in packageStates) + { + var phaseKey = pkg.Targeted.ToString(); + if (!result.TryGetValue(phaseKey, out var bucket)) + { + bucket = new List(); + result[phaseKey] = bucket; + } + + bucket.Add(new FinalStatusPackageInfo + { + AppName = string.IsNullOrEmpty(pkg.Name) ? pkg.Id : pkg.Name, + State = pkg.InstallationState.ToString(), + IsError = pkg.InstallationState == AppInstallationState.Error, + IsCompleted = IsCompleted(pkg.InstallationState), + Targeted = pkg.Targeted.ToString(), + }); + } + + return result; + } + + private static bool IsCompleted(AppInstallationState s) => + s == AppInstallationState.Installed + || s == AppInstallationState.Skipped + || s == AppInstallationState.Postponed + || s == AppInstallationState.Error; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs new file mode 100644 index 00000000..573f55d0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Reflection; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Termination +{ + /// + /// Launches AutopilotMonitor.SummaryDialog.exe in the signed-in user's session + /// with a freshly-written final-status.json. Plan §4.x M4.6.β. + /// + /// Sequence (Legacy parity): + /// + /// + /// Resolve the dialog exe next to the agent binary (Assembly.Location). + /// Write the final-status.json to the agent state directory. + /// Copy exe + dependencies + final-status.json to a per-launch temp directory under + /// %ProgramData%\AutopilotMonitor-Summary\ (the dialog deletes itself via --cleanup). + /// Grant the signed-in user delete permission on that temp directory so the dialog's + /// self-cleanup step works (runs as user). + /// Invoke with the right args. + /// + /// + /// Fire-and-forget: the launcher does not wait for the dialog to exit. Returns false + /// on any failure, never throws. Result is logged by the caller so a dialog failure never + /// blocks the cleanup/shutdown path. + /// + /// + public static class SummaryDialogLauncher + { + internal const string DialogExeName = "AutopilotMonitor.SummaryDialog.exe"; + internal const string DialogConfigName = "AutopilotMonitor.SummaryDialog.exe.config"; + internal const string NewtonsoftDependency = "Newtonsoft.Json.dll"; + internal const string SummaryTempRootEnvVar = "AutopilotMonitor-Summary"; + internal const string FinalStatusFileName = "final-status.json"; + + /// + /// Writes to /final-status.json + /// and launches the dialog. When is + /// false, only the JSON is written (skipping the launch) — tests and backend-consumable + /// audit trail benefit from always having the file on disk. + /// + /// true when both write and launch (if enabled) succeeded. + public static bool WriteAndLaunch( + FinalStatus status, + AgentConfiguration configuration, + string stateDirectory, + AgentLogger logger, + string dialogExePathOverride = null) + { + if (status == null) throw new ArgumentNullException(nameof(status)); + if (configuration == null) throw new ArgumentNullException(nameof(configuration)); + if (string.IsNullOrEmpty(stateDirectory)) throw new ArgumentException("stateDirectory required.", nameof(stateDirectory)); + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + // 1) Write final-status.json always (cheap, useful for audit even if dialog skipped). + string statusPath; + try + { + Directory.CreateDirectory(stateDirectory); + statusPath = Path.Combine(stateDirectory, FinalStatusFileName); + File.WriteAllText(statusPath, JsonConvert.SerializeObject(status, Formatting.Indented)); + logger.Info($"SummaryDialogLauncher: final-status.json written to {statusPath}."); + } + catch (Exception ex) + { + logger.Warning($"SummaryDialogLauncher: final-status.json write failed: {ex.Message}"); + return false; + } + + if (!configuration.ShowEnrollmentSummary) + { + logger.Info("SummaryDialogLauncher: ShowEnrollmentSummary=false — skipping dialog launch."); + return true; + } + + // 2) Resolve dialog exe. + var dialogExe = dialogExePathOverride; + if (string.IsNullOrEmpty(dialogExe)) + { + var agentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty; + dialogExe = Path.Combine(agentDir, DialogExeName); + } + + if (!File.Exists(dialogExe)) + { + logger.Warning($"SummaryDialogLauncher: dialog exe not found at '{dialogExe}' — skipping launch."); + return false; + } + + // 3) Copy to temp dir (dialog self-deletes via --cleanup). + string tempDir; + string tempExe; + try + { + tempDir = Path.Combine(Path.GetTempPath(), SummaryTempRootEnvVar, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + + tempExe = Path.Combine(tempDir, DialogExeName); + File.Copy(dialogExe, tempExe, overwrite: true); + + var sourceDir = Path.GetDirectoryName(dialogExe) ?? string.Empty; + TryCopy(Path.Combine(sourceDir, DialogConfigName), Path.Combine(tempDir, DialogConfigName)); + TryCopy(Path.Combine(sourceDir, NewtonsoftDependency), Path.Combine(tempDir, NewtonsoftDependency)); + + var tempStatusFile = Path.Combine(tempDir, FinalStatusFileName); + File.Copy(statusPath, tempStatusFile, overwrite: true); + } + catch (Exception ex) + { + logger.Warning($"SummaryDialogLauncher: temp dir setup failed: {ex.Message}"); + return false; + } + + // 4) Compose args. Legacy contract: --status-file --timeout --cleanup [--branding-url ]. + var tempStatusArg = Path.Combine(tempDir, FinalStatusFileName); + var args = $"--status-file \"{tempStatusArg}\" --timeout {configuration.EnrollmentSummaryTimeoutSeconds} --cleanup"; + if (!string.IsNullOrWhiteSpace(configuration.EnrollmentSummaryBrandingImageUrl)) + args += $" --branding-url \"{configuration.EnrollmentSummaryBrandingImageUrl}\""; + + // 5) Launch in user session with retry. + var retrySeconds = configuration.EnrollmentSummaryLaunchRetrySeconds > 0 + ? configuration.EnrollmentSummaryLaunchRetrySeconds + : 120; + + var launched = UserSessionProcessLauncher.LaunchInUserSession(tempExe, args, logger, retrySeconds); + if (launched) + logger.Info($"SummaryDialogLauncher: dialog launched (exe='{tempExe}', retrySeconds={retrySeconds})."); + else + logger.Warning("SummaryDialogLauncher: dialog launch failed or no user session available."); + + return launched; + } + + private static void TryCopy(string source, string destination) + { + try { if (File.Exists(source)) File.Copy(source, destination, overwrite: true); } + catch { /* best-effort — the dialog degrades gracefully on missing config/deps */ } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/BackendTelemetryUploader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/BackendTelemetryUploader.cs new file mode 100644 index 00000000..17d9f62a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/BackendTelemetryUploader.cs @@ -0,0 +1,318 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Produktions-. Plan §2.7a / §4.x M4.4.2. + /// + /// HTTP-Skelett gegen POST /api/agent/telemetry. Sendet einen Batch von + /// s als JSON-Array; Backend routet pro Item nach + /// in die Ziel-Tabelle (Events / Signals / + /// DecisionTransitions) und dedupliziert via (PartitionKey, RowKey). + /// + /// + /// Retry/Back-off übernimmt (M4.1) — dieser + /// Uploader macht genau einen HTTP-Call und mapped die Response auf + /// . + /// + /// + /// mTLS: Client-Zertifikat wird über den vom Caller eingerichteten + /// () gesendet — + /// dieser Uploader ist cert-agnostisch. Tests injizieren einen + /// mit Fake-. + /// + /// + public sealed class BackendTelemetryUploader : IBackendTelemetryUploader + { + private readonly HttpClient _httpClient; + private readonly string _endpointUrl; + private readonly string _tenantId; + private readonly string? _manufacturer; + private readonly string? _model; + private readonly string? _serialNumber; + private readonly string? _bootstrapToken; + private readonly string? _agentVersion; + + public BackendTelemetryUploader( + HttpClient httpClient, + string baseUrl, + string tenantId, + string? manufacturer = null, + string? model = null, + string? serialNumber = null, + string? bootstrapToken = null, + string? agentVersion = null) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + if (string.IsNullOrEmpty(baseUrl)) throw new ArgumentException("BaseUrl is mandatory.", nameof(baseUrl)); + if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId)); + + _endpointUrl = baseUrl.TrimEnd('/') + Constants.ApiEndpoints.IngestTelemetry; + _tenantId = tenantId; + _manufacturer = manufacturer; + _model = model; + _serialNumber = serialNumber; + _bootstrapToken = bootstrapToken; + _agentVersion = agentVersion; + } + + public async Task UploadBatchAsync( + IReadOnlyList items, + CancellationToken cancellationToken) + { + if (items == null) throw new ArgumentNullException(nameof(items)); + + // Empty batch: no-op success. Avoids burning a round-trip when the spool pulls an + // empty page (possible in race conditions after a concurrent drain succeeded). + if (items.Count == 0) return UploadResult.Ok(); + + // Bandwidth: the agent ships on low-bandwidth links (tethered 4G, remote OOBE), + // so the body is gzip-compressed on the wire. ~70 % reduction for JSON-heavy + // telemetry batches (matches legacy IngestEventsAsync). Response-side gzip is + // handled transparently by HttpClientHandler.AutomaticDecompression in + // MtlsHttpClientFactory — we only need to compress outbound here. + var body = SerializeBatch(items); + var compressedBody = CompressWithGzip(body); + using (var request = new HttpRequestMessage(HttpMethod.Post, _endpointUrl)) + { + request.Content = new ByteArrayContent(compressedBody); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + request.Content.Headers.ContentEncoding.Add("gzip"); + AddSecurityHeaders(request); + + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Caller-initiated cancellation — propagate; the orchestrator's drain + // loop will stop entirely rather than schedule a retry. + throw; + } + catch (TaskCanceledException ex) + { + // HttpClient timeout (not caller cancellation) surfaces as TaskCanceledException + // with cancellationToken.IsCancellationRequested == false. + return UploadResult.Transient($"timeout: {ex.Message}"); + } + catch (HttpRequestException ex) + { + // DNS failure, socket error, etc. — network-layer is retryable. + return UploadResult.Transient($"network: {ex.Message}"); + } + + using (response) + { + return await MapResponseAsync(response).ConfigureAwait(false); + } + } + } + + private static async Task MapResponseAsync(HttpResponseMessage response) + { + var statusCode = (int)response.StatusCode; + + if (response.IsSuccessStatusCode) + { + // M4.6.ε — parse backend-to-agent control signals from the 2xx response body. + // Body is best-effort JSON; anything unparseable degrades cleanly to plain Ok(). + return await TryReadControlSignalsAsync(response).ConfigureAwait(false); + } + + var reason = await TryReadReasonAsync(response).ConfigureAwait(false); + var shortReason = $"http {statusCode}{(string.IsNullOrEmpty(reason) ? string.Empty : ": " + reason)}"; + + switch (response.StatusCode) + { + case HttpStatusCode.Unauthorized: // 401 + case HttpStatusCode.Forbidden: // 403 + return UploadResult.Permanent($"unauthorized: {shortReason}"); + + case HttpStatusCode.RequestTimeout: // 408 + case (HttpStatusCode)429: // TooManyRequests — enum available net48 SP1+, cast for safety + return UploadResult.Transient(shortReason); + } + + if (statusCode >= 500 && statusCode <= 599) return UploadResult.Transient(shortReason); + if (statusCode >= 400 && statusCode <= 499) return UploadResult.Permanent(shortReason); + + // Unexpected range (1xx, 3xx). Treat as transient — orchestrator retries and logs. + return UploadResult.Transient(shortReason); + } + + private static async Task TryReadReasonAsync(HttpResponseMessage response) + { + try + { + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (string.IsNullOrEmpty(body)) return string.Empty; + return body.Length <= 200 ? body : body.Substring(0, 200); + } + catch + { + return string.Empty; + } + } + + /// + /// M4.6.ε — parse backend-to-agent control signals from a 2xx batch-response body. Fields + /// are optional: an empty body or a body without any of these fields is treated as + /// a plain . Parse failures are logged into the response + /// surface as a plain Ok — we never fail an upload over a malformed response body. + /// + /// Recognised field names (case-insensitive, PascalCase and camelCase both accepted to + /// match Legacy IngestEventsResponse and a future M5 batch response): + /// deviceBlocked (bool), unblockAt (ISO-8601 UTC), deviceKillSignal + /// (bool), adminAction (string), actions ([]). + /// + /// + private static async Task TryReadControlSignalsAsync(HttpResponseMessage response) + { + string body; + try { body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); } + catch { return UploadResult.Ok(); } + + if (string.IsNullOrWhiteSpace(body)) return UploadResult.Ok(); + + JObject? root; + try { root = JObject.Parse(body); } + catch { return UploadResult.Ok(); } + if (root == null) return UploadResult.Ok(); + + var deviceBlocked = TryBool(root, "deviceBlocked", "DeviceBlocked") ?? false; + var unblockAt = TryDateTime(root, "unblockAt", "UnblockAt"); + var deviceKillSignal = TryBool(root, "deviceKillSignal", "DeviceKillSignal") ?? false; + var adminAction = TryString(root, "adminAction", "AdminAction"); + var actions = TryActions(root); + + var carriesSignal = deviceBlocked + || unblockAt.HasValue + || deviceKillSignal + || !string.IsNullOrEmpty(adminAction) + || (actions != null && actions.Count > 0); + + return carriesSignal + ? UploadResult.OkWithSignals( + deviceBlocked: deviceBlocked, + unblockAt: unblockAt, + deviceKillSignal: deviceKillSignal, + adminAction: string.IsNullOrEmpty(adminAction) ? null : adminAction, + actions: actions) + : UploadResult.Ok(); + } + + private static bool? TryBool(JObject root, params string[] names) + { + foreach (var n in names) + { + var tok = root[n]; + if (tok == null) continue; + if (tok.Type == JTokenType.Boolean) return tok.Value(); + if (tok.Type == JTokenType.String && bool.TryParse(tok.Value(), out var parsed)) return parsed; + } + return null; + } + + private static string? TryString(JObject root, params string[] names) + { + foreach (var n in names) + { + var tok = root[n]; + if (tok == null) continue; + if (tok.Type == JTokenType.String) return tok.Value(); + } + return null; + } + + private static DateTime? TryDateTime(JObject root, params string[] names) + { + foreach (var n in names) + { + var tok = root[n]; + if (tok == null) continue; + if (tok.Type == JTokenType.Date) + { + var v = tok.Value(); + return v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(); + } + if (tok.Type == JTokenType.String) + { + var raw = tok.Value(); + if (DateTime.TryParse(raw, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) + return parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime(); + } + } + return null; + } + + private static System.Collections.Generic.List? TryActions(JObject root) + { + var tok = root["actions"] ?? root["Actions"]; + if (tok == null || tok.Type != JTokenType.Array) return null; + + try + { + var list = tok.ToObject>(); + return list == null || list.Count == 0 ? null : list; + } + catch + { + return null; + } + } + + private static string SerializeBatch(IReadOnlyList items) + { + // Newtonsoft PascalCase default + TelemetryItemKind carries [StringEnumConverter] → + // Backend routes by string "Event"/"Signal"/"DecisionTransition". Wire-format + // matches Plan §4.x M5 contract (PascalCase, StringEnum, ISO-8601 UTC). + return JsonConvert.SerializeObject(items, Formatting.None); + } + + private static byte[] CompressWithGzip(string text) + { + var bytes = Encoding.UTF8.GetBytes(text); + using (var output = new MemoryStream()) + { + using (var gzip = new GZipStream(output, CompressionMode.Compress)) + { + gzip.Write(bytes, 0, bytes.Length); + } + return output.ToArray(); + } + } + + private void AddSecurityHeaders(HttpRequestMessage request) + { + // X-Tenant-Id lets the backend run auth checks before parsing the body. + request.Headers.Add("X-Tenant-Id", _tenantId); + + // Hardware headers for whitelist/Autopilot validation. + if (!string.IsNullOrEmpty(_manufacturer)) request.Headers.Add("X-Device-Manufacturer", _manufacturer); + if (!string.IsNullOrEmpty(_model)) request.Headers.Add("X-Device-Model", _model); + if (!string.IsNullOrEmpty(_serialNumber)) request.Headers.Add("X-Device-SerialNumber", _serialNumber); + + // Bootstrap token auth for pre-MDM agents (cert-less OOBE phase). + if (!string.IsNullOrEmpty(_bootstrapToken)) request.Headers.Add("X-Bootstrap-Token", _bootstrapToken); + + // Agent version for version-based kill-switch + server-side diagnostics. + if (!string.IsNullOrEmpty(_agentVersion)) request.Headers.Add("X-Agent-Version", _agentVersion); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/DrainResult.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/DrainResult.cs new file mode 100644 index 00000000..e7d4bc9e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/DrainResult.cs @@ -0,0 +1,24 @@ +#nullable enable +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Aggregat-Ergebnis eines -Laufs. Plan §2.7a. + /// + public sealed class DrainResult + { + public DrainResult(int uploadedItems, int failedBatches, string? lastErrorReason) + { + UploadedItems = uploadedItems; + FailedBatches = failedBatches; + LastErrorReason = lastErrorReason; + } + + public int UploadedItems { get; } + public int FailedBatches { get; } + public string? LastErrorReason { get; } + + public bool Success => FailedBatches == 0; + + public static DrainResult Empty() => new DrainResult(0, 0, null); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/IBackendTelemetryUploader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/IBackendTelemetryUploader.cs new file mode 100644 index 00000000..43007de9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/IBackendTelemetryUploader.cs @@ -0,0 +1,23 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Abstraction über den Backend-Aufruf zur Batch-Telemetry-Übertragung. Plan §2.7a. + /// + /// Produktionsimplementierung in BackendTelemetryUploader (ruft POST /api/agent/telemetry). + /// Tests verwenden einen Fake-Uploader — der Orchestrator ist HTTP-agnostisch. + /// + /// + public interface IBackendTelemetryUploader + { + /// + /// Sendet als einen Batch an das Backend. Backend-Ingest + /// ist idempotent via (PartitionKey, RowKey) — Doppel-Upload nach Retry ist no-op. + /// + Task UploadBatchAsync(IReadOnlyList items, CancellationToken cancellationToken); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/ITelemetrySpool.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/ITelemetrySpool.cs new file mode 100644 index 00000000..0c168336 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/ITelemetrySpool.cs @@ -0,0 +1,46 @@ +#nullable enable +using System.Collections.Generic; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Persistente Queue für s. Plan §2.7a. + /// + /// Invarianten: + /// + /// TelemetryItemId-Vergabe: streng monoton, single-writer — vergibt + /// den Cursor atomar unter Lock. + /// Sofort-Flush (L.12): jeder erfolgreiche Enqueue steht auf Disk bevor die Methode zurückkehrt. + /// Idempotenz: mit kleinerer/gleicher ID als der bereits + /// persistierte Cursor ist no-op (keine Regression bei Doppel-Aufrufen). + /// + /// + /// + public interface ITelemetrySpool + { + /// + /// Persistiert , vergibt TelemetryItemId (und — wenn + /// — SessionTraceOrdinal), gibt das fertige + /// zurück. + /// + TelemetryItem Enqueue(TelemetryItemDraft draft); + + /// + /// Liest alle noch nicht als uploaded markierten Items in TelemetryItemId-Reihenfolge, + /// maximal Stück. Liste kann leer sein. + /// + IReadOnlyList Peek(int max); + + /// + /// Markiert alle Items bis einschließlich als hochgeladen. + /// Monoton steigend — Aufrufe mit kleinerem Wert sind no-op (Idempotenz-Garantie). + /// + void MarkUploaded(long upToItemIdInclusive); + + /// Höchste bisher vergebene TelemetryItemId; -1 wenn Spool leer. + long LastAssignedItemId { get; } + + /// Cursor: bis zu dieser ID inklusive wurde bereits erfolgreich hochgeladen; -1 initial. + long LastUploadedItemId { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/ITelemetryTransport.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/ITelemetryTransport.cs new file mode 100644 index 00000000..0a39c696 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/ITelemetryTransport.cs @@ -0,0 +1,28 @@ +#nullable enable +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Einziger Transport-Einstiegspunkt im V2-Agent. Plan §2.7a / L.10. + /// + /// Vereint Enqueue (mit TelemetryItemId-Vergabe) und Drain (koordiniert + idempotent + resume-fähig). + /// + /// + public interface ITelemetryTransport : IDisposable + { + /// Persistiert im Spool und gibt das fertige zurück. + TelemetryItem Enqueue(TelemetryItemDraft draft); + + /// + /// Drainiert alle bisher enqueued Items in TelemetryItemId-Reihenfolge gegen das Backend. + /// Idempotent, resume-fähig. Wirft nicht bei transienten Fehlern — gibt zurück. + /// + Task DrainAllAsync(CancellationToken cancellationToken = default); + + /// Cursor für Crash-Recovery (§2.7 recovery-step 3); -1 wenn noch nichts hochgeladen. + long LastUploadedItemId { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItem.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItem.cs new file mode 100644 index 00000000..d2c33792 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItem.cs @@ -0,0 +1,107 @@ +#nullable enable +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Einheitliche Transport-Einheit für Events, Signals und DecisionTransitions. Plan §2.7a. + /// + /// Storage-Schlüssel ( / ) sind typspezifisch + /// (Signals = D10(SessionSignalOrdinal), Transitions = D6(StepIndex), Events = + /// bestehendes Schema) und gehen nur in die Azure-Table-Ablage. + /// + /// + /// Transport-Cursor () ist dagegen monoton über alle + /// Item-Typen innerhalb einer Session und die alleinige Größe für Retry, Back-Pressure + /// und Crash-Recovery. + /// + /// + public sealed class TelemetryItem + { + public TelemetryItem( + TelemetryItemKind kind, + string partitionKey, + string rowKey, + long telemetryItemId, + long? sessionTraceOrdinal, + string payloadJson, + bool requiresImmediateFlush, + DateTime enqueuedAtUtc, + int retryCount = 0) + { + if (string.IsNullOrEmpty(partitionKey)) + { + throw new ArgumentException("PartitionKey is mandatory.", nameof(partitionKey)); + } + + if (string.IsNullOrEmpty(rowKey)) + { + throw new ArgumentException("RowKey is mandatory.", nameof(rowKey)); + } + + if (telemetryItemId < 0) + { + throw new ArgumentOutOfRangeException( + nameof(telemetryItemId), + "TelemetryItemId must be non-negative."); + } + + if (payloadJson == null) + { + throw new ArgumentNullException(nameof(payloadJson)); + } + + if (retryCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(retryCount), "RetryCount must be non-negative."); + } + + Kind = kind; + PartitionKey = partitionKey; + RowKey = rowKey; + TelemetryItemId = telemetryItemId; + SessionTraceOrdinal = sessionTraceOrdinal; + PayloadJson = payloadJson; + RequiresImmediateFlush = requiresImmediateFlush; + EnqueuedAtUtc = enqueuedAtUtc; + RetryCount = retryCount; + } + + public TelemetryItemKind Kind { get; } + + /// Azure-Table-PartitionKey, typisch {tenantId}_{sessionId}. + public string PartitionKey { get; } + + public string RowKey { get; } + + /// Transport-Cursor — monoton pro Session über ALLE Item-Typen. + public long TelemetryItemId { get; } + + /// Plan §2.2 — equals wenn session-zugehörig, null bei Agent-globalen Items. + public long? SessionTraceOrdinal { get; } + + /// Bereits fertig serialisiertes JSON der Payload (Event / DecisionSignal / DecisionTransition). + public string PayloadJson { get; } + + /// Hinweis an den Orchestrator, diesen Item sofort zu drainen (Terminal-Flush). Nicht Teil der Atomaritäts-Garantie. + public bool RequiresImmediateFlush { get; } + + public DateTime EnqueuedAtUtc { get; } + + /// Anzahl der bisher fehlgeschlagenen Upload-Versuche. Wird vom Orchestrator incrementiert. + public int RetryCount { get; } + + /// Copy-with-incremented . Immutable-Erhalt (§2.3 L.3). + public TelemetryItem WithRetryIncremented() => + new TelemetryItem( + Kind, + PartitionKey, + RowKey, + TelemetryItemId, + SessionTraceOrdinal, + PayloadJson, + RequiresImmediateFlush, + EnqueuedAtUtc, + RetryCount + 1); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemDraft.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemDraft.cs new file mode 100644 index 00000000..8ebd55eb --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemDraft.cs @@ -0,0 +1,53 @@ +#nullable enable +using System; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Entwurf eines — vor Vergabe von TelemetryItemId / SessionTraceOrdinal + /// durch den . Plan §2.7a. + /// + public sealed class TelemetryItemDraft + { + public TelemetryItemDraft( + TelemetryItemKind kind, + string partitionKey, + string rowKey, + string payloadJson, + bool isSessionScoped, + bool requiresImmediateFlush = false) + { + if (string.IsNullOrEmpty(partitionKey)) + { + throw new ArgumentException("PartitionKey is mandatory.", nameof(partitionKey)); + } + + if (string.IsNullOrEmpty(rowKey)) + { + throw new ArgumentException("RowKey is mandatory.", nameof(rowKey)); + } + + if (payloadJson == null) + { + throw new ArgumentNullException(nameof(payloadJson)); + } + + Kind = kind; + PartitionKey = partitionKey; + RowKey = rowKey; + PayloadJson = payloadJson; + IsSessionScoped = isSessionScoped; + RequiresImmediateFlush = requiresImmediateFlush; + } + + public TelemetryItemKind Kind { get; } + public string PartitionKey { get; } + public string RowKey { get; } + public string PayloadJson { get; } + + /// True → SessionTraceOrdinal wird zum TelemetryItemId gesetzt. False → null (Agent-globale Items). + public bool IsSessionScoped { get; } + + public bool RequiresImmediateFlush { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemKind.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemKind.cs new file mode 100644 index 00000000..ccea120d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemKind.cs @@ -0,0 +1,29 @@ +#nullable enable +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Typ eines . Plan §2.7a / L.10. + /// + /// Drei getrennte Backend-Tabellen: + /// → Events, + /// → Signals, + /// → DecisionTransitions. + /// Der generische Transport routet auf Backend-Seite nach Kind. + /// + /// + /// String-serialisiert via — Backend-Routing darf + /// nicht von Integer-Ordering abhängen, das bei zukünftigen Erweiterungen bricht + /// (L.14 forward-compat). + /// + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum TelemetryItemKind + { + Event = 0, + Signal, + DecisionTransition, + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemSerializer.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemSerializer.cs new file mode 100644 index 00000000..92504023 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemSerializer.cs @@ -0,0 +1,45 @@ +#nullable enable +using System; +using AutopilotMonitor.DecisionCore.Serialization; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Serialize / deserialize JSONL records. + /// Plan §2.7a Spool on disk. + /// + /// Die innere wird als escaped-JSON-String geschrieben + /// (nicht als geöffnetes Objekt) — das hält den Spool-Eintrag vollständig ctor-bindbar und + /// transportiert den Payload byte-für-byte unverändert zum Backend. + /// + /// + public static class TelemetryItemSerializer + { + public static string Serialize(TelemetryItem item) + { + if (item == null) throw new ArgumentNullException(nameof(item)); + return JsonConvert.SerializeObject(item, DecisionCoreJsonSettings.Create()); + } + + public static TelemetryItem Deserialize(string line) + { + if (line == null) throw new ArgumentNullException(nameof(line)); + + try + { + var result = JsonConvert.DeserializeObject(line, DecisionCoreJsonSettings.Create()); + if (result == null) + { + throw new JsonSerializationException($"TelemetryItem deserialization produced null: {line}"); + } + return result; + } + catch (Exception ex) when (ex is ArgumentException || ex is ArgumentOutOfRangeException) + { + throw new JsonSerializationException( + $"TelemetryItem constructor rejected payload: {ex.Message}", ex); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetrySpool.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetrySpool.cs new file mode 100644 index 00000000..dbc90b7b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetrySpool.cs @@ -0,0 +1,179 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using AutopilotMonitor.DecisionCore.Engine; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Disk-backed — append-only JSONL + separater Cursor-File. + /// Plan §2.7a / L.12 Sofort-Flush. + /// + /// Layout auf Disk: + /// + /// <dir>/spool.jsonl — eine -Zeile pro enqueue, append-only + /// <dir>/upload-cursor.json — { "LastUploadedItemId": N }, atomar geschrieben + /// + /// + /// + /// Recovery: beim Konstruktor werden beide Files gescannt. LastAssignedItemId = max + /// TelemetryItemId in spool.jsonl (ab der letzten parsbaren Zeile); LastUploadedItemId + /// = Wert aus cursor-File (oder -1 bei Fehler). + /// + /// + public sealed class TelemetrySpool : ITelemetrySpool + { + private readonly string _spoolPath; + private readonly UploadCursorPersistence _cursor; + private readonly IClock _clock; + private readonly object _lock = new object(); + + private long _lastAssignedItemId = -1; + private long _lastUploadedItemId = -1; + + public TelemetrySpool(string directoryPath, IClock clock) + { + if (string.IsNullOrEmpty(directoryPath)) + { + throw new ArgumentException("DirectoryPath is mandatory.", nameof(directoryPath)); + } + + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + + if (!Directory.Exists(directoryPath)) + { + Directory.CreateDirectory(directoryPath); + } + + _spoolPath = Path.Combine(directoryPath, "spool.jsonl"); + _cursor = new UploadCursorPersistence(Path.Combine(directoryPath, "upload-cursor.json")); + + _lastAssignedItemId = ScanLastAssignedItemId(); + _lastUploadedItemId = _cursor.Load(); + } + + public long LastAssignedItemId + { + get { lock (_lock) return _lastAssignedItemId; } + } + + public long LastUploadedItemId + { + get { lock (_lock) return _lastUploadedItemId; } + } + + public TelemetryItem Enqueue(TelemetryItemDraft draft) + { + if (draft == null) throw new ArgumentNullException(nameof(draft)); + + lock (_lock) + { + var itemId = _lastAssignedItemId + 1; + long? traceOrdinal = draft.IsSessionScoped ? itemId : (long?)null; + + var item = new TelemetryItem( + kind: draft.Kind, + partitionKey: draft.PartitionKey, + rowKey: draft.RowKey, + telemetryItemId: itemId, + sessionTraceOrdinal: traceOrdinal, + payloadJson: draft.PayloadJson, + requiresImmediateFlush: draft.RequiresImmediateFlush, + enqueuedAtUtc: _clock.UtcNow); + + var line = TelemetryItemSerializer.Serialize(item); + var bytes = Encoding.UTF8.GetBytes(line + "\n"); + + using (var fs = new FileStream( + _spoolPath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + options: FileOptions.WriteThrough)) + { + fs.Write(bytes, 0, bytes.Length); + fs.Flush(flushToDisk: true); + } + + _lastAssignedItemId = itemId; + return item; + } + } + + public IReadOnlyList Peek(int max) + { + if (max <= 0) return Array.Empty(); + + lock (_lock) + { + if (!File.Exists(_spoolPath)) return Array.Empty(); + + var pending = new List(); + foreach (var line in File.ReadAllLines(_spoolPath, Encoding.UTF8)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + + TelemetryItem parsed; + try + { + parsed = TelemetryItemSerializer.Deserialize(line); + } + catch + { + // Corrupt tail (crash mid-append) — stop reading, the items before + // are still valid. Orchestrator will drain them; the unparsable + // tail stays in the file but is never Peek-returned. + break; + } + + if (parsed.TelemetryItemId <= _lastUploadedItemId) continue; + + pending.Add(parsed); + if (pending.Count >= max) break; + } + + return pending; + } + } + + public void MarkUploaded(long upToItemIdInclusive) + { + lock (_lock) + { + if (upToItemIdInclusive <= _lastUploadedItemId) return; // idempotency — never regress + if (upToItemIdInclusive > _lastAssignedItemId) + { + throw new InvalidOperationException( + $"Cannot mark uploaded beyond last-assigned id (requested={upToItemIdInclusive}, lastAssigned={_lastAssignedItemId})."); + } + + _cursor.Save(upToItemIdInclusive); + _lastUploadedItemId = upToItemIdInclusive; + } + } + + private long ScanLastAssignedItemId() + { + if (!File.Exists(_spoolPath)) return -1; + + long highest = -1; + foreach (var line in File.ReadAllLines(_spoolPath, Encoding.UTF8)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + var item = TelemetryItemSerializer.Deserialize(line); + if (item.TelemetryItemId > highest) highest = item.TelemetryItemId; + } + catch + { + break; + } + } + return highest; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryUploadOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryUploadOrchestrator.cs new file mode 100644 index 00000000..a23e174d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryUploadOrchestrator.cs @@ -0,0 +1,209 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.DecisionCore.Engine; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Produktiver . Plan §2.7a / L.10 / L.17 (Transient-Retry 100/400/1600ms). + /// + /// Drain-Semantik (§2.7a): „koordiniert + idempotent + resume-fähig", nicht cross-table-atomar. + /// Uploads laufen batchweise in TelemetryItemId-Reihenfolge, Backend dedupliziert via + /// (PartitionKey, RowKey) — Re-Upload nach Retry ist no-op. + /// + /// + public sealed class TelemetryUploadOrchestrator : ITelemetryTransport + { + /// Plan L.17 — Retry-Backoffs für transiente Fehler. + public static readonly IReadOnlyList DefaultRetryBackoffs = new[] + { + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(400), + TimeSpan.FromMilliseconds(1600), + }; + + private readonly ITelemetrySpool _spool; + private readonly IBackendTelemetryUploader _uploader; + private readonly IClock _clock; + private readonly int _batchSize; + private readonly IReadOnlyList _retryBackoffs; + private readonly SemaphoreSlim _drainGuard = new SemaphoreSlim(1, 1); + private bool _disposed; + + // M4.6.ε — backend-to-agent control signals. + private DateTime? _pausedUntilUtc; + + public TelemetryUploadOrchestrator( + ITelemetrySpool spool, + IBackendTelemetryUploader uploader, + IClock clock, + int batchSize = 100, + IReadOnlyList? retryBackoffs = null) + { + _spool = spool ?? throw new ArgumentNullException(nameof(spool)); + _uploader = uploader ?? throw new ArgumentNullException(nameof(uploader)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + + if (batchSize <= 0) throw new ArgumentOutOfRangeException(nameof(batchSize), "BatchSize must be positive."); + _batchSize = batchSize; + + _retryBackoffs = retryBackoffs ?? DefaultRetryBackoffs; + } + + /// + /// Raised once per successful upload — flows + /// here so Program.cs can synthesize terminate_session ServerActions from + /// / + /// and dispatch directly. Plan §4.x M4.6.ε. + /// + /// The orchestrator already handles internally + /// (pauses the drain loop until ). Handlers should + /// NOT call re-entrantly. + /// + /// + public event EventHandler? ServerResponseReceived; + + /// + /// true when the last successful upload returned + /// and the paused-until window has not expired. Drain is a no-op in this state. + /// + public bool IsPaused => _pausedUntilUtc.HasValue && _pausedUntilUtc.Value > _clock.UtcNow; + + public long LastUploadedItemId => _spool.LastUploadedItemId; + + public TelemetryItem Enqueue(TelemetryItemDraft draft) + { + if (_disposed) throw new ObjectDisposedException(nameof(TelemetryUploadOrchestrator)); + return _spool.Enqueue(draft); + } + + public async Task DrainAllAsync(CancellationToken cancellationToken = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(TelemetryUploadOrchestrator)); + + await _drainGuard.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + int uploaded = 0; + int failedBatches = 0; + string? lastError = null; + + while (!cancellationToken.IsCancellationRequested) + { + // M4.6.ε — honour a backend-issued DeviceBlocked quarantine. The block is + // checked per batch so UnblockAt can auto-resume uploads mid-session. + if (IsPaused) + { + lastError = $"device blocked until {_pausedUntilUtc:O}"; + break; + } + + var batch = _spool.Peek(_batchSize); + if (batch.Count == 0) break; + + var outcome = await TryUploadWithRetryAsync(batch, cancellationToken).ConfigureAwait(false); + + if (outcome.Success) + { + _spool.MarkUploaded(batch[batch.Count - 1].TelemetryItemId); + uploaded += batch.Count; + + ApplyControlSignals(outcome); + RaiseServerResponse(outcome); + + // Stop draining immediately if the backend just quarantined the device — + // the next loop iteration would see IsPaused and break anyway, but there + // is no point in even Peeking another batch. + if (outcome.DeviceBlocked) break; + } + else + { + // Stop drain — cursor stays put, re-Peek next drain will return the same + // batch (plus anything newly enqueued), backend will dedup on PK/RK. + failedBatches++; + lastError = outcome.ErrorReason; + break; + } + } + + return new DrainResult(uploaded, failedBatches, lastError); + } + finally + { + _drainGuard.Release(); + } + } + + private void ApplyControlSignals(UploadResult outcome) + { + if (outcome.DeviceBlocked) + { + // UnblockAt == null → indefinite block, represented as DateTime.MaxValue so + // IsPaused stays true until Clear() is called (admin un-block on next run). + _pausedUntilUtc = outcome.UnblockAt ?? DateTime.MaxValue; + } + } + + private void RaiseServerResponse(UploadResult outcome) + { + // Only raise when there is actually something to tell the subscriber. + var carriesSignal = outcome.DeviceBlocked + || outcome.DeviceKillSignal + || !string.IsNullOrEmpty(outcome.AdminAction) + || (outcome.Actions != null && outcome.Actions.Count > 0); + if (!carriesSignal) return; + + try { ServerResponseReceived?.Invoke(this, outcome); } + catch + { + // Handler exceptions MUST NOT abort the drain loop — the upload itself succeeded, + // and the cursor is already forwarded. Swallow + log via the caller's logger chain. + } + } + + private async Task TryUploadWithRetryAsync( + IReadOnlyList batch, + CancellationToken cancellationToken) + { + UploadResult last = UploadResult.Transient("no-attempt"); + + for (int attempt = 0; attempt <= _retryBackoffs.Count; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + last = await _uploader.UploadBatchAsync(batch, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + last = UploadResult.Transient($"{ex.GetType().Name}: {ex.Message}"); + } + + if (last.Success) return last; + if (!last.IsTransient) return last; // permanent — don't retry + + if (attempt < _retryBackoffs.Count) + { + await _clock.Delay(_retryBackoffs[attempt], cancellationToken).ConfigureAwait(false); + } + } + + return last; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _drainGuard.Dispose(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadCursorPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadCursorPersistence.cs new file mode 100644 index 00000000..758e50c1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadCursorPersistence.cs @@ -0,0 +1,89 @@ +#nullable enable +using System; +using System.IO; +using System.Text; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Atomarer Writer für upload-cursor.json (enthält nur LastUploadedItemId). Plan §2.7a. + /// + /// Keine Checksum: bei JSON-Parse-Fehler wird der Cursor auf -1 zurückgesetzt — backend dedupliziert + /// via (PartitionKey, RowKey), Doppel-Upload ist no-op (§2.7a Drain-Semantik). + /// + /// + public sealed class UploadCursorPersistence + { + private readonly string _path; + private readonly string _tempPath; + private readonly object _lock = new object(); + + public UploadCursorPersistence(string path) + { + if (string.IsNullOrEmpty(path)) throw new ArgumentException("Path is mandatory.", nameof(path)); + _path = path; + _tempPath = path + ".tmp"; + + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + } + + public long Load() + { + lock (_lock) + { + if (!File.Exists(_path)) return -1; + try + { + var json = File.ReadAllText(_path, Encoding.UTF8); + var dto = JsonConvert.DeserializeObject(json); + return dto?.LastUploadedItemId ?? -1; + } + catch + { + return -1; + } + } + } + + public void Save(long lastUploadedItemId) + { + lock (_lock) + { + var dto = new CursorDto { LastUploadedItemId = lastUploadedItemId }; + var json = JsonConvert.SerializeObject(dto); + var bytes = Encoding.UTF8.GetBytes(json); + + using (var fs = new FileStream( + _tempPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 512, + options: FileOptions.WriteThrough)) + { + fs.Write(bytes, 0, bytes.Length); + fs.Flush(flushToDisk: true); + } + + if (File.Exists(_path)) + { + File.Replace(_tempPath, _path, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + else + { + File.Move(_tempPath, _path); + } + } + } + + private sealed class CursorDto + { + public long LastUploadedItemId { get; set; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadResult.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadResult.cs new file mode 100644 index 00000000..0033f305 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadResult.cs @@ -0,0 +1,104 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry +{ + /// + /// Ergebnis eines Batch-Upload-Versuchs gegen das Backend. Plan §2.7a + §4.x M4.6.ε. + /// + /// M4.6.ε: Carries optional backend-to-agent control signals that Legacy embedded in + /// IngestEventsResponse: / + /// (non-terminal quarantine), (terminal force-self-destruct), + /// (portal-side Mark-Failed / Mark-Succeeded) and the generic + /// list. Program.cs subscribes to the transport's response-hook and + /// feeds these through the ServerActionDispatcher / DeviceBlocked pause path. + /// + /// + public sealed class UploadResult + { + private UploadResult( + bool success, + string? errorReason, + bool isTransient, + bool deviceBlocked, + DateTime? unblockAt, + bool deviceKillSignal, + string? adminAction, + IReadOnlyList? actions) + { + Success = success; + ErrorReason = errorReason; + IsTransient = isTransient; + DeviceBlocked = deviceBlocked; + UnblockAt = unblockAt; + DeviceKillSignal = deviceKillSignal; + AdminAction = adminAction; + Actions = actions; + } + + public bool Success { get; } + + /// Null bei Success, gesetzt bei Fehler. + public string? ErrorReason { get; } + + /// Bei Success ignoriert. Bei Fehler: true → Retry sinnvoll, false → dauerhafter Fehler (kein Retry). + public bool IsTransient { get; } + + /// + /// Non-terminal quarantine signalled by the backend. The agent should stop draining + /// uploads until (if set) elapses — the session stays alive so + /// the Admin can un-block without discarding enrollment state. Plan §4.x M4.6.ε. + /// + public bool DeviceBlocked { get; } + + /// UTC time at which the quarantine auto-expires, or null for an indefinite block. + public DateTime? UnblockAt { get; } + + /// + /// Terminal kill-signal issued by an administrator. Legacy synthesised a + /// terminate_session with forceSelfDestruct=true + /// and gracePeriodSeconds=0. V2 Program.cs replicates that synthesis + dispatches + /// through the same termination path as the real decision-terminal. + /// + public bool DeviceKillSignal { get; } + + /// + /// Portal-side Mark-Failed / Mark-Succeeded outcome string (e.g. "failed" / + /// "succeeded"). Legacy synthesised a soft terminate_session + /// that respects the local SelfDestructOnComplete toggle. + /// + public string? AdminAction { get; } + + /// Generic backend-queued actions (s) for this batch. + public IReadOnlyList? Actions { get; } + + public static UploadResult Ok() => + new UploadResult(true, null, isTransient: false, + deviceBlocked: false, unblockAt: null, deviceKillSignal: false, adminAction: null, actions: null); + + /// + /// Success with backend-to-agent control signals. Used by + /// when the 2xx response body carries one of the + /// quarantine / kill / admin / actions fields. + /// + public static UploadResult OkWithSignals( + bool deviceBlocked = false, + DateTime? unblockAt = null, + bool deviceKillSignal = false, + string? adminAction = null, + IReadOnlyList? actions = null) => + new UploadResult(true, null, isTransient: false, + deviceBlocked: deviceBlocked, unblockAt: unblockAt, + deviceKillSignal: deviceKillSignal, adminAction: adminAction, actions: actions); + + public static UploadResult Transient(string reason) => + new UploadResult(false, reason ?? throw new ArgumentNullException(nameof(reason)), isTransient: true, + deviceBlocked: false, unblockAt: null, deviceKillSignal: false, adminAction: null, actions: null); + + public static UploadResult Permanent(string reason) => + new UploadResult(false, reason ?? throw new ArgumentNullException(nameof(reason)), isTransient: false, + deviceBlocked: false, unblockAt: null, deviceKillSignal: false, adminAction: null, actions: null); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/V2Isolation.targets b/src/Agent/AutopilotMonitor.Agent.V2.Core/V2Isolation.targets new file mode 100644 index 00000000..6ffe6330 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/V2Isolation.targets @@ -0,0 +1,25 @@ + + + + + + + <_LegacyProjectRefs Include="@(ProjectReference)" + Condition=" + $([System.String]::Copy('%(ProjectReference.Identity)').EndsWith('AutopilotMonitor.Agent.Core.csproj')) + or $([System.String]::Copy('%(ProjectReference.Identity)').EndsWith('AutopilotMonitor.Agent.csproj'))" /> + + + + + diff --git a/src/Agent/AutopilotMonitor.Agent.V2/AssemblyInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2/AssemblyInfo.cs new file mode 100644 index 00000000..8fe41a0e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Runtime.CompilerServices; + +// Internals are exposed to the V2.Core test project so it can cover the startup helpers +// (Program.Guards.cs: DetectPreviousExit, CheckSessionAgeEmergencyBreak, TryReadBootstrapConfig, +// TryReadAwaitEnrollmentConfig, WriteCrashLog, etc.) without duplicating the code into V2.Core. +// Plan §4.x M4.6.α. +[assembly: InternalsVisibleTo("AutopilotMonitor.Agent.V2.Core.Tests")] diff --git a/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj new file mode 100644 index 00000000..4825c809 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj @@ -0,0 +1,65 @@ + + + + Exe + net48 + latest + AnyCPU + AutopilotMonitor.Agent.V2 + AutopilotMonitor.Agent.V2 + + + 2.0 + + $(MSBuildProjectDirectory)\buildcounter.txt + + $(BUILD_NUMBER) + + <_CounterFromFile Condition="'$(BUILD_NUMBER)' == '' and Exists('$(BuildCounterFile)')">$([System.IO.File]::ReadAllText('$(BuildCounterFile)').Trim()) + <_CounterFromFile Condition="'$(BUILD_NUMBER)' == '' and !Exists('$(BuildCounterFile)')">0 + $([MSBuild]::Add($(_CounterFromFile), 1)) + + $(VersionPrefix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + $(Version) + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs new file mode 100644 index 00000000..eb039f37 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2 +{ + /// + /// --run-gather-rules standalone diagnostic mode. Plan §4.x M4.6.δ. + /// + /// Fetches remote config, executes all startup gather rules against a freshly-registered + /// ephemeral session, uploads the collected events, and exits. Parity with Legacy + /// Program.GatherRulesMode.cs — the V2 differences are minimal (V2 TenantIdResolver + /// instead of GetTenantIdFromRegistry, V2 BackendApiClient ctor). + /// + /// + public static partial class Program + { + private const int GatherRulesMaxWaitSeconds = 120; + + internal static int RunGatherRulesMode(string[] args) + { + var consoleMode = args.Contains("--console") || Environment.UserInteractive; + var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); + var logger = new AgentLogger(logDir) { EnableConsoleOutput = consoleMode }; + var agentVersion = GetAgentVersion(); + + if (consoleMode) + { + Console.WriteLine("Autopilot Monitor Agent V2 — Run Gather Rules"); + Console.WriteLine("============================================="); + Console.WriteLine(); + } + + try + { + var tenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger); + if (string.IsNullOrEmpty(tenantId)) + { + logger.Error("--run-gather-rules: TenantId could not be resolved — device is not MDM-enrolled."); + if (consoleMode) Console.Error.WriteLine("ERROR: device is not MDM-enrolled (no TenantId)."); + return 2; + } + + // Always use a fresh ephemeral session id — keeps the gather-rules run out of + // any active enrollment session's spool / journal. + var sessionId = Guid.NewGuid().ToString(); + var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl; + + var config = new AgentConfiguration + { + ApiBaseUrl = apiBaseUrl, + SessionId = sessionId, + TenantId = tenantId, + LogDirectory = logDir, + SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory), + UseClientCertAuth = true, + ImeLogPathOverride = GetArgValue(args, "--ime-log-path"), + CommandLineArgs = FormatArgsForGatherLog(args), + }; + + logger.Info("======================= --run-gather-rules mode ======================="); + + if (consoleMode) + { + Console.WriteLine($"Session ID: {sessionId} (new, ephemeral)"); + Console.WriteLine($"Tenant ID: {tenantId}"); + Console.WriteLine($"API URL: {apiBaseUrl}"); + Console.WriteLine(); + } + + using (var apiClient = new BackendApiClient(apiBaseUrl, config, logger, agentVersion)) + using (var remoteConfigService = new RemoteConfigService(apiClient, tenantId, logger)) + { + // Step 1 — fetch remote config. + AgentConfigResponse remoteConfig = null; + try + { + var configTask = remoteConfigService.FetchConfigAsync(); + configTask.Wait(TimeSpan.FromSeconds(15)); + remoteConfig = remoteConfigService.CurrentConfig; + logger.Info("Remote config fetched."); + if (consoleMode) Console.WriteLine("Remote config fetched."); + } + catch (Exception ex) + { + logger.Warning($"Remote config fetch failed (continuing): {ex.Message}"); + if (consoleMode) Console.WriteLine($"WARNING: Remote config fetch failed: {ex.Message}"); + } + + var rules = remoteConfig?.GatherRules; + var startupRules = rules? + .Where(r => r.Enabled && r.Trigger == "startup") + .ToList() ?? new List(); + + if (startupRules.Count == 0) + { + logger.Info("No enabled startup gather rules found — nothing to execute."); + if (consoleMode) Console.WriteLine("No startup gather rules found. Nothing to execute."); + return 0; + } + + // Step 2 — register the ephemeral session. + try + { + var hw = HardwareInfo.GetHardwareInfo(logger); + var registration = new SessionRegistration + { + SessionId = sessionId, + TenantId = tenantId, + SerialNumber = hw.SerialNumber, + Manufacturer = hw.Manufacturer, + Model = hw.Model, + DeviceName = Environment.MachineName, + OsName = DeviceInfoProvider.GetOsName(), + OsBuild = DeviceInfoProvider.GetOsBuild(), + OsDisplayVersion = DeviceInfoProvider.GetOsDisplayVersion(), + OsEdition = DeviceInfoProvider.GetOsEdition(), + OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name, + StartedAt = DateTime.UtcNow, + AgentVersion = agentVersion, + EnrollmentType = "gather_rules", + }; + + var regResponse = apiClient.RegisterSessionAsync(registration).GetAwaiter().GetResult(); + if (regResponse != null && regResponse.Success) + logger.Info($"Session registered: {regResponse.SessionId}"); + else + logger.Warning($"Session registration: {regResponse?.Message ?? "null response"}"); + } + catch (Exception ex) + { + logger.Warning($"Session registration failed (continuing): {ex.Message}"); + } + + // Step 3 — collect events in-memory, never touch the live agent's spool. + var collectedEvents = new List(); + var evtLock = new object(); + long sequence = 0; + + Action emitEvent = evt => + { + evt.Sequence = Interlocked.Increment(ref sequence); + lock (evtLock) collectedEvents.Add(evt); + logger.Debug($"Gather event: {evt.EventType} — {evt.Message}"); + if (consoleMode) + Console.WriteLine($" [{evt.Timestamp:HH:mm:ss}] [{evt.EventType}] {evt.Message}"); + }; + + emitEvent(new EnrollmentEvent + { + SessionId = sessionId, + TenantId = tenantId, + Timestamp = DateTime.UtcNow, + EventType = "gather_rules_collection_started", + Severity = EventSeverity.Info, + Source = "Agent", + Phase = EnrollmentPhase.Start, + Message = $"Gather rules collection started ({startupRules.Count} rule(s))", + Data = new Dictionary + { + { "ruleCount", startupRules.Count }, + { "agentVersion", agentVersion }, + }, + }); + + // Step 4 — run the executor and wait. + using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride)) + { + executor.UpdateRules(rules); + + if (consoleMode) Console.WriteLine($"Running {startupRules.Count} rule(s)..."); + var allCompleted = executor.WaitForStartupRules(GatherRulesMaxWaitSeconds); + if (!allCompleted) + { + logger.Warning($"Some gather rules did not complete within {GatherRulesMaxWaitSeconds}s."); + if (consoleMode) Console.WriteLine($"WARNING: Some rules timed out after {GatherRulesMaxWaitSeconds}s."); + } + else + { + logger.Info("All startup rules completed."); + if (consoleMode) Console.WriteLine("All rules completed."); + } + } + + // Step 5 — emit completion event, then upload. + List eventsToUpload; + lock (evtLock) eventsToUpload = new List(collectedEvents); + + var totalEventCount = eventsToUpload.Count + 1; + emitEvent(new EnrollmentEvent + { + SessionId = sessionId, + TenantId = tenantId, + Timestamp = DateTime.UtcNow, + EventType = "gather_rules_collection_completed", + Severity = EventSeverity.Info, + Source = "Agent", + Phase = EnrollmentPhase.Start, + Message = $"Gather rules collection completed ({totalEventCount} event(s) collected)", + Data = new Dictionary { { "totalEvents", totalEventCount } }, + }); + lock (evtLock) eventsToUpload = new List(collectedEvents); + + // Step 6 — upload. + if (eventsToUpload.Count > 0) + { + try + { + var request = new IngestEventsRequest + { + SessionId = sessionId, + TenantId = tenantId, + Events = eventsToUpload, + }; + var uploadResponse = apiClient.IngestEventsAsync(request).GetAwaiter().GetResult(); + if (uploadResponse != null && uploadResponse.Success) + { + logger.Info($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully."); + if (consoleMode) Console.WriteLine($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully."); + } + else + { + logger.Warning($"Upload failed: {uploadResponse?.Message ?? "null response"}"); + if (consoleMode) Console.WriteLine($"Upload failed: {uploadResponse?.Message ?? "null response"}"); + } + } + catch (Exception ex) + { + logger.Error("Failed to upload events.", ex); + if (consoleMode) Console.Error.WriteLine($"ERROR uploading events: {ex.Message}"); + } + } + else + { + logger.Info("No events were collected."); + if (consoleMode) Console.WriteLine("No events were collected."); + } + } + + if (consoleMode) Console.WriteLine("\nGather rules run complete."); + return 0; + } + catch (Exception ex) + { + logger.Error("--run-gather-rules failed.", ex); + if (consoleMode) Console.Error.WriteLine($"ERROR: {ex.Message}"); + return 1; + } + } + + private static string FormatArgsForGatherLog(string[] args) => FormatArgsForLog(args); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs new file mode 100644 index 00000000..6057ca7f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs @@ -0,0 +1,416 @@ +using System; +using System.Diagnostics; +using System.Diagnostics.Eventing.Reader; +using System.IO; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Agent.V2 +{ + /// + /// Startup guards ported from Legacy Program.cs. Plan §4.x M4.6.α. + /// + /// Multi-instance guard — prevents a second agent process from running alongside + /// one that was started by the Scheduled Task. + /// clean-exit.marker — written in so the next + /// start can distinguish clean shutdown from hard kill / reboot kill / exception crash. + /// Previous-exit classification — reads the marker, crash logs and the system + /// event log (boot time 6009) and determines the previous exit type. + /// Enrollment-complete marker check — ghost-restart detection: if a previous + /// session completed successfully but the Scheduled Task survived self-destruct, retry + /// cleanup and exit. + /// Session-age emergency break — absolute cap (AbsoluteMaxSessionHours) + /// across restarts prevents zombie agents from persisting forever. + /// Crash log writer — writes a last-resort crash_*.log next to the agent log + /// directory when an uncaught exception escapes Main. + /// + /// + public static partial class Program + { + internal const string CleanExitMarkerFileName = "clean-exit.marker"; + internal const string EnrollmentCompleteMarkerFileName = "enrollment-complete.marker"; + private const string CrashLogPrefix = "crash_"; + + // ----------------------------------------------------------------- Multi-instance guard + + /// + /// true when another process with the same filename (module name) is alive. + /// Race-safe by design: we count processes sharing our name; if 1, only we exist. + /// + internal static bool IsAnotherAgentInstanceRunning() + { + try + { + var self = Process.GetCurrentProcess(); + var siblings = Process.GetProcessesByName(self.ProcessName); + return siblings.Length > 1; + } + catch + { + // Without WMI or PerfCounter rights the probe will fail — assume no conflict so + // we do not block startup on a diagnostic false negative. + return false; + } + } + + // ----------------------------------------------------------------- clean-exit marker + + /// + /// Registers a handler that writes clean-exit.marker + /// as a last action so the next start can distinguish clean shutdown from hard kill. + /// Safe to call multiple times — duplicate handlers are filtered by delegate equality. + /// + internal static EventHandler RegisterCleanExitMarker(string dataDirectory) + { + var markerPath = Path.Combine(dataDirectory, CleanExitMarkerFileName); + EventHandler handler = (_, _) => + { + try { File.WriteAllText(markerPath, DateTime.UtcNow.ToString("O")); } catch { } + }; + AppDomain.CurrentDomain.ProcessExit += handler; + return handler; + } + + // ----------------------------------------------------------------- Previous-exit detection + + /// Lightweight bag returned by . + internal sealed class PreviousExitSummary + { + public string ExitType { get; internal set; } // "first_run" | "clean" | "exception_crash" | "hard_kill" | "reboot_kill" + public string CrashExceptionType { get; internal set; } // populated when ExitType == "exception_crash" + public DateTime? LastBootUtc { get; internal set; } // populated when ExitType == "reboot_kill" + } + + /// + /// Reads clean-exit.marker + crash_*.log + session files + (if needed) the + /// Windows event log to classify the previous exit. Deletes the marker and crash logs so + /// the next cycle starts fresh. Legacy parity — same rules, same outputs. + /// + internal static PreviousExitSummary DetectPreviousExit(string dataDirectory, string logDirectory) + { + var summary = new PreviousExitSummary(); + + var cleanExitMarker = Path.Combine(dataDirectory, CleanExitMarkerFileName); + var hadCleanExit = File.Exists(cleanExitMarker); + var crashLogs = Directory.Exists(logDirectory) + ? Directory.GetFiles(logDirectory, CrashLogPrefix + "*.log") + : Array.Empty(); + + if (hadCleanExit) + { + summary.ExitType = "clean"; + } + else if (crashLogs.Length > 0) + { + summary.ExitType = "exception_crash"; + try + { + Array.Sort(crashLogs, StringComparer.Ordinal); + var mostRecent = crashLogs[crashLogs.Length - 1]; + var crashContent = File.ReadAllText(mostRecent); + var fatalIdx = crashContent.IndexOf("FATAL: ", StringComparison.Ordinal); + if (fatalIdx >= 0) + { + var afterFatal = crashContent.Substring(fatalIdx + 7); + var colonIdx = afterFatal.IndexOf(':'); + if (colonIdx > 0) + summary.CrashExceptionType = afterFatal.Substring(0, colonIdx).Trim(); + } + } + catch { /* best-effort */ } + } + else + { + // No clean-exit marker and no crash log — either first run, hard kill or reboot kill. + var persistence = new SessionIdPersistence(dataDirectory); + if (persistence.SessionExists()) + { + summary.ExitType = "hard_kill"; + summary.LastBootUtc = GetLastBootTimeFromEventLog(); + var sessionCreated = persistence.LoadSessionCreatedAt(); + if (summary.LastBootUtc.HasValue && sessionCreated.HasValue + && summary.LastBootUtc.Value > sessionCreated.Value) + { + summary.ExitType = "reboot_kill"; + } + } + else + { + summary.ExitType = "first_run"; + } + } + + // Clean up markers so the next cycle starts from zero. + try { File.Delete(cleanExitMarker); } catch { } + foreach (var crashLog in crashLogs) + try { File.Delete(crashLog); } catch { } + + return summary; + } + + /// + /// Queries the Windows System event log for the latest OS boot event (ID 6009). Returns + /// null when the query fails (e.g. on reduced-privilege runs in dev environments). + /// + internal static DateTime? GetLastBootTimeFromEventLog() + { + try + { + var query = new EventLogQuery( + "System", + PathType.LogName, + "*[System[Provider[@Name='EventLog'] and (EventID=6009)]]") + { + ReverseDirection = true, + }; + using (var reader = new EventLogReader(query)) + { + var record = reader.ReadEvent(); + if (record?.TimeCreated != null) + { + using (record) + return record.TimeCreated.Value.ToUniversalTime(); + } + } + } + catch { /* permission denied, event log service down — fall through */ } + return null; + } + + // ----------------------------------------------------------------- Marker checks + + /// + /// true when an enrollment-complete.marker or the Deployed registry + /// marker indicates that this device already completed enrollment. Legacy parity — + /// triggers a self-destruct retry and the agent exits cleanly. + /// + internal static bool CheckEnrollmentCompleteMarker( + string dataDirectory, + string stateDirectory, + bool selfDestructOnComplete, + Func cleanupServiceFactory, + AgentLogger logger, + bool consoleMode) + { + // Registry guard: "Deployed" key set by --install but no active session → ghost restart. + try + { + using (var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(DeploymentRegistryKey)) + { + var deployed = regKey?.GetValue(DeploymentRegistryValue) as string; + if (!string.IsNullOrEmpty(deployed)) + { + var persistence = new SessionIdPersistence(dataDirectory); + if (!persistence.SessionExists()) + { + logger.Info($"Ghost restart detected (Deployed={deployed}, no active session)."); + if (selfDestructOnComplete) + { + TryRetryCleanup(cleanupServiceFactory, logger, "ghost_restart"); + } + if (consoleMode) + Console.Out.WriteLine("Agent was previously deployed but no active session. Exiting."); + return true; + } + logger.Info($"Deployment marker present ({deployed}) with active session — continuing normally."); + } + } + } + catch (Exception regEx) + { + logger.Warning($"Registry deployment marker check failed: {regEx.Message}"); + } + + // File-based enrollment-complete marker. + var markerPath = Path.Combine(stateDirectory, EnrollmentCompleteMarkerFileName); + if (!File.Exists(markerPath)) return false; + + logger.Info("Enrollment-complete marker detected from a previous session."); + + if (!selfDestructOnComplete) + { + if (consoleMode) + Console.Out.WriteLine("Enrollment already completed (no cleanup configured). Agent will exit."); + return true; + } + + logger.Info("Enrollment already completed. Attempting self-destruct retry (scheduled task may have failed)."); + if (consoleMode) Console.Out.WriteLine("Enrollment already completed. Attempting cleanup retry..."); + + TryRetryCleanup(cleanupServiceFactory, logger, "complete_marker"); + return true; + } + + /// + /// Emergency break: exits the agent if the current session has been alive longer than + /// AbsoluteMaxSessionHours. Respects WhiteGlove-paused sessions (the age clock + /// restarts on Part-2 resume). Legacy parity. + /// + internal static bool CheckSessionAgeEmergencyBreak( + string dataDirectory, + string stateDirectory, + int absoluteMaxSessionHours, + bool selfDestructOnComplete, + Func cleanupServiceFactory, + AgentLogger logger, + bool consoleMode) + { + try + { + var persistence = new SessionIdPersistence(dataDirectory); + + if (persistence.IsWhiteGloveResume()) + { + logger.Info("Emergency break: WhiteGlove resume detected — skipping session age check."); + return false; + } + + var sessionCreatedAt = persistence.LoadSessionCreatedAt(); + if (sessionCreatedAt == null) + { + if (persistence.SessionExists()) + { + persistence.SaveSessionCreatedAt(DateTime.UtcNow); + logger.Info("Emergency break: initialised missing session.created for existing session."); + } + return false; + } + + var sessionAgeHours = (DateTime.UtcNow - sessionCreatedAt.Value).TotalHours; + if (sessionAgeHours <= absoluteMaxSessionHours) + { + logger.Info($"Emergency break: session age {sessionAgeHours:F1}h within limit ({absoluteMaxSessionHours}h)."); + return false; + } + + logger.Warning($"EMERGENCY BREAK: session age {sessionAgeHours:F1}h exceeds maximum {absoluteMaxSessionHours}h — forcing cleanup."); + if (consoleMode) + Console.Out.WriteLine($"EMERGENCY: Session is {sessionAgeHours:F1}h old (max: {absoluteMaxSessionHours}h). Forcing cleanup."); + + // Write enrollment-complete.marker so the next start exits cleanly even if + // the cleanup retry below fails (Scheduled Task still alive, files locked, …). + try + { + Directory.CreateDirectory(stateDirectory); + var markerPath = Path.Combine(stateDirectory, EnrollmentCompleteMarkerFileName); + File.WriteAllText(markerPath, + $"Emergency break at {DateTime.UtcNow:O} (session age: {sessionAgeHours:F1}h)"); + } + catch (Exception ex) { logger.Warning($"Failed to write enrollment-complete.marker: {ex.Message}"); } + + if (selfDestructOnComplete) + { + TryRetryCleanup(cleanupServiceFactory, logger, "emergency_break"); + } + + persistence.Delete(logger); + logger.Info("Emergency break: cleanup completed. Agent will exit."); + return true; + } + catch (Exception ex) + { + logger.Error("Emergency break check failed.", ex); + return false; + } + } + + private static void TryRetryCleanup( + Func cleanupServiceFactory, + AgentLogger logger, + string reason) + { + if (cleanupServiceFactory == null) return; + try + { + var service = cleanupServiceFactory(); + service.ExecuteSelfDestruct(); + logger.Info($"Cleanup retry ({reason}) completed."); + } + catch (Exception ex) + { + logger.Warning($"Cleanup retry ({reason}) failed: {ex.Message}"); + } + } + + // ----------------------------------------------------------------- Crash log + + /// + /// Writes a last-resort crash_*.log next to the agent log directory when an uncaught + /// exception escapes . The next start reads this file and emits an + /// exception_crash previous-exit classification. + /// + internal static void WriteCrashLog(string logDirectory, Exception ex) + { + try + { + Directory.CreateDirectory(logDirectory); + var crashPath = Path.Combine(logDirectory, $"{CrashLogPrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.log"); + File.WriteAllText(crashPath, $"[{DateTime.UtcNow:u}] FATAL: {ex}"); + } + catch { /* nowhere left to log */ } + } + + // ----------------------------------------------------------------- Bootstrap-config read + + /// + /// Reads a persisted bootstrap-config.json (if present) that was written by + /// --install. Returns null on miss or corrupt file. + /// + internal static BootstrapConfigFile TryReadBootstrapConfig(string dataDirectory, AgentLogger logger) + { + try + { + var path = Path.Combine(dataDirectory, BootstrapConfigFileName); + if (!File.Exists(path)) return null; + var json = File.ReadAllText(path); + return Newtonsoft.Json.JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + logger?.Debug($"TryReadBootstrapConfig: {ex.Message}"); + return null; + } + } + + /// + /// Reads a persisted await-enrollment.json (if present). Returns null on + /// miss or corrupt file. + /// + internal static AwaitEnrollmentConfigFile TryReadAwaitEnrollmentConfig(string dataDirectory, AgentLogger logger) + { + try + { + var path = Path.Combine(dataDirectory, AwaitEnrollmentConfigFileName); + if (!File.Exists(path)) return null; + var json = File.ReadAllText(path); + return Newtonsoft.Json.JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + logger?.Debug($"TryReadAwaitEnrollmentConfig: {ex.Message}"); + return null; + } + } + + internal static void DeleteAwaitEnrollmentConfig(string dataDirectory, AgentLogger logger) + { + try + { + var path = Path.Combine(dataDirectory, AwaitEnrollmentConfigFileName); + if (File.Exists(path)) + { + File.Delete(path); + logger?.Info("Await-enrollment: config file removed; subsequent restarts proceed normally."); + } + } + catch (Exception ex) + { + logger?.Warning($"Await-enrollment: could not remove config file: {ex.Message}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.ImeMatchingMode.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.ImeMatchingMode.cs new file mode 100644 index 00000000..ddff89f8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.ImeMatchingMode.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2 +{ + /// + /// --run-ime-matching standalone IME log pattern-matching mode. Plan §4.x M4.6.δ. + /// + /// Parses IME log files from a given path (single file or directory) with the same pattern + /// set the live agent uses, and writes all matches to ime_pattern_matching.log next + /// to the input. Parity-port of Legacy Program.ImeMatchingMode.cs. + /// + /// + public static partial class Program + { + // Same file-name patterns the live ImeLogTracker watches. + private static readonly string[] ImeLogFilePatterns = new[] + { + "IntuneManagementExtension.log", + "_IntuneManagementExtension.log", + "IntuneManagementExtension-????????-??????.log", + "AppWorkload.log", + "AppWorkload-????????-??????.log", + "AgentExecutor.log", + "AgentExecutor-????????-??????.log", + "HealthScripts.log", + "HealthScripts-????????-??????.log", + }; + + private const string ImePatternsGuidPattern = @"(?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})"; + private const string ImePatternsGitHubUrl = "https://raw.githubusercontent.com/okieselbach/Autopilot-Monitor/refs/heads/main/rules/dist/ime-log-patterns.json"; + + internal static int RunImeMatchingMode(string[] args) + { + Console.WriteLine("Autopilot Monitor Agent V2 — IME Pattern Matching"); + Console.WriteLine("=================================================="); + Console.WriteLine(); + + var pathIndex = Array.IndexOf(args, "--run-ime-matching"); + if (pathIndex < 0 || pathIndex + 1 >= args.Length) + { + Console.Error.WriteLine("ERROR: --run-ime-matching requires a path argument (file or directory)."); + Console.Error.WriteLine("Usage: AutopilotMonitor.Agent.V2.exe --run-ime-matching [--patterns ]"); + return 1; + } + + var inputPath = args[pathIndex + 1]; + var patternsFile = GetArgValue(args, "--patterns"); + + var logFiles = new List(); + string outputDir; + + if (File.Exists(inputPath)) + { + logFiles.Add(Path.GetFullPath(inputPath)); + outputDir = Path.GetDirectoryName(Path.GetFullPath(inputPath)); + Console.WriteLine($"Mode: Single file"); + Console.WriteLine($"Input: {Path.GetFullPath(inputPath)}"); + } + else if (Directory.Exists(inputPath)) + { + var fullDir = Path.GetFullPath(inputPath); + foreach (var pattern in ImeLogFilePatterns) + { + try { logFiles.AddRange(Directory.GetFiles(fullDir, pattern)); } + catch (DirectoryNotFoundException) { } + } + logFiles.Sort(StringComparer.OrdinalIgnoreCase); + outputDir = fullDir; + + Console.WriteLine($"Mode: Directory scan"); + Console.WriteLine($"Input: {fullDir}"); + Console.WriteLine($"Log files: {logFiles.Count}"); + + if (logFiles.Count == 0) + { + Console.WriteLine(); + Console.WriteLine("No IME log files found in the specified directory."); + Console.WriteLine("Expected files: IntuneManagementExtension*.log, AppWorkload*.log, AgentExecutor*.log, HealthScripts*.log"); + return 1; + } + } + else + { + Console.Error.WriteLine($"ERROR: Path not found: {inputPath}"); + return 1; + } + + var patterns = !string.IsNullOrEmpty(patternsFile) + ? LoadImePatternsFromFile(patternsFile) + : LoadImePatternsFromGitHub(); + + if (patterns == null || patterns.Count == 0) + { + Console.Error.WriteLine("ERROR: No patterns loaded. Cannot proceed."); + return 1; + } + + Console.WriteLine($"Loaded: {patterns.Count} patterns ({patterns.Count(p => p.Enabled)} enabled)"); + + var compiledPatterns = CompileImeMatchingPatterns(patterns); + Console.WriteLine($"Compiled: {compiledPatterns.Count} patterns"); + + var outputPath = Path.Combine(outputDir, "ime_pattern_matching.log"); + Console.WriteLine($"Output: {outputPath}"); + Console.WriteLine(); + + if (File.Exists(outputPath)) File.Delete(outputPath); + + int totalLines = 0; + int totalMatches = 0; + var matchCountByPattern = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var filePath in logFiles) + { + var fileName = Path.GetFileName(filePath); + Console.Write($" Scanning {fileName}..."); + + int fileLines = 0; + int fileMatches = 0; + + try + { + using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + { + string line; + while ((line = reader.ReadLine()) != null) + { + fileLines++; + string messageToMatch = CmTraceLogParser.TryParseLine(line, out var entry) + ? entry.Message + : line; + if (string.IsNullOrEmpty(messageToMatch)) continue; + + foreach (var pattern in compiledPatterns) + { + try + { + var match = pattern.Regex.Match(messageToMatch); + if (match.Success) + { + fileMatches++; + var logEntry = $"[{fileName}] [{pattern.PatternId}] {line}"; + File.AppendAllText(outputPath, logEntry + Environment.NewLine); + + if (!matchCountByPattern.ContainsKey(pattern.PatternId)) + matchCountByPattern[pattern.PatternId] = 0; + matchCountByPattern[pattern.PatternId]++; + } + } + catch (RegexMatchTimeoutException) { } + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($" ERROR: {ex.Message}"); + continue; + } + + totalLines += fileLines; + totalMatches += fileMatches; + Console.WriteLine($" {fileLines} lines, {fileMatches} matches"); + } + + Console.WriteLine(); + Console.WriteLine("=== Summary ==="); + Console.WriteLine($"Files scanned: {logFiles.Count}"); + Console.WriteLine($"Lines parsed: {totalLines}"); + Console.WriteLine($"Total matches: {totalMatches}"); + Console.WriteLine($"Output: {outputPath}"); + + if (matchCountByPattern.Count > 0) + { + Console.WriteLine(); + Console.WriteLine("Matches by pattern:"); + foreach (var kvp in matchCountByPattern.OrderByDescending(x => x.Value)) + Console.WriteLine($" {kvp.Key,-40} {kvp.Value,5}"); + } + + return 0; + } + + private static List LoadImePatternsFromGitHub() + { + try + { + Console.Write(" Downloading patterns from GitHub..."); + using (var handler = new HttpClientHandler()) + using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }) + { + var json = client.GetStringAsync(ImePatternsGitHubUrl).GetAwaiter().GetResult(); + var wrapper = JsonConvert.DeserializeObject(json); + var patterns = wrapper?.Rules ?? new List(); + Console.WriteLine(" OK"); + return patterns; + } + } + catch (Exception ex) + { + Console.WriteLine($" FAILED: {ex.Message}"); + + // Fallback: cached remote-config.json (written by previous live agent runs). + try + { + var cachePath = Environment.ExpandEnvironmentVariables(CachedRemoteConfigPath); + if (File.Exists(cachePath)) + { + Console.Write(" Falling back to cached remote-config.json..."); + var json = File.ReadAllText(cachePath); + var config = JsonConvert.DeserializeObject(json); + if (config?.ImeLogPatterns != null && config.ImeLogPatterns.Count > 0) + { + Console.WriteLine(" OK"); + return config.ImeLogPatterns; + } + } + } + catch { /* falls through to null */ } + + Console.WriteLine(" No fallback available."); + return null; + } + } + + private static List LoadImePatternsFromFile(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + Console.Error.WriteLine($"ERROR: Patterns file not found: {filePath}"); + return null; + } + var json = File.ReadAllText(filePath); + var wrapper = JsonConvert.DeserializeObject(json); + return wrapper?.Rules ?? new List(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"ERROR: Failed to load patterns file: {ex.Message}"); + return null; + } + } + + private static List CompileImeMatchingPatterns(List patterns) + { + var compiled = new List(); + foreach (var pattern in patterns.Where(p => p.Enabled)) + { + try + { + var regexStr = pattern.Pattern.Replace("{GUID}", ImePatternsGuidPattern); + var regex = new Regex( + regexStr, + RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, + TimeSpan.FromSeconds(1)); + compiled.Add(new CompiledImeMatchPattern { PatternId = pattern.PatternId, Regex = regex }); + } + catch (Exception ex) + { + Console.WriteLine($" WARNING: Failed to compile pattern {pattern.PatternId}: {ex.Message}"); + } + } + return compiled; + } + + private sealed class CompiledImeMatchPattern + { + public string PatternId { get; set; } + public Regex Regex { get; set; } + } + + private sealed class PatternsFileWrapper + { + [JsonProperty("rules")] + public List Rules { get; set; } = new List(); + } + + private sealed class CachedConfigWrapper + { + [JsonProperty("imeLogPatterns")] + public List ImeLogPatterns { get; set; } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs new file mode 100644 index 00000000..0d44ca35 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs @@ -0,0 +1,256 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Shared; +using Microsoft.Win32; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2 +{ + /// + /// --install mode: deploys the agent payload to the canonical install directory, + /// persists bootstrap and await-enrollment config for the Scheduled Task, registers + starts + /// the task, and writes the deployment registry marker. Plan §4.x M4.6.α. + /// + /// Ported 1:1 from Legacy Program.InstallMode.cs — the OOBE bootstrap script and the + /// Intune Platform Script contract expect exactly this sequence and these file locations. + /// The only delta is the V2 exe name / build-output directory. + /// + /// + public static partial class Program + { + internal const string DeploymentRegistryKey = @"SOFTWARE\AutopilotMonitor"; + internal const string DeploymentRegistryValue = "Deployed"; + internal const string BootstrapConfigFileName = "bootstrap-config.json"; + internal const string AwaitEnrollmentConfigFileName = "await-enrollment.json"; + private const string InstalledAgentExeName = "AutopilotMonitor.Agent.V2.exe"; + + internal static int RunInstallMode(string[] args) + { + var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); + var logger = new AgentLogger(logDir, AgentLogLevel.Debug); + var consoleMode = args.Contains("--console") || Environment.UserInteractive; + logger.EnableConsoleOutput = consoleMode; + + try + { + logger.Info("======================= Agent install mode (--install) ======================="); + + EnsureAgentDirectories(logger); + + var sourceExePath = Assembly.GetExecutingAssembly().Location; + var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty; + var targetAgentDir = Environment.ExpandEnvironmentVariables( + Path.Combine(Constants.AgentDataDirectory, DefaultAgentSubdirectory)); + var targetExePath = Path.Combine(targetAgentDir, InstalledAgentExeName); + + if (!string.Equals( + Path.GetFullPath(sourceDir).TrimEnd('\\'), + Path.GetFullPath(targetAgentDir).TrimEnd('\\'), + StringComparison.OrdinalIgnoreCase)) + { + logger.Info($"Install mode called from '{sourceDir}'. Deploying payload to '{targetAgentDir}'."); + CopyDirectory(sourceDir, targetAgentDir, logger); + } + else + { + logger.Info("Agent already running from target install directory; payload copy not required."); + } + + // Persist bootstrap config if --bootstrap-token was provided. The Scheduled Task + // command line has no args — the agent picks this up on the first post-install run. + var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token"); + var tenantIdArg = GetArgValue(args, "--tenant-id"); + if (!string.IsNullOrEmpty(bootstrapTokenArg)) + { + var bootstrapConfigPath = Path.Combine( + Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), + BootstrapConfigFileName); + var bootstrapConfig = new BootstrapConfigFile + { + BootstrapToken = bootstrapTokenArg, + TenantId = tenantIdArg, + }; + File.WriteAllText(bootstrapConfigPath, JsonConvert.SerializeObject(bootstrapConfig)); + logger.Info("Bootstrap config persisted for Scheduled Task."); + } + + // Persist await-enrollment config if requested. + if (args.Contains("--await-enrollment")) + { + var awaitConfigPath = Path.Combine( + Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), + AwaitEnrollmentConfigFileName); + var awaitTimeoutArg = GetArgValue(args, "--await-enrollment-timeout"); + var timeoutMinutes = 480; + if (!string.IsNullOrEmpty(awaitTimeoutArg) && int.TryParse(awaitTimeoutArg, out var parsed)) + timeoutMinutes = parsed; + + var awaitConfig = new AwaitEnrollmentConfigFile { TimeoutMinutes = timeoutMinutes }; + File.WriteAllText(awaitConfigPath, JsonConvert.SerializeObject(awaitConfig)); + logger.Info($"Await-enrollment config persisted for Scheduled Task (timeout: {timeoutMinutes}min)."); + } + + var taskName = Constants.ScheduledTaskName; + var taskCommand = $"\"{targetExePath}\""; + + logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}"); + + var createExitCode = RunProcess( + "schtasks.exe", + $"/Create /TN \"{taskName}\" /TR \"{taskCommand}\" /SC ONSTART /RU SYSTEM /RL HIGHEST /F", + logger); + + if (createExitCode != 0) + throw new InvalidOperationException( + $"Failed to create/update Scheduled Task '{taskName}' (exit code {createExitCode})."); + + logger.Info($"Scheduled Task '{taskName}' created/updated successfully."); + + var startExitCode = RunProcess( + "schtasks.exe", + $"/Run /TN \"{taskName}\"", + logger); + + if (startExitCode != 0) + throw new InvalidOperationException( + $"Failed to start Scheduled Task '{taskName}' (exit code {startExitCode})."); + + logger.Info($"Scheduled Task '{taskName}' started successfully."); + + TryWriteDeploymentMarker(logger); + + if (consoleMode) + { + Console.WriteLine("Installation completed successfully."); + Console.WriteLine($"Task: {taskName}"); + Console.WriteLine($"Executable: {targetExePath}"); + Console.WriteLine($"Log: {logDir}"); + } + + return 0; + } + catch (Exception ex) + { + logger.Error("Install mode failed.", ex); + if (consoleMode) Console.Error.WriteLine($"ERROR: {ex.Message}"); + return 1; + } + } + + private static void EnsureAgentDirectories(AgentLogger logger) + { + var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); + var paths = new[] + { + basePath, + Path.Combine(basePath, DefaultAgentSubdirectory), + Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory), + Environment.ExpandEnvironmentVariables(Constants.LogDirectory), + }; + + foreach (var path in paths) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + logger.Info($"Created directory: {path}"); + } + else + { + logger.Debug($"Directory already exists: {path}"); + } + } + } + + private static int RunProcess(string fileName, string arguments, AgentLogger logger) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + logger.Info($"Executing: {fileName} {arguments}"); + + using (var process = Process.Start(psi)) + { + if (process == null) + throw new InvalidOperationException($"Failed to start process: {fileName}"); + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (!string.IsNullOrWhiteSpace(stdout)) logger.Info($"Process output: {stdout.Trim()}"); + if (!string.IsNullOrWhiteSpace(stderr)) logger.Warning($"Process error output: {stderr.Trim()}"); + + logger.Info($"Process exit code: {process.ExitCode}"); + return process.ExitCode; + } + } + + private static void CopyDirectory(string sourceDir, string targetDir, AgentLogger logger) + { + if (!Directory.Exists(sourceDir)) + throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}"); + + Directory.CreateDirectory(targetDir); + + foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories)) + { + var rel = dir.Substring(sourceDir.Length).TrimStart('\\'); + Directory.CreateDirectory(Path.Combine(targetDir, rel)); + } + + foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)) + { + var rel = file.Substring(sourceDir.Length).TrimStart('\\'); + var dest = Path.Combine(targetDir, rel); + Directory.CreateDirectory(Path.GetDirectoryName(dest) ?? targetDir); + + try + { + File.Copy(file, dest, overwrite: true); + } + catch (Exception ex) + { + // Keep going on locked destinations — the Scheduled Task will pick up the + // latest available payload, and the runtime self-update path will retry later. + if (File.Exists(dest)) + { + logger.Warning($"Could not overwrite '{dest}': {ex.Message}. Keeping existing file."); + continue; + } + throw; + } + } + + logger.Info($"Payload deployment completed: '{sourceDir}' -> '{targetDir}'"); + } + + private static void TryWriteDeploymentMarker(AgentLogger logger) + { + try + { + using (var regKey = Registry.LocalMachine.CreateSubKey(DeploymentRegistryKey)) + { + regKey.SetValue(DeploymentRegistryValue, DateTime.UtcNow.ToString("O")); + } + logger.Info($"Deployment registry marker written (HKLM\\{DeploymentRegistryKey}\\{DeploymentRegistryValue})."); + } + catch (Exception ex) + { + logger.Warning($"Failed to write deployment registry marker: {ex.Message}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.ServerActionSynthesis.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.ServerActionSynthesis.cs new file mode 100644 index 00000000..63935dba --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.ServerActionSynthesis.cs @@ -0,0 +1,44 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Orchestration; + +namespace AutopilotMonitor.Agent.V2 +{ + /// + /// Helpers for synthesising Terminated-event args out of ServerAction payloads + /// (Plan §4.x M4.6.ε, Codex Finding 2 fix). Pure + internal so + /// AutopilotMonitor.Agent.V2.Core.Tests can exercise them against canned param + /// dictionaries without a full orchestrator harness. + /// + public static partial class Program + { + /// + /// Maps a terminate_session ServerAction's adminOutcome param onto an + /// . Portal Mark-Succeeded was previously + /// hard-coded to , so operators saw + /// failures in SummaryDialog + got diagnostics uploads fired even when they had + /// manually marked a session succeeded. + /// + /// Mapping: + /// + /// "Succeeded" (case-insensitive) → . + /// "Failed", other non-empty values → . + /// Missing/null/empty → (default-failure-safe + /// for a terminate_session action with no explicit outcome — e.g. a kill-signal-driven + /// synthesis that only sets origin=kill_signal). + /// + /// + /// + internal static EnrollmentTerminationOutcome MapAdminOutcome(IReadOnlyDictionary? parameters) + { + if (parameters == null) return EnrollmentTerminationOutcome.Failed; + if (!parameters.TryGetValue("adminOutcome", out var value) || string.IsNullOrEmpty(value)) + return EnrollmentTerminationOutcome.Failed; + + return string.Equals(value, "Succeeded", StringComparison.OrdinalIgnoreCase) + ? EnrollmentTerminationOutcome.Succeeded + : EnrollmentTerminationOutcome.Failed; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs new file mode 100644 index 00000000..04937b7c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -0,0 +1,838 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Runtime; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Termination; +using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2 +{ + /// + /// V2-Agent entry point. Plan §4.x M4.5.b + M4.6.α. + /// + /// Boot sequence (same order as Legacy for feature parity): + /// + /// + /// --help / --version short-circuit + /// --install forks to and exits + /// Multi-instance guard — single-agent invariant + /// Register ProcessExit → writes clean-exit.marker + /// Ensure agent directories exist + /// + + /// Load cached remote-config.json → + AllowAgentDowngrade + /// — on success restarts the process; on failure continues with current binary + /// — reads markers + event log to classify last shutdown + /// — ghost-restart detection + cleanup retry + /// — absolute session-age watchdog + /// Resolve TenantId (registry → bootstrap-config.json fallback) + /// Build (CLI args + persisted bootstrap / await-enrollment config) + /// Get/create SessionId via + /// (Optional) Wait for MDM certificate in --await-enrollment mode + /// Build + → fetch config + /// — H-2 mitigation post-cert + /// Build mTLS → + /// Build + + /// orchestrator.Start() → emit -derived event + /// Wait for Ctrl+C / ProcessExit / EnrollmentTerminated + /// orchestrator.Stop(), exit 0 + /// + /// + public static partial class Program + { + private const string DefaultStateDirectory = @"%ProgramData%\AutopilotMonitor"; + private const string DefaultLogDirectory = @"%ProgramData%\AutopilotMonitor\Logs"; + private const string DefaultAgentSubdirectory = "Agent"; + private const string DefaultV2StateSubdirectory = "V2"; + private const string DefaultTransportSubdirectory = "V2Transport"; + private const string CachedRemoteConfigPath = @"%ProgramData%\AutopilotMonitor\Config\remote-config.json"; + + public static int Main(string[] args) + { + if (args.Contains("--help") || args.Contains("-h") || args.Contains("-?")) + { + PrintUsage(); + return 0; + } + + if (args.Contains("--version")) + { + PrintVersion(); + return 0; + } + + // --install forks to a separate flow that exits when done (never falls through into RunAgent). + if (args.Contains("--install")) + { + return RunInstallMode(args); + } + + // --run-gather-rules / --run-ime-matching: standalone diagnostic modes (M4.6.δ). + // They neither touch the Scheduled Task nor the live agent's spool — safe to run + // alongside a normal monitoring agent for troubleshooting. + if (args.Contains("--run-gather-rules")) + { + return RunGatherRulesMode(args); + } + + if (args.Contains("--run-ime-matching")) + { + return RunImeMatchingMode(args); + } + + var consoleMode = args.Contains("--console") || Environment.UserInteractive; + + // Multi-instance guard — prevents a second agent process from running alongside one + // that was started by the Scheduled Task. + if (IsAnotherAgentInstanceRunning()) + { + var msg = "Another agent process is already running. This instance will exit."; + if (consoleMode) Console.Error.WriteLine($"ERROR: {msg}"); + try + { + var earlyLogger = new AgentLogger(Environment.ExpandEnvironmentVariables(DefaultLogDirectory)); + earlyLogger.Warning(msg); + } + catch { /* best-effort */ } + return 1; + } + + var dataDirectory = Environment.ExpandEnvironmentVariables(DefaultStateDirectory); + var logDirectory = Environment.ExpandEnvironmentVariables(DefaultLogDirectory); + + try { Directory.CreateDirectory(dataDirectory); } catch { } + try { Directory.CreateDirectory(logDirectory); } catch { } + + // Register the clean-exit marker writer BEFORE any risky startup work so an OS + // shutdown during self-update still produces a "clean" classification. + RegisterCleanExitMarker(dataDirectory); + + // Startup self-update. Legacy parity: cleanup leftover .old files, load cached + // backend hash / downgrade policy, then attempt the update. Failures here never + // abort startup — we prefer to run the current version than delay. + SelfUpdater.LogInit(GetAgentVersion()); + + var agentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty; + SelfUpdater.CleanupPreviousUpdate(agentDir, msg => { if (consoleMode) Console.Out.WriteLine(msg); }); + + var allowAgentDowngrade = LoadCachedSelfUpdateContext(); + + try + { + SelfUpdater.CheckAndApplyUpdateAsync( + currentVersion: GetAgentVersion(), + agentDir: agentDir, + consoleMode: consoleMode, + allowDowngrade: allowAgentDowngrade).GetAwaiter().GetResult(); + } + catch (Exception selfUpdateEx) + { + // SelfUpdater is designed to swallow its own errors; catch anything that still + // escapes so startup does not abort on an unexpected failure in the update path. + SelfUpdater.Log($"Self-update outer exception: {selfUpdateEx.Message}"); + } + + // At this point either (a) no update was applied, or (b) update applied but restart + // didn't happen — continue startup with the current binary. + + var logger = new AgentLogger(logDirectory) { EnableConsoleOutput = consoleMode }; + logger.Info($"AutopilotMonitor.Agent.V2 starting (version {GetAgentVersion()})."); + logger.Info($"Command line: {FormatArgsForLog(args)}"); + + try + { + return RunAgent(args, logger, dataDirectory, logDirectory, consoleMode); + } + catch (Exception ex) + { + logger.Error("V2 agent startup failed.", ex); + WriteCrashLog(logDirectory, ex); + if (consoleMode) Console.Error.WriteLine($"FATAL: {ex.Message}"); + return 1; + } + } + + // ---------------------------------------------------------------- Orchestration + + private static int RunAgent( + string[] args, + AgentLogger logger, + string dataDirectory, + string logDirectory, + bool consoleMode) + { + var stateSubdir = Path.Combine(dataDirectory, DefaultV2StateSubdirectory); + var transportDir = Path.Combine(dataDirectory, DefaultTransportSubdirectory); + + // Previous-exit classification (for the agent_started event + observability). + var previousExit = DetectPreviousExit(dataDirectory, logDirectory); + if (previousExit.ExitType != "first_run") + { + var crashSuffix = previousExit.CrashExceptionType != null ? $" ({previousExit.CrashExceptionType})" : ""; + logger.Info($"Previous exit: {previousExit.ExitType}{crashSuffix}"); + } + + // Merge persisted bootstrap-config.json + await-enrollment.json into CLI args early. + var bootstrapConfig = TryReadBootstrapConfig(dataDirectory, logger); + var awaitConfig = TryReadAwaitEnrollmentConfig(dataDirectory, logger); + + var tenantIdFromRegistry = TenantIdResolver.ResolveFromEnrollmentRegistry(logger); + var tenantId = !string.IsNullOrEmpty(tenantIdFromRegistry) + ? tenantIdFromRegistry + : bootstrapConfig?.TenantId; + + // --install time may have written a bootstrap config that holds a tenantId even when + // the registry is not yet populated (bootstrap token path). Honour that. + + if (args.Contains("--new-session")) + { + new SessionIdPersistence(dataDirectory).Delete(logger); + logger.Info("--new-session: cleared persisted SessionId."); + } + + var agentConfig = BuildAgentConfiguration(args, tenantId, sessionId: null, bootstrapConfig, awaitConfig); + + // Build a cleanup-service factory used by guards: instantiated lazily and with the + // current agentConfig so command-line overrides (e.g. --no-cleanup) take effect. + Func cleanupServiceFactory = () => new CleanupService(agentConfig, logger); + + if (CheckEnrollmentCompleteMarker( + dataDirectory, stateSubdir, + agentConfig.SelfDestructOnComplete, cleanupServiceFactory, logger, consoleMode)) + { + logger.Info("Enrollment-complete marker handled — agent exiting."); + return 0; + } + + if (CheckSessionAgeEmergencyBreak( + dataDirectory, stateSubdir, + agentConfig.AbsoluteMaxSessionHours, agentConfig.SelfDestructOnComplete, + cleanupServiceFactory, logger, consoleMode)) + { + logger.Info("Emergency break fired — agent exiting."); + return 0; + } + + // Session is safe to start — create/recover SessionId now (AFTER guards so a dead + // session is not resurrected before the watchdog can kill it). + agentConfig.SessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger); + + if (agentConfig.AwaitEnrollment) + { + logger.Info($"--await-enrollment: polling for MDM certificate (timeout: {agentConfig.AwaitEnrollmentTimeoutMinutes}min)."); + using (var cts = new CancellationTokenSource()) + { + var cert = EnrollmentAwaiter + .WaitForMdmCertificateAsync( + thumbprint: null, + timeoutMinutes: agentConfig.AwaitEnrollmentTimeoutMinutes, + logger: logger, + cancellationToken: cts.Token) + .GetAwaiter().GetResult(); + if (cert == null) + { + logger.Error("Await-enrollment: timed out waiting for MDM certificate — exiting."); + return 3; + } + } + + // Re-resolve TenantId — enrollment typically writes the registry key alongside the cert. + if (string.IsNullOrEmpty(agentConfig.TenantId)) + { + agentConfig.TenantId = TenantIdResolver.ResolveFromEnrollmentRegistry(logger); + if (!string.IsNullOrEmpty(agentConfig.TenantId)) + logger.Info($"Await-enrollment: TenantId discovered from registry: {agentConfig.TenantId}"); + } + + // Await-enrollment is one-shot — remove the persisted config so subsequent restarts proceed normally. + DeleteAwaitEnrollmentConfig(dataDirectory, logger); + } + + if (string.IsNullOrEmpty(agentConfig.TenantId)) + { + logger.Error("V2 agent cannot start: TenantId not available (registry empty + no bootstrap config)."); + return 2; + } + + var backendApiClient = new BackendApiClient( + baseUrl: agentConfig.ApiBaseUrl, + configuration: agentConfig, + logger: logger, + agentVersion: GetAgentVersion()); + + // M4.6.γ — Emergency + Distress reporters. Plumbed into RemoteConfigService so + // Config-fetch failures (auth vs network) flow to the correct channel. Also fires + // an initial AuthCertificateMissing distress when the MDM cert was expected but + // not found (Legacy parity — surfaces pre-MDM-enrollment dead-ends to the backend + // via the cert-less distress channel). + var hardwareForReporters = HardwareInfo.GetHardwareInfo(logger); + var distressReporter = new DistressReporter( + baseUrl: agentConfig.ApiBaseUrl, + tenantId: agentConfig.TenantId, + manufacturer: hardwareForReporters.Manufacturer, + model: hardwareForReporters.Model, + serialNumber: hardwareForReporters.SerialNumber, + agentVersion: GetAgentVersion(), + logger: logger); + + var emergencyReporter = new EmergencyReporter( + apiClient: backendApiClient, + sessionId: agentConfig.SessionId, + tenantId: agentConfig.TenantId, + agentVersion: GetAgentVersion(), + logger: logger); + + if (agentConfig.UseClientCertAuth && backendApiClient.ClientCertificate == null) + { + _ = distressReporter.TrySendAsync( + DistressErrorType.AuthCertificateMissing, + "MDM certificate not found in LocalMachine or CurrentUser store"); + } + + var remoteConfigService = new RemoteConfigService( + backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter); + var remoteConfig = remoteConfigService.FetchConfigAsync().GetAwaiter().GetResult(); + + logger.SetLogLevel(agentConfig.LogLevel); + + // Propagate the backend-expected SHA so the runtime hash-mismatch trigger (M4.6.α + // continues this wire; actual runtime trigger will be wired via ServerActionDispatcher + // in M4.6.β) has the up-to-date integrity hash. Also refresh AllowAgentDowngrade. + if (!string.IsNullOrEmpty(remoteConfig.LatestAgentSha256)) + SelfUpdater.BackendExpectedSha256 = remoteConfig.LatestAgentSha256; + + // H-2 mitigation: delete the persisted bootstrap-config.json once the MDM cert + // proves it can authenticate. Non-blocking — any failure leaves the file for retry. + try + { + BootstrapConfigCleanup + .TryDeleteIfCertReadyAsync(agentConfig, logger, GetAgentVersion()) + .GetAwaiter().GetResult(); + } + catch (Exception ex) + { + logger.Debug($"BootstrapConfigCleanup outer exception: {ex.Message}"); + } + + HttpClient mtlsHttpClient; + BackendTelemetryUploader uploader; + try + { + mtlsHttpClient = MtlsHttpClientFactory.Create( + resolver: new DefaultCertificateResolver(), + logger: logger); + } + catch (InvalidOperationException ex) + { + logger.Error("mTLS HttpClient creation failed — cannot upload telemetry.", ex); + return 4; + } + + try + { + uploader = new BackendTelemetryUploader( + httpClient: mtlsHttpClient, + baseUrl: agentConfig.ApiBaseUrl, + tenantId: agentConfig.TenantId, + manufacturer: hardwareForReporters.Manufacturer, + model: hardwareForReporters.Model, + serialNumber: hardwareForReporters.SerialNumber, + bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null, + agentVersion: GetAgentVersion()); + } + catch (Exception ex) + { + logger.Error("BackendTelemetryUploader construction failed.", ex); + return 5; + } + + var classifiers = new IClassifier[] + { + new WhiteGloveSealingClassifier(), + new WhiteGlovePart2CompletionClassifier(), + }; + + var componentFactory = new DefaultComponentFactory( + agentConfig: agentConfig, + remoteConfig: remoteConfig, + networkMetrics: backendApiClient.NetworkMetrics, + agentVersion: GetAgentVersion(), + stateDirectory: stateSubdir); + + var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection)remoteConfig.WhiteGloveSealingPatternIds + ?? Array.Empty(); + + var agentMaxLifetime = agentConfig.AgentMaxLifetimeMinutes > 0 + ? (TimeSpan?)TimeSpan.FromMinutes(agentConfig.AgentMaxLifetimeMinutes) + : null; + + // Diagnostics upload delegate — wraps the production DiagnosticsPackageService. + // Instantiated lazily + per-invocation (cheap) so we always pick up current config. + var diagnosticsService = new DiagnosticsPackageService(agentConfig, logger, backendApiClient); + Func> uploadDiagnosticsAsync = + (succeeded, suffix) => diagnosticsService.CreateAndUploadAsync(succeeded, suffix); + + var agentStartTimeUtc = DateTime.UtcNow; + + using (var orchestrator = new EnrollmentOrchestrator( + sessionId: agentConfig.SessionId, + tenantId: agentConfig.TenantId, + stateDirectory: stateSubdir, + transportDirectory: transportDir, + clock: SystemClock.Instance, + logger: logger, + uploader: uploader, + classifiers: classifiers, + componentFactory: componentFactory, + whiteGloveSealingPatternIds: whiteGloveSealingPatternIds, + agentMaxLifetime: agentMaxLifetime)) + { + using (var shutdown = new ManualResetEventSlim(false)) + { + // M4.6.δ — Analyzer manager. Emits through the live orchestrator's event + // emitter. RunStartup fires after orchestrator.Start; RunShutdown is wired + // into the termination handler so it runs before diagnostics upload. + var analyzerManager = new AgentAnalyzerManager( + configuration: agentConfig, + logger: logger, + emitEvent: evt => { orchestrator.EventEmitter.Emit(evt); }, + analyzerConfig: remoteConfig.Analyzers); + + // M4.6.β — the peripheral termination sequence (FinalStatus + SummaryDialog + // + diagnostics upload + enrollment-complete.marker + CleanupService) lives + // in Program.cs, not in the kernel. We compose it here and hook it onto the + // orchestrator's typed Terminated event. + var terminationHandler = new EnrollmentTerminationHandler( + configuration: agentConfig, + logger: logger, + stateDirectory: stateSubdir, + agentStartTimeUtc: agentStartTimeUtc, + currentStateAccessor: () => orchestrator.CurrentState, + packageStatesAccessor: () => componentFactory.ImePackageStates, + cleanupServiceFactory: () => new CleanupService(agentConfig, logger), + uploadDiagnosticsAsync: uploadDiagnosticsAsync, + signalShutdown: () => shutdown.Set(), + analyzerManager: analyzerManager); + + // ServerActionDispatcher — handlers are live and the M4.6.ε response-parse + // path below feeds it. RotateConfig refetches remote config; + // RequestDiagnostics triggers the same diagnostics pipeline as enrollment-end; + // TerminateSession routes to the termination handler (reason: server-requested). + var serverActionDispatcher = new ServerActionDispatcher( + configuration: agentConfig, + logger: logger, + rotateConfigAsync: async () => + { + try { var _ = await remoteConfigService.FetchConfigAsync(); return true; } + catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; } + }, + uploadDiagnosticsAsync: async (suffix) => + await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix), + onTerminateRequested: action => + { + var forceSelfDestruct = action?.Params != null + && action.Params.TryGetValue("forceSelfDestruct", out var f) + && string.Equals(f, "true", StringComparison.OrdinalIgnoreCase); + if (forceSelfDestruct && !agentConfig.SelfDestructOnComplete) + { + logger.Warning("ServerAction terminate_session: forceSelfDestruct=true overrides SelfDestructOnComplete=false."); + agentConfig.SelfDestructOnComplete = true; + } + + // Codex Finding 2: forward adminOutcome from the ServerAction params so + // a portal Mark-Succeeded really lands as Succeeded locally (was + // hard-coded to Failed before, masquerading every admin override as an + // error in SummaryDialog + firing spurious diagnostics uploads). + var mappedOutcome = MapAdminOutcome(action?.Params); + + logger.Warning($"ServerAction terminate_session received (ruleId={action?.RuleId}, reason={action?.Reason}, outcome={mappedOutcome}) — invoking termination handler."); + // Synthesise a Terminated event as if the kernel fired it. + terminationHandler.Handle( + sender: null, + args: new EnrollmentTerminatedEventArgs( + reason: EnrollmentTerminationReason.DecisionTerminalStage, + outcome: mappedOutcome, + stageName: orchestrator.CurrentState?.Stage.ToString(), + terminatedAtUtc: DateTime.UtcNow, + details: $"Server-requested termination: ruleId={action?.RuleId}, reason={action?.Reason}")); + return Task.CompletedTask; + }, + emitEvent: evt => { orchestrator.EventEmitter.Emit(evt); }); + + // M4.6.ε — BackendTelemetryUploader response-plumbing. The orchestrator parses + // DeviceBlocked / DeviceKillSignal / AdminAction / Actions out of the 2xx + // response body (see BackendTelemetryUploader.TryReadControlSignalsAsync) and + // raises ServerResponseReceived. We translate those into ServerActions and + // dispatch. Legacy parity with IngestEventsResponse synthesis in + // EventUploadOrchestrator.OnEventsUploaded(). + WireTelemetryServerResponse(orchestrator, serverActionDispatcher, logger); + + ConsoleCancelEventHandler cancelHandler = (s, e) => + { + e.Cancel = true; + logger.Info("Ctrl+C received — initiating graceful shutdown."); + shutdown.Set(); + }; + EventHandler processExitHandler = (s, e) => + { + logger.Info("ProcessExit — initiating graceful shutdown."); + shutdown.Set(); + }; + + Console.CancelKeyPress += cancelHandler; + AppDomain.CurrentDomain.ProcessExit += processExitHandler; + orchestrator.Terminated += terminationHandler.Handle; + + try + { + orchestrator.Start(); + + // Emit the agent_version_check event now that the EventEmitter is alive. + // VersionCheckEventBuilder.TryBuild is a no-op when no markers are present. + EmitVersionCheckEventIfAny(orchestrator, agentConfig, logger); + + // M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory / + // IntegrityBypass). Runs on a background task inside AgentAnalyzerManager. + try { analyzerManager.RunStartup(); } + catch (Exception ex) { logger.Warning($"AnalyzerManager.RunStartup threw: {ex.Message}"); } + + // M4.6.γ — fire-and-forget startup probes (geo / timezone / NTP). Runs on + // the ThreadPool so a slow network never delays the critical path. + _ = Task.Run(async () => + { + try + { + await StartupEnvironmentProbes + .RunAsync(agentConfig, logger, orchestrator.EventEmitter) + .ConfigureAwait(false); + } + catch (Exception ex) + { + logger.Warning($"StartupEnvironmentProbes: outer exception: {ex.Message}"); + } + }); + + logger.Info($"V2 agent runtime ready (session={agentConfig.SessionId}, tenant={agentConfig.TenantId})."); + if (consoleMode) + Console.Out.WriteLine("AutopilotMonitor.Agent.V2 running. Press Ctrl+C to stop."); + + shutdown.Wait(); + } + finally + { + Console.CancelKeyPress -= cancelHandler; + AppDomain.CurrentDomain.ProcessExit -= processExitHandler; + orchestrator.Terminated -= terminationHandler.Handle; + + try { orchestrator.Stop(); } + catch (Exception ex) { logger.Error("Orchestrator stop failed.", ex); } + + try { mtlsHttpClient.Dispose(); } catch { } + try { backendApiClient.Dispose(); } catch { } + } + } + } + + logger.Info("AutopilotMonitor.Agent.V2 stopped cleanly."); + return 0; + } + + /// + /// M4.6.ε — routes + /// control signals through the . Synthesises the + /// same terminate_session s that Legacy built in + /// EventUploadOrchestrator.OnEventsUploaded (kill-signal → force self-destruct; + /// admin-action → soft terminate respecting SelfDestructOnComplete). + /// + private static void WireTelemetryServerResponse( + EnrollmentOrchestrator orchestrator, + ServerActionDispatcher dispatcher, + AgentLogger logger) + { + orchestrator.Transport.ServerResponseReceived += (sender, upload) => + { + var toDispatch = new System.Collections.Generic.List(); + + if (upload.DeviceKillSignal) + { + logger.Warning("Backend signalled DeviceKillSignal — synthesising terminate_session (force self-destruct)."); + toDispatch.Add(new ServerAction + { + Type = ServerActionTypes.TerminateSession, + Reason = "DeviceKillSignal from administrator", + QueuedAt = DateTime.UtcNow, + Params = new System.Collections.Generic.Dictionary + { + { "forceSelfDestruct", "true" }, + { "gracePeriodSeconds", "0" }, + { "origin", "kill_signal" }, + }, + }); + } + else if (!string.IsNullOrEmpty(upload.AdminAction)) + { + logger.Warning($"Backend signalled AdminAction={upload.AdminAction} — synthesising terminate_session (soft)."); + toDispatch.Add(new ServerAction + { + Type = ServerActionTypes.TerminateSession, + Reason = $"Admin marked session as {upload.AdminAction}", + QueuedAt = DateTime.UtcNow, + Params = new System.Collections.Generic.Dictionary + { + { "adminOutcome", upload.AdminAction }, + { "gracePeriodSeconds", "0" }, + { "origin", "admin_action" }, + }, + }); + } + + if (upload.Actions != null && upload.Actions.Count > 0) + { + foreach (var a in upload.Actions) toDispatch.Add(a); + } + + if (upload.DeviceBlocked) + { + // DeviceBlocked is non-terminal: the transport already paused its drain + // loop in TelemetryUploadOrchestrator.ApplyControlSignals; we just log it + // here so it surfaces on the console / agent log. + var until = upload.UnblockAt.HasValue ? $"until {upload.UnblockAt.Value:O}" : "indefinitely"; + logger.Warning($"Backend signalled DeviceBlocked {until} — uploads paused, session remains alive."); + } + + if (toDispatch.Count == 0) return; + + try { dispatcher.DispatchAsync(toDispatch).GetAwaiter().GetResult(); } + catch (Exception ex) { logger.Error("ServerActionDispatcher.DispatchAsync threw during server-response wiring.", ex); } + }; + } + + private static void EmitVersionCheckEventIfAny( + EnrollmentOrchestrator orchestrator, + AgentConfiguration agentConfig, + AgentLogger logger) + { + try + { + var buildResult = VersionCheckEventBuilder.TryBuild( + sessionId: agentConfig.SessionId, + tenantId: agentConfig.TenantId, + agentStartTimeUtc: DateTime.UtcNow); + + if (!string.IsNullOrEmpty(buildResult?.ParseError)) + logger.Warning($"VersionCheckEventBuilder parse error: {buildResult.ParseError}"); + + if (buildResult?.Event != null) + { + orchestrator.EventEmitter.Emit(buildResult.Event); + logger.Info($"agent_version_check emitted (outcome={buildResult.Outcome})."); + } + else if (buildResult?.Deduped == true) + { + logger.Debug($"agent_version_check deduped (outcome={buildResult.Outcome})."); + } + } + catch (Exception ex) + { + logger.Warning($"VersionCheckEventBuilder emission failed: {ex.Message}"); + } + } + + // ---------------------------------------------------------------- Configuration + + private static AgentConfiguration BuildAgentConfiguration( + string[] args, + string tenantId, + string sessionId, + BootstrapConfigFile bootstrapConfig, + AwaitEnrollmentConfigFile awaitConfig) + { + var apiBaseUrl = GetArgValue(args, "--api-url", "--backend-api") ?? Constants.ApiBaseUrl; + var imeLogPathOverride = GetArgValue(args, "--ime-log-path"); + var imeMatchLogPath = GetArgValue(args, "--ime-match-log"); + + var bootstrapToken = GetArgValue(args, "--bootstrap-token") + ?? bootstrapConfig?.BootstrapToken; + + var awaitEnrollment = args.Contains("--await-enrollment") || awaitConfig != null; + var rebootOnComplete = args.Contains("--reboot-on-complete"); + var disableGeoLocation = args.Contains("--disable-geolocation"); + var keepLogFile = args.Contains("--keep-logfile"); + var noCleanup = args.Contains("--no-cleanup"); + + var awaitTimeoutRaw = GetArgValue(args, "--await-enrollment-timeout"); + var awaitTimeoutMinutes = 480; + if (!string.IsNullOrEmpty(awaitTimeoutRaw) && int.TryParse(awaitTimeoutRaw, out var parsedTimeout)) + awaitTimeoutMinutes = parsedTimeout; + else if (awaitConfig != null) + awaitTimeoutMinutes = awaitConfig.TimeoutMinutes; + + var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken); + + var cliLogLevel = GetArgValue(args, "--log-level"); + var logLevel = AgentLogLevel.Info; + if (!string.IsNullOrEmpty(cliLogLevel) && Enum.TryParse(cliLogLevel, ignoreCase: true, out var parsedLevel)) + logLevel = parsedLevel; + + return new AgentConfiguration + { + ApiBaseUrl = apiBaseUrl, + SessionId = sessionId, + TenantId = tenantId, + SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory), + LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory), + UploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds, + MaxBatchSize = Constants.MaxBatchSize, + LogLevel = logLevel, + UseClientCertAuth = !useBootstrapTokenAuth, + BootstrapToken = bootstrapToken, + UseBootstrapTokenAuth = useBootstrapTokenAuth, + AwaitEnrollment = awaitEnrollment, + AwaitEnrollmentTimeoutMinutes = awaitTimeoutMinutes, + RebootOnComplete = rebootOnComplete, + EnableGeoLocation = !disableGeoLocation, + ImeLogPathOverride = imeLogPathOverride, + ImeMatchLogPath = imeMatchLogPath, + KeepLogFile = keepLogFile, + SelfDestructOnComplete = !noCleanup, + CommandLineArgs = FormatArgsForLog(args), + }; + } + + private static bool LoadCachedSelfUpdateContext() + { + try + { + var path = Environment.ExpandEnvironmentVariables(CachedRemoteConfigPath); + if (!File.Exists(path)) return false; + + var json = File.ReadAllText(path); + var cached = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + if (cached == null) return false; + + if (!string.IsNullOrEmpty(cached.LatestAgentSha256)) + { + SelfUpdater.BackendExpectedSha256 = cached.LatestAgentSha256; + SelfUpdater.Log( + $"Self-update: loaded backend integrity hash from cached config (sha256={cached.LatestAgentSha256.Substring(0, Math.Min(12, cached.LatestAgentSha256.Length))}...)"); + } + + return cached.AllowAgentDowngrade; + } + catch (Exception ex) + { + SelfUpdater.Log($"Self-update: cached config read failed: {ex.Message}"); + return false; + } + } + + internal static string GetArgValue(string[] args, params string[] names) + { + if (args == null || args.Length < 2 || names == null) return null; + for (int i = 0; i < args.Length - 1; i++) + { + foreach (var name in names) + { + if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + } + } + return null; + } + + private static string FormatArgsForLog(string[] args) + { + if (args == null || args.Length == 0) return string.Empty; + + var parts = new System.Collections.Generic.List(args.Length); + for (int i = 0; i < args.Length; i++) + { + var a = args[i]; + if (string.Equals(a, "--bootstrap-token", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) + { + parts.Add(a); + parts.Add("[redacted]"); + i++; + continue; + } + parts.Add(a); + } + return string.Join(" ", parts); + } + + // ---------------------------------------------------------------- Info + + private static void PrintUsage() + { + var v = GetAgentVersion(); + Console.Out.WriteLine($"Autopilot Monitor Agent V2 v{v}"); + Console.Out.WriteLine(); + Console.Out.WriteLine("Usage: AutopilotMonitor.Agent.V2.exe [options]"); + Console.Out.WriteLine(); + Console.Out.WriteLine("Modes:"); + Console.Out.WriteLine(" --install Deploy payload, create Scheduled Task, and start it"); + Console.Out.WriteLine(" --tenant-id Tenant ID for bootstrap-config (used with --install)"); + Console.Out.WriteLine(" (default) Run enrollment monitoring"); + Console.Out.WriteLine(); + Console.Out.WriteLine("General options:"); + Console.Out.WriteLine(" --help, -h, -? Show this help message"); + Console.Out.WriteLine(" --version Print version and exit"); + Console.Out.WriteLine(" --console Enable console output (mirrors log to stdout)"); + Console.Out.WriteLine(" --log-level Override log level (Info, Debug, Verbose, Trace)"); + Console.Out.WriteLine(" --new-session Force a new session ID (delete persisted session)"); + Console.Out.WriteLine(" --keep-logfile Preserve log directory after self-destruct cleanup"); + Console.Out.WriteLine(" --no-cleanup Disable self-destruct on enrollment completion"); + Console.Out.WriteLine(" --reboot-on-complete Reboot the device after enrollment completes"); + Console.Out.WriteLine(" --disable-geolocation Skip geo-location detection"); + Console.Out.WriteLine(); + Console.Out.WriteLine("Authentication:"); + Console.Out.WriteLine(" --bootstrap-token Use bootstrap token auth (pre-MDM OOBE phase)"); + Console.Out.WriteLine(); + Console.Out.WriteLine("Await-enrollment mode:"); + Console.Out.WriteLine(" --await-enrollment Wait for MDM certificate before starting monitoring"); + Console.Out.WriteLine(" --await-enrollment-timeout Timeout in minutes (default: 480)"); + Console.Out.WriteLine(); + Console.Out.WriteLine("Overrides:"); + Console.Out.WriteLine(" --api-url Override backend API base URL (alias: --backend-api)"); + Console.Out.WriteLine(" --ime-log-path Override IME logs directory"); + Console.Out.WriteLine(" --ime-match-log Write matched IME log lines to file (debug)"); + } + + private static void PrintVersion() + { + Console.Out.WriteLine(GetAgentVersion()); + } + + private static string GetAgentVersion() + { + try + { + var asm = Assembly.GetExecutingAssembly(); + var info = asm.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(info)) return info; + + var file = asm.GetCustomAttribute()?.Version; + if (!string.IsNullOrWhiteSpace(file)) return file; + + return asm.GetName().Version?.ToString() ?? "0.0.0.0"; + } + catch + { + return "0.0.0.0"; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt new file mode 100644 index 00000000..c739b42c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -0,0 +1 @@ +44 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 89a41372..7e179677 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1039 +1071 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj new file mode 100644 index 00000000..ec7bf44b --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj @@ -0,0 +1,29 @@ + + + + + net8.0 + latest + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassifierTickFromSessionStartTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassifierTickFromSessionStartTests.cs new file mode 100644 index 00000000..39af8c67 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassifierTickFromSessionStartTests.cs @@ -0,0 +1,135 @@ +using System; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Plan §4.x M4.4.4 — ClassifierTick is armed on SessionStarted so the + /// WhiteGlove classifier re-evaluates periodically from the start of every session, + /// not only reactively on the first WG-relevant signal (which never fires for non-WG + /// paths and closed the M3 re-trigger-lücke). + /// + public sealed class ClassifierTickFromSessionStartTests + { + private static readonly DateTime SessionStartUtc = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + [Fact] + public void SessionStarted_from_fresh_state_arms_ClassifierTick_deadline() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step = engine.Reduce(state, MakeSessionStarted(ordinal: 0)); + + Assert.True(step.Transition.Taken); + Assert.Single(step.NewState.Deadlines); + Assert.Equal(DeadlineNames.ClassifierTick, step.NewState.Deadlines[0].Name); + } + + [Fact] + public void Armed_ClassifierTick_dueAt_equals_signalOccurredAtUtc_plus_30s() + { + // Plan §2.6 — deadline dueAt is deterministic from the triggering signal's + // OccurredAtUtc, not from clock.UtcNow. Replay must reproduce it. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step = engine.Reduce(state, MakeSessionStarted(ordinal: 0)); + var tick = step.NewState.Deadlines[0]; + + Assert.Equal(SessionStartUtc.AddSeconds(30), tick.DueAtUtc); + Assert.Equal(DecisionSignalKind.DeadlineFired, tick.FiresSignalKind); + Assert.NotNull(tick.FiresPayload); + Assert.Equal(DeadlineNames.ClassifierTick, tick.FiresPayload![SignalPayloadKeys.Deadline]); + } + + [Fact] + public void SessionStarted_emits_ScheduleDeadline_effect_for_ClassifierTick() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step = engine.Reduce(state, MakeSessionStarted(ordinal: 0)); + + Assert.Single(step.Effects); + var effect = step.Effects[0]; + Assert.Equal(DecisionEffectKind.ScheduleDeadline, effect.Kind); + Assert.NotNull(effect.Deadline); + Assert.Equal(DeadlineNames.ClassifierTick, effect.Deadline!.Name); + Assert.Equal(step.NewState.Deadlines[0].DueAtUtc, effect.Deadline.DueAtUtc); + } + + [Fact] + public void DeadEnd_SessionStarted_on_active_state_does_NOT_arm_ClassifierTick() + { + // Plan §2.5 — dead-end branch (replay of truncated log, session already mid-flight) + // must not reset state; specifically, no new deadline. + var engine = new DecisionEngine(); + var active = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .WithStage(SessionStage.AwaitingHello) + .WithStepIndex(5) + .WithLastAppliedSignalOrdinal(4) + .Build(); + + var step = engine.Reduce(active, MakeSessionStarted(ordinal: 5)); + + Assert.False(step.Transition.Taken); + Assert.Empty(step.NewState.Deadlines); + Assert.Empty(step.Effects); + } + + [Fact] + public void ClassifierTick_from_session_start_persists_in_snapshot_and_fires_via_DeadlineFired() + { + // End-to-end: SessionStarted arms the tick; firing the deadline runs classifier + + // re-arms. Wiring depends only on the generic DeadlineFired dispatcher + the + // existing HandleClassifierTickDeadlineFired handler from M3.3+. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var startStep = engine.Reduce(state, MakeSessionStarted(ordinal: 0)); + Assert.Single(startStep.NewState.Deadlines); + + var firedSignal = MakeDeadlineFired( + ordinal: 1, + occurredAtUtc: SessionStartUtc.AddSeconds(30), + deadlineName: DeadlineNames.ClassifierTick); + + var firedStep = engine.Reduce(startStep.NewState, firedSignal); + + Assert.True(firedStep.Transition.Taken); + // After fire: the deadline is cancelled + re-armed (same name, new dueAt). + Assert.Single(firedStep.NewState.Deadlines); + Assert.Equal(DeadlineNames.ClassifierTick, firedStep.NewState.Deadlines[0].Name); + Assert.Equal(SessionStartUtc.AddSeconds(60), firedStep.NewState.Deadlines[0].DueAtUtc); + } + + private static DecisionSignal MakeSessionStarted(long ordinal) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 1, + occurredAtUtc: SessionStartUtc, + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "test")); + + private static DecisionSignal MakeDeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: DecisionSignalKind.DeadlineFired, + kindSchemaVersion: 1, + occurredAtUtc: occurredAtUtc, + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Synthetic, $"deadline:{deadlineName}:fired", "test"), + payload: new System.Collections.Generic.Dictionary + { + [SignalPayloadKeys.Deadline] = deadlineName, + }); + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateBuilderTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateBuilderTests.cs new file mode 100644 index 00000000..2400d58e --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateBuilderTests.cs @@ -0,0 +1,81 @@ +using System; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + public sealed class DecisionStateBuilderTests + { + [Fact] + public void Build_withoutChanges_returnsEquivalentState() + { + var original = DecisionState.CreateInitial("s", "t"); + var rebuilt = original.ToBuilder().Build(); + + Assert.NotSame(original, rebuilt); + Assert.Equal(original.SessionId, rebuilt.SessionId); + Assert.Equal(original.Stage, rebuilt.Stage); + Assert.Equal(original.StepIndex, rebuilt.StepIndex); + Assert.Equal(original.LastAppliedSignalOrdinal, rebuilt.LastAppliedSignalOrdinal); + } + + [Fact] + public void WithStage_returnsNewInstance_originalUnchanged() + { + var original = DecisionState.CreateInitial("s", "t"); + var updated = original.ToBuilder().WithStage(SessionStage.AwaitingHello).Build(); + + Assert.Equal(SessionStage.SessionStarted, original.Stage); + Assert.Equal(SessionStage.AwaitingHello, updated.Stage); + } + + [Fact] + public void AddDeadline_sameName_replacesExisting() + { + var original = DecisionState.CreateInitial("s", "t"); + var dl1 = new ActiveDeadline( + name: "hello_safety", + dueAtUtc: new DateTime(2026, 4, 20, 10, 5, 0, DateTimeKind.Utc), + firesSignalKind: DecisionSignalKind.DeadlineFired); + var dl2 = new ActiveDeadline( + name: "hello_safety", + dueAtUtc: new DateTime(2026, 4, 20, 10, 10, 0, DateTimeKind.Utc), + firesSignalKind: DecisionSignalKind.DeadlineFired); + + var result = original.ToBuilder().AddDeadline(dl1).AddDeadline(dl2).Build(); + + Assert.Single(result.Deadlines); + Assert.Equal(dl2.DueAtUtc, result.Deadlines[0].DueAtUtc); + } + + [Fact] + public void CancelDeadline_removesByName() + { + var original = DecisionState.CreateInitial("s", "t"); + var dl = new ActiveDeadline( + name: "hello_safety", + dueAtUtc: DateTime.UtcNow.AddMinutes(5), + firesSignalKind: DecisionSignalKind.DeadlineFired); + + var withDeadline = original.ToBuilder().AddDeadline(dl).Build(); + var cancelled = withDeadline.ToBuilder().CancelDeadline("hello_safety").Build(); + + Assert.Single(withDeadline.Deadlines); + Assert.Empty(cancelled.Deadlines); + } + + [Fact] + public void WithCurrentEnrollmentPhase_stampsSourceOrdinal() + { + var original = DecisionState.CreateInitial("s", "t"); + var updated = original.ToBuilder() + .WithCurrentEnrollmentPhase(EnrollmentPhase.AccountSetup, sourceSignalOrdinal: 7) + .Build(); + + Assert.Equal(EnrollmentPhase.AccountSetup, updated.CurrentEnrollmentPhase!.Value); + Assert.Equal(7, updated.CurrentEnrollmentPhase!.SourceSignalOrdinal); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DiagnosticSignalHandlerTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DiagnosticSignalHandlerTests.cs new file mode 100644 index 00000000..e91536c4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DiagnosticSignalHandlerTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Plan §4.x M4.4.3 — and + /// are diagnostic signals that + /// previously fell through to HandleUnhandledSignal and produced + /// unhandled_signal_kind:* dead-ends in the journal. They are now first-class + /// neutral observations: bookkeeping advances, the transition is Taken, the stage + /// is unchanged, no effects, no hypothesis impact. + /// + public sealed class DiagnosticSignalHandlerTests + { + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_records_taken_transition_without_deadend(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step = engine.Reduce(state, MakeSignal(kind, ordinal: 3)); + + Assert.True(step.Transition.Taken); + Assert.Null(step.Transition.DeadEndReason); + Assert.Equal(kind.ToString(), step.Transition.Trigger); + } + + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_leaves_stage_and_hypotheses_unchanged(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var midFlight = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .WithStage(SessionStage.EspAccountSetup) + .WithStepIndex(10) + .WithLastAppliedSignalOrdinal(9) + .Build(); + + var step = engine.Reduce(midFlight, MakeSignal(kind, ordinal: 10)); + + Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage); + Assert.Same(midFlight.EnrollmentType, step.NewState.EnrollmentType); + Assert.Same(midFlight.WhiteGloveSealing, step.NewState.WhiteGloveSealing); + Assert.Same(midFlight.DeviceOnlyDeployment, step.NewState.DeviceOnlyDeployment); + } + + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_advances_bookkeeping(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .WithStepIndex(7) + .WithLastAppliedSignalOrdinal(6) + .Build(); + + var step = engine.Reduce(state, MakeSignal(kind, ordinal: 7)); + + Assert.Equal(8, step.NewState.StepIndex); + Assert.Equal(7, step.NewState.LastAppliedSignalOrdinal); + } + + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_emits_no_effects(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step = engine.Reduce(state, MakeSignal(kind)); + + Assert.Empty(step.Effects); + } + + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_does_not_cancel_active_deadlines(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var stateWithDeadline = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .AddDeadline(new ActiveDeadline( + name: DeadlineNames.HelloSafety, + dueAtUtc: new DateTime(2026, 4, 20, 10, 5, 0, DateTimeKind.Utc), + firesSignalKind: DecisionSignalKind.DeadlineFired)) + .Build(); + + var step = engine.Reduce(stateWithDeadline, MakeSignal(kind)); + + Assert.Single(step.NewState.Deadlines); + Assert.Equal(DeadlineNames.HelloSafety, step.NewState.Deadlines[0].Name); + } + + [Fact] + public void Diagnostic_signals_at_higher_schema_version_fall_through_to_unhandled() + { + // §2.2 L.6 — dispatch is keyed on (Kind, SchemaVersion). If a collector ever emits + // a v2 variant before the reducer handles it, the kernel must dead-end (not + // silently record), so replay flags the version gap. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step = engine.Reduce(state, MakeSignal(DecisionSignalKind.DeviceInfoCollected, schemaVersion: 2)); + + Assert.False(step.Transition.Taken); + Assert.Equal("unhandled_signal_kind:DeviceInfoCollected:v2", step.Transition.DeadEndReason); + } + + private static DecisionSignal MakeSignal( + DecisionSignalKind kind, + long ordinal = 0, + int schemaVersion = 1, + IReadOnlyDictionary? payload = null) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: schemaVersion, + occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc), + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Raw, $"test:{kind}", "test"), + payload: payload); + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs new file mode 100644 index 00000000..d7953b76 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ClassifierAwareReplayHarness.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Tests.Harness +{ + /// + /// Replay harness that also executes + /// effects, synthesizing the resulting + /// signal and feeding it back into the reducer. Emulates the production EffectRunner + /// for the pure-synchronous classifier case used in M3.3 / M3.4 (plan §2.4). + /// + /// Anti-loop (plan §2.4): a classifier is skipped when the snapshot's + /// matches the last + /// recorded for that classifier — tested + /// end-to-end by the Anti-Loop scenario. + /// + /// + public sealed class ClassifierAwareReplayHarness + { + private readonly IDecisionEngine _engine; + private readonly IReadOnlyDictionary _classifiers; + + public ClassifierAwareReplayHarness( + IDecisionEngine engine, + IReadOnlyDictionary classifiers) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _classifiers = classifiers ?? throw new ArgumentNullException(nameof(classifiers)); + } + + public ReplayResult Replay( + string sessionId, + string tenantId, + IReadOnlyList signals) + { + if (signals == null) throw new ArgumentNullException(nameof(signals)); + + var state = DecisionState.CreateInitial(sessionId, tenantId); + var allTransitions = new List(signals.Count * 2); + ClassifierRunStats = new Dictionary(); + + long nextSyntheticOrdinal = long.MinValue; // reassigned once we know the max real ordinal. + foreach (var s in signals) + { + if (s.SessionSignalOrdinal + 1 > nextSyntheticOrdinal) + { + nextSyntheticOrdinal = s.SessionSignalOrdinal + 1; + } + } + if (nextSyntheticOrdinal == long.MinValue) nextSyntheticOrdinal = 0; + + foreach (var signal in signals) + { + var step = _engine.Reduce(state, signal); + state = step.NewState; + allTransitions.Add(step.Transition); + + // Process effects: the only effect kind the harness acts on is RunClassifier; + // scheduling / cancellation / event emission are bookkept by state + the + // transition record. + foreach (var effect in step.Effects) + { + if (effect.Kind != DecisionEffectKind.RunClassifier) continue; + if (string.IsNullOrEmpty(effect.ClassifierId)) continue; + if (!_classifiers.TryGetValue(effect.ClassifierId!, out var classifier)) continue; + + // Anti-loop: skip if snapshot hash matches the last verdict hash for this + // classifier. For M3.3 we only track WhiteGloveSealing on state, so the + // check is tight. Other classifiers will get their own state fields in M3.4+. + var snapshot = effect.ClassifierSnapshot + ?? throw new InvalidOperationException( + $"RunClassifier effect for '{effect.ClassifierId}' missing snapshot."); + + var hashable = snapshot as IClassifierSnapshot; + var snapshotHash = hashable?.ComputeInputHash() ?? string.Empty; + var lastVerdictId = ClassifierVerdictLookup.LookupLastVerdictId(state, effect.ClassifierId!); + if (!string.IsNullOrEmpty(snapshotHash) && + !string.IsNullOrEmpty(lastVerdictId) && + snapshotHash == lastVerdictId) + { + Increment(effect.ClassifierId!, "skipped_by_antiloop"); + continue; + } + + var verdict = classifier.Classify(snapshot); + Increment(effect.ClassifierId!, "run"); + + var verdictSignal = new DecisionSignal( + sessionSignalOrdinal: nextSyntheticOrdinal, + sessionTraceOrdinal: nextSyntheticOrdinal, + kind: DecisionSignalKind.ClassifierVerdictIssued, + kindSchemaVersion: 1, + occurredAtUtc: signal.OccurredAtUtc, + sourceOrigin: $"harness:classifier:{effect.ClassifierId}", + evidence: new Evidence( + kind: EvidenceKind.Synthetic, + identifier: $"classifier:{effect.ClassifierId}:{verdict.InputHash}", + summary: $"{effect.ClassifierId}={verdict.Level}/{verdict.Score}"), + payload: new Dictionary + { + ["classifier"] = verdict.ClassifierId, + ["level"] = verdict.Level.ToString(), + ["score"] = verdict.Score.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["reason"] = verdict.Reason, + ["inputHash"] = verdict.InputHash, + }); + nextSyntheticOrdinal++; + + var verdictStep = _engine.Reduce(state, verdictSignal); + state = verdictStep.NewState; + allTransitions.Add(verdictStep.Transition); + } + } + + return new ReplayResult( + finalState: state, + transitions: allTransitions, + finalStepHash: ComputeStepHash(state)); + } + + /// Run counts per classifier (keys: "run", "skipped_by_antiloop"). For test assertions. + public IDictionary ClassifierRunStats { get; private set; } = new Dictionary(); + + private void Increment(string classifierId, string bucket) + { + var key = $"{classifierId}:{bucket}"; + ClassifierRunStats[key] = ClassifierRunStats.TryGetValue(key, out var v) ? v + 1 : 1; + } + + private static string ComputeStepHash(DecisionState state) + { + var canonical = string.Join( + "|", + state.SessionId, + state.TenantId, + state.Stage.ToString(), + state.Outcome?.ToString() ?? "null", + state.StepIndex.ToString(), + state.LastAppliedSignalOrdinal.ToString(), + state.WhiteGloveSealing.Level.ToString(), + state.WhiteGloveSealing.Score.ToString()); + + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(canonical)); + var sb = new System.Text.StringBuilder(16); + for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2")); + return sb.ToString(); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/FixtureLoader.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/FixtureLoader.cs new file mode 100644 index 00000000..fc8dfbe1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/FixtureLoader.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.DecisionCore.Tests.Harness +{ + /// + /// Load a DecisionSignal JSONL fixture from disk. Plan §4 M2 harness integration. + /// Comment lines starting with '#' and blank lines are ignored. + /// + public static class FixtureLoader + { + public static IReadOnlyList LoadJsonl(string path) + { + var signals = new List(); + foreach (var rawLine in File.ReadAllLines(path)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') continue; + signals.Add(SignalSerializer.Deserialize(line)); + } + return signals; + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs new file mode 100644 index 00000000..51da02d6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/ReplayHarness.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Tests.Harness +{ + /// + /// Replay harness — plan §4 M2. + /// + /// Reads a JSONL stream of records, feeds them through + /// sequentially, and computes a deterministic + /// over the end state plus a terminal-stage + /// assertion hook. M3 lights up the real reducer; M2 only needs the empty-stream path + /// to run deterministically (gate requirement). + /// + /// + public sealed class ReplayHarness + { + private readonly IDecisionEngine _engine; + + public ReplayHarness(IDecisionEngine engine) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + } + + public ReplayResult Replay( + string sessionId, + string tenantId, + IReadOnlyList signals) + { + if (signals == null) + { + throw new ArgumentNullException(nameof(signals)); + } + + var state = DecisionState.CreateInitial(sessionId, tenantId); + var transitions = new List(signals.Count); + + foreach (var signal in signals) + { + var step = _engine.Reduce(state, signal); + state = step.NewState; + transitions.Add(step.Transition); + } + + return new ReplayResult( + finalState: state, + transitions: transitions, + finalStepHash: ComputeStepHash(state)); + } + + /// + /// Convenience for fixture files on disk. Each line is one + /// JSON record (comment lines starting with '#' and blank lines ignored). + /// + public ReplayResult ReplayFromJsonlFile( + string path, + string sessionId, + string tenantId, + Func signalParser) + { + if (signalParser == null) + { + throw new ArgumentNullException(nameof(signalParser)); + } + + var signals = new List(); + foreach (var rawLine in File.ReadAllLines(path)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') + { + continue; + } + signals.Add(signalParser(line)); + } + + return Replay(sessionId, tenantId, signals); + } + + /// + /// Deterministic 16-hex-char SHA256 prefix over session-identifying terminal fields. + /// Stable against irrelevant fields (SchemaVersion bump, etc.) by design — M3 will + /// expand this once the reducer populates richer state; M2 only needs stability. + /// + private static string ComputeStepHash(DecisionState state) + { + var canonical = string.Join( + "|", + state.SessionId, + state.TenantId, + state.Stage.ToString(), + state.Outcome?.ToString() ?? "null", + state.StepIndex.ToString(), + state.LastAppliedSignalOrdinal.ToString()); + + using (var sha = SHA256.Create()) + { + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical)); + var sb = new StringBuilder(16); + for (int i = 0; i < 8; i++) + { + sb.Append(bytes[i].ToString("x2")); + } + return sb.ToString(); + } + } + } + + public sealed class ReplayResult + { + public ReplayResult( + DecisionState finalState, + IReadOnlyList transitions, + string finalStepHash) + { + FinalState = finalState; + Transitions = transitions; + FinalStepHash = finalStepHash; + } + + public DecisionState FinalState { get; } + + public IReadOnlyList Transitions { get; } + + public string FinalStepHash { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClock.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClock.cs new file mode 100644 index 00000000..6d44d97c --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClock.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.DecisionCore.Engine; + +namespace AutopilotMonitor.DecisionCore.Tests.Harness +{ + /// + /// Deterministic for reducer / harness / scenario tests. + /// Plan §2.6 — replay advances the clock to signal.OccurredAtUtc before each + /// reduce call; every delay is instant. Never used at runtime. + /// + public sealed class VirtualClock : IClock + { + private DateTime _now; + + public VirtualClock(DateTime initialUtcNow) + { + if (initialUtcNow.Kind != DateTimeKind.Utc && initialUtcNow.Kind != DateTimeKind.Unspecified) + { + throw new ArgumentException("VirtualClock expects UTC/unspecified DateTime.", nameof(initialUtcNow)); + } + _now = DateTime.SpecifyKind(initialUtcNow, DateTimeKind.Utc); + } + + public DateTime UtcNow => _now; + + public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Advance(delay); + return Task.CompletedTask; + } + + public void SetUtcNow(DateTime utcNow) + { + utcNow = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc); + if (utcNow < _now) + { + throw new InvalidOperationException( + $"VirtualClock cannot go backwards (current={_now:O}, requested={utcNow:O})."); + } + _now = utcNow; + } + + public void Advance(TimeSpan delta) + { + if (delta < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(delta), "Advance delta must be non-negative."); + } + _now = _now.Add(delta); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClockTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClockTests.cs new file mode 100644 index 00000000..9db44273 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClockTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Harness +{ + public sealed class VirtualClockTests + { + [Fact] + public void UtcNow_reflectsInitialValue() + { + var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(t0); + Assert.Equal(t0, clock.UtcNow); + } + + [Fact] + public void Advance_increasesUtcNow() + { + var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(t0); + + clock.Advance(TimeSpan.FromMinutes(5)); + + Assert.Equal(t0.AddMinutes(5), clock.UtcNow); + } + + [Fact] + public void Advance_negative_throws() + { + var clock = new VirtualClock(DateTime.UtcNow); + Assert.Throws(() => clock.Advance(TimeSpan.FromMinutes(-1))); + } + + [Fact] + public void SetUtcNow_backwards_throws() + { + var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(t0); + Assert.Throws(() => clock.SetUtcNow(t0.AddMinutes(-1))); + } + + [Fact] + public async Task Delay_completesImmediately_AndAdvancesClock() + { + var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(t0); + var expectedNow = t0.AddHours(7); + + // In real wall-clock this would take 7 hours; VirtualClock returns instantly. + var start = DateTime.UtcNow; + await clock.Delay(TimeSpan.FromHours(7)); + var elapsed = DateTime.UtcNow - start; + + Assert.Equal(expectedNow, clock.UtcNow); + Assert.True(elapsed < TimeSpan.FromSeconds(1), + $"VirtualClock.Delay should be instant, took {elapsed.TotalMilliseconds} ms."); + } + + [Fact] + public async Task Delay_withCancelledToken_throwsOperationCanceled() + { + var clock = new VirtualClock(DateTime.UtcNow); + using (var cts = new CancellationTokenSource()) + { + cts.Cancel(); + await Assert.ThrowsAsync(() => + clock.Delay(TimeSpan.FromSeconds(1), cts.Token)); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs new file mode 100644 index 00000000..70d50436 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.DecisionCore.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Plan §4 M2 gate: "Harness durchläuft leeren Signal-Stream deterministisch." + /// M3 adds scenario-replay tests against real fixtures once the reducer is live. + /// + public sealed class HarnessTests + { + [Fact] + public void Replay_emptySignalStream_producesInitialState() + { + var harness = new ReplayHarness(new DecisionEngine()); + + var result = harness.Replay( + sessionId: "session-empty", + tenantId: "tenant-empty", + signals: Array.Empty()); + + Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage); + Assert.Null(result.FinalState.Outcome); + Assert.Equal(0, result.FinalState.StepIndex); + Assert.Equal(-1, result.FinalState.LastAppliedSignalOrdinal); + Assert.Empty(result.Transitions); + } + + [Fact] + public void Replay_emptyStream_hashIsDeterministic_sameInput_sameHash() + { + var harness = new ReplayHarness(new DecisionEngine()); + + var hash1 = harness.Replay("s", "t", Array.Empty()).FinalStepHash; + var hash2 = harness.Replay("s", "t", Array.Empty()).FinalStepHash; + + Assert.Equal(hash1, hash2); + Assert.Equal(16, hash1.Length); + } + + [Fact] + public void Replay_differentSessionIds_produceDifferentHashes() + { + var harness = new ReplayHarness(new DecisionEngine()); + + var hashA = harness.Replay("session-A", "t", Array.Empty()).FinalStepHash; + var hashB = harness.Replay("session-B", "t", Array.Empty()).FinalStepHash; + + Assert.NotEqual(hashA, hashB); + } + + [Fact] + public void Replay_withNullSignals_throws() + { + var harness = new ReplayHarness(new DecisionEngine()); + Assert.Throws(() => harness.Replay("s", "t", null!)); + } + + [Fact] + public void Replay_singleSessionStartedSignal_recordsOneTakenTransition() + { + var harness = new ReplayHarness(new DecisionEngine()); + var signal = new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 1, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "harness", + evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "init")); + + var result = harness.Replay("s", "t", new List { signal }); + + Assert.Single(result.Transitions); + Assert.True(result.Transitions[0].Taken); + Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage); + Assert.Equal(1, result.FinalState.StepIndex); + Assert.Equal(0, result.FinalState.LastAppliedSignalOrdinal); + } + + [Fact] + public void Replay_unknownSignalKind_recordsDeadEnd_stateUnchanged() + { + // An unhandled signal advances bookkeeping but leaves stage/outcome alone + // and records a DeadEndReason — plan §2.5 fail-safe shape for non-handlers. + // AppInstallCompleted is scheduled for v11.1; still unhandled in M3.3. + var harness = new ReplayHarness(new DecisionEngine()); + var signal = new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.AppInstallCompleted, + kindSchemaVersion: 1, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "harness", + evidence: new Evidence(EvidenceKind.Raw, "App:test", "test")); + + var result = harness.Replay("s", "t", new List { signal }); + + var t = Assert.Single(result.Transitions); + Assert.False(t.Taken); + Assert.StartsWith("unhandled_signal_kind", t.DeadEndReason); + Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs new file mode 100644 index 00000000..5f82ce21 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs @@ -0,0 +1,122 @@ +using System; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Plan §2.5 L.16 — the reducer kernel catches handler exceptions, records them as + /// DeadEndReason="reducer_exception" transitions, and leaves state unchanged + /// except for bookkeeping. M3.0 has no handlers that throw naturally; we verify the + /// kernel's fail-safe path by forcing a handler exception through a broken state. + /// + public sealed class ReducerFailSafeTests + { + [Fact] + public void Reduce_nullState_throws_ArgumentNullException() + { + // This is a programmer-error path — distinct from reducer-internal exceptions. + var engine = new DecisionEngine(); + Assert.Throws(() => + engine.Reduce(null!, MakeSignal(DecisionSignalKind.SessionStarted))); + } + + [Fact] + public void Reduce_nullSignal_throws_ArgumentNullException() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + Assert.Throws(() => engine.Reduce(state, null!)); + } + + [Fact] + public void Reduce_sessionStartedInActiveState_producesDeadEnd_stateUnchanged() + { + // Build a state that is NOT at StepIndex 0 & SessionStarted — simulates mid-flight replay. + var engine = new DecisionEngine(); + var active = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .WithStage(SessionStage.AwaitingHello) + .WithStepIndex(5) + .WithLastAppliedSignalOrdinal(4) + .Build(); + + var signal = MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 5); + + var step = engine.Reduce(active, signal); + + Assert.False(step.Transition.Taken); + Assert.StartsWith("session_started_in_active_state", step.Transition.DeadEndReason); + Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage); + Assert.Equal(6, step.NewState.StepIndex); + Assert.Equal(5, step.NewState.LastAppliedSignalOrdinal); + } + + [Fact] + public void Reduce_unhandledKind_producesDeadEnd_withVersionedReason() + { + // Pick a kind that no sub-milestone has wired yet. AppInstallCompleted is + // scheduled for v11.1 app-install observability; update this test when wired. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + var signal = MakeSignal(DecisionSignalKind.AppInstallCompleted, schemaVersion: 1); + + var step = engine.Reduce(state, signal); + + Assert.False(step.Transition.Taken); + Assert.Equal("unhandled_signal_kind:AppInstallCompleted:v1", step.Transition.DeadEndReason); + Assert.Equal(SessionStage.SessionStarted, step.NewState.Stage); + Assert.Equal(1, step.NewState.StepIndex); + } + + [Fact] + public void Reduce_sessionAborted_terminatesWithAbortedOutcome_clearsDeadlines() + { + var engine = new DecisionEngine(); + var withDeadline = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .AddDeadline(new ActiveDeadline( + name: "hello_safety", + dueAtUtc: DateTime.UtcNow.AddMinutes(5), + firesSignalKind: DecisionSignalKind.DeadlineFired)) + .WithStage(SessionStage.AwaitingHello) + .Build(); + + var step = engine.Reduce(withDeadline, MakeSignal(DecisionSignalKind.SessionAborted)); + + Assert.Equal(SessionStage.Failed, step.NewState.Stage); + Assert.Equal(SessionOutcome.Aborted, step.NewState.Outcome); + Assert.Empty(step.NewState.Deadlines); + Assert.True(step.Transition.Taken); + } + + [Fact] + public void ReducerVersion_matchesAssemblyVersion_stableAcrossReduces() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step1 = engine.Reduce(state, MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 0)); + var step2 = engine.Reduce(step1.NewState, MakeSignal(DecisionSignalKind.SessionRecovered, ordinal: 1)); + + Assert.Equal("2.0.0.0", engine.ReducerVersion); + Assert.Equal("2.0.0.0", step1.Transition.ReducerVersion); + Assert.Equal("2.0.0.0", step2.Transition.ReducerVersion); + } + + private static DecisionSignal MakeSignal( + DecisionSignalKind kind, + long ordinal = 0, + int schemaVersion = 1) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: schemaVersion, + occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc), + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Synthetic, $"test:{kind}", "test")); + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs new file mode 100644 index 00000000..17c543d8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs @@ -0,0 +1,110 @@ +using System; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Scenarios +{ + /// + /// Plan §4 M3.1 — Classic UserDriven-v1 scenarios. + /// Each test loads a committed, anonymized DecisionSignal JSONL fixture and asserts + /// on the reducer's terminal state + key hypothesis / fact outcomes. + /// + public sealed class ClassicScenarioTests : ScenarioTestBase + { + [Fact] + public void UserDrivenHappy_reaches_Completed_withEnrollmentComplete() + { + var result = RunFixture( + fixtureFilename: "userdriven-happy-v1.jsonl", + sessionId: "session-anon-0001", + tenantId: "tenant-anon-0001"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Equal("Success", result.FinalState.HelloOutcome!.Value); + Assert.NotNull(result.FinalState.HelloResolvedUtc); + Assert.NotNull(result.FinalState.DesktopArrivedUtc); + Assert.NotNull(result.FinalState.EspFinalExitUtc); + Assert.Equal(EnrollmentPhase.AccountSetup, result.FinalState.CurrentEnrollmentPhase!.Value); + Assert.Empty(result.FinalState.Deadlines); + + // EnrollmentType should reach Strong after ImeUserSessionCompleted. + Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level); + Assert.Equal("ime_user_session_completed", result.FinalState.EnrollmentType.Reason); + + // All 7 signals produced taken transitions. + Assert.Equal(7, result.Transitions.Count); + Assert.All(result.Transitions, t => Assert.True(t.Taken)); + + // A completion effect should have been emitted at the last step. + var last = result.Transitions[^1]; + Assert.Equal("DesktopArrived", last.Trigger); + } + + [Fact] + public void UserDrivenHelloTimeout_completes_withHelloOutcomeTimeout() + { + var result = RunFixture( + fixtureFilename: "userdriven-hello-timeout-v1.jsonl", + sessionId: "session-anon-0002", + tenantId: "tenant-anon-0002"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Equal("Timeout", result.FinalState.HelloOutcome!.Value); + Assert.NotNull(result.FinalState.HelloResolvedUtc); + Assert.NotNull(result.FinalState.DesktopArrivedUtc); + Assert.Empty(result.FinalState.Deadlines); + + // The terminating signal is DeadlineFired:hello_safety. + Assert.EndsWith("hello_safety", result.Transitions[^1].Trigger); + Assert.True(result.Transitions[^1].Taken); + } + + [Fact] + public void LateAadj_neverCompletesPrematurely_hypothesisAnnotated() + { + var result = RunFixture( + fixtureFilename: "late-aadj-v1.jsonl", + sessionId: "session-anon-0003", + tenantId: "tenant-anon-0003"); + + // Session still completes normally — via Hello + Desktop, NOT via late-AADJ. + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Equal("Success", result.FinalState.HelloOutcome!.Value); + + // AadJoinedWithUser fact is recorded from the Late-AADJ signal. + Assert.NotNull(result.FinalState.AadJoinedWithUser); + Assert.True(result.FinalState.AadJoinedWithUser!.Value); + + // EnrollmentType reason carries the late-AADJ annotation (set by the handler), + // and then advances to "ime_user_session_completed" on the IME signal. + Assert.Equal("ime_user_session_completed", result.FinalState.EnrollmentType.Reason); + + // Hypothesis chain: + // 1. EspPhaseChanged(AccountSetup) -> Weak / account_setup_observed + // 2. AadUserJoinedLate -> Weak / late_aadj_observed (stage unchanged) + // 3. ImeUserSessionCompleted -> Strong / ime_user_session_completed + Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level); + + // Critical regression guard: the AadUserJoinedLate transition did NOT take the + // session to a terminal stage (stayed on EspAccountSetup). It is taken=true (the + // fact was applied) but FromStage==ToStage. + var aadTransition = Assert.Single(result.Transitions, t => t.Trigger == "AadUserJoinedLate"); + Assert.True(aadTransition.Taken); + Assert.Equal(aadTransition.FromStage, aadTransition.ToStage); + Assert.Equal(SessionStage.EspAccountSetup, aadTransition.ToStage); + } + + [Fact] + public void UserDrivenHappy_replayIsDeterministic_sameHashAcrossRuns() + { + var r1 = RunFixture("userdriven-happy-v1.jsonl", "s", "t"); + var r2 = RunFixture("userdriven-happy-v1.jsonl", "s", "t"); + Assert.Equal(r1.FinalStepHash, r2.FinalStepHash); + Assert.Equal(16, r1.FinalStepHash.Length); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/EdgeScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/EdgeScenarioTests.cs new file mode 100644 index 00000000..b071d66e --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/EdgeScenarioTests.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.DecisionCore.Tests.Harness; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Scenarios +{ + /// + /// Plan §4 M3.5 — Edge scenarios: hybrid reboot, ESP terminal failure, restart-recovery + /// determinism. + /// + public sealed class EdgeScenarioTests : ScenarioTestBase + { + private static ClassifierAwareReplayHarness NewHarness() => + new ClassifierAwareReplayHarness( + new DecisionEngine(), + new Dictionary + { + [WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(), + [WhiteGlovePart2CompletionClassifier.ClassifierId] = new WhiteGlovePart2CompletionClassifier(), + }); + + [Fact] + public void HybridReboot_midEsp_preservesStage_recordsRebootFact_completesNormally() + { + var signals = LoadFixture("hybrid-reboot-v1.jsonl"); + var result = NewHarness().Replay("session-anon-0030", "tenant-anon-0030", signals); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Equal("Success", result.FinalState.HelloOutcome!.Value); + + // Reboot fact is recorded. + Assert.NotNull(result.FinalState.SystemRebootUtc); + + // Stage flow: EspDeviceSetup -> (Reboot) preserved -> EspResumed preserved -> + // EspAccountSetup on next EspPhaseChanged -> AwaitingHello -> Completed. + // Importantly, no WhiteGloveSealed — reboot alone doesn't imply WG. + Assert.NotEqual(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); + Assert.Null(result.FinalState.ShellCoreWhiteGloveSuccessSeen); + } + + [Fact] + public void EspTerminalFailure_transitionsToFailed_withReasonFromPayload() + { + var signals = LoadFixture("esp-terminal-failure-v1.jsonl"); + var result = NewHarness().Replay("session-anon-0031", "tenant-anon-0031", signals); + + Assert.Equal(SessionStage.Failed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome); + Assert.Empty(result.FinalState.Deadlines); + + // The last transition carries the payload-derived reason via trigger. + var last = result.Transitions[^1]; + Assert.Equal("EspTerminalFailure", last.Trigger); + Assert.True(last.Taken); + } + + [Fact] + public void RestartRecovery_replayTwice_producesIdenticalFinalStepHash() + { + // Determinism: the same signal stream through a fresh engine + fresh state + // yields the same FinalStepHash. Simulates the Orchestrator replaying a + // persisted SignalLog after an agent crash. Plan L.2 Event-Sourcing. + var signals = LoadFixture("whiteglove-part2-happy-v1.jsonl"); + + var r1 = NewHarness().Replay("session-anon-replay", "tenant-anon-replay", signals); + var r2 = NewHarness().Replay("session-anon-replay", "tenant-anon-replay", signals); + + Assert.Equal(r1.FinalStepHash, r2.FinalStepHash); + Assert.Equal(r1.FinalState.Stage, r2.FinalState.Stage); + Assert.Equal(r1.FinalState.Outcome, r2.FinalState.Outcome); + Assert.Equal(r1.Transitions.Count, r2.Transitions.Count); + } + + [Fact] + public void RestartRecovery_prefixReplay_yieldsIntermediateState() + { + // Replay a truncated log and verify the state matches what full-replay had at + // the same point (simulates partial recovery after crash between signals). + var all = LoadFixture("userdriven-happy-v1.jsonl"); + var prefix = new List(); + for (int i = 0; i < 3 && i < all.Count; i++) prefix.Add(all[i]); + + var prefixResult = NewHarness().Replay("s", "t", prefix); + + Assert.Equal(SessionStage.EspAccountSetup, prefixResult.FinalState.Stage); + Assert.Null(prefixResult.FinalState.Outcome); + Assert.NotNull(prefixResult.FinalState.CurrentEnrollmentPhase); + Assert.Equal(EnrollmentPhase.AccountSetup, prefixResult.FinalState.CurrentEnrollmentPhase!.Value); + } + + [Fact] + public void AllSixCommittedFixtures_produceTerminalState() + { + // Global sanity: every committed anonymized fixture ends in a terminal stage. + // This catches regressions where a new reducer change breaks a scenario end. + string[] fixtures = + { + "userdriven-happy-v1.jsonl", + "userdriven-hello-timeout-v1.jsonl", + "late-aadj-v1.jsonl", + "selfdeploying-happy-v1.jsonl", + "selfdeploying-esp-exit-unknown-v1.jsonl", + "whiteglove-inline-v1.jsonl", + "whiteglove-signal-correlated-v1.jsonl", + "whiteglove-false-positive-v1.jsonl", + "whiteglove-part2-happy-v1.jsonl", + "whiteglove-part2-stuck-v1.jsonl", + "hybrid-reboot-v1.jsonl", + "esp-terminal-failure-v1.jsonl", + }; + + foreach (var fx in fixtures) + { + var signals = LoadFixture(fx); + var result = NewHarness().Replay($"session:{fx}", $"tenant:{fx}", signals); + + var isTerminal = result.FinalState.Stage == SessionStage.Completed + || result.FinalState.Stage == SessionStage.Failed + || result.FinalState.Stage == SessionStage.WhiteGloveSealed + || result.FinalState.Stage == SessionStage.WhiteGloveCompletedPart2; + + Assert.True( + isTerminal, + $"Fixture {fx} ended in non-terminal stage {result.FinalState.Stage}."); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ScenarioTestBase.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ScenarioTestBase.cs new file mode 100644 index 00000000..0d79cfde --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ScenarioTestBase.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.DecisionCore.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Scenarios +{ + /// + /// Shared plumbing for scenario-replay tests. Plan §4 M3 gate. + /// + /// Subclasses call with a fixture filename + /// (rooted at tests/fixtures/enrollment-sessions/) and a terminal assertion. + /// + /// + public abstract class ScenarioTestBase + { + /// Locate the tests/fixtures/enrollment-sessions directory relative to the test binary. + protected static string FixtureRoot() + { + // bin//net8.0 — walk up until we find the repo root (has AutopilotMonitor.sln). + var dir = AppContext.BaseDirectory; + for (int i = 0; i < 12; i++) + { + if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) + { + return Path.Combine(dir, "tests", "fixtures", "enrollment-sessions"); + } + var parent = Directory.GetParent(dir)?.FullName; + if (parent == null || parent == dir) break; + dir = parent; + } + throw new DirectoryNotFoundException( + "Could not locate repo root (AutopilotMonitor.sln) walking up from " + + AppContext.BaseDirectory); + } + + protected static IReadOnlyList LoadFixture(string filename) + { + var path = Path.Combine(FixtureRoot(), filename); + Assert.True(File.Exists(path), $"Fixture not found: {path}"); + return FixtureLoader.LoadJsonl(path); + } + + /// + /// Run a fixture through the real and return the replay result. + /// Subclasses then assert on terminal stage / outcome / hypotheses / hash. + /// + protected static ReplayResult RunFixture(string fixtureFilename, string sessionId, string tenantId) + { + var signals = LoadFixture(fixtureFilename); + var harness = new ReplayHarness(new DecisionEngine()); + return harness.Replay(sessionId, tenantId, signals); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs new file mode 100644 index 00000000..84681f78 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/SelfDeployingScenarioTests.cs @@ -0,0 +1,104 @@ +using System.Linq; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Scenarios +{ + /// + /// Plan §4 M3.2 — SelfDeploying-v1 and Device-Only scenarios. + /// + public sealed class SelfDeployingScenarioTests : ScenarioTestBase + { + [Fact] + public void SelfDeployingHappy_completes_onProvisioningComplete_noUserFacts() + { + var result = RunFixture( + fixtureFilename: "selfdeploying-happy-v1.jsonl", + sessionId: "session-anon-0004", + tenantId: "tenant-anon-0004"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Empty(result.FinalState.Deadlines); + + // No user-presence facts should have been produced. + Assert.Null(result.FinalState.HelloResolvedUtc); + Assert.Null(result.FinalState.DesktopArrivedUtc); + Assert.Null(result.FinalState.AadJoinedWithUser); + Assert.Null(result.FinalState.AccountSetupEnteredUtc); + + // DeviceOnly hypothesis confirmed as DeviceOnly (no user presence seen). + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.DeviceOnlyDeployment.Level); + Assert.Equal( + DecisionEngine.DeviceOnlyReasons.DeviceOnly, + result.FinalState.DeviceOnlyDeployment.Reason); + + // EnrollmentType upgraded to Strong by the SelfDeploying handler. + Assert.Equal(HypothesisLevel.Strong, result.FinalState.EnrollmentType.Level); + Assert.Equal("selfdeploying_provisioning_complete", result.FinalState.EnrollmentType.Reason); + + Assert.Equal(3, result.Transitions.Count); + Assert.All(result.Transitions, t => Assert.True(t.Taken)); + } + + [Fact] + public void SelfDeployingHappy_schedulesAndCancelsDeviceOnlyEspDetection() + { + var result = RunFixture( + fixtureFilename: "selfdeploying-happy-v1.jsonl", + sessionId: "session-anon-0004", + tenantId: "tenant-anon-0004"); + + // Replay intermediate check via harness is not exposed, but we can assert the + // final state has no active deadlines (the provisioning-complete handler clears them). + Assert.Empty(result.FinalState.Deadlines); + } + + [Fact] + public void DeviceOnlyEspExitUnknown_deadlineFires_then_provisioningCompletes() + { + var result = RunFixture( + fixtureFilename: "selfdeploying-esp-exit-unknown-v1.jsonl", + sessionId: "session-anon-0005", + tenantId: "tenant-anon-0005"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + + // DeviceOnlyDeployment goes through Strong (by deadline) -> Confirmed (by provisioning). + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.DeviceOnlyDeployment.Level); + Assert.Equal( + DecisionEngine.DeviceOnlyReasons.DeviceOnly, + result.FinalState.DeviceOnlyDeployment.Reason); + + // The deadline transition is present and taken (hypothesis update without stage change). + var deadlineTransition = Assert.Single( + result.Transitions, + t => t.Trigger == $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}"); + Assert.True(deadlineTransition.Taken); + Assert.Equal(deadlineTransition.FromStage, deadlineTransition.ToStage); + Assert.Equal(SessionStage.EspDeviceSetup, deadlineTransition.ToStage); + + Assert.Equal(4, result.Transitions.Count); + } + + [Fact] + public void Classic_AccountSetupPhase_CancelsDeviceOnlyEspDetection() + { + // Re-use the UserDriven-Happy fixture (already in M3.1): it goes + // DeviceSetup -> AccountSetup, so the DeviceOnlyEspDetection deadline is + // scheduled and then cancelled before it can fire. The final state must + // carry no DeviceOnlyEspDetection deadline, and the DeviceOnly hypothesis + // stays Unknown — the UserDriven path does not classify itself as DeviceOnly. + var result = RunFixture( + fixtureFilename: "userdriven-happy-v1.jsonl", + sessionId: "s", tenantId: "t"); + + Assert.DoesNotContain( + result.FinalState.Deadlines, + d => d.Name == DeadlineNames.DeviceOnlyEspDetection); + Assert.Equal(HypothesisLevel.Unknown, result.FinalState.DeviceOnlyDeployment.Level); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart1ScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart1ScenarioTests.cs new file mode 100644 index 00000000..a83a7f2e --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart1ScenarioTests.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.DecisionCore.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Scenarios +{ + /// + /// Plan §4 M3.3 — WhiteGlove Part 1 scenarios. Uses the classifier-aware harness so + /// RunClassifier effects are executed synchronously and the resulting verdicts feed + /// back into the reducer. + /// + public sealed class WhiteGlovePart1ScenarioTests : ScenarioTestBase + { + private static ClassifierAwareReplayHarness NewHarness() => + new ClassifierAwareReplayHarness( + new DecisionEngine(), + new Dictionary + { + [WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(), + }); + + private static ReplayResult RunWgFixture(string fixture, string sessionId, string tenantId) + { + var signals = LoadFixture(fixture); + return NewHarness().Replay(sessionId, tenantId, signals); + } + + [Fact] + public void WhiteGloveInline_shellCoreAlone_sealsWhiteGlove() + { + var result = RunWgFixture("whiteglove-inline-v1.jsonl", "session-anon-0010", "tenant-anon-0010"); + + Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome); + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); + Assert.Equal(80, result.FinalState.WhiteGloveSealing.Score); + Assert.NotNull(result.FinalState.ShellCoreWhiteGloveSuccessSeen); + Assert.True(result.FinalState.ShellCoreWhiteGloveSuccessSeen!.Value); + Assert.Empty(result.FinalState.Deadlines); + } + + [Fact] + public void WhiteGloveSignalCorrelated_patternThenShellCore_sealsAfterSecondSignal() + { + var harness = NewHarness(); + var signals = LoadFixture("whiteglove-signal-correlated-v1.jsonl"); + var result = harness.Replay("session-anon-0011", "tenant-anon-0011", signals); + + Assert.Equal(SessionStage.WhiteGloveSealed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.WhiteGlovePart1Sealed, result.FinalState.Outcome); + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); + Assert.True(result.FinalState.WhiteGloveSealing.Score >= WhiteGloveSealingClassifier.HighThreshold); + + // The tick fired between the two WG signals — snapshot unchanged at that point, + // so it was anti-loop-skipped. The SealingPattern run and the ShellCore run both + // produced fresh verdicts. + var runs = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:run", out var r) ? r : 0; + var skipped = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:skipped_by_antiloop", out var s) ? s : 0; + Assert.Equal(2, runs); + Assert.Equal(1, skipped); + } + + [Fact] + public void WhiteGloveFalsePositive_LateAadjAfterSealingPattern_rejectsWg_completesClassic() + { + var result = RunWgFixture("whiteglove-false-positive-v1.jsonl", "session-anon-0012", "tenant-anon-0012"); + + // The session must NOT end in WhiteGloveSealed. + Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage); + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + + // WhiteGlove verdict should be Rejected (hard-excluded by AadJoinedWithUser). + Assert.Equal(HypothesisLevel.Rejected, result.FinalState.WhiteGloveSealing.Level); + Assert.Equal(0, result.FinalState.WhiteGloveSealing.Score); + + // The late-AADJ fact + Hello + Desktop are all recorded. + Assert.NotNull(result.FinalState.AadJoinedWithUser); + Assert.True(result.FinalState.AadJoinedWithUser!.Value); + Assert.NotNull(result.FinalState.HelloResolvedUtc); + Assert.NotNull(result.FinalState.DesktopArrivedUtc); + Assert.Equal("Success", result.FinalState.HelloOutcome!.Value); + } + + [Fact] + public void ClassifierAntiLoop_sameSnapshot_runsOnceThenSkips() + { + var harness = NewHarness(); + var signals = LoadFixture("whiteglove-antiloop-v1.jsonl"); + var result = harness.Replay("session-anon-0013", "tenant-anon-0013", signals); + + // SealingPattern was seen but insufficient alone for Confirmed. + Assert.NotEqual(SessionStage.WhiteGloveSealed, result.FinalState.Stage); + Assert.Equal(HypothesisLevel.Weak, result.FinalState.WhiteGloveSealing.Level); + Assert.Equal(40, result.FinalState.WhiteGloveSealing.Score); + + var runs = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:run", out var r) ? r : 0; + var skipped = harness.ClassifierRunStats.TryGetValue("whiteglove-sealing:skipped_by_antiloop", out var s) ? s : 0; + Assert.Equal(1, runs); + Assert.Equal(2, skipped); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart2ScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart2ScenarioTests.cs new file mode 100644 index 00000000..fda424e5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/WhiteGlovePart2ScenarioTests.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.DecisionCore.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Scenarios +{ + /// + /// Plan §4 M3.4 — WhiteGlove Part 2 scenarios. + /// + public sealed class WhiteGlovePart2ScenarioTests : ScenarioTestBase + { + private static ClassifierAwareReplayHarness NewHarness() => + new ClassifierAwareReplayHarness( + new DecisionEngine(), + new Dictionary + { + [WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(), + [WhiteGlovePart2CompletionClassifier.ClassifierId] = new WhiteGlovePart2CompletionClassifier(), + }); + + [Fact] + public void WhiteGlovePart2Happy_fourUserSignals_sealPart2() + { + var signals = LoadFixture("whiteglove-part2-happy-v1.jsonl"); + var result = NewHarness().Replay("session-anon-0020", "tenant-anon-0020", signals); + + Assert.Equal(SessionStage.WhiteGloveCompletedPart2, result.FinalState.Stage); + Assert.Equal(SessionOutcome.WhiteGlovePart2Complete, result.FinalState.Outcome); + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGlovePart2Completion.Level); + Assert.Equal(100, result.FinalState.WhiteGlovePart2Completion.Score); + + // All four Part-2 facts should be present. + Assert.NotNull(result.FinalState.UserAadSignInCompleteUtc); + Assert.NotNull(result.FinalState.HelloResolvedPart2Utc); + Assert.NotNull(result.FinalState.DesktopArrivedPart2Utc); + Assert.NotNull(result.FinalState.AccountSetupCompletedPart2Utc); + + // Part-1 hypothesis still Confirmed (not reset by the Part-2 transition). + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); + + // SystemReboot fact recorded from SessionRecovered. + Assert.NotNull(result.FinalState.SystemRebootUtc); + + Assert.Empty(result.FinalState.Deadlines); + } + + [Fact] + public void WhiteGlovePart2Stuck_safetyDeadlineFires_failsSession() + { + var signals = LoadFixture("whiteglove-part2-stuck-v1.jsonl"); + var result = NewHarness().Replay("session-anon-0021", "tenant-anon-0021", signals); + + Assert.Equal(SessionStage.Failed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome); + Assert.Equal(HypothesisLevel.Rejected, result.FinalState.WhiteGlovePart2Completion.Level); + Assert.Equal("part2_user_absent", result.FinalState.WhiteGlovePart2Completion.Reason); + + // No user-signal facts captured. + Assert.Null(result.FinalState.UserAadSignInCompleteUtc); + Assert.Null(result.FinalState.HelloResolvedPart2Utc); + Assert.Null(result.FinalState.DesktopArrivedPart2Utc); + Assert.Null(result.FinalState.AccountSetupCompletedPart2Utc); + + // Part 1 verdict remains Confirmed even as Part 2 failed. + Assert.Equal(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); + Assert.Empty(result.FinalState.Deadlines); + } + + [Fact] + public void WhiteGlovePart2_bridge_armsSafetyDeadline_onRecoverFromSealed() + { + // Intermediate-state check by running a shortened fixture: after Part-1 seals and + // SessionRecovered fires, the safety deadline should be active. The happy path + // then cancels it when the classifier reaches Confirmed; here we just verify the + // bridge behaviour by loading only the first 4 signals of the happy fixture. + var full = LoadFixture("whiteglove-part2-happy-v1.jsonl"); + var prefix = new List(); + for (int i = 0; i < 4 && i < full.Count; i++) prefix.Add(full[i]); + + var result = NewHarness().Replay("session-anon-0022", "tenant-anon-0022", prefix); + + Assert.Equal(SessionStage.WhiteGloveAwaitingUserSignIn, result.FinalState.Stage); + Assert.Contains( + result.FinalState.Deadlines, + d => d.Name == DeadlineNames.WhiteGlovePart2Safety); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs new file mode 100644 index 00000000..b5e68ddd --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + public sealed class SerializationTests + { + [Fact] + public void SignalSerializer_roundtrip_preservesAllFields() + { + var original = new DecisionSignal( + sessionSignalOrdinal: 42, + sessionTraceOrdinal: 99, + kind: DecisionSignalKind.EspPhaseChanged, + kindSchemaVersion: 1, + occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc), + sourceOrigin: "EspAndHelloTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "esp-phase-detector-v1", + summary: "AccountSetup observed in registry", + rawPointer: "blob://events/42", + derivationInputs: new Dictionary + { + ["registryKey"] = "HKLM\\...Autopilot\\EspStatus", + ["rawValue"] = "2", + }), + payload: new Dictionary + { + ["phase"] = "AccountSetup", + }); + + var json = SignalSerializer.Serialize(original); + var roundtripped = SignalSerializer.Deserialize(json); + + Assert.Equal(original.SessionSignalOrdinal, roundtripped.SessionSignalOrdinal); + Assert.Equal(original.SessionTraceOrdinal, roundtripped.SessionTraceOrdinal); + Assert.Equal(original.Kind, roundtripped.Kind); + Assert.Equal(original.KindSchemaVersion, roundtripped.KindSchemaVersion); + Assert.Equal(original.OccurredAtUtc, roundtripped.OccurredAtUtc); + Assert.Equal(DateTimeKind.Utc, roundtripped.OccurredAtUtc.Kind); + Assert.Equal(original.SourceOrigin, roundtripped.SourceOrigin); + Assert.Equal(original.Evidence.Kind, roundtripped.Evidence.Kind); + Assert.Equal(original.Evidence.Identifier, roundtripped.Evidence.Identifier); + Assert.Equal(original.Evidence.Summary, roundtripped.Evidence.Summary); + Assert.Equal(original.Evidence.RawPointer, roundtripped.Evidence.RawPointer); + Assert.Equal("HKLM\\...Autopilot\\EspStatus", roundtripped.Evidence.DerivationInputs!["registryKey"]); + Assert.Equal("AccountSetup", roundtripped.Payload!["phase"]); + } + + [Fact] + public void SignalSerializer_Deserialize_MissingEvidence_throws() + { + var json = "{\"SessionSignalOrdinal\":0,\"Kind\":\"SessionStarted\",\"KindSchemaVersion\":1,\"OccurredAtUtc\":\"2026-04-20T10:00:00Z\",\"SourceOrigin\":\"test\"}"; + Assert.Throws(() => SignalSerializer.Deserialize(json)); + } + + [Fact] + public void UnknownFallbackEnumConverter_unknownValue_mapsToFallback() + { + // An old backend reading a row produced by a newer DecisionCore that added a + // new Stage value must not crash — it reads it as Unknown instead. + var settings = DecisionCoreJsonSettings.Create(); + var json = "\"NeueUnbekannteStage\""; + + var result = JsonConvert.DeserializeObject(json, settings); + + Assert.Equal(SessionStage.Unknown, result); + } + + [Fact] + public void UnknownFallbackEnumConverter_knownValue_roundtripsByName() + { + var settings = DecisionCoreJsonSettings.Create(); + var original = SessionStage.AwaitingHello; + + var json = JsonConvert.SerializeObject(original, settings); + var roundtripped = JsonConvert.DeserializeObject(json, settings); + + Assert.Contains("AwaitingHello", json); + Assert.Equal(original, roundtripped); + } + + [Fact] + public void UnknownFallbackEnumConverter_legacyNumericValue_mapsIfDefined() + { + var settings = DecisionCoreJsonSettings.Create(); + // EnrollmentPhase.AccountSetup is integer value 4. + var json = "4"; + + var result = JsonConvert.DeserializeObject(json, settings); + + Assert.Equal(EnrollmentPhase.AccountSetup, result); + } + + [Fact] + public void UnknownFallbackEnumConverter_outOfRangeNumeric_mapsToFallback() + { + var settings = DecisionCoreJsonSettings.Create(); + var json = "9999"; + + var result = JsonConvert.DeserializeObject(json, settings); + + Assert.Equal(HypothesisLevel.Unknown, result); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs new file mode 100644 index 00000000..dde91b8b --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs @@ -0,0 +1,129 @@ +using System; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// M2 smoke tests — verify the M1 scaffolding is wired up and the core invariants + /// (immutability, validation, stub behaviour) hold. Real reducer + classifier tests + /// arrive in M3. + /// + public sealed class SmokeTests + { + [Fact] + public void DecisionState_CreateInitial_producesValidNonTerminalState() + { + var state = DecisionState.CreateInitial("session-1", "tenant-1"); + + Assert.Equal("session-1", state.SessionId); + Assert.Equal("tenant-1", state.TenantId); + Assert.Equal(SessionStage.SessionStarted, state.Stage); + Assert.Null(state.Outcome); + Assert.Equal(HypothesisLevel.Unknown, state.EnrollmentType.Level); + Assert.Equal(HypothesisLevel.Unknown, state.WhiteGloveSealing.Level); + Assert.Equal(HypothesisLevel.Unknown, state.WhiteGlovePart2Completion.Level); + Assert.Equal(HypothesisLevel.Unknown, state.DeviceOnlyDeployment.Level); + Assert.Empty(state.Deadlines); + Assert.Equal(-1, state.LastAppliedSignalOrdinal); + Assert.Equal(0, state.StepIndex); + Assert.Equal(DecisionState.CurrentSchemaVersion, state.SchemaVersion); + } + + [Fact] + public void Evidence_Derived_withoutDerivationInputs_throws() + { + Assert.Throws(() => new Evidence( + kind: EvidenceKind.Derived, + identifier: "detector-v1", + summary: "test", + derivationInputs: null)); + } + + [Fact] + public void Evidence_Raw_withEmptyIdentifier_throws() + { + Assert.Throws(() => new Evidence( + kind: EvidenceKind.Raw, + identifier: "", + summary: "test")); + } + + [Fact] + public void DecisionSignal_invalidSchemaVersion_throws() + { + var evidence = new Evidence(EvidenceKind.Raw, "rec-1", "summary"); + Assert.Throws(() => new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 0, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "test", + evidence: evidence)); + } + + [Fact] + public void DecisionEngine_Reduce_SessionStarted_advancesStep() + { + // M3.0: Reduce is no longer a stub; SessionStarted handler advances StepIndex + // and records a taken transition. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + var signal = new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 1, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "test")); + + var step = engine.Reduce(state, signal); + + Assert.Equal(1, step.NewState.StepIndex); + Assert.Equal(0, step.NewState.LastAppliedSignalOrdinal); + Assert.True(step.Transition.Taken); + Assert.Equal("SessionStarted", step.Transition.Trigger); + Assert.Null(step.Transition.DeadEndReason); + // M4.4.4: SessionStarted arms the ClassifierTick deadline up-front. + Assert.Single(step.Effects); + Assert.Equal(DecisionEffectKind.ScheduleDeadline, step.Effects[0].Kind); + Assert.Equal(DeadlineNames.ClassifierTick, step.Effects[0].Deadline!.Name); + } + + [Fact] + public void DecisionEngine_ReducerVersion_matchesAssemblyVersion() + { + var engine = new DecisionEngine(); + // AssemblyVersion in the DecisionCore csproj is 2.0.0.0. + Assert.Equal("2.0.0.0", engine.ReducerVersion); + } + + [Fact] + public void Hypothesis_With_returnsNewInstance_originalUnchanged() + { + var original = Hypothesis.UnknownInstance; + var updated = original.With(level: HypothesisLevel.Strong, score: 80); + + Assert.Equal(HypothesisLevel.Unknown, original.Level); + Assert.Equal(0, original.Score); + Assert.Equal(HypothesisLevel.Strong, updated.Level); + Assert.Equal(80, updated.Score); + Assert.NotSame(original, updated); + } + + [Fact] + public void EnrollmentPhase_referenceFromSharedWorks() + { + // Plan §2.3: EnrollmentPhase comes from Shared, not duplicated in DecisionCore. + var phase = EnrollmentPhase.AccountSetup; + var fact = new SignalFact(phase, 42); + Assert.Equal(EnrollmentPhase.AccountSetup, fact.Value); + Assert.Equal(42, fact.SourceSignalOrdinal); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/SessionStageExtensionsTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/SessionStageExtensionsTests.cs new file mode 100644 index 00000000..d51abebd --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/SessionStageExtensionsTests.cs @@ -0,0 +1,35 @@ +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.State +{ + public sealed class SessionStageExtensionsTests + { + [Theory] + [InlineData(SessionStage.Completed, true)] + [InlineData(SessionStage.Failed, true)] + [InlineData(SessionStage.WhiteGloveSealed, true)] + [InlineData(SessionStage.WhiteGloveCompletedPart2, true)] + [InlineData(SessionStage.Unknown, false)] + [InlineData(SessionStage.SessionStarted, false)] + [InlineData(SessionStage.AwaitingHello, false)] + [InlineData(SessionStage.EspAccountSetup, false)] + [InlineData(SessionStage.WhiteGloveCandidate, false)] + [InlineData(SessionStage.WhiteGloveAwaitingUserSignIn, false)] + public void IsTerminal_classifies_stages_correctly(SessionStage stage, bool expected) + { + Assert.Equal(expected, stage.IsTerminal()); + } + + [Theory] + [InlineData(SessionStage.WhiteGloveSealed, true)] + [InlineData(SessionStage.Completed, false)] + [InlineData(SessionStage.Failed, false)] + [InlineData(SessionStage.WhiteGloveCompletedPart2, false)] + [InlineData(SessionStage.WhiteGloveCandidate, false)] + public void IsPauseBeforePart2_only_true_for_WhiteGloveSealed(SessionStage stage, bool expected) + { + Assert.Equal(expected, stage.IsPauseBeforePart2()); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationEnableIndexDualWriteTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationEnableIndexDualWriteTests.cs new file mode 100644 index 00000000..081e198d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationEnableIndexDualWriteTests.cs @@ -0,0 +1,34 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Default-value + round-trip tests for +/// (Plan §M5.d). The flag gates the index-table dual-write rollout — defaulting to false +/// keeps pre-M5.d behaviour bit-exact and lets operators flip it on deliberately. +/// +public class AdminConfigurationEnableIndexDualWriteTests +{ + [Fact] + public void EnableIndexDualWrite_defaults_to_false_on_new_config() + { + var cfg = new AdminConfiguration(); + Assert.False(cfg.EnableIndexDualWrite); + } + + [Fact] + public void EnableIndexDualWrite_defaults_to_false_on_CreateDefault() + { + // CreateDefault() is called when no admin-config row exists yet — if it somehow + // enabled dual-write it would break the "default-off" contract immediately. + var cfg = AdminConfiguration.CreateDefault(); + Assert.False(cfg.EnableIndexDualWrite); + } + + [Fact] + public void EnableIndexDualWrite_true_persists_on_the_config() + { + var cfg = new AdminConfiguration { EnableIndexDualWrite = true }; + Assert.True(cfg.EnableIndexDualWrite); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationWhiteGloveSealingPatternIdsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationWhiteGloveSealingPatternIdsTests.cs new file mode 100644 index 00000000..da460942 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationWhiteGloveSealingPatternIdsTests.cs @@ -0,0 +1,69 @@ +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests +{ + /// + /// Tests for . Unlike the + /// ModernDeployment helper, this feature is off by default — the fallback for + /// null / empty / invalid JSON must be an empty list so M3-compatible behaviour is + /// preserved until an operator opts in. + /// + public sealed class AdminConfigurationWhiteGloveSealingPatternIdsTests + { + [Fact] + public void GetWhiteGloveSealingPatternIds_NullJson_ReturnsEmpty() + { + var cfg = new AdminConfiguration(); + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Empty(ids); + } + + [Fact] + public void GetWhiteGloveSealingPatternIds_WhitespaceJson_ReturnsEmpty() + { + var cfg = new AdminConfiguration { WhiteGloveSealingPatternIdsJson = " " }; + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Empty(ids); + } + + [Fact] + public void GetWhiteGloveSealingPatternIds_InvalidJson_ReturnsEmpty() + { + var cfg = new AdminConfiguration { WhiteGloveSealingPatternIdsJson = "not-json" }; + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Empty(ids); + } + + [Fact] + public void GetWhiteGloveSealingPatternIds_ValidJson_RoundTrips() + { + var cfg = new AdminConfiguration + { + WhiteGloveSealingPatternIdsJson = "[\"wg-seal-1\",\"wg-seal-2\",\"wg-seal-3\"]" + }; + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Equal(new[] { "wg-seal-1", "wg-seal-2", "wg-seal-3" }, ids); + } + + [Fact] + public void GetWhiteGloveSealingPatternIds_EmptyArray_ReturnsEmptyList() + { + // Explicit empty array means "feature opted-in but no patterns yet" — same + // observable behaviour as the default but distinguishable in storage. + var cfg = new AdminConfiguration { WhiteGloveSealingPatternIdsJson = "[]" }; + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Empty(ids); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs new file mode 100644 index 00000000..7611e771 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DecisionGraphBuilderTests.cs @@ -0,0 +1,180 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — pure projection, zero IO. Covers node +/// de-duplication, terminal classification, dead-end preservation, visit-count semantics, and +/// empty / out-of-order inputs. +/// +public class DecisionGraphBuilderTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static DecisionTransitionRecord Transition( + int step, string from, string to, bool taken = true, string? deadEnd = null, string trigger = "t", + string reducer = "1.0.0") + => new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = step, + FromStage = from, + ToStage = to, + Taken = taken, + DeadEndReason = deadEnd, + Trigger = trigger, + ReducerVersion = reducer, + IsTerminal = DecisionGraphBuilder.IsTerminalStage(to), + OccurredAtUtc = new DateTime(2026, 4, 21, 10, step, 0, DateTimeKind.Utc), + }; + + // ============================================================ Empty input + + [Fact] + public void Build_returns_empty_projection_when_no_transitions() + { + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, Array.Empty()); + + Assert.Equal(TenantId, graph.TenantId); + Assert.Equal(SessionId, graph.SessionId); + Assert.Empty(graph.Nodes); + Assert.Empty(graph.Edges); + Assert.Equal(string.Empty, graph.ReducerVersion); + } + + // ============================================================ Node de-duplication + + [Fact] + public void Build_deduplicates_stages_into_unique_nodes() + { + // Same stage visited twice through different paths — appears once in Nodes. + var transitions = new[] + { + Transition(0, "SessionStarted", "EspInProgress"), + Transition(1, "EspInProgress", "AccountSetup"), + Transition(2, "AccountSetup", "EspInProgress"), // back-edge (hypothetical) + Transition(3, "EspInProgress", "Completed"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + // Unique stages: SessionStarted, EspInProgress, AccountSetup, Completed = 4 nodes. + Assert.Equal(4, graph.Nodes.Count); + Assert.Contains(graph.Nodes, n => n.Id == "SessionStarted"); + Assert.Contains(graph.Nodes, n => n.Id == "EspInProgress"); + Assert.Contains(graph.Nodes, n => n.Id == "AccountSetup"); + Assert.Contains(graph.Nodes, n => n.Id == "Completed"); + } + + // ============================================================ Terminal classification + + [Theory] + [InlineData("Completed", "Succeeded")] + [InlineData("Failed", "Failed")] + [InlineData("WhiteGloveSealed", "PausedForPart2")] + [InlineData("WhiteGloveCompletedPart2", "Succeeded")] + public void Build_marks_terminal_stages_and_derives_outcome(string terminalStage, string expectedOutcome) + { + var transitions = new[] + { + Transition(0, "Start", terminalStage), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + var node = graph.Nodes.Single(n => n.Id == terminalStage); + + Assert.True(node.IsTerminal); + Assert.Equal(expectedOutcome, node.TerminalOutcome); + } + + [Fact] + public void Build_leaves_non_terminal_nodes_unmarked() + { + var transitions = new[] { Transition(0, "SessionStarted", "EspInProgress") }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.All(graph.Nodes, n => + { + Assert.False(n.IsTerminal); + Assert.Null(n.TerminalOutcome); + }); + } + + // ============================================================ Edges + + [Fact] + public void Build_preserves_all_edges_including_dead_ends() + { + var transitions = new[] + { + Transition(0, "EspInProgress", "Completed", taken: true), + Transition(1, "EspInProgress", "EspInProgress", taken: false, + deadEnd: "hybrid_reboot_gate_blocking", trigger: "EspExiting"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.Equal(2, graph.Edges.Count); + Assert.Contains(graph.Edges, e => e.Taken && e.ToStage == "Completed"); + var deadEnd = graph.Edges.Single(e => !e.Taken); + Assert.Equal("hybrid_reboot_gate_blocking", deadEnd.DeadEndReason); + Assert.Equal("EspExiting", deadEnd.Trigger); + } + + [Fact] + public void Build_orders_edges_by_StepIndex_even_if_input_is_shuffled() + { + // Repository sorts, but defend anyway — the builder is used directly in tests and + // could be fed arbitrary lists. + var transitions = new[] + { + Transition(2, "AccountSetup", "Completed"), + Transition(0, "SessionStarted", "EspInProgress"), + Transition(1, "EspInProgress", "AccountSetup"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.Equal(new[] { 0, 1, 2 }, graph.Edges.Select(e => e.StepIndex)); + } + + // ============================================================ VisitCount + + [Fact] + public void Build_counts_only_taken_transitions_toward_VisitCount() + { + var transitions = new[] + { + Transition(0, "Start", "EspInProgress", taken: true), + Transition(1, "EspInProgress", "EspInProgress", taken: false, deadEnd: "blocked"), + Transition(2, "EspInProgress", "Completed", taken: true), + Transition(3, "Start", "EspInProgress", taken: true), // hypothetical retry + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + var esp = graph.Nodes.Single(n => n.Id == "EspInProgress"); + Assert.Equal(2, esp.VisitCount); // two taken → EspInProgress; the dead-end does not count + + var completed = graph.Nodes.Single(n => n.Id == "Completed"); + Assert.Equal(1, completed.VisitCount); + } + + [Fact] + public void Build_exposes_ReducerVersion_from_first_row() + { + var transitions = new[] + { + Transition(0, "Start", "EspInProgress", reducer: "1.4.2"), + Transition(1, "EspInProgress", "Completed", reducer: "1.4.2"), + }; + + var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions); + + Assert.Equal("1.4.2", graph.ReducerVersion); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs new file mode 100644 index 00000000..46ab9b94 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetSessionSignalsFunctionTests.cs @@ -0,0 +1,61 @@ +using AutopilotMonitor.Functions.Functions.Sessions; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the pure helpers on . The HTTP-trigger +/// end needs a live runtime harness; this file covers the deterministic query-string +/// normalisation that decides how many rows we load. +/// +public class GetSessionSignalsFunctionTests +{ + [Fact] + public void ParseMaxResults_defaults_when_query_param_missing() + { + var result = GetSessionSignalsFunction.ParseMaxResults(""); + + Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result); + } + + [Fact] + public void ParseMaxResults_defaults_when_query_param_is_garbage() + { + var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=banana"); + + Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result); + } + + [Fact] + public void ParseMaxResults_honours_valid_value_inside_range() + { + var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=250"); + + Assert.Equal(250, result); + } + + [Fact] + public void ParseMaxResults_caps_at_MaxResultsCap() + { + var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=999999"); + + Assert.Equal(GetSessionSignalsFunction.MaxResultsCap, result); + } + + [Theory] + [InlineData("?maxResults=0")] + [InlineData("?maxResults=-5")] + public void ParseMaxResults_floors_at_one_for_non_positive_values(string query) + { + var result = GetSessionSignalsFunction.ParseMaxResults(query); + + Assert.Equal(1, result); + } + + [Fact] + public void ParseMaxResults_handles_null_query_string() + { + var result = GetSessionSignalsFunction.ParseMaxResults(null!); + + Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IndexDualWriteConsistencyTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IndexDualWriteConsistencyTests.cs new file mode 100644 index 00000000..36be3e64 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IndexDualWriteConsistencyTests.cs @@ -0,0 +1,285 @@ +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Functions.Services.Indexing; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// M5 release-gate (Plan §M5-Pflicht line 1395): per-index-table PK/RK consistency +/// between primary rows and their derived index rows. For each index table, walk a +/// canonical primary row through the Factory + Handler pipeline and verify that the +/// captured index record carries enough back-ref information to reconstruct the +/// primary's (PartitionKey, RowKey) via / +/// key builders. +/// +/// Also asserts pipeline idempotency: running the same primary row through twice +/// produces identical upsert keys — the consumer's UpsertReplace batches stay +/// deduplicating even under timer-triggered resubmits. +/// +/// +public class IndexDualWriteConsistencyTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static readonly DateTime Occurred = + new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc); + + private static (IndexReconcileHandler handler, FakeIndexTableRepository repo) Sut() + { + var repo = new FakeIndexTableRepository(); + var handler = new IndexReconcileHandler(repo, NullLogger.Instance); + return (handler, repo); + } + + // ============================================================ SignalsByKind ↔ Signals + + [Fact] + public async Task SignalsByKind_row_carries_back_ref_to_primary_Signals_row() + { + var primary = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 42, + Kind = "EspTerminalFailure", + OccurredAtUtc = Occurred, + SourceOrigin = "EspAndHelloTrackerAdapter", + }; + + var (handler, repo) = Sut(); + await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(primary)); + + var index = Assert.Single(repo.SignalsByKind); + + // Back-ref fields on the index row must reconstruct the primary PK/RK. + var reconstructedPk = TableSignalRepository.BuildPartitionKey(index.TenantId, index.SessionId); + var reconstructedRk = TableSignalRepository.BuildRowKey(index.SessionSignalOrdinal); + + Assert.Equal( + TableSignalRepository.BuildPartitionKey(primary.TenantId, primary.SessionId), + reconstructedPk); + Assert.Equal( + TableSignalRepository.BuildRowKey(primary.SessionSignalOrdinal), + reconstructedRk); + } + + // ============================================================ SessionsByTerminal ↔ DecisionTransitions + + [Fact] + public async Task SessionsByTerminal_row_carries_back_ref_to_primary_DecisionTransitions_row() + { + var primary = TerminalTransition(); + + var (handler, repo) = Sut(); + await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary)); + + var index = Assert.Single(repo.SessionsByTerminal); + + var reconstructedPk = TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId); + var reconstructedRk = TableDecisionTransitionRepository.BuildRowKey(index.StepIndex); + + Assert.Equal( + TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId), + reconstructedPk); + Assert.Equal( + TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex), + reconstructedRk); + } + + [Fact] + public async Task SessionsByStage_row_carries_back_ref_to_primary_DecisionTransitions_row() + { + var primary = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 3, + FromStage = "AwaitingEsp", + ToStage = "EspInProgress", + Taken = true, + IsTerminal = false, + OccurredAtUtc = Occurred, + }; + + var (handler, repo) = Sut(); + await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary)); + + var index = Assert.Single(repo.SessionsByStage); + + var reconstructedPk = TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId); + var reconstructedRk = TableDecisionTransitionRepository.BuildRowKey(index.StepIndex); + + Assert.Equal( + TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId), + reconstructedPk); + Assert.Equal( + TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex), + reconstructedRk); + } + + [Fact] + public async Task DeadEndsByReason_row_carries_back_ref_to_primary_DecisionTransitions_row() + { + var primary = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 5, + FromStage = "EspInProgress", + ToStage = "EspInProgress", + Taken = false, + IsTerminal = false, + DeadEndReason = "hybrid_reboot_gate_blocking", + OccurredAtUtc = Occurred, + }; + + var (handler, repo) = Sut(); + await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary)); + + var index = Assert.Single(repo.DeadEndsByReason); + + Assert.Equal( + TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId), + TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId)); + Assert.Equal( + TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex), + TableDecisionTransitionRepository.BuildRowKey(index.StepIndex)); + } + + [Fact] + public async Task ClassifierVerdictsByIdLevel_row_carries_back_ref_to_primary_DecisionTransitions_row() + { + var primary = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 7, + FromStage = "EspInProgress", + ToStage = "WhiteGloveSealed", + Taken = true, + IsTerminal = true, + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Strong", + OccurredAtUtc = Occurred, + }; + + var (handler, repo) = Sut(); + await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary)); + + var index = Assert.Single(repo.ClassifierVerdictsByIdLevel); + + Assert.Equal( + TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId), + TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId)); + Assert.Equal( + TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex), + TableDecisionTransitionRepository.BuildRowKey(index.StepIndex)); + } + + // ============================================================ pipeline idempotency + + [Fact] + public async Task Resubmitting_same_primary_rows_produces_same_index_keys() + { + // Simulates the M5.d.4 timer re-enqueueing primary rows it already handled — + // the handler's index-row keys must be key-stable so the real TableIndexRepository's + // UpsertReplace collapses duplicates rather than appending rows. + var (handler, repo) = Sut(); + + var signal = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 1, + Kind = "DesktopArrived", + OccurredAtUtc = Occurred, + }; + var transition = TerminalTransition(); + + // First pass: initial ingest fan-out. + await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(signal)); + await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(transition)); + + // Second pass: 2h-timer reconcile resubmits the same primaries. + await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(signal)); + await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(transition)); + + // The fake repo captures one row per call, but all duplicated rows must map to the + // same (PK, RK) — the real Azure Tables Upsert would collapse them. + Assert.Equal(2, repo.SignalsByKind.Count); + var signalKeys = repo.SignalsByKind + .Select(r => ( + Pk: IndexRowKeys.BuildSignalsByKindPk(r.TenantId, r.SignalKind), + Rk: IndexRowKeys.BuildSignalsByKindRk(r.OccurredAtUtc, r.SessionId, r.SessionSignalOrdinal))) + .Distinct() + .ToList(); + Assert.Single(signalKeys); + + Assert.Equal(2, repo.SessionsByTerminal.Count); + var terminalKeys = repo.SessionsByTerminal + .Select(r => ( + Pk: IndexRowKeys.BuildSessionsByTerminalPk(r.TenantId, r.TerminalStage), + Rk: IndexRowKeys.BuildSessionsByTerminalRk(r.OccurredAtUtc, r.SessionId))) + .Distinct() + .ToList(); + Assert.Single(terminalKeys); + + Assert.Equal(2, repo.SessionsByStage.Count); + var stageKeys = repo.SessionsByStage + .Select(r => ( + Pk: IndexRowKeys.BuildSessionsByStagePk(r.TenantId, r.Stage), + Rk: IndexRowKeys.BuildSessionsByStageRk(r.LastUpdatedUtc, r.SessionId))) + .Distinct() + .ToList(); + Assert.Single(stageKeys); + + Assert.Equal(2, repo.ClassifierVerdictsByIdLevel.Count); + var verdictKeys = repo.ClassifierVerdictsByIdLevel + .Select(r => ( + Pk: IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(r.TenantId, r.ClassifierId, r.HypothesisLevel), + Rk: IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(r.OccurredAtUtc, r.SessionId, r.StepIndex))) + .Distinct() + .ToList(); + Assert.Single(verdictKeys); + } + + // ============================================================ helpers + + private static DecisionTransitionRecord TerminalTransition() + => new() + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 7, + FromStage = "EspInProgress", + ToStage = "WhiteGloveSealed", + Taken = true, + IsTerminal = true, + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Strong", + OccurredAtUtc = Occurred, + }; + + private sealed class FakeIndexTableRepository : IIndexTableRepository + { + public List SessionsByTerminal { get; } = new(); + public List SessionsByStage { get; } = new(); + public List DeadEndsByReason { get; } = new(); + public List ClassifierVerdictsByIdLevel { get; } = new(); + public List SignalsByKind { get; } = new(); + + public Task StoreSessionsByTerminalAsync(IReadOnlyList records, CancellationToken ct = default) + { SessionsByTerminal.AddRange(records); return Task.FromResult(records.Count); } + public Task StoreSessionsByStageAsync(IReadOnlyList records, CancellationToken ct = default) + { SessionsByStage.AddRange(records); return Task.FromResult(records.Count); } + public Task StoreDeadEndsByReasonAsync(IReadOnlyList records, CancellationToken ct = default) + { DeadEndsByReason.AddRange(records); return Task.FromResult(records.Count); } + public Task StoreClassifierVerdictsByIdLevelAsync(IReadOnlyList records, CancellationToken ct = default) + { ClassifierVerdictsByIdLevel.AddRange(records); return Task.FromResult(records.Count); } + public Task StoreSignalsByKindAsync(IReadOnlyList records, CancellationToken ct = default) + { SignalsByKind.AddRange(records); return Task.FromResult(records.Count); } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileEnvelopeFactoryTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileEnvelopeFactoryTests.cs new file mode 100644 index 00000000..36b88258 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileEnvelopeFactoryTests.cs @@ -0,0 +1,167 @@ +using AutopilotMonitor.Functions.Services.Indexing; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pure tests for (Plan §2.8, §M5.d). +/// Verifies the shape-only projection from primary record → queue envelope — the +/// consumer (M5.d.3) relies on these fields being copied verbatim. +/// +public class IndexReconcileEnvelopeFactoryTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static readonly DateTime Occurred = + new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc); + + // ============================================================ FromSignal + + [Fact] + public void FromSignal_copies_discriminators_and_back_ref() + { + var record = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 42, + SessionTraceOrdinal = 117, + Kind = "EspTerminalFailure", + KindSchemaVersion = 2, + OccurredAtUtc = Occurred, + SourceOrigin = "EspAndHelloTrackerAdapter", + PayloadJson = "{}", + }; + + var envelope = IndexReconcileEnvelopeFactory.FromSignal(record); + + Assert.Equal("1", envelope.EnvelopeVersion); + Assert.Equal("Signal", envelope.SourceKind); + Assert.Equal(TenantId, envelope.TenantId); + Assert.Equal(SessionId, envelope.SessionId); + Assert.Equal(Occurred, envelope.OccurredAtUtc); + Assert.Equal(42L, envelope.SessionSignalOrdinal); + Assert.Equal("EspTerminalFailure", envelope.SignalKind); + Assert.Equal("EspAndHelloTrackerAdapter", envelope.SourceOrigin); + + // DecisionTransition-only fields stay null on Signal envelopes. + Assert.Null(envelope.StepIndex); + Assert.Null(envelope.FromStage); + Assert.Null(envelope.ToStage); + Assert.Null(envelope.Taken); + Assert.Null(envelope.IsTerminal); + Assert.Null(envelope.DeadEndReason); + Assert.Null(envelope.ClassifierVerdictId); + Assert.Null(envelope.ClassifierHypothesisLevel); + } + + // ============================================================ FromDecisionTransition + + [Fact] + public void FromDecisionTransition_taken_terminal_with_classifier_copies_all_discriminators() + { + var record = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 7, + SessionTraceOrdinal = 118, + SignalOrdinalRef = 42, + OccurredAtUtc = Occurred, + Trigger = "EspExiting", + FromStage = "EspInProgress", + ToStage = "WhiteGloveSealed", + Taken = true, + DeadEndReason = null, + ReducerVersion = "1.0.0", + IsTerminal = true, + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Strong", + PayloadJson = "{}", + }; + + var envelope = IndexReconcileEnvelopeFactory.FromDecisionTransition(record); + + Assert.Equal("1", envelope.EnvelopeVersion); + Assert.Equal("DecisionTransition", envelope.SourceKind); + Assert.Equal(TenantId, envelope.TenantId); + Assert.Equal(SessionId, envelope.SessionId); + Assert.Equal(Occurred, envelope.OccurredAtUtc); + Assert.Equal(7, envelope.StepIndex); + Assert.Equal("EspInProgress", envelope.FromStage); + Assert.Equal("WhiteGloveSealed", envelope.ToStage); + Assert.Equal(true, envelope.Taken); + Assert.Equal(true, envelope.IsTerminal); + Assert.Null(envelope.DeadEndReason); + Assert.Equal("whiteglove-sealing", envelope.ClassifierVerdictId); + Assert.Equal("Strong", envelope.ClassifierHypothesisLevel); + + // Signal-only fields stay null on DecisionTransition envelopes. + Assert.Null(envelope.SessionSignalOrdinal); + Assert.Null(envelope.SignalKind); + Assert.Null(envelope.SourceOrigin); + } + + [Fact] + public void FromDecisionTransition_dead_end_carries_reason_without_classifier_fields() + { + var record = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 3, + OccurredAtUtc = Occurred, + Trigger = "EspExiting", + FromStage = "EspInProgress", + ToStage = "EspInProgress", + Taken = false, + DeadEndReason = "hybrid_reboot_gate_blocking", + ReducerVersion = "1.0.0", + IsTerminal = false, + PayloadJson = "{}", + }; + + var envelope = IndexReconcileEnvelopeFactory.FromDecisionTransition(record); + + Assert.Equal(false, envelope.Taken); + Assert.Equal(false, envelope.IsTerminal); + Assert.Equal("hybrid_reboot_gate_blocking", envelope.DeadEndReason); + Assert.Null(envelope.ClassifierVerdictId); + Assert.Null(envelope.ClassifierHypothesisLevel); + } + + // ============================================================ BuildBatch + + [Fact] + public void BuildBatch_preserves_input_order_and_kind_tags() + { + var signals = new List + { + new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 1, Kind = "EspPhaseChanged", OccurredAtUtc = Occurred }, + new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 2, Kind = "DesktopArrived", OccurredAtUtc = Occurred }, + }; + var transitions = new List + { + new() { TenantId = TenantId, SessionId = SessionId, StepIndex = 1, FromStage = "A", ToStage = "B", Taken = true, OccurredAtUtc = Occurred }, + }; + + var envelopes = IndexReconcileEnvelopeFactory.BuildBatch(signals, transitions); + + Assert.Equal(3, envelopes.Count); + Assert.Equal("Signal", envelopes[0].SourceKind); + Assert.Equal(1L, envelopes[0].SessionSignalOrdinal); + Assert.Equal("Signal", envelopes[1].SourceKind); + Assert.Equal(2L, envelopes[1].SessionSignalOrdinal); + Assert.Equal("DecisionTransition", envelopes[2].SourceKind); + Assert.Equal(1, envelopes[2].StepIndex); + } + + [Fact] + public void BuildBatch_returns_empty_for_null_or_empty_inputs() + { + Assert.Empty(IndexReconcileEnvelopeFactory.BuildBatch( + new List(), new List())); + Assert.Empty(IndexReconcileEnvelopeFactory.BuildBatch(null!, null!)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileEnvelopeSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileEnvelopeSerializationTests.cs new file mode 100644 index 00000000..9386bf06 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileEnvelopeSerializationTests.cs @@ -0,0 +1,102 @@ +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// JSON round-trip tests for (Plan §2.8, §M5.d). +/// The producer (M5.d.2) serializes with Newtonsoft; the queue-triggered consumer +/// (M5.d.3) must deserialize the same shape back — verified here so the contract +/// doesn't drift silently as fields are added. +/// +public class IndexReconcileEnvelopeSerializationTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static readonly DateTime Occurred = + new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc); + + [Fact] + public void Signal_envelope_round_trips_through_newtonsoft() + { + var original = new IndexReconcileEnvelope + { + EnvelopeVersion = "1", + SourceKind = "Signal", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + SessionSignalOrdinal = 42, + SignalKind = "EspTerminalFailure", + SourceOrigin = "EspAndHelloTrackerAdapter", + }; + + var json = JsonConvert.SerializeObject(original); + var restored = JsonConvert.DeserializeObject(json); + + Assert.NotNull(restored); + Assert.Equal("1", restored!.EnvelopeVersion); + Assert.Equal("Signal", restored.SourceKind); + Assert.Equal(TenantId, restored.TenantId); + Assert.Equal(SessionId, restored.SessionId); + Assert.Equal(Occurred, restored.OccurredAtUtc.ToUniversalTime()); + Assert.Equal(42L, restored.SessionSignalOrdinal); + Assert.Equal("EspTerminalFailure", restored.SignalKind); + Assert.Equal("EspAndHelloTrackerAdapter", restored.SourceOrigin); + Assert.Null(restored.StepIndex); + Assert.Null(restored.ClassifierVerdictId); + } + + [Fact] + public void DecisionTransition_envelope_round_trips_all_discriminators() + { + var original = new IndexReconcileEnvelope + { + EnvelopeVersion = "1", + SourceKind = "DecisionTransition", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + StepIndex = 7, + FromStage = "EspInProgress", + ToStage = "WhiteGloveSealed", + Taken = true, + IsTerminal = true, + DeadEndReason = null, + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Strong", + }; + + var json = JsonConvert.SerializeObject(original); + var restored = JsonConvert.DeserializeObject(json); + + Assert.NotNull(restored); + Assert.Equal("DecisionTransition", restored!.SourceKind); + Assert.Equal(7, restored.StepIndex); + Assert.Equal("EspInProgress", restored.FromStage); + Assert.Equal("WhiteGloveSealed", restored.ToStage); + Assert.Equal(true, restored.Taken); + Assert.Equal(true, restored.IsTerminal); + Assert.Null(restored.DeadEndReason); + Assert.Equal("whiteglove-sealing", restored.ClassifierVerdictId); + Assert.Equal("Strong", restored.ClassifierHypothesisLevel); + Assert.Null(restored.SessionSignalOrdinal); + } + + [Fact] + public void Deserialization_tolerates_missing_optional_fields() + { + // A minimal envelope from a consumer-compatible-but-older writer should still parse. + const string minimal = + "{\"envelopeVersion\":\"1\",\"sourceKind\":\"Signal\",\"tenantId\":\"t\",\"sessionId\":\"s\",\"occurredAtUtc\":\"2026-04-21T14:07:33Z\"}"; + + var restored = JsonConvert.DeserializeObject(minimal); + + Assert.NotNull(restored); + Assert.Equal("Signal", restored!.SourceKind); + Assert.Null(restored.SessionSignalOrdinal); + Assert.Null(restored.SignalKind); + Assert.Null(restored.StepIndex); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileHandlerTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileHandlerTests.cs new file mode 100644 index 00000000..0f5f08a6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileHandlerTests.cs @@ -0,0 +1,321 @@ +using AutopilotMonitor.Functions.Services.Indexing; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Routing tests for (Plan §2.8, §M5.d.3). Verifies +/// the 0–3 fan-out decisions per envelope shape using a capture-only fake repo — +/// the real TableIndexRepository mapping is covered by . +/// +public class IndexReconcileHandlerTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static readonly DateTime Occurred = + new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc); + + private static (IndexReconcileHandler handler, FakeIndexTableRepository repo) Sut() + { + var repo = new FakeIndexTableRepository(); + var handler = new IndexReconcileHandler(repo, NullLogger.Instance); + return (handler, repo); + } + + // ============================================================ Signal routing + + [Fact] + public async Task Signal_envelope_writes_exactly_one_SignalsByKind_row() + { + var (handler, repo) = Sut(); + + await handler.HandleAsync(new IndexReconcileEnvelope + { + SourceKind = "Signal", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + SessionSignalOrdinal = 42, + SignalKind = "EspTerminalFailure", + SourceOrigin = "EspAndHelloTrackerAdapter", + }); + + Assert.Single(repo.SignalsByKind); + var row = repo.SignalsByKind[0]; + Assert.Equal(TenantId, row.TenantId); + Assert.Equal(SessionId, row.SessionId); + Assert.Equal("EspTerminalFailure", row.SignalKind); + Assert.Equal(42L, row.SessionSignalOrdinal); + Assert.Equal("EspAndHelloTrackerAdapter", row.SourceOrigin); + Assert.Equal(Occurred, row.OccurredAtUtc); + + // Other tables untouched. + Assert.Empty(repo.SessionsByTerminal); + Assert.Empty(repo.SessionsByStage); + Assert.Empty(repo.DeadEndsByReason); + Assert.Empty(repo.ClassifierVerdictsByIdLevel); + } + + [Fact] + public async Task Signal_envelope_missing_ordinal_is_dropped() + { + var (handler, repo) = Sut(); + + await handler.HandleAsync(new IndexReconcileEnvelope + { + SourceKind = "Signal", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + SessionSignalOrdinal = null, + SignalKind = "EspPhaseChanged", + }); + + Assert.Empty(repo.SignalsByKind); + } + + [Fact] + public async Task Signal_envelope_missing_kind_is_dropped() + { + var (handler, repo) = Sut(); + + await handler.HandleAsync(new IndexReconcileEnvelope + { + SourceKind = "Signal", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + SessionSignalOrdinal = 1, + SignalKind = string.Empty, + }); + + Assert.Empty(repo.SignalsByKind); + } + + // ============================================================ DecisionTransition routing — all 4 paths + + [Fact] + public async Task DecisionTransition_terminal_taken_with_classifier_writes_three_index_rows() + { + var (handler, repo) = Sut(); + + // WhiteGlove-sealing: Taken=true, IsTerminal=true, classifier verdict attached. + // Expected: SessionsByTerminal + SessionsByStage + ClassifierVerdictsByIdLevel (3 rows). + await handler.HandleAsync(new IndexReconcileEnvelope + { + SourceKind = "DecisionTransition", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + StepIndex = 7, + FromStage = "EspInProgress", + ToStage = "WhiteGloveSealed", + Taken = true, + IsTerminal = true, + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Strong", + }); + + Assert.Single(repo.SessionsByTerminal); + Assert.Equal("WhiteGloveSealed", repo.SessionsByTerminal[0].TerminalStage); + Assert.Equal(7, repo.SessionsByTerminal[0].StepIndex); + + Assert.Single(repo.SessionsByStage); + Assert.Equal("WhiteGloveSealed", repo.SessionsByStage[0].Stage); + Assert.Equal(Occurred, repo.SessionsByStage[0].LastUpdatedUtc); + + Assert.Single(repo.ClassifierVerdictsByIdLevel); + Assert.Equal("whiteglove-sealing", repo.ClassifierVerdictsByIdLevel[0].ClassifierId); + Assert.Equal("Strong", repo.ClassifierVerdictsByIdLevel[0].HypothesisLevel); + + // Dead-end path not triggered for Taken=true. + Assert.Empty(repo.DeadEndsByReason); + // Signal path never touched on a DecisionTransition envelope. + Assert.Empty(repo.SignalsByKind); + } + + [Fact] + public async Task DecisionTransition_taken_non_terminal_no_classifier_writes_only_SessionsByStage() + { + var (handler, repo) = Sut(); + + await handler.HandleAsync(new IndexReconcileEnvelope + { + SourceKind = "DecisionTransition", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + StepIndex = 3, + FromStage = "AwaitingEsp", + ToStage = "EspInProgress", + Taken = true, + IsTerminal = false, + }); + + Assert.Empty(repo.SessionsByTerminal); + Assert.Single(repo.SessionsByStage); + Assert.Empty(repo.DeadEndsByReason); + Assert.Empty(repo.ClassifierVerdictsByIdLevel); + } + + [Fact] + public async Task DecisionTransition_dead_end_with_classifier_writes_DeadEndsByReason_and_verdict() + { + var (handler, repo) = Sut(); + + await handler.HandleAsync(new IndexReconcileEnvelope + { + SourceKind = "DecisionTransition", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + StepIndex = 5, + FromStage = "EspInProgress", + ToStage = "EspInProgress", + Taken = false, + IsTerminal = false, + DeadEndReason = "hybrid_reboot_gate_blocking", + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Weak", + }); + + Assert.Single(repo.DeadEndsByReason); + Assert.Equal("hybrid_reboot_gate_blocking", repo.DeadEndsByReason[0].DeadEndReason); + Assert.Equal("EspInProgress", repo.DeadEndsByReason[0].FromStage); + Assert.Equal("EspInProgress", repo.DeadEndsByReason[0].AttemptedToStage); + + Assert.Single(repo.ClassifierVerdictsByIdLevel); + Assert.Equal("Weak", repo.ClassifierVerdictsByIdLevel[0].HypothesisLevel); + + // Taken=false → SessionsByStage is NOT updated (no stage entry). + Assert.Empty(repo.SessionsByStage); + Assert.Empty(repo.SessionsByTerminal); + } + + [Fact] + public async Task DecisionTransition_dead_end_without_reason_writes_nothing_to_DeadEnds() + { + // Defense in depth: an envelope with Taken=false but no reason should not create a + // DeadEnd row (empty reason would collide into a single PK across unrelated blocks). + var (handler, repo) = Sut(); + + await handler.HandleAsync(new IndexReconcileEnvelope + { + SourceKind = "DecisionTransition", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + StepIndex = 1, + FromStage = "A", + ToStage = "A", + Taken = false, + DeadEndReason = null, + }); + + Assert.Empty(repo.DeadEndsByReason); + } + + [Fact] + public async Task DecisionTransition_missing_stepIndex_is_dropped_entirely() + { + var (handler, repo) = Sut(); + + await handler.HandleAsync(new IndexReconcileEnvelope + { + SourceKind = "DecisionTransition", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + StepIndex = null, + ToStage = "WhiteGloveSealed", + Taken = true, + IsTerminal = true, + }); + + Assert.Empty(repo.SessionsByTerminal); + Assert.Empty(repo.SessionsByStage); + Assert.Empty(repo.DeadEndsByReason); + Assert.Empty(repo.ClassifierVerdictsByIdLevel); + } + + // ============================================================ Misc + + [Fact] + public async Task Unknown_SourceKind_is_logged_and_dropped() + { + var (handler, repo) = Sut(); + + await handler.HandleAsync(new IndexReconcileEnvelope + { + SourceKind = "FutureThingWeDontKnowAbout", + TenantId = TenantId, + SessionId = SessionId, + OccurredAtUtc = Occurred, + }); + + Assert.Empty(repo.SignalsByKind); + Assert.Empty(repo.SessionsByTerminal); + Assert.Empty(repo.SessionsByStage); + Assert.Empty(repo.DeadEndsByReason); + Assert.Empty(repo.ClassifierVerdictsByIdLevel); + } + + [Fact] + public async Task Null_envelope_is_logged_and_dropped() + { + var (handler, repo) = Sut(); + + await handler.HandleAsync(null!); + + Assert.Empty(repo.SignalsByKind); + } + + // ============================================================ FakeIndexTableRepository + + private sealed class FakeIndexTableRepository : IIndexTableRepository + { + public List SessionsByTerminal { get; } = new(); + public List SessionsByStage { get; } = new(); + public List DeadEndsByReason { get; } = new(); + public List ClassifierVerdictsByIdLevel { get; } = new(); + public List SignalsByKind { get; } = new(); + + public Task StoreSessionsByTerminalAsync( + IReadOnlyList records, CancellationToken cancellationToken = default) + { + SessionsByTerminal.AddRange(records); + return Task.FromResult(records.Count); + } + + public Task StoreSessionsByStageAsync( + IReadOnlyList records, CancellationToken cancellationToken = default) + { + SessionsByStage.AddRange(records); + return Task.FromResult(records.Count); + } + + public Task StoreDeadEndsByReasonAsync( + IReadOnlyList records, CancellationToken cancellationToken = default) + { + DeadEndsByReason.AddRange(records); + return Task.FromResult(records.Count); + } + + public Task StoreClassifierVerdictsByIdLevelAsync( + IReadOnlyList records, CancellationToken cancellationToken = default) + { + ClassifierVerdictsByIdLevel.AddRange(records); + return Task.FromResult(records.Count); + } + + public Task StoreSignalsByKindAsync( + IReadOnlyList records, CancellationToken cancellationToken = default) + { + SignalsByKind.AddRange(records); + return Task.FromResult(records.Count); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileTimerTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileTimerTests.cs new file mode 100644 index 00000000..61e0e993 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IndexReconcileTimerTests.cs @@ -0,0 +1,156 @@ +using AutopilotMonitor.Functions.Functions.Admin; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the 2h safety-net (Plan §2.8, §M5.d.4). +/// Covers the flag-gate short-circuit, the happy path (enqueue every primary row +/// in the window), and the empty-window case. +/// +public class IndexReconcileTimerTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static readonly DateTime Cutoff = new(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + + // ============================================================ flag-off short-circuit + + [Fact] + public async Task Feature_flag_off_skips_queries_and_enqueue() + { + var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: false); + var signalRepo = new Mock(MockBehavior.Strict); + var transitionRepo = new Mock(MockBehavior.Strict); + var producer = new Mock(MockBehavior.Strict); + + var timer = new IndexReconcileTimer( + adminConfigSvc, + signalRepo.Object, + transitionRepo.Object, + producer.Object, + NullLogger.Instance); + + await timer.RunReconcileAsync(Cutoff, default); + + // Strict mocks throw if any unconfigured call happens — so zero setups + no throw + // proves the timer short-circuited before any query / enqueue. + signalRepo.VerifyNoOtherCalls(); + transitionRepo.VerifyNoOtherCalls(); + producer.VerifyNoOtherCalls(); + } + + // ============================================================ happy path + + [Fact] + public async Task Flag_on_with_primary_rows_enqueues_all_envelopes() + { + var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: true); + + var signals = new List + { + new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 1, Kind = "EspPhaseChanged", OccurredAtUtc = Cutoff.AddMinutes(5) }, + new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 2, Kind = "DesktopArrived", OccurredAtUtc = Cutoff.AddMinutes(10) }, + }; + var transitions = new List + { + new() { TenantId = TenantId, SessionId = SessionId, StepIndex = 1, Taken = true, FromStage = "A", ToStage = "B", OccurredAtUtc = Cutoff.AddMinutes(7) }, + }; + + var signalRepo = new Mock(); + signalRepo + .Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny(), It.IsAny())) + .ReturnsAsync(signals); + var transitionRepo = new Mock(); + transitionRepo + .Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny(), It.IsAny())) + .ReturnsAsync(transitions); + + List? captured = null; + var producer = new Mock(); + producer + .Setup(p => p.EnqueueBatchAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((e, _) => captured = e.ToList()) + .ReturnsAsync(3); + + var timer = new IndexReconcileTimer( + adminConfigSvc, + signalRepo.Object, + transitionRepo.Object, + producer.Object, + NullLogger.Instance); + + await timer.RunReconcileAsync(Cutoff, default); + + Assert.NotNull(captured); + Assert.Equal(3, captured!.Count); + Assert.Equal("Signal", captured[0].SourceKind); + Assert.Equal(1L, captured[0].SessionSignalOrdinal); + Assert.Equal("Signal", captured[1].SourceKind); + Assert.Equal(2L, captured[1].SessionSignalOrdinal); + Assert.Equal("DecisionTransition", captured[2].SourceKind); + Assert.Equal(1, captured[2].StepIndex); + } + + // ============================================================ empty-window: no enqueue + + [Fact] + public async Task Flag_on_empty_window_skips_enqueue() + { + var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: true); + + var signalRepo = new Mock(); + signalRepo + .Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var transitionRepo = new Mock(); + transitionRepo + .Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var producer = new Mock(MockBehavior.Strict); + + var timer = new IndexReconcileTimer( + adminConfigSvc, + signalRepo.Object, + transitionRepo.Object, + producer.Object, + NullLogger.Instance); + + await timer.RunReconcileAsync(Cutoff, default); + + // Empty batch → no producer call (strict mock throws otherwise). + producer.VerifyNoOtherCalls(); + } + + // ============================================================ cutoff window invariant + + [Fact] + public void ReconcileWindow_is_4_hours_and_larger_than_timer_cadence() + { + // The 2h cron fires every 2h but scans a 4h window — so every primary row is + // re-checked at least twice before falling out of range. Protect the invariant. + Assert.Equal(TimeSpan.FromHours(4), IndexReconcileTimer.ReconcileWindow); + } + + // ============================================================ helpers + + private static AdminConfigurationService BuildAdminConfigService(bool enableIndexDualWrite) + { + var mock = new Mock( + Mock.Of(), + NullLogger.Instance, + new MemoryCache(new MemoryCacheOptions())); + + mock.Setup(s => s.GetConfigurationAsync()) + .ReturnsAsync(new AdminConfiguration { EnableIndexDualWrite = enableIndexDualWrite }); + + return mock.Object; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IndexRowKeysTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IndexRowKeysTests.cs new file mode 100644 index 00000000..8882b94f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IndexRowKeysTests.cs @@ -0,0 +1,153 @@ +using AutopilotMonitor.Functions.DataAccess.TableStorage; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pure PK/RK format tests for (Plan §2.8 query matrix, §M5.d). +/// These verify the string shape contract the queue-driven index writer + the reconciliation +/// timer both depend on — any drift here silently breaks cross-session queries. +/// +public class IndexRowKeysTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static readonly DateTime Occurred = + new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc); + + // ============================================================ SessionsByTerminal + + [Fact] + public void SessionsByTerminal_pk_is_tenant_underscore_terminalStage() + { + Assert.Equal( + $"{TenantId}_WhiteGloveSealed", + IndexRowKeys.BuildSessionsByTerminalPk(TenantId, "WhiteGloveSealed")); + } + + [Fact] + public void SessionsByTerminal_rk_is_yyyyMMddHHmmss_underscore_sessionId() + { + Assert.Equal( + $"20260421140733_{SessionId}", + IndexRowKeys.BuildSessionsByTerminalRk(Occurred, SessionId)); + } + + // ============================================================ SessionsByStage + + [Fact] + public void SessionsByStage_pk_is_tenant_underscore_stage() + { + Assert.Equal( + $"{TenantId}_EspInProgress", + IndexRowKeys.BuildSessionsByStagePk(TenantId, "EspInProgress")); + } + + [Fact] + public void SessionsByStage_rk_pads_ticks_to_19_digits_for_lex_ordering() + { + var rk = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId); + + // 19-digit zero-padding is the contract — string comparison then matches numeric time order. + var parts = rk.Split('_'); + Assert.Equal(19, parts[0].Length); + Assert.Equal(Occurred.Ticks.ToString("D19"), parts[0]); + Assert.Equal(SessionId, parts[1]); + } + + [Fact] + public void SessionsByStage_rk_orders_lexicographically_matching_chronology() + { + var earlier = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId); + var later = IndexRowKeys.BuildSessionsByStageRk(Occurred.AddSeconds(1), SessionId); + + Assert.True(string.CompareOrdinal(earlier, later) < 0); + } + + // ============================================================ DeadEndsByReason + + [Fact] + public void DeadEndsByReason_pk_is_tenant_underscore_reason() + { + Assert.Equal( + $"{TenantId}_hybrid_reboot_gate_blocking", + IndexRowKeys.BuildDeadEndsByReasonPk(TenantId, "hybrid_reboot_gate_blocking")); + } + + [Fact] + public void DeadEndsByReason_rk_combines_timestamp_session_stepIndex_padded_to_6() + { + Assert.Equal( + $"20260421140733_{SessionId}_000042", + IndexRowKeys.BuildDeadEndsByReasonRk(Occurred, SessionId, 42)); + } + + // ============================================================ ClassifierVerdictsByIdLevel + + [Fact] + public void ClassifierVerdictsByIdLevel_pk_is_tenant_underscore_classifierId_underscore_level() + { + Assert.Equal( + $"{TenantId}_whiteglove-sealing_Weak", + IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(TenantId, "whiteglove-sealing", "Weak")); + } + + [Fact] + public void ClassifierVerdictsByIdLevel_rk_combines_timestamp_session_stepIndex_padded_to_6() + { + Assert.Equal( + $"20260421140733_{SessionId}_000007", + IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(Occurred, SessionId, 7)); + } + + // ============================================================ SignalsByKind + + [Fact] + public void SignalsByKind_pk_is_tenant_underscore_signalKind() + { + Assert.Equal( + $"{TenantId}_EspTerminalFailure", + IndexRowKeys.BuildSignalsByKindPk(TenantId, "EspTerminalFailure")); + } + + [Fact] + public void SignalsByKind_rk_combines_timestamp_session_ordinal_padded_to_10() + { + Assert.Equal( + $"20260421140733_{SessionId}_0000000123", + IndexRowKeys.BuildSignalsByKindRk(Occurred, SessionId, 123)); + } + + // ============================================================ UTC coercion + sanitizing + + [Fact] + public void Timestamp_always_normalizes_to_UTC() + { + // If a caller passes Local-kind DateTime, conversion must still produce the UTC wall clock. + var localKind = DateTime.SpecifyKind(Occurred, DateTimeKind.Local); + var rk = IndexRowKeys.BuildSessionsByTerminalRk(localKind, SessionId); + + Assert.Equal($"{localKind.ToUniversalTime():yyyyMMddHHmmss}_{SessionId}", rk); + } + + [Theory] + [InlineData("foo/bar", "foo_bar")] + [InlineData("foo\\bar", "foo_bar")] + [InlineData("foo#bar", "foo_bar")] + [InlineData("foo?bar", "foo_bar")] + [InlineData("clean_name", "clean_name")] + [InlineData("", "")] + public void Sanitize_replaces_forbidden_key_chars_with_underscore(string input, string expected) + { + Assert.Equal(expected, IndexRowKeys.Sanitize(input)); + } + + [Fact] + public void Sanitize_preserves_enum_style_identifiers_unchanged() + { + // Discriminators are overwhelmingly enum names — sanitize must not mangle them. + Assert.Equal("WhiteGloveSealed", IndexRowKeys.Sanitize("WhiteGloveSealed")); + Assert.Equal("EspTerminalFailure", IndexRowKeys.Sanitize("EspTerminalFailure")); + Assert.Equal("hybrid_reboot_gate_blocking", IndexRowKeys.Sanitize("hybrid_reboot_gate_blocking")); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IngestTelemetryFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IngestTelemetryFunctionTests.cs new file mode 100644 index 00000000..15ff904f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IngestTelemetryFunctionTests.cs @@ -0,0 +1,178 @@ +using System.IO; +using System.Text; +using AutopilotMonitor.Functions.Functions.Ingest; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for the pure helpers on . The HTTP-trigger end +/// of the function needs a live runtime harness to exercise; this file covers the deterministic +/// bits (PartitionKey parsing, body size-cap guard). +/// +public class IngestTelemetryFunctionTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + [Fact] + public void TryParsePartitionKey_splits_tenant_and_session_on_single_underscore() + { + var ok = IngestTelemetryFunction.TryParsePartitionKey( + $"{TenantId}_{SessionId}", out var tenant, out var session); + + Assert.True(ok); + Assert.Equal(TenantId, tenant); + Assert.Equal(SessionId, session); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("no-underscore-here")] + [InlineData("_missing-tenant")] + [InlineData("missing-session_")] + [InlineData("too_many_parts_here")] + public void TryParsePartitionKey_rejects_malformed_shapes(string? input) + { + var ok = IngestTelemetryFunction.TryParsePartitionKey( + input!, out var tenant, out var session); + + Assert.False(ok); + Assert.Equal(string.Empty, tenant); + Assert.Equal(string.Empty, session); + } + + // ============================================================ Payload size-cap guard + + [Fact] + public async Task ReadBodyWithSizeCapAsync_accepts_body_under_cap() + { + var payload = Encoding.UTF8.GetBytes("[{\"kind\":\"Event\"}]"); + using var stream = new MemoryStream(payload); + + var (exceeded, body) = await IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 1024); + + Assert.False(exceeded); + Assert.Equal("[{\"kind\":\"Event\"}]", body); + } + + [Fact] + public async Task ReadBodyWithSizeCapAsync_accepts_body_exactly_at_cap() + { + // Strict greater-than semantics (matches legacy NdjsonParser): equal-to is OK. + var payload = new byte[100]; + using var stream = new MemoryStream(payload); + + var (exceeded, body) = await IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 100); + + Assert.False(exceeded); + Assert.Equal(100, Encoding.UTF8.GetByteCount(body)); + } + + [Fact] + public async Task ReadBodyWithSizeCapAsync_rejects_body_over_cap_without_draining_full_stream() + { + // 1 MB payload with a 100-byte cap → short-circuits after a single buffer read. + var payload = new byte[1_000_000]; + using var stream = new MemoryStream(payload); + + var (exceeded, body) = await IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 100); + + Assert.True(exceeded); + Assert.Equal(string.Empty, body); + // Stream position advanced past the cap but not to EOF — the helper bails early to + // bound memory on hostile senders. + Assert.True(stream.Position < stream.Length); + } + + [Fact] + public async Task ReadBodyWithSizeCapAsync_empty_stream_returns_empty_body() + { + using var stream = new MemoryStream(Array.Empty()); + + var (exceeded, body) = await IngestTelemetryFunction.ReadBodyWithSizeCapAsync(stream, maxBytes: 1024); + + Assert.False(exceeded); + Assert.Equal(string.Empty, body); + } + + // ============================================================ Batch PartitionKey uniformity + + private static TelemetryItemDto Item(string partitionKey) + => new TelemetryItemDto + { + Kind = "Signal", + PartitionKey = partitionKey, + RowKey = "0000000001", + TelemetryItemId = 1, + PayloadJson = "{}", + EnqueuedAtUtc = DateTime.UtcNow, + }; + + [Fact] + public void FindMismatchingPartitionKey_returns_false_for_empty_batch() + { + var result = IngestTelemetryFunction.FindMismatchingPartitionKey( + new List(), out var idx, out var value); + + Assert.False(result); + Assert.Equal(-1, idx); + Assert.Null(value); + } + + [Fact] + public void FindMismatchingPartitionKey_returns_false_for_single_item_batch() + { + var result = IngestTelemetryFunction.FindMismatchingPartitionKey( + new[] { Item($"{TenantId}_{SessionId}") }, out var idx, out var value); + + Assert.False(result); + Assert.Equal(-1, idx); + Assert.Null(value); + } + + [Fact] + public void FindMismatchingPartitionKey_returns_false_when_all_items_share_PartitionKey() + { + var pk = $"{TenantId}_{SessionId}"; + var items = new[] { Item(pk), Item(pk), Item(pk) }; + + var result = IngestTelemetryFunction.FindMismatchingPartitionKey(items, out var idx, out var value); + + Assert.False(result); + Assert.Equal(-1, idx); + Assert.Null(value); + } + + [Fact] + public void FindMismatchingPartitionKey_returns_index_and_value_on_mismatch() + { + // Session-mismatch in the middle of the batch — what a misbehaving client could send. + var first = $"{TenantId}_{SessionId}"; + var second = $"{TenantId}_c3d4e5f6-a7b8-9012-cdef-123456789012"; // same tenant, different session + var items = new[] { Item(first), Item(first), Item(second) }; + + var result = IngestTelemetryFunction.FindMismatchingPartitionKey(items, out var idx, out var value); + + Assert.True(result); + Assert.Equal(2, idx); + Assert.Equal(second, value); + } + + [Fact] + public void FindMismatchingPartitionKey_is_case_sensitive() + { + // PartitionKeys are GUIDs — matching is ordinal. A lowercase/uppercase-swapped second + // item is treated as a different partition (safe-by-default; the agent emits lowercase). + var first = $"{TenantId}_{SessionId}"; + var second = first.ToUpperInvariant(); + var items = new[] { Item(first), Item(second) }; + + var result = IngestTelemetryFunction.FindMismatchingPartitionKey(items, out var idx, out var value); + + Assert.True(result); + Assert.Equal(1, idx); + Assert.Equal(second, value); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ReducerVerifierTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ReducerVerifierTests.cs new file mode 100644 index 00000000..ce990ce9 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ReducerVerifierTests.cs @@ -0,0 +1,194 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pure-function tests for . Covers the five detectors: +/// empty session, signal ordinal gaps, step index gaps, orphaned transitions, and +/// ReducerVersion drift — plus the happy-path (all contiguous, versions match). +/// +public class ReducerVerifierTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string CurrentReducerVersion = "1.4.2.0"; + + private static SignalRecord Signal(long ordinal) + => new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = ordinal, + SessionTraceOrdinal = ordinal, + Kind = "EspPhaseChanged", + KindSchemaVersion = 1, + OccurredAtUtc = DateTime.UtcNow, + SourceOrigin = "test", + PayloadJson = "{}", + }; + + private static DecisionTransitionRecord Transition( + int stepIndex, long signalRef, string reducerVersion = CurrentReducerVersion) + => new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = stepIndex, + SessionTraceOrdinal = signalRef, + SignalOrdinalRef = signalRef, + OccurredAtUtc = DateTime.UtcNow, + Trigger = "t", + FromStage = "SessionStarted", + ToStage = "EspInProgress", + Taken = true, + ReducerVersion = reducerVersion, + }; + + // ============================================================ Empty + + [Fact] + public void Verify_empty_session_emits_info_issue_and_returns_zero_counts() + { + var report = ReducerVerifier.Verify( + TenantId, SessionId, + Array.Empty(), + Array.Empty(), + CurrentReducerVersion); + + Assert.Equal(0, report.SignalCount); + Assert.Equal(0, report.TransitionCount); + Assert.Single(report.Issues); + Assert.Equal("empty_session", report.Issues[0].Kind); + Assert.Equal("Info", report.Issues[0].Severity); + } + + // ============================================================ Happy path + + [Fact] + public void Verify_happy_path_contiguous_signals_and_transitions_no_drift() + { + var signals = new[] { Signal(1), Signal(2), Signal(3) }; + var transitions = new[] { Transition(1, 1), Transition(2, 2), Transition(3, 3) }; + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.Equal(3, report.SignalCount); + Assert.Equal(3, report.TransitionCount); + Assert.True(report.SignalOrdinalsContiguous); + Assert.True(report.StepIndicesContiguous); + Assert.False(report.ReducerVersionDrift); + Assert.Equal(0, report.OrphanedTransitionCount); + Assert.Empty(report.Issues); + } + + // ============================================================ Signal ordinal gaps + + [Fact] + public void Verify_detects_signal_ordinal_gap() + { + var signals = new[] { Signal(1), Signal(2), Signal(5) }; // gap: 3, 4 missing + + var report = ReducerVerifier.Verify( + TenantId, SessionId, signals, Array.Empty(), CurrentReducerVersion); + + Assert.False(report.SignalOrdinalsContiguous); + Assert.Equal(1, report.SignalOrdinalFirst); + Assert.Equal(5, report.SignalOrdinalLast); + Assert.Contains(report.Issues, i => i.Kind == "signal_ordinal_gap" && i.Message.Contains("missing 2")); + } + + [Fact] + public void Verify_caps_ordinal_gap_issues_and_adds_overflow_message() + { + // Construct >20 gaps: alternating contiguous-then-jump pattern. + var ordinals = new List(); + for (var i = 0; i < 25; i++) ordinals.Add((long)(i * 10 + 1)); // 1,11,21,31,... → 24 gaps + var signals = ordinals.Select(Signal).ToArray(); + + var report = ReducerVerifier.Verify( + TenantId, SessionId, signals, Array.Empty(), CurrentReducerVersion); + + var gapIssues = report.Issues.Where(i => i.Kind == "signal_ordinal_gap").ToList(); + // 20 detailed + 1 overflow summary = 21 + Assert.Equal(21, gapIssues.Count); + Assert.Contains(gapIssues, i => i.Message.StartsWith("... ") && i.Message.Contains("additional")); + } + + // ============================================================ Step-index gaps + + [Fact] + public void Verify_detects_step_index_gap() + { + var signals = new[] { Signal(1), Signal(2), Signal(3) }; + var transitions = new[] { Transition(1, 1), Transition(3, 3) }; // step 2 missing + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.False(report.StepIndicesContiguous); + Assert.Contains(report.Issues, i => i.Kind == "step_index_gap"); + } + + // ============================================================ Orphaned transitions + + [Fact] + public void Verify_flags_transition_referencing_missing_signal() + { + var signals = new[] { Signal(1), Signal(2) }; + // Transition step 2 references signal ordinal 99 which doesn't exist. + var transitions = new[] { Transition(1, 1), Transition(2, 99) }; + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.Equal(1, report.OrphanedTransitionCount); + Assert.Contains(report.Issues, i => i.Kind == "orphaned_transition" && i.Message.Contains("99")); + } + + // ============================================================ ReducerVersion drift + + [Fact] + public void Verify_flags_ReducerVersion_drift_as_Info_level() + { + var signals = new[] { Signal(1) }; + var transitions = new[] { Transition(1, 1, reducerVersion: "1.2.0.0") }; + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.True(report.ReducerVersionDrift); + Assert.Equal("1.2.0.0", report.StoredReducerVersion); + Assert.Equal(CurrentReducerVersion, report.CurrentReducerVersion); + + var drift = report.Issues.Single(i => i.Kind == "reducer_version_drift"); + // Drift is informational, not an error — it's a known condition when replays span + // reducer versions and the report makes sense to show regardless. + Assert.Equal("Info", drift.Severity); + } + + [Fact] + public void Verify_does_not_flag_drift_when_stored_version_matches_current() + { + var signals = new[] { Signal(1) }; + var transitions = new[] { Transition(1, 1, reducerVersion: CurrentReducerVersion) }; + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.False(report.ReducerVersionDrift); + Assert.DoesNotContain(report.Issues, i => i.Kind == "reducer_version_drift"); + } + + // ============================================================ Ordering + + [Fact] + public void Verify_handles_out_of_order_input_because_the_verifier_sorts_before_checking() + { + var signals = new[] { Signal(3), Signal(1), Signal(2) }; + var transitions = new[] { Transition(3, 3), Transition(1, 1), Transition(2, 2) }; + + var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion); + + Assert.True(report.SignalOrdinalsContiguous); + Assert.True(report.StepIndicesContiguous); + Assert.Equal(1, report.SignalOrdinalFirst); + Assert.Equal(3, report.SignalOrdinalLast); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs new file mode 100644 index 00000000..7cb55cbf --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableDecisionTransitionRepositoryMappingTests.cs @@ -0,0 +1,200 @@ +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pure mapping tests for . Sibling of +/// — same rationale. +/// +public class TableDecisionTransitionRepositoryMappingTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + [Fact] + public void BuildPartitionKey_is_tenant_underscore_session() + { + var pk = TableDecisionTransitionRepository.BuildPartitionKey(TenantId, SessionId); + Assert.Equal($"{TenantId}_{SessionId}", pk); + } + + [Fact] + public void BuildRowKey_pads_stepIndex_to_10_digits() + { + Assert.Equal("0000000000", TableDecisionTransitionRepository.BuildRowKey(0)); + Assert.Equal("0000000042", TableDecisionTransitionRepository.BuildRowKey(42)); + Assert.Equal("2147483647", TableDecisionTransitionRepository.BuildRowKey(int.MaxValue)); + } + + [Fact] + public void ToEntity_projects_all_typed_columns() + { + var record = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 7, + SessionTraceOrdinal = 117, + SignalOrdinalRef = 42, + OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), + Trigger = "EspExiting", + FromStage = "EspInProgress", + ToStage = "WhiteGloveSealed", + Taken = true, + DeadEndReason = null, + ReducerVersion = "1.0.0", + IsTerminal = true, + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Strong", + PayloadJson = "{\"foo\":\"bar\"}", + }; + + var entity = TableDecisionTransitionRepository.ToEntity(record); + + Assert.Equal($"{TenantId}_{SessionId}", entity.PartitionKey); + Assert.Equal("0000000007", entity.RowKey); + Assert.Equal(TenantId, entity.GetString("TenantId")); + Assert.Equal(SessionId, entity.GetString("SessionId")); + Assert.Equal(7, entity.GetInt32("StepIndex")); + Assert.Equal(117L, entity.GetInt64("SessionTraceOrdinal")); + Assert.Equal(42L, entity.GetInt64("SignalOrdinalRef")); + Assert.Equal(record.OccurredAtUtc, entity.GetDateTime("OccurredAtUtc")); + Assert.Equal("EspExiting", entity.GetString("Trigger")); + Assert.Equal("EspInProgress", entity.GetString("FromStage")); + Assert.Equal("WhiteGloveSealed", entity.GetString("ToStage")); + Assert.Equal(true, entity.GetBoolean("Taken")); + Assert.Null(entity.GetString("DeadEndReason")); + Assert.Equal("1.0.0", entity.GetString("ReducerVersion")); + Assert.Equal(true, entity.GetBoolean("IsTerminal")); + Assert.Equal("whiteglove-sealing", entity.GetString("ClassifierVerdictId")); + Assert.Equal("Strong", entity.GetString("ClassifierHypothesisLevel")); + Assert.Equal("{\"foo\":\"bar\"}", entity.GetString("PayloadJson")); + } + + [Fact] + public void ToEntity_preserves_dead_end_reason_for_blocked_transitions() + { + var record = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 3, + SessionTraceOrdinal = 5, + SignalOrdinalRef = 3, + OccurredAtUtc = DateTime.UtcNow, + Trigger = "EspExiting", + FromStage = "EspInProgress", + ToStage = "EspInProgress", // no transition — dead end + Taken = false, + DeadEndReason = "hybrid_reboot_gate_blocking", + ReducerVersion = "1.0.0", + IsTerminal = false, + PayloadJson = "{}", + }; + + var entity = TableDecisionTransitionRepository.ToEntity(record); + + Assert.Equal(false, entity.GetBoolean("Taken")); + Assert.Equal("hybrid_reboot_gate_blocking", entity.GetString("DeadEndReason")); + } + + [Fact] + public void ToEntity_chunks_oversized_PayloadJson() + { + var oversized = new string('x', 75_000); + var record = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 1, + SessionTraceOrdinal = 1, + SignalOrdinalRef = 1, + OccurredAtUtc = DateTime.UtcNow, + Trigger = "t", + FromStage = "F", + ToStage = "T", + Taken = true, + ReducerVersion = "1.0.0", + PayloadJson = oversized, + }; + + var entity = TableDecisionTransitionRepository.ToEntity(record); + + Assert.Equal("3", entity.GetString("PayloadJson_ChunkCount")); + Assert.False(entity.ContainsKey("PayloadJson")); + } + + // ============================================================ Reverse mapping (read path) + + [Fact] + public void FromEntity_round_trips_all_typed_columns() + { + var original = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 7, + SessionTraceOrdinal = 117, + SignalOrdinalRef = 42, + OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), + Trigger = "EspExiting", + FromStage = "EspInProgress", + ToStage = "WhiteGloveSealed", + Taken = true, + DeadEndReason = null, + ReducerVersion = "1.0.0", + IsTerminal = true, + ClassifierVerdictId = "whiteglove-sealing", + ClassifierHypothesisLevel = "Strong", + PayloadJson = "{\"foo\":\"bar\"}", + }; + + var entity = TableDecisionTransitionRepository.ToEntity(original); + var restored = TableDecisionTransitionRepository.FromEntity(entity); + + Assert.Equal(original.TenantId, restored.TenantId); + Assert.Equal(original.SessionId, restored.SessionId); + Assert.Equal(original.StepIndex, restored.StepIndex); + Assert.Equal(original.SessionTraceOrdinal, restored.SessionTraceOrdinal); + Assert.Equal(original.SignalOrdinalRef, restored.SignalOrdinalRef); + Assert.Equal(original.OccurredAtUtc, restored.OccurredAtUtc.ToUniversalTime()); + Assert.Equal(original.Trigger, restored.Trigger); + Assert.Equal(original.FromStage, restored.FromStage); + Assert.Equal(original.ToStage, restored.ToStage); + Assert.Equal(original.Taken, restored.Taken); + Assert.Equal(original.DeadEndReason, restored.DeadEndReason); + Assert.Equal(original.ReducerVersion, restored.ReducerVersion); + Assert.Equal(original.IsTerminal, restored.IsTerminal); + Assert.Equal(original.ClassifierVerdictId, restored.ClassifierVerdictId); + Assert.Equal(original.ClassifierHypothesisLevel, restored.ClassifierHypothesisLevel); + Assert.Equal(original.PayloadJson, restored.PayloadJson); + } + + [Fact] + public void FromEntity_reassembles_chunked_PayloadJson() + { + var oversized = new string('y', 75_000); + var original = new DecisionTransitionRecord + { + TenantId = TenantId, + SessionId = SessionId, + StepIndex = 1, + SessionTraceOrdinal = 1, + SignalOrdinalRef = 1, + OccurredAtUtc = DateTime.UtcNow, + Trigger = "t", + FromStage = "F", + ToStage = "T", + Taken = true, + ReducerVersion = "1.0.0", + PayloadJson = oversized, + }; + + var entity = TableDecisionTransitionRepository.ToEntity(original); + var restored = TableDecisionTransitionRepository.FromEntity(entity); + + Assert.Equal(75_000, restored.PayloadJson.Length); + Assert.Equal(oversized, restored.PayloadJson); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableIndexRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableIndexRepositoryMappingTests.cs new file mode 100644 index 00000000..2339fc12 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableIndexRepositoryMappingTests.cs @@ -0,0 +1,167 @@ +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pure mapping tests for 's 5 ToEntity overloads +/// (Plan §2.8, §M5.d). Transaction-batching + Azurite round-trip are out of scope here; +/// this file verifies that every projected column and key lands on the entity as expected. +/// +public class TableIndexRepositoryMappingTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static readonly DateTime Occurred = + new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc); + + // ============================================================ SessionsByTerminal + + [Fact] + public void SessionsByTerminal_ToEntity_projects_keys_and_typed_columns() + { + var record = new SessionsByTerminalRecord + { + TenantId = TenantId, + SessionId = SessionId, + TerminalStage = "WhiteGloveSealed", + OccurredAtUtc = Occurred, + StepIndex = 12, + }; + + var entity = TableIndexRepository.ToEntity(record); + + Assert.Equal($"{TenantId}_WhiteGloveSealed", entity.PartitionKey); + Assert.Equal($"20260421140733_{SessionId}", entity.RowKey); + Assert.Equal(TenantId, entity.GetString("TenantId")); + Assert.Equal(SessionId, entity.GetString("SessionId")); + Assert.Equal("WhiteGloveSealed", entity.GetString("TerminalStage")); + Assert.Equal(Occurred, entity.GetDateTime("OccurredAtUtc")); + Assert.Equal(12, entity.GetInt32("StepIndex")); + } + + // ============================================================ SessionsByStage + + [Fact] + public void SessionsByStage_ToEntity_uses_padded_ticks_rowkey_for_lex_ordering() + { + var record = new SessionsByStageRecord + { + TenantId = TenantId, + SessionId = SessionId, + Stage = "EspInProgress", + LastUpdatedUtc = Occurred, + StepIndex = 4, + }; + + var entity = TableIndexRepository.ToEntity(record); + + Assert.Equal($"{TenantId}_EspInProgress", entity.PartitionKey); + Assert.Equal($"{Occurred.Ticks:D19}_{SessionId}", entity.RowKey); + Assert.Equal("EspInProgress", entity.GetString("Stage")); + Assert.Equal(Occurred, entity.GetDateTime("LastUpdatedUtc")); + Assert.Equal(4, entity.GetInt32("StepIndex")); + } + + // ============================================================ DeadEndsByReason + + [Fact] + public void DeadEndsByReason_ToEntity_includes_attempted_stage_and_from_stage() + { + var record = new DeadEndsByReasonRecord + { + TenantId = TenantId, + SessionId = SessionId, + DeadEndReason = "hybrid_reboot_gate_blocking", + StepIndex = 7, + FromStage = "EspInProgress", + AttemptedToStage = "WhiteGloveSealed", + OccurredAtUtc = Occurred, + }; + + var entity = TableIndexRepository.ToEntity(record); + + Assert.Equal($"{TenantId}_hybrid_reboot_gate_blocking", entity.PartitionKey); + Assert.Equal($"20260421140733_{SessionId}_000007", entity.RowKey); + Assert.Equal("hybrid_reboot_gate_blocking", entity.GetString("DeadEndReason")); + Assert.Equal(7, entity.GetInt32("StepIndex")); + Assert.Equal("EspInProgress", entity.GetString("FromStage")); + Assert.Equal("WhiteGloveSealed", entity.GetString("AttemptedToStage")); + Assert.Equal(Occurred, entity.GetDateTime("OccurredAtUtc")); + } + + // ============================================================ ClassifierVerdictsByIdLevel + + [Fact] + public void ClassifierVerdictsByIdLevel_ToEntity_combines_classifierId_and_level_in_pk() + { + var record = new ClassifierVerdictsByIdLevelRecord + { + TenantId = TenantId, + SessionId = SessionId, + ClassifierId = "whiteglove-sealing", + HypothesisLevel = "Weak", + StepIndex = 5, + OccurredAtUtc = Occurred, + }; + + var entity = TableIndexRepository.ToEntity(record); + + Assert.Equal($"{TenantId}_whiteglove-sealing_Weak", entity.PartitionKey); + Assert.Equal($"20260421140733_{SessionId}_000005", entity.RowKey); + Assert.Equal("whiteglove-sealing", entity.GetString("ClassifierId")); + Assert.Equal("Weak", entity.GetString("HypothesisLevel")); + Assert.Equal(5, entity.GetInt32("StepIndex")); + Assert.Equal(Occurred, entity.GetDateTime("OccurredAtUtc")); + } + + // ============================================================ SignalsByKind + + [Fact] + public void SignalsByKind_ToEntity_carries_ordinal_backref_for_primary_row_hop() + { + var record = new SignalsByKindRecord + { + TenantId = TenantId, + SessionId = SessionId, + SignalKind = "EspTerminalFailure", + SessionSignalOrdinal = 987L, + OccurredAtUtc = Occurred, + SourceOrigin = "EspAndHelloTrackerAdapter", + }; + + var entity = TableIndexRepository.ToEntity(record); + + Assert.Equal($"{TenantId}_EspTerminalFailure", entity.PartitionKey); + Assert.Equal($"20260421140733_{SessionId}_0000000987", entity.RowKey); + Assert.Equal("EspTerminalFailure", entity.GetString("SignalKind")); + Assert.Equal(987L, entity.GetInt64("SessionSignalOrdinal")); + Assert.Equal(Occurred, entity.GetDateTime("OccurredAtUtc")); + Assert.Equal("EspAndHelloTrackerAdapter", entity.GetString("SourceOrigin")); + } + + // ============================================================ Null-tolerance on optional string columns + + [Fact] + public void DeadEndsByReason_ToEntity_tolerates_null_strings_by_defaulting_to_empty() + { + // The repo is called from a queue worker; a malformed upstream message shouldn't NRE the mapper. + var record = new DeadEndsByReasonRecord + { + TenantId = TenantId, + SessionId = SessionId, + DeadEndReason = null!, + StepIndex = 1, + FromStage = null!, + AttemptedToStage = null!, + OccurredAtUtc = Occurred, + }; + + var entity = TableIndexRepository.ToEntity(record); + + Assert.Equal(string.Empty, entity.GetString("DeadEndReason")); + Assert.Equal(string.Empty, entity.GetString("FromStage")); + Assert.Equal(string.Empty, entity.GetString("AttemptedToStage")); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs new file mode 100644 index 00000000..3a180240 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TableSignalRepositoryMappingTests.cs @@ -0,0 +1,174 @@ +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pure mapping tests for . The transaction pipeline itself +/// needs a live Azurite harness (out of scope for unit tests); the deterministic bit — how the +/// record projects onto the Azure Table entity — is covered here. +/// +public class TableSignalRepositoryMappingTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + [Fact] + public void BuildPartitionKey_is_tenant_underscore_session() + { + var pk = TableSignalRepository.BuildPartitionKey(TenantId, SessionId); + Assert.Equal($"{TenantId}_{SessionId}", pk); + } + + [Fact] + public void BuildRowKey_pads_ordinal_to_19_digits_for_lex_ordering() + { + Assert.Equal("0000000000000000000", TableSignalRepository.BuildRowKey(0)); + Assert.Equal("0000000000000000042", TableSignalRepository.BuildRowKey(42)); + Assert.Equal("9223372036854775807", TableSignalRepository.BuildRowKey(long.MaxValue)); + } + + [Fact] + public void BuildRowKey_orders_lexicographically_matching_numeric_order() + { + var a = TableSignalRepository.BuildRowKey(9); + var b = TableSignalRepository.BuildRowKey(10); + // 19-digit padding means string comparison matches numeric order (the property of the key). + Assert.True(string.CompareOrdinal(a, b) < 0); + } + + [Fact] + public void ToEntity_projects_all_typed_columns() + { + var record = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 17, + SessionTraceOrdinal = 117, + Kind = "EspPhaseChanged", + KindSchemaVersion = 2, + OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), + SourceOrigin = "EspAndHelloTrackerAdapter", + PayloadJson = "{\"foo\":\"bar\"}", + }; + + var entity = TableSignalRepository.ToEntity(record); + + Assert.Equal($"{TenantId}_{SessionId}", entity.PartitionKey); + Assert.Equal("0000000000000000017", entity.RowKey); + Assert.Equal(TenantId, entity.GetString("TenantId")); + Assert.Equal(SessionId, entity.GetString("SessionId")); + Assert.Equal(17L, entity.GetInt64("SessionSignalOrdinal")); + Assert.Equal(117L, entity.GetInt64("SessionTraceOrdinal")); + Assert.Equal("EspPhaseChanged", entity.GetString("Kind")); + Assert.Equal(2, entity.GetInt32("KindSchemaVersion")); + Assert.Equal(record.OccurredAtUtc, entity.GetDateTime("OccurredAtUtc")); + Assert.Equal("EspAndHelloTrackerAdapter", entity.GetString("SourceOrigin")); + Assert.Equal("{\"foo\":\"bar\"}", entity.GetString("PayloadJson")); + } + + [Fact] + public void ToEntity_chunks_oversized_PayloadJson_to_survive_the_32K_char_per_property_limit() + { + var oversized = new string('x', 75_000); + var record = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 1, + SessionTraceOrdinal = 1, + Kind = "EspPhaseChanged", + KindSchemaVersion = 1, + OccurredAtUtc = DateTime.UtcNow, + SourceOrigin = "test", + PayloadJson = oversized, + }; + + var entity = TableSignalRepository.ToEntity(record); + + Assert.Equal("3", entity.GetString("PayloadJson_ChunkCount")); + Assert.Equal(30_000, entity.GetString("PayloadJson_0").Length); + Assert.Equal(30_000, entity.GetString("PayloadJson_1").Length); + Assert.Equal(15_000, entity.GetString("PayloadJson_2").Length); + Assert.False(entity.ContainsKey("PayloadJson")); + } + + [Fact] + public void ToEntity_tolerates_null_PayloadJson_without_throwing() + { + var record = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 1, + SessionTraceOrdinal = 1, + Kind = "EspPhaseChanged", + KindSchemaVersion = 1, + OccurredAtUtc = DateTime.UtcNow, + SourceOrigin = "test", + PayloadJson = null!, + }; + + var entity = TableSignalRepository.ToEntity(record); + + Assert.Equal(string.Empty, entity.GetString("PayloadJson")); + } + + // ============================================================ Reverse mapping (read path) + + [Fact] + public void FromEntity_round_trips_a_small_payload() + { + var original = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 42, + SessionTraceOrdinal = 117, + Kind = "EspExiting", + KindSchemaVersion = 2, + OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), + SourceOrigin = "EspAndHelloTrackerAdapter", + PayloadJson = "{\"kind\":\"EspExiting\",\"evidence\":{\"kind\":\"Raw\"}}", + }; + + var entity = TableSignalRepository.ToEntity(original); + var restored = TableSignalRepository.FromEntity(entity); + + Assert.Equal(original.TenantId, restored.TenantId); + Assert.Equal(original.SessionId, restored.SessionId); + Assert.Equal(original.SessionSignalOrdinal, restored.SessionSignalOrdinal); + Assert.Equal(original.SessionTraceOrdinal, restored.SessionTraceOrdinal); + Assert.Equal(original.Kind, restored.Kind); + Assert.Equal(original.KindSchemaVersion, restored.KindSchemaVersion); + Assert.Equal(original.OccurredAtUtc, restored.OccurredAtUtc.ToUniversalTime()); + Assert.Equal(original.SourceOrigin, restored.SourceOrigin); + Assert.Equal(original.PayloadJson, restored.PayloadJson); + } + + [Fact] + public void FromEntity_reassembles_chunked_PayloadJson() + { + // Same-sized chunking behaviour we wrote — verifies the read path undoes it. + var oversized = new string('x', 75_000); + var original = new SignalRecord + { + TenantId = TenantId, + SessionId = SessionId, + SessionSignalOrdinal = 1, + SessionTraceOrdinal = 1, + Kind = "EspPhaseChanged", + KindSchemaVersion = 1, + OccurredAtUtc = DateTime.UtcNow, + SourceOrigin = "t", + PayloadJson = oversized, + }; + + var entity = TableSignalRepository.ToEntity(original); + var restored = TableSignalRepository.FromEntity(entity); + + Assert.Equal(75_000, restored.PayloadJson.Length); + Assert.Equal(oversized, restored.PayloadJson); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TelemetryPayloadParserTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TelemetryPayloadParserTests.cs new file mode 100644 index 00000000..f9465881 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TelemetryPayloadParserTests.cs @@ -0,0 +1,258 @@ +using AutopilotMonitor.Functions.Functions.Ingest; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pure parsing tests for . Verifies the wire → storage +/// mapping the relies on: defensive null handling, typed +/// column extraction, and the index-discriminator projection (IsTerminal, ClassifierVerdictId, +/// ClassifierHypothesisLevel). +/// +public class TelemetryPayloadParserTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static TelemetryItemDto Dto(string kind, string payloadJson, long? traceOrdinal = null) + => new TelemetryItemDto + { + Kind = kind, + PartitionKey = $"{TenantId}_{SessionId}", + RowKey = "0000000001", + TelemetryItemId = 1, + SessionTraceOrdinal = traceOrdinal, + PayloadJson = payloadJson, + RequiresImmediateFlush = false, + EnqueuedAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), + RetryCount = 0, + }; + + // ============================================================ Defensive parsing + + [Theory] + [InlineData("")] + [InlineData("{ this is not json }")] + [InlineData("null")] + public void ParseEvent_returns_null_for_missing_or_malformed_payload(string payload) + { + var result = TelemetryPayloadParser.ParseEvent(Dto("Event", payload), TenantId, SessionId); + Assert.Null(result); + } + + [Theory] + [InlineData("")] + [InlineData("{ this is not json }")] + public void ParseSignal_returns_null_for_missing_or_malformed_payload(string payload) + { + var result = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload), TenantId, SessionId); + Assert.Null(result); + } + + [Theory] + [InlineData("")] + [InlineData("{ this is not json }")] + public void ParseTransition_returns_null_for_missing_or_malformed_payload(string payload) + { + var result = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId); + Assert.Null(result); + } + + // ============================================================ Event + + [Fact] + public void ParseEvent_deserialises_enrollment_event_shape() + { + var payload = + "{" + + "\"EventId\":\"evt-123\"," + + "\"EventType\":\"test_event\"," + + "\"Severity\":1," + + "\"Source\":\"test\"," + + "\"Phase\":2," + + "\"Message\":\"hello\"," + + "\"Sequence\":42," + + "\"Timestamp\":\"2026-04-21T10:00:00Z\"," + + "\"ReceivedAt\":\"2026-04-21T10:00:01Z\"" + + "}"; + + var evt = TelemetryPayloadParser.ParseEvent(Dto("Event", payload), TenantId, SessionId); + + Assert.NotNull(evt); + Assert.Equal("evt-123", evt!.EventId); + Assert.Equal("test_event", evt.EventType); + Assert.Equal(42, evt.Sequence); + } + + // ============================================================ Signal + + [Fact] + public void ParseSignal_extracts_typed_columns_and_preserves_full_payload() + { + var payload = + "{" + + "\"SessionSignalOrdinal\":17," + + "\"SessionTraceOrdinal\":117," + + "\"Kind\":\"EspPhaseChanged\"," + + "\"KindSchemaVersion\":2," + + "\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"," + + "\"SourceOrigin\":\"EspAndHelloTrackerAdapter\"," + + "\"Evidence\":{\"Kind\":\"Raw\"}" + + "}"; + + var rec = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload), TenantId, SessionId); + + Assert.NotNull(rec); + Assert.Equal(TenantId, rec!.TenantId); + Assert.Equal(SessionId, rec.SessionId); + Assert.Equal(17L, rec.SessionSignalOrdinal); + Assert.Equal(117L, rec.SessionTraceOrdinal); + Assert.Equal("EspPhaseChanged", rec.Kind); + Assert.Equal(2, rec.KindSchemaVersion); + Assert.Equal(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), rec.OccurredAtUtc.ToUniversalTime()); + Assert.Equal("EspAndHelloTrackerAdapter", rec.SourceOrigin); + Assert.Equal(payload, rec.PayloadJson); + } + + [Fact] + public void ParseSignal_falls_back_to_envelope_SessionTraceOrdinal_when_payload_missing_field() + { + // Payload intentionally omits SessionTraceOrdinal; the envelope carries it. + var payload = "{\"SessionSignalOrdinal\":1,\"Kind\":\"SessionStarted\"}"; + + var rec = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload, traceOrdinal: 99), TenantId, SessionId); + + Assert.NotNull(rec); + Assert.Equal(99L, rec!.SessionTraceOrdinal); + } + + [Theory] + [InlineData("{\"Kind\":\"SessionStarted\"}")] // key absent + [InlineData("{\"SessionSignalOrdinal\":null,\"Kind\":\"SessionStarted\"}")] // key present but null + public void ParseSignal_drops_item_when_SessionSignalOrdinal_is_missing_or_null(string payload) + { + // A missing ordinal would silently default to 0 → collide with a legitimate ordinal-0 + // row on UpsertReplace. The parser must reject it so the caller loop drops the item. + var rec = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload), TenantId, SessionId); + Assert.Null(rec); + } + + [Fact] + public void ParseSignal_accepts_explicit_ordinal_zero() + { + // 0 is a legal value (the agent seeds ordinals from 0 on a fresh SignalLog). Only + // absent/null is rejected — an explicit 0 must round-trip. + var payload = "{\"SessionSignalOrdinal\":0,\"Kind\":\"SessionStarted\"}"; + + var rec = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload), TenantId, SessionId); + + Assert.NotNull(rec); + Assert.Equal(0L, rec!.SessionSignalOrdinal); + } + + // ============================================================ DecisionTransition + + [Fact] + public void ParseTransition_extracts_core_columns_and_flags_taken_false_when_dead_end() + { + var payload = + "{" + + "\"StepIndex\":3," + + "\"SessionTraceOrdinal\":10," + + "\"SignalOrdinalRef\":9," + + "\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"," + + "\"Trigger\":\"EspExiting\"," + + "\"FromStage\":\"EspInProgress\"," + + "\"ToStage\":\"EspInProgress\"," + + "\"Taken\":false," + + "\"DeadEndReason\":\"hybrid_reboot_gate_blocking\"," + + "\"ReducerVersion\":\"1.0.0\"" + + "}"; + + var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId); + + Assert.NotNull(rec); + Assert.False(rec!.Taken); + Assert.Equal("hybrid_reboot_gate_blocking", rec.DeadEndReason); + Assert.False(rec.IsTerminal); + Assert.Null(rec.ClassifierVerdictId); + } + + [Fact] + public void ParseTransition_projects_IsTerminal_true_for_terminal_stages() + { + var terminalStages = new[] { "Completed", "Failed", "WhiteGloveSealed", "WhiteGloveCompletedPart2" }; + + foreach (var stage in terminalStages) + { + var payload = + "{\"StepIndex\":1,\"SessionTraceOrdinal\":1,\"SignalOrdinalRef\":1," + + $"\"Trigger\":\"t\",\"FromStage\":\"X\",\"ToStage\":\"{stage}\",\"Taken\":true," + + "\"ReducerVersion\":\"1.0.0\",\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"}"; + + var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId); + + Assert.NotNull(rec); + Assert.True(rec!.IsTerminal, $"Expected {stage} to be terminal"); + } + } + + [Theory] + [InlineData("{\"SessionTraceOrdinal\":1,\"ToStage\":\"EspInProgress\"}")] // key absent + [InlineData("{\"StepIndex\":null,\"SessionTraceOrdinal\":1,\"ToStage\":\"EspInProgress\"}")] // key present but null + public void ParseTransition_drops_item_when_StepIndex_is_missing_or_null(string payload) + { + // Same defense as ParseSignal: a missing StepIndex would default to 0 → collide with the + // legitimate step-0 row on UpsertReplace. + var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId); + Assert.Null(rec); + } + + [Fact] + public void ParseTransition_accepts_explicit_stepIndex_zero() + { + // Step indices start at 0 (the first reducer call produces StepIndex=0). An explicit 0 + // must not be confused with a missing key. + var payload = + "{\"StepIndex\":0,\"SessionTraceOrdinal\":1,\"SignalOrdinalRef\":1," + + "\"Trigger\":\"t\",\"FromStage\":\"Pending\",\"ToStage\":\"EspInProgress\",\"Taken\":true," + + "\"ReducerVersion\":\"1.0.0\",\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"}"; + + var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId); + + Assert.NotNull(rec); + Assert.Equal(0, rec!.StepIndex); + } + + [Fact] + public void ParseTransition_extracts_classifier_verdict_nested_fields() + { + var payload = + "{\"StepIndex\":1,\"SessionTraceOrdinal\":1,\"SignalOrdinalRef\":1," + + "\"Trigger\":\"t\",\"FromStage\":\"X\",\"ToStage\":\"WhiteGloveSealed\",\"Taken\":true," + + "\"ReducerVersion\":\"1.0.0\",\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"," + + "\"ClassifierVerdict\":{\"ClassifierId\":\"whiteglove-sealing\",\"HypothesisLevel\":\"Strong\"}}"; + + var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId); + + Assert.NotNull(rec); + Assert.Equal("whiteglove-sealing", rec!.ClassifierVerdictId); + Assert.Equal("Strong", rec.ClassifierHypothesisLevel); + } + + // ============================================================ IsTerminalStage + + [Theory] + [InlineData("Completed", true)] + [InlineData("Failed", true)] + [InlineData("WhiteGloveSealed", true)] + [InlineData("WhiteGloveCompletedPart2", true)] + [InlineData("EspInProgress", false)] + [InlineData("AccountSetup", false)] + [InlineData("", false)] + [InlineData("completed", false)] // case-sensitive — enum names are PascalCase + public void IsTerminalStage_matches_DecisionCore_authoritative_list(string stage, bool expected) + { + Assert.Equal(expected, TelemetryPayloadParser.IsTerminalStage(stage)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TelemetryWireRoundTripTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TelemetryWireRoundTripTests.cs new file mode 100644 index 00000000..1f218f4c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TelemetryWireRoundTripTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Functions.Functions.Ingest; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Cross-layer round-trip (Codex Finding 1 follow-up gate): proves that a +/// / serialized on the V2 agent +/// deserializes correctly on the backend into a / +/// . Failure here = the agent's uploaded Signals or +/// Transitions never reach the Inspector / index pipeline intact. +/// +/// This test replicates the JsonSerializerSettings the agent-side emitters use +/// (Newtonsoft + + ). +/// The backend project can't reference the net48 agent assembly directly (one-way cross-TFM +/// constraint), so we exercise the wire contract via the shared DecisionCore types +/// plus the agreed serialization settings. Any drift between agent and backend would break +/// this assertion immediately. +/// +/// +public class TelemetryWireRoundTripTests +{ + private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static readonly DateTime At = new(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + // Must mirror TelemetrySignalEmitter / TelemetryTransitionEmitter in V2.Core. + private static readonly JsonSerializerSettings AgentWireFormat = new() + { + Converters = { new StringEnumConverter() }, + NullValueHandling = NullValueHandling.Ignore, + }; + + private static TelemetryItemDto DraftToDto(string kind, string payloadJson, long sessionTraceOrdinal) => + new TelemetryItemDto + { + Kind = kind, + PartitionKey = $"{TenantId}_{SessionId}", + RowKey = "0", + TelemetryItemId = 1, + SessionTraceOrdinal = sessionTraceOrdinal, + PayloadJson = payloadJson, + EnqueuedAtUtc = At, + RetryCount = 0, + }; + + // ============================================================ Signal wire round-trip + + [Fact] + public void DecisionSignal_serialized_with_agent_settings_parses_into_SignalRecord() + { + var signal = new DecisionSignal( + sessionSignalOrdinal: 42, + sessionTraceOrdinal: 142, + kind: DecisionSignalKind.EspTerminalFailure, + kindSchemaVersion: 2, + occurredAtUtc: At, + sourceOrigin: "EspAndHelloTrackerAdapter", + evidence: new Evidence(EvidenceKind.Raw, "raw-42", "evidence-42")); + + var payloadJson = JsonConvert.SerializeObject(signal, AgentWireFormat); + var dto = DraftToDto("Signal", payloadJson, sessionTraceOrdinal: 142); + + var record = TelemetryPayloadParser.ParseSignal(dto, TenantId, SessionId); + + Assert.NotNull(record); + Assert.Equal(42L, record!.SessionSignalOrdinal); + Assert.Equal(142L, record.SessionTraceOrdinal); + Assert.Equal("EspTerminalFailure", record.Kind); + Assert.Equal(2, record.KindSchemaVersion); + Assert.Equal(At, record.OccurredAtUtc.ToUniversalTime()); + Assert.Equal("EspAndHelloTrackerAdapter", record.SourceOrigin); + Assert.Equal(payloadJson, record.PayloadJson); + } + + [Fact] + public void DecisionSignal_with_explicit_ordinal_zero_round_trips() + { + // Defense against the Finding 3 regression path: ordinal 0 is a legal value and must + // survive serialize → parse. (The parser's guard rejects *missing* ordinals, not + // legitimate 0s.) + var signal = new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.SessionStarted, + kindSchemaVersion: 1, + occurredAtUtc: At, + sourceOrigin: "SignalIngress", + evidence: new Evidence(EvidenceKind.Raw, "s", "start")); + + var payloadJson = JsonConvert.SerializeObject(signal, AgentWireFormat); + var record = TelemetryPayloadParser.ParseSignal( + DraftToDto("Signal", payloadJson, 0), TenantId, SessionId); + + Assert.NotNull(record); + Assert.Equal(0L, record!.SessionSignalOrdinal); + } + + // ============================================================ DecisionTransition wire round-trip + + [Fact] + public void DecisionTransition_Taken_terminal_with_classifier_round_trips_all_index_discriminators() + { + var verdict = new ClassifierVerdict( + classifierId: "whiteglove-sealing", + level: HypothesisLevel.Strong, + score: 80, + contributingFactors: new List { "PatternMatched" }, + reason: "matched sealing pattern", + inputHash: "abc123"); + + var transition = new DecisionTransition( + stepIndex: 7, + sessionTraceOrdinal: 207, + signalOrdinalRef: 42, + occurredAtUtc: At, + trigger: "EspExiting", + fromStage: SessionStage.EspDeviceSetup, + toStage: SessionStage.WhiteGloveSealed, + taken: true, + deadEndReason: null, + reducerVersion: "1.0.0", + classifierVerdict: verdict); + + var payloadJson = JsonConvert.SerializeObject(transition, AgentWireFormat); + var dto = DraftToDto("DecisionTransition", payloadJson, sessionTraceOrdinal: 207); + + var record = TelemetryPayloadParser.ParseTransition(dto, TenantId, SessionId); + + Assert.NotNull(record); + Assert.Equal(7, record!.StepIndex); + Assert.Equal(207L, record.SessionTraceOrdinal); + Assert.Equal(42L, record.SignalOrdinalRef); + Assert.Equal("EspExiting", record.Trigger); + Assert.Equal("EspDeviceSetup", record.FromStage); + Assert.Equal("WhiteGloveSealed", record.ToStage); + Assert.True(record.Taken); + Assert.True(record.IsTerminal); // Projected from ToStage + Assert.Equal("whiteglove-sealing", record.ClassifierVerdictId); + Assert.Equal("Strong", record.ClassifierHypothesisLevel); // StringEnumConverter → "Strong" + Assert.Equal("1.0.0", record.ReducerVersion); + Assert.Equal(payloadJson, record.PayloadJson); + } + + [Fact] + public void DecisionTransition_dead_end_preserves_DeadEndReason_and_drops_classifier_fields() + { + var blocked = new DecisionTransition( + stepIndex: 3, + sessionTraceOrdinal: 103, + signalOrdinalRef: 5, + occurredAtUtc: At, + trigger: "EspExiting", + fromStage: SessionStage.EspDeviceSetup, + toStage: SessionStage.EspDeviceSetup, + taken: false, + deadEndReason: "hybrid_reboot_gate_blocking", + reducerVersion: "1.0.0"); + + var payloadJson = JsonConvert.SerializeObject(blocked, AgentWireFormat); + var record = TelemetryPayloadParser.ParseTransition( + DraftToDto("DecisionTransition", payloadJson, 103), TenantId, SessionId); + + Assert.NotNull(record); + Assert.False(record!.Taken); + Assert.Equal("hybrid_reboot_gate_blocking", record.DeadEndReason); + Assert.False(record.IsTerminal); + Assert.Null(record.ClassifierVerdictId); + Assert.Null(record.ClassifierHypothesisLevel); + } + + [Fact] + public void DecisionTransition_with_step_zero_round_trips() + { + // StepIndex=0 is the first reducer step per session. Must survive serialize → parse + // (Finding 3 guard rejects missing StepIndex, not legitimate 0). + var first = new DecisionTransition( + stepIndex: 0, + sessionTraceOrdinal: 100, + signalOrdinalRef: 0, + occurredAtUtc: At, + trigger: "SessionStarted", + fromStage: SessionStage.Unknown, + toStage: SessionStage.AwaitingEspPhaseChange, + taken: true, + deadEndReason: null, + reducerVersion: "1.0.0"); + + var payloadJson = JsonConvert.SerializeObject(first, AgentWireFormat); + var record = TelemetryPayloadParser.ParseTransition( + DraftToDto("DecisionTransition", payloadJson, 100), TenantId, SessionId); + + Assert.NotNull(record); + Assert.Equal(0, record!.StepIndex); + Assert.Equal("AwaitingEspPhaseChange", record.ToStage); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj index 407cd929..6da0673c 100644 --- a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj +++ b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj @@ -18,9 +18,11 @@ + + @@ -30,6 +32,7 @@ + diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs index 14749faa..cea7905d 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs @@ -36,6 +36,11 @@ namespace AutopilotMonitor.Functions.DataAccess services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + // V2 Decision Engine repositories (Plan §M5). + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/IndexRowKeys.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/IndexRowKeys.cs new file mode 100644 index 00000000..e798c548 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/IndexRowKeys.cs @@ -0,0 +1,87 @@ +using System; +using System.Globalization; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Pure PK/RK builders for the 5 V2 Decision Engine index tables + /// (Plan §2.8 query matrix, §M5.d). State-less + zero-IO so every key shape is + /// unit-testable against canned inputs without an Azurite harness. + /// + internal static class IndexRowKeys + { + private const string TimestampFormat = "yyyyMMddHHmmss"; + + // Azure Tables forbids these characters in PK/RK values. Discriminators are typically + // enum names (safe ASCII); the sanitize step is defensive cover for free-form strings + // like DeadEndReason that could in principle include a slash. + private static readonly char[] ForbiddenKeyChars = { '/', '\\', '#', '?' }; + + // ============================================================ SessionsByTerminal + + public static string BuildSessionsByTerminalPk(string tenantId, string terminalStage) + => $"{tenantId}_{Sanitize(terminalStage)}"; + + public static string BuildSessionsByTerminalRk(DateTime occurredAtUtc, string sessionId) + => $"{FormatTimestamp(occurredAtUtc)}_{sessionId}"; + + // ============================================================ SessionsByStage + + public static string BuildSessionsByStagePk(string tenantId, string stage) + => $"{tenantId}_{Sanitize(stage)}"; + + /// + /// Ticks are zero-padded to 19 digits (DateTime.MaxValue has 19-digit ticks) so + /// lexicographic RK ordering matches numeric time order. + /// + public static string BuildSessionsByStageRk(DateTime lastUpdatedUtc, string sessionId) + => $"{lastUpdatedUtc.ToUniversalTime().Ticks.ToString("D19", CultureInfo.InvariantCulture)}_{sessionId}"; + + // ============================================================ DeadEndsByReason + + public static string BuildDeadEndsByReasonPk(string tenantId, string deadEndReason) + => $"{tenantId}_{Sanitize(deadEndReason)}"; + + public static string BuildDeadEndsByReasonRk(DateTime occurredAtUtc, string sessionId, int stepIndex) + => $"{FormatTimestamp(occurredAtUtc)}_{sessionId}_{stepIndex.ToString("D6", CultureInfo.InvariantCulture)}"; + + // ============================================================ ClassifierVerdictsByIdLevel + + public static string BuildClassifierVerdictsByIdLevelPk(string tenantId, string classifierId, string hypothesisLevel) + => $"{tenantId}_{Sanitize(classifierId)}_{Sanitize(hypothesisLevel)}"; + + public static string BuildClassifierVerdictsByIdLevelRk(DateTime occurredAtUtc, string sessionId, int stepIndex) + => $"{FormatTimestamp(occurredAtUtc)}_{sessionId}_{stepIndex.ToString("D6", CultureInfo.InvariantCulture)}"; + + // ============================================================ SignalsByKind + + public static string BuildSignalsByKindPk(string tenantId, string signalKind) + => $"{tenantId}_{Sanitize(signalKind)}"; + + public static string BuildSignalsByKindRk(DateTime occurredAtUtc, string sessionId, long sessionSignalOrdinal) + => $"{FormatTimestamp(occurredAtUtc)}_{sessionId}_{sessionSignalOrdinal.ToString("D10", CultureInfo.InvariantCulture)}"; + + // ============================================================ Helpers + + private static string FormatTimestamp(DateTime utc) + => utc.ToUniversalTime().ToString(TimestampFormat, CultureInfo.InvariantCulture); + + /// + /// Replaces characters Azure Tables disallows in PK/RK values with _. Enum-derived + /// discriminators never trigger this; it's defensive cover for free-form strings. + /// + internal static string Sanitize(string? value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + if (value.IndexOfAny(ForbiddenKeyChars) < 0) return value; + + var buffer = new char[value.Length]; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + buffer[i] = Array.IndexOf(ForbiddenKeyChars, c) >= 0 ? '_' : c; + } + return new string(buffer); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index f70f3d03..947cc8f8 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -541,8 +541,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "MaintenanceBlockDurationHours", config.MaintenanceBlockDurationHours }, { "DiagnosticsGlobalLogPathsJson", config.DiagnosticsGlobalLogPathsJson }, { "ModernDeploymentHarmlessEventIdsJson", config.ModernDeploymentHarmlessEventIdsJson ?? string.Empty }, + { "WhiteGloveSealingPatternIdsJson", config.WhiteGloveSealingPatternIdsJson ?? string.Empty }, { "NvdApiKey", config.NvdApiKey }, { "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled }, + { "EnableIndexDualWrite", config.EnableIndexDualWrite }, { "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc }, { "MaxDiagnosticsDownloadSizeMB", config.MaxDiagnosticsDownloadSizeMB }, { "DiagnosticsDownloadTimeoutSeconds", config.DiagnosticsDownloadTimeoutSeconds }, @@ -593,8 +595,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage MaintenanceBlockDurationHours = entity.GetInt32("MaintenanceBlockDurationHours") ?? 12, DiagnosticsGlobalLogPathsJson = entity.GetString("DiagnosticsGlobalLogPathsJson"), ModernDeploymentHarmlessEventIdsJson = entity.GetString("ModernDeploymentHarmlessEventIdsJson"), + WhiteGloveSealingPatternIdsJson = entity.GetString("WhiteGloveSealingPatternIdsJson"), NvdApiKey = entity.GetString("NvdApiKey"), VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true, + EnableIndexDualWrite = entity.GetBoolean("EnableIndexDualWrite") ?? false, VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"), MaxDiagnosticsDownloadSizeMB = entity.GetInt32("MaxDiagnosticsDownloadSizeMB") ?? 500, DiagnosticsDownloadTimeoutSeconds = entity.GetInt32("DiagnosticsDownloadTimeoutSeconds") ?? 120, diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs new file mode 100644 index 00000000..44c091c0 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDecisionTransitionRepository.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Azure Tables implementation of . Upserts to + /// the DecisionTransitions table via entity-group-transactions (chunked to the 100-op + /// limit). + /// + public sealed class TableDecisionTransitionRepository : IDecisionTransitionRepository + { + /// Azure Table Storage limit per entity-group-transaction. + internal const int TransactionChunkSize = 100; + + private readonly TableStorageService _storage; + private readonly ILogger _logger; + + public TableDecisionTransitionRepository( + TableStorageService storage, + ILogger logger) + { + _storage = storage; + _logger = logger; + } + + public async Task StoreBatchAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default) + { + if (records == null || records.Count == 0) return 0; + + foreach (var r in records) + { + SecurityValidator.EnsureValidGuid(r.TenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(r.SessionId, "SessionId"); + } + + var table = _storage.GetTableClient(Constants.TableNames.DecisionTransitions); + var committed = 0; + + foreach (var group in records.GroupBy(r => (r.TenantId, r.SessionId))) + { + var ordered = group.OrderBy(r => r.StepIndex).ToList(); + + for (var offset = 0; offset < ordered.Count; offset += TransactionChunkSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var chunk = ordered.Skip(offset).Take(TransactionChunkSize).ToList(); + var actions = chunk + .Select(r => new TableTransactionAction( + TableTransactionActionType.UpsertReplace, + ToEntity(r))) + .ToList(); + + await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false); + committed += chunk.Count; + } + + _logger.LogDebug( + "DecisionTransitions: committed {Count} rows for {Tenant}_{Session}", + ordered.Count, group.Key.TenantId, group.Key.SessionId); + } + + return committed; + } + + public async Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default) + { + SecurityValidator.EnsureValidGuid(tenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(sessionId, "SessionId"); + + var table = _storage.GetTableClient(Constants.TableNames.DecisionTransitions); + var pk = BuildPartitionKey(tenantId, sessionId); + + var results = new List(capacity: Math.Min(maxResults, 128)); + var pages = table.QueryAsync( + filter: $"PartitionKey eq '{pk}'", + maxPerPage: Math.Min(maxResults, 1000), + cancellationToken: cancellationToken); + + await foreach (var entity in pages.ConfigureAwait(false)) + { + if (results.Count >= maxResults) break; + results.Add(FromEntity(entity)); + } + + results.Sort((a, b) => a.StepIndex.CompareTo(b.StepIndex)); + return results; + } + + public async Task> QueryByTimestampAtOrAfterAsync( + DateTime cutoffUtc, int maxResults = 50_000, CancellationToken cancellationToken = default) + { + var table = _storage.GetTableClient(Constants.TableNames.DecisionTransitions); + var filter = $"Timestamp ge datetime'{cutoffUtc.ToUniversalTime():yyyy-MM-ddTHH:mm:ss.fffffffZ}'"; + + var results = new List(capacity: 128); + var pages = table.QueryAsync( + filter: filter, + maxPerPage: 1000, + cancellationToken: cancellationToken); + + await foreach (var entity in pages.ConfigureAwait(false)) + { + if (results.Count >= maxResults) + { + _logger.LogWarning( + "DecisionTransitions: time-range query reached maxResults cap {Cap} at cutoff {Cutoff:o} — narrow window or bump cap", + maxResults, cutoffUtc); + break; + } + results.Add(FromEntity(entity)); + } + + return results; + } + + /// + /// Projects an Azure back into a , + /// reassembling chunked PayloadJson if present. Internal for mapping tests. + /// + internal static DecisionTransitionRecord FromEntity(TableEntity entity) + { + return new DecisionTransitionRecord + { + TenantId = entity.GetString("TenantId") ?? string.Empty, + SessionId = entity.GetString("SessionId") ?? string.Empty, + StepIndex = entity.GetInt32("StepIndex") ?? 0, + SessionTraceOrdinal = entity.GetInt64("SessionTraceOrdinal") ?? 0, + SignalOrdinalRef = entity.GetInt64("SignalOrdinalRef") ?? 0, + OccurredAtUtc = entity.GetDateTime("OccurredAtUtc") ?? default, + Trigger = entity.GetString("Trigger") ?? string.Empty, + FromStage = entity.GetString("FromStage") ?? string.Empty, + ToStage = entity.GetString("ToStage") ?? string.Empty, + Taken = entity.GetBoolean("Taken") ?? false, + DeadEndReason = entity.GetString("DeadEndReason"), + ReducerVersion = entity.GetString("ReducerVersion") ?? string.Empty, + IsTerminal = entity.GetBoolean("IsTerminal") ?? false, + ClassifierVerdictId = entity.GetString("ClassifierVerdictId"), + ClassifierHypothesisLevel = entity.GetString("ClassifierHypothesisLevel"), + PayloadJson = TableStorageChunking.ReassembleProperty(entity, "PayloadJson") ?? string.Empty, + }; + } + + /// + /// Projects a onto its Azure + /// shape. Keys: PK = {TenantId}_{SessionId}, RK = {StepIndex:D10}. + /// + internal static TableEntity ToEntity(DecisionTransitionRecord r) + { + var pk = BuildPartitionKey(r.TenantId, r.SessionId); + var rk = BuildRowKey(r.StepIndex); + + var entity = new TableEntity(pk, rk) + { + ["TenantId"] = r.TenantId, + ["SessionId"] = r.SessionId, + ["StepIndex"] = r.StepIndex, + ["SessionTraceOrdinal"] = r.SessionTraceOrdinal, + ["SignalOrdinalRef"] = r.SignalOrdinalRef, + ["OccurredAtUtc"] = r.OccurredAtUtc, + ["Trigger"] = r.Trigger ?? string.Empty, + ["FromStage"] = r.FromStage ?? string.Empty, + ["ToStage"] = r.ToStage ?? string.Empty, + ["Taken"] = r.Taken, + ["DeadEndReason"] = r.DeadEndReason, + ["ReducerVersion"] = r.ReducerVersion ?? string.Empty, + ["IsTerminal"] = r.IsTerminal, + ["ClassifierVerdictId"] = r.ClassifierVerdictId, + ["ClassifierHypothesisLevel"] = r.ClassifierHypothesisLevel, + }; + + foreach (var kv in TableStorageChunking.ChunkProperty("PayloadJson", r.PayloadJson ?? string.Empty)) + { + entity[kv.Key] = kv.Value; + } + + return entity; + } + + internal static string BuildPartitionKey(string tenantId, string sessionId) + => $"{tenantId}_{sessionId}"; + + internal static string BuildRowKey(int stepIndex) + => stepIndex.ToString("D10"); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableIndexRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableIndexRepository.cs new file mode 100644 index 00000000..44474624 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableIndexRepository.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Azure Tables implementation of (Plan §2.8, §M5.d). + /// One class covers all 5 index tables — each table's projection lives in a dedicated + /// ToEntity overload, while the shared batched-transaction write path + /// (group-by-PK, 100-op chunk) mirrors . + /// + public sealed class TableIndexRepository : IIndexTableRepository + { + /// Azure Table Storage limit per entity-group-transaction. + internal const int TransactionChunkSize = 100; + + private readonly TableStorageService _storage; + private readonly ILogger _logger; + + public TableIndexRepository(TableStorageService storage, ILogger logger) + { + _storage = storage; + _logger = logger; + } + + // ============================================================ public Store methods + + public Task StoreSessionsByTerminalAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default) + => StoreAsync(Constants.TableNames.SessionsByTerminal, records, ToEntity, cancellationToken); + + public Task StoreSessionsByStageAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default) + => StoreAsync(Constants.TableNames.SessionsByStage, records, ToEntity, cancellationToken); + + public Task StoreDeadEndsByReasonAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default) + => StoreAsync(Constants.TableNames.DeadEndsByReason, records, ToEntity, cancellationToken); + + public Task StoreClassifierVerdictsByIdLevelAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default) + => StoreAsync(Constants.TableNames.ClassifierVerdictsByIdLevel, records, ToEntity, cancellationToken); + + public Task StoreSignalsByKindAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default) + => StoreAsync(Constants.TableNames.SignalsByKind, records, ToEntity, cancellationToken); + + // ============================================================ shared write path + + private async Task StoreAsync( + string tableName, + IReadOnlyList records, + Func toEntity, + CancellationToken cancellationToken) + { + if (records is null || records.Count == 0) return 0; + + var entities = new List(records.Count); + foreach (var r in records) + { + var entity = toEntity(r); + SecurityValidator.EnsureValidGuid(entity.GetString("TenantId"), "TenantId"); + SecurityValidator.EnsureValidGuid(entity.GetString("SessionId"), "SessionId"); + entities.Add(entity); + } + + var table = _storage.GetTableClient(tableName); + var committed = 0; + + foreach (var group in entities.GroupBy(e => e.PartitionKey)) + { + var ordered = group.OrderBy(e => e.RowKey, StringComparer.Ordinal).ToList(); + + for (var offset = 0; offset < ordered.Count; offset += TransactionChunkSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var chunk = ordered.Skip(offset).Take(TransactionChunkSize).ToList(); + var actions = chunk + .Select(e => new TableTransactionAction(TableTransactionActionType.UpsertReplace, e)) + .ToList(); + + await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false); + committed += chunk.Count; + } + } + + _logger.LogDebug("{Table}: committed {Count} index rows", tableName, committed); + return committed; + } + + // ============================================================ per-record entity mapping + + internal static TableEntity ToEntity(SessionsByTerminalRecord r) => new( + IndexRowKeys.BuildSessionsByTerminalPk(r.TenantId, r.TerminalStage), + IndexRowKeys.BuildSessionsByTerminalRk(r.OccurredAtUtc, r.SessionId)) + { + ["TenantId"] = r.TenantId, + ["SessionId"] = r.SessionId, + ["TerminalStage"] = r.TerminalStage ?? string.Empty, + ["OccurredAtUtc"] = r.OccurredAtUtc, + ["StepIndex"] = r.StepIndex, + }; + + internal static TableEntity ToEntity(SessionsByStageRecord r) => new( + IndexRowKeys.BuildSessionsByStagePk(r.TenantId, r.Stage), + IndexRowKeys.BuildSessionsByStageRk(r.LastUpdatedUtc, r.SessionId)) + { + ["TenantId"] = r.TenantId, + ["SessionId"] = r.SessionId, + ["Stage"] = r.Stage ?? string.Empty, + ["LastUpdatedUtc"] = r.LastUpdatedUtc, + ["StepIndex"] = r.StepIndex, + }; + + internal static TableEntity ToEntity(DeadEndsByReasonRecord r) => new( + IndexRowKeys.BuildDeadEndsByReasonPk(r.TenantId, r.DeadEndReason), + IndexRowKeys.BuildDeadEndsByReasonRk(r.OccurredAtUtc, r.SessionId, r.StepIndex)) + { + ["TenantId"] = r.TenantId, + ["SessionId"] = r.SessionId, + ["DeadEndReason"] = r.DeadEndReason ?? string.Empty, + ["StepIndex"] = r.StepIndex, + ["FromStage"] = r.FromStage ?? string.Empty, + ["AttemptedToStage"] = r.AttemptedToStage ?? string.Empty, + ["OccurredAtUtc"] = r.OccurredAtUtc, + }; + + internal static TableEntity ToEntity(ClassifierVerdictsByIdLevelRecord r) => new( + IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(r.TenantId, r.ClassifierId, r.HypothesisLevel), + IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(r.OccurredAtUtc, r.SessionId, r.StepIndex)) + { + ["TenantId"] = r.TenantId, + ["SessionId"] = r.SessionId, + ["ClassifierId"] = r.ClassifierId ?? string.Empty, + ["HypothesisLevel"] = r.HypothesisLevel ?? string.Empty, + ["StepIndex"] = r.StepIndex, + ["OccurredAtUtc"] = r.OccurredAtUtc, + }; + + internal static TableEntity ToEntity(SignalsByKindRecord r) => new( + IndexRowKeys.BuildSignalsByKindPk(r.TenantId, r.SignalKind), + IndexRowKeys.BuildSignalsByKindRk(r.OccurredAtUtc, r.SessionId, r.SessionSignalOrdinal)) + { + ["TenantId"] = r.TenantId, + ["SessionId"] = r.SessionId, + ["SignalKind"] = r.SignalKind ?? string.Empty, + ["SessionSignalOrdinal"] = r.SessionSignalOrdinal, + ["OccurredAtUtc"] = r.OccurredAtUtc, + ["SourceOrigin"] = r.SourceOrigin ?? string.Empty, + }; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs new file mode 100644 index 00000000..c35ffc60 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSignalRepository.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Azure Tables implementation of . Upserts to the + /// Signals table via entity-group-transactions (chunked to the 100-op limit). + /// + public sealed class TableSignalRepository : ISignalRepository + { + /// Azure Table Storage limit per entity-group-transaction. + internal const int TransactionChunkSize = 100; + + private readonly TableStorageService _storage; + private readonly ILogger _logger; + + public TableSignalRepository(TableStorageService storage, ILogger logger) + { + _storage = storage; + _logger = logger; + } + + public async Task StoreBatchAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default) + { + if (records == null || records.Count == 0) return 0; + + foreach (var r in records) + { + SecurityValidator.EnsureValidGuid(r.TenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(r.SessionId, "SessionId"); + } + + var table = _storage.GetTableClient(Constants.TableNames.Signals); + var committed = 0; + + foreach (var group in records.GroupBy(r => (r.TenantId, r.SessionId))) + { + var ordered = group.OrderBy(r => r.SessionSignalOrdinal).ToList(); + + for (var offset = 0; offset < ordered.Count; offset += TransactionChunkSize) + { + cancellationToken.ThrowIfCancellationRequested(); + + var chunk = ordered.Skip(offset).Take(TransactionChunkSize).ToList(); + var actions = chunk + .Select(r => new TableTransactionAction( + TableTransactionActionType.UpsertReplace, + ToEntity(r))) + .ToList(); + + await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false); + committed += chunk.Count; + } + + _logger.LogDebug( + "Signals: committed {Count} rows for {Tenant}_{Session}", + ordered.Count, group.Key.TenantId, group.Key.SessionId); + } + + return committed; + } + + public async Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default) + { + SecurityValidator.EnsureValidGuid(tenantId, "TenantId"); + SecurityValidator.EnsureValidGuid(sessionId, "SessionId"); + + var table = _storage.GetTableClient(Constants.TableNames.Signals); + var pk = BuildPartitionKey(tenantId, sessionId); + + var results = new List(capacity: Math.Min(maxResults, 128)); + var pages = table.QueryAsync( + filter: $"PartitionKey eq '{pk}'", + maxPerPage: Math.Min(maxResults, 1000), + cancellationToken: cancellationToken); + + await foreach (var entity in pages.ConfigureAwait(false)) + { + if (results.Count >= maxResults) break; + results.Add(FromEntity(entity)); + } + + // RowKey is D19(SessionSignalOrdinal) — Azure Tables returns PK-scoped rows in RowKey + // lex order which matches numeric ordinal order (that's the whole point of the padding). + // Explicit sort is redundant but cheap; keeps the invariant under a test. + results.Sort((a, b) => a.SessionSignalOrdinal.CompareTo(b.SessionSignalOrdinal)); + return results; + } + + public async Task> QueryByTimestampAtOrAfterAsync( + DateTime cutoffUtc, int maxResults = 50_000, CancellationToken cancellationToken = default) + { + var table = _storage.GetTableClient(Constants.TableNames.Signals); + // Timestamp is the Azure Tables system-managed server-side property. Filter shape + // follows OData datetime literal format. + var filter = $"Timestamp ge datetime'{cutoffUtc.ToUniversalTime():yyyy-MM-ddTHH:mm:ss.fffffffZ}'"; + + var results = new List(capacity: 128); + var pages = table.QueryAsync( + filter: filter, + maxPerPage: 1000, + cancellationToken: cancellationToken); + + await foreach (var entity in pages.ConfigureAwait(false)) + { + if (results.Count >= maxResults) + { + _logger.LogWarning( + "Signals: time-range query reached maxResults cap {Cap} at cutoff {Cutoff:o} — narrow window or bump cap", + maxResults, cutoffUtc); + break; + } + results.Add(FromEntity(entity)); + } + + return results; + } + + /// + /// Projects an Azure back into a , + /// reassembling chunked PayloadJson if present. Internal for mapping tests. + /// + internal static SignalRecord FromEntity(TableEntity entity) + { + return new SignalRecord + { + TenantId = entity.GetString("TenantId") ?? string.Empty, + SessionId = entity.GetString("SessionId") ?? string.Empty, + SessionSignalOrdinal = entity.GetInt64("SessionSignalOrdinal") ?? 0, + SessionTraceOrdinal = entity.GetInt64("SessionTraceOrdinal") ?? 0, + Kind = entity.GetString("Kind") ?? string.Empty, + KindSchemaVersion = entity.GetInt32("KindSchemaVersion") ?? 0, + OccurredAtUtc = entity.GetDateTime("OccurredAtUtc") ?? default, + SourceOrigin = entity.GetString("SourceOrigin") ?? string.Empty, + PayloadJson = TableStorageChunking.ReassembleProperty(entity, "PayloadJson") ?? string.Empty, + }; + } + + /// + /// Projects a onto its Azure shape. + /// Keys: PK = {TenantId}_{SessionId}, RK = {SessionSignalOrdinal:D19} + /// (19 digits covers long.MaxValue for lexicographic ordering). + /// + internal static TableEntity ToEntity(SignalRecord r) + { + var pk = BuildPartitionKey(r.TenantId, r.SessionId); + var rk = BuildRowKey(r.SessionSignalOrdinal); + + var entity = new TableEntity(pk, rk) + { + ["TenantId"] = r.TenantId, + ["SessionId"] = r.SessionId, + ["SessionSignalOrdinal"] = r.SessionSignalOrdinal, + ["SessionTraceOrdinal"] = r.SessionTraceOrdinal, + ["Kind"] = r.Kind ?? string.Empty, + ["KindSchemaVersion"] = r.KindSchemaVersion, + ["OccurredAtUtc"] = r.OccurredAtUtc, + ["SourceOrigin"] = r.SourceOrigin ?? string.Empty, + }; + + // Guard the 32 K-char per-property limit — DecisionSignal Evidence+Payload can cross it. + foreach (var kv in TableStorageChunking.ChunkProperty("PayloadJson", r.PayloadJson ?? string.Empty)) + { + entity[kv.Key] = kv.Value; + } + + return entity; + } + + internal static string BuildPartitionKey(string tenantId, string sessionId) + => $"{tenantId}_{sessionId}"; + + internal static string BuildRowKey(long sessionSignalOrdinal) + => sessionSignalOrdinal.ToString("D19"); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/IndexReconcileTimer.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/IndexReconcileTimer.cs new file mode 100644 index 00000000..96af808d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/IndexReconcileTimer.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Indexing; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Admin +{ + /// + /// 2h safety-net timer (Plan §2.8, §M5.d.4) that recovers missed index-table writes. + /// Scans the last 4h of primary Signals + DecisionTransitions rows and + /// re-enqueues envelopes onto the telemetry-index-reconcile queue via the + /// same producer the hot ingest path uses. The queue-triggered handler (M5.d.3) + /// then upserts index rows idempotently. + /// + /// Why 2h / 4h: window is deliberately larger than cadence so every row + /// gets re-checked at least twice before falling out of scan range. Queue failures + /// or pod restarts during ingest are recoverable within the overlap. + /// + /// + /// Feature gate: . + /// When false, the scan is short-circuited — no queries, no enqueues. + /// + /// + public sealed class IndexReconcileTimer + { + // Cron "0 0 */2 * * *" = top of every second hour (00:00, 02:00, 04:00 UTC, …) + // — matches the MaintenanceFunction cadence so ops only has two timer heartbeats + // to babysit. + private const string Cron = "0 0 */2 * * *"; + + /// Safety-net window: scan primaries committed in the last N hours. + internal static readonly TimeSpan ReconcileWindow = TimeSpan.FromHours(4); + + private readonly AdminConfigurationService _adminConfig; + private readonly ISignalRepository _signalRepo; + private readonly IDecisionTransitionRepository _transitionRepo; + private readonly IIndexReconcileProducer _producer; + private readonly ILogger _logger; + + public IndexReconcileTimer( + AdminConfigurationService adminConfig, + ISignalRepository signalRepo, + IDecisionTransitionRepository transitionRepo, + IIndexReconcileProducer producer, + ILogger logger) + { + _adminConfig = adminConfig; + _signalRepo = signalRepo; + _transitionRepo = transitionRepo; + _producer = producer; + _logger = logger; + } + + [Function("IndexReconcileTimer")] + public Task Run([TimerTrigger(Cron)] object timer, CancellationToken cancellationToken) + { + var cutoff = DateTime.UtcNow - ReconcileWindow; + return RunReconcileAsync(cutoff, cancellationToken); + } + + /// + /// Testable core. Separated from the TimerTrigger entry so unit tests can drive it + /// with a deterministic cutoff without an Azure Functions host. + /// + internal async Task RunReconcileAsync(DateTime cutoffUtc, CancellationToken cancellationToken) + { + var config = await _adminConfig.GetConfigurationAsync().ConfigureAwait(false); + if (!config.EnableIndexDualWrite) + { + _logger.LogInformation( + "IndexReconcileTimer: EnableIndexDualWrite=false — skipping scan (cutoff {Cutoff:o})", + cutoffUtc); + return; + } + + var signals = await _signalRepo + .QueryByTimestampAtOrAfterAsync(cutoffUtc, cancellationToken: cancellationToken) + .ConfigureAwait(false); + var transitions = await _transitionRepo + .QueryByTimestampAtOrAfterAsync(cutoffUtc, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + var envelopes = IndexReconcileEnvelopeFactory.BuildBatch(signals, transitions); + + _logger.LogInformation( + "IndexReconcileTimer: re-enqueueing {Count} envelope(s) ({S} signals + {T} transitions) since {Cutoff:o}", + envelopes.Count, signals.Count, transitions.Count, cutoffUtc); + + if (envelopes.Count == 0) return; + + // The producer is already flag-gated and swallows queue-side failures. We trust + // its contract here — timer never throws back into the host. + await _producer.EnqueueBatchAsync(envelopes, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 36540dd5..8a027f6a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -141,7 +141,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 25, // IntegrityBypassAnalyzer flag + ConfigVersion = 26, // WhiteGloveSealingPatternIds wired from AdminConfiguration UploadIntervalSeconds = 10, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, @@ -176,7 +176,8 @@ namespace AutopilotMonitor.Functions.Functions.Config SendTraceEvents = tenantConfig.SendTraceEvents, UnrestrictedMode = tenantConfig.UnrestrictedModeEnabled && tenantConfig.UnrestrictedMode, GatherRules = gatherRules, - ImeLogPatterns = imeLogPatterns + ImeLogPatterns = imeLogPatterns, + WhiteGloveSealingPatternIds = adminConfig.GetWhiteGloveSealingPatternIds(), }); return response; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IndexReconcileFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IndexReconcileFunction.cs new file mode 100644 index 00000000..62f8e848 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IndexReconcileFunction.cs @@ -0,0 +1,65 @@ +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services.Indexing; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Functions.Ingest +{ + /// + /// Queue-triggered consumer for the telemetry-index-reconcile queue (Plan §2.8, §M5.d.3). + /// Thin wrapper around — deserializes the JSON message + /// and hands it off; no business logic lives here. + /// + /// Connection: AzureTableStorageConnectionString (same storage account the + /// producer writes to — queue + tables colocate for atomic dev/test setup). + /// + /// + /// Retry / poison: platform default (5 retries, then + /// telemetry-index-reconcile-poison). Malformed JSON is logged + dropped here so it + /// doesn't retry-loop forever; transient repo failures propagate so the platform retries. + /// + /// + public sealed class IndexReconcileFunction + { + private readonly IndexReconcileHandler _handler; + private readonly ILogger _logger; + + public IndexReconcileFunction( + IndexReconcileHandler handler, + ILogger logger) + { + _handler = handler; + _logger = logger; + } + + [Function("IndexReconcile")] + public async Task Run( + [QueueTrigger(Constants.QueueNames.TelemetryIndexReconcile, Connection = "AzureTableStorageConnectionString")] string message, + CancellationToken cancellationToken) + { + IndexReconcileEnvelope? envelope; + try + { + envelope = JsonConvert.DeserializeObject(message); + } + catch (JsonException ex) + { + // Malformed JSON is permanent; don't let it retry-loop the queue. + _logger.LogWarning(ex, "IndexReconcile: malformed envelope JSON — dropping"); + return; + } + + if (envelope is null) + { + _logger.LogWarning("IndexReconcile: null envelope after deserialization — dropping"); + return; + } + + await _handler.HandleAsync(envelope, cancellationToken); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs new file mode 100644 index 00000000..1f7d962d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs @@ -0,0 +1,520 @@ +using System.IO; +using System.Net; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Functions.Services.Indexing; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Functions.Ingest +{ + /// + /// V2 ingest endpoint. Consumes a heterogeneous batch of s + /// (Events + Signals + DecisionTransitions in a single JSON array, gzip-compressed on + /// the wire — the UseRequestDecompression middleware decompresses before we + /// parse). Plan §2.7a / §M5 / M4.6.ε. + /// + /// Routing by : + /// + /// Event → (full pipeline parity with + /// /api/agent/ingest: rule engine, app-install aggregation, SignalR, vulnerability + /// correlation, webhooks, SLA breach, AdminAction detection, ServerAction delivery). + /// The processor is a deliberate copy of the legacy pipeline — see M5.b.2 rationale. + /// Signal → + /// DecisionTransition → + /// + /// + /// + /// Response (M4.6.ε): the agent parses DeviceBlocked/UnblockAt/ + /// DeviceKillSignal/AdminAction/Actions from the 2xx body and routes + /// kill-switches through its ServerActionDispatcher. Populated from the same + /// services the legacy /api/agent/ingest endpoint uses so behaviour is at parity. + /// + /// + public sealed class IngestTelemetryFunction + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepo; + private readonly ISignalRepository _signalRepo; + private readonly IDecisionTransitionRepository _transitionRepo; + private readonly IIndexReconcileProducer _indexReconcileProducer; + private readonly EventIngestProcessor _eventProcessor; + private readonly TenantConfigurationService _configService; + private readonly RateLimitService _rateLimitService; + private readonly AutopilotDeviceValidator _autopilotDeviceValidator; + private readonly CorporateIdentifierValidator _corporateIdentifierValidator; + private readonly DeviceAssociationValidator _deviceAssociationValidator; + private readonly BootstrapSessionService _bootstrapSessionService; + private readonly BlockedDeviceService _blockedDeviceService; + private readonly BlockedVersionService _blockedVersionService; + + public IngestTelemetryFunction( + ILogger logger, + ISessionRepository sessionRepo, + ISignalRepository signalRepo, + IDecisionTransitionRepository transitionRepo, + IIndexReconcileProducer indexReconcileProducer, + EventIngestProcessor eventProcessor, + TenantConfigurationService configService, + RateLimitService rateLimitService, + AutopilotDeviceValidator autopilotDeviceValidator, + CorporateIdentifierValidator corporateIdentifierValidator, + DeviceAssociationValidator deviceAssociationValidator, + BootstrapSessionService bootstrapSessionService, + BlockedDeviceService blockedDeviceService, + BlockedVersionService blockedVersionService) + { + _logger = logger; + _sessionRepo = sessionRepo; + _signalRepo = signalRepo; + _transitionRepo = transitionRepo; + _indexReconcileProducer = indexReconcileProducer; + _eventProcessor = eventProcessor; + _configService = configService; + _rateLimitService = rateLimitService; + _autopilotDeviceValidator = autopilotDeviceValidator; + _corporateIdentifierValidator = corporateIdentifierValidator; + _deviceAssociationValidator = deviceAssociationValidator; + _bootstrapSessionService = bootstrapSessionService; + _blockedDeviceService = blockedDeviceService; + _blockedVersionService = blockedVersionService; + } + + [Function("IngestTelemetry")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/telemetry")] HttpRequestData req) + { + try + { + var tenantIdHeader = req.Headers.Contains("X-Tenant-Id") + ? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault() + : null; + + if (string.IsNullOrEmpty(tenantIdHeader)) + { + return AsOutput(await WriteErrorAsync(req, HttpStatusCode.BadRequest, "X-Tenant-Id header is required")); + } + + var (validation, errorResponse) = await req.ValidateSecurityAsync( + tenantIdHeader, + _configService, + _rateLimitService, + _autopilotDeviceValidator, + _corporateIdentifierValidator, + _logger, + bootstrapSessionService: _bootstrapSessionService, + deviceAssociationValidator: _deviceAssociationValidator); + + if (errorResponse != null) return AsOutput(errorResponse); + + // Device + version kill-switches: short-circuit before parsing the body. + var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber") + ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() + : null; + var agentVersionHeader = req.Headers.Contains("X-Agent-Version") + ? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() + : null; + + var killResponse = await CheckKillSwitchesAsync( + req, tenantIdHeader, serialNumberHeader, agentVersionHeader); + if (killResponse != null) return AsOutput(killResponse); + + // Defense in depth: cap decompressed body size before buffering it all into + // memory + deserialising. Mirrors the legacy /api/agent/ingest NDJSON guard + // using the same tenant-config knob. Request body here is already gzip- + // decompressed by UseRequestDecompression — so the cap is on the actual JSON + // bytes we'll feed to the parser. + var tenantConfig = await _configService.GetConfigurationAsync(tenantIdHeader); + var maxPayloadBytes = (tenantConfig?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024; + + var (exceeded, body) = await ReadBodyWithSizeCapAsync(req.Body, maxPayloadBytes); + if (exceeded) + { + _logger.LogWarning( + "IngestTelemetry: payload exceeds {Max}MB cap for tenant {Tenant}", + maxPayloadBytes / (1024 * 1024), tenantIdHeader); + return AsOutput(await WriteErrorAsync( + req, HttpStatusCode.RequestEntityTooLarge, + $"Payload exceeds {maxPayloadBytes / (1024 * 1024)} MB cap")); + } + + List? items; + try + { + items = JsonConvert.DeserializeObject>(body); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "IngestTelemetry: malformed JSON body"); + return AsOutput(await WriteErrorAsync(req, HttpStatusCode.BadRequest, "Malformed JSON body")); + } + + if (items == null || items.Count == 0) + { + return AsOutput(await WriteErrorAsync(req, HttpStatusCode.BadRequest, "No telemetry items provided")); + } + + // Extract tenant+session from the first item's PartitionKey for body-vs-header tenant check + // and for AdminAction / ServerAction lookups. All items in a batch must belong to one + // session — anything else is a client bug and gets the whole batch rejected (see + // the uniformity check below). + if (!TryParsePartitionKey(items[0].PartitionKey, out var bodyTenantId, out var sessionId)) + { + return AsOutput(await WriteErrorAsync(req, HttpStatusCode.BadRequest, "Malformed PartitionKey")); + } + + if (!string.Equals(bodyTenantId, tenantIdHeader, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "IngestTelemetry: TenantId mismatch — header={Header}, body={Body}", + tenantIdHeader, bodyTenantId); + return AsOutput(await WriteErrorAsync(req, HttpStatusCode.Forbidden, "TenantId mismatch between header and payload")); + } + + // Defense-in-depth: every item must carry the same PartitionKey as the first. Without + // this check a mixed-session batch would stamp every item with session A's identity + // inside PersistItemsAsync, silently relocating session B's signals/transitions into + // session A's primary rows. + if (FindMismatchingPartitionKey(items, out var mismatchIndex, out var mismatchedValue)) + { + _logger.LogWarning( + "IngestTelemetry: PartitionKey mismatch at item[{Index}] — expected={Expected}, got={Got}", + mismatchIndex, items[0].PartitionKey, mismatchedValue); + return AsOutput(await WriteErrorAsync( + req, HttpStatusCode.BadRequest, + "All telemetry items in a batch must share the same PartitionKey")); + } + + // Partition + persist. Events are routed through EventIngestProcessor which runs the + // full pipeline (rule engine / app-install aggregation / SignalR / webhooks / ...); + // Signal + Transition go straight to their repositories. + var outcome = await PersistItemsAsync(items, bodyTenantId, sessionId, validation); + + _logger.LogInformation( + "IngestTelemetry: tenant={Tenant} session={Session} events={E} signals={S} transitions={T} unknown={U}", + bodyTenantId, sessionId, outcome.EventCount, outcome.SignalCount, outcome.TransitionCount, outcome.UnknownCount); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new IngestEventsResponse + { + Success = true, + EventsReceived = items.Count, + EventsProcessed = outcome.EventCount + outcome.SignalCount + outcome.TransitionCount, + Message = $"Stored {outcome.EventCount} events, {outcome.SignalCount} signals, {outcome.TransitionCount} transitions", + ProcessedAt = DateTime.UtcNow, + AdminAction = outcome.AdminAction, + Actions = outcome.PendingActions, + }); + + return new IngestEventsOutput + { + HttpResponse = response, + SignalRMessages = outcome.SignalRMessages, + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "IngestTelemetry: unhandled exception"); + return AsOutput(await WriteErrorAsync(req, HttpStatusCode.InternalServerError, "Internal server error")); + } + } + + private static IngestEventsOutput AsOutput(HttpResponseData response) + => new IngestEventsOutput + { + HttpResponse = response, + SignalRMessages = Array.Empty(), + }; + + /// + /// Runs device-serial and agent-version kill-switch checks. Returns a 200 response with + /// DeviceBlocked=true (and optional DeviceKillSignal=true) if the caller + /// should stop — or null if the request may proceed. + /// + private async Task CheckKillSwitchesAsync( + HttpRequestData req, string tenantId, string? serialNumber, string? agentVersion) + { + if (!string.IsNullOrEmpty(serialNumber)) + { + // Session-aware block: without body-parse we can't discriminate on SessionId, + // so we use the tenant/serial blanket check. Session-scoped blocks still require + // the agent to upload; the response just carries DeviceBlocked=true regardless of + // session — tighter scoping lands once the body is parsed (M5.b.2 pipeline share). + var (isBlocked, unblockAt, blockAction, _) = + await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumber); + if (isBlocked) + { + var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase); + _logger.LogWarning( + "IngestTelemetry: {Action} device tenant={Tenant} serial={Serial} unblockAt={UnblockAt}", + isKill ? "KILL" : "Block", tenantId, serialNumber, unblockAt); + return await WriteDeviceBlockedAsync(req, isKill, unblockAt, + isKill ? "Device has been issued a remote kill signal." + : "Device is temporarily blocked by an administrator."); + } + } + + if (!string.IsNullOrEmpty(agentVersion)) + { + var (isVersionBlocked, versionAction, matchedPattern) = + await _blockedVersionService.IsVersionBlockedAsync(agentVersion); + if (isVersionBlocked) + { + var isKill = string.Equals(versionAction, "Kill", StringComparison.OrdinalIgnoreCase); + _logger.LogWarning( + "IngestTelemetry: version {Action} tenant={Tenant} agentVersion={AgentVersion} pattern={Pattern}", + isKill ? "KILL" : "block", tenantId, agentVersion, matchedPattern); + return await WriteDeviceBlockedAsync(req, isKill, null, + isKill ? $"Agent version {agentVersion} has been issued a remote kill signal (pattern: {matchedPattern})." + : $"Agent version {agentVersion} is blocked by administrator (pattern: {matchedPattern})."); + } + } + + return null; + } + + private async Task WriteDeviceBlockedAsync( + HttpRequestData req, bool isKill, DateTime? unblockAt, string message) + { + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new IngestEventsResponse + { + Success = false, + DeviceBlocked = true, + DeviceKillSignal = isKill, + UnblockAt = unblockAt, + Message = message, + ProcessedAt = DateTime.UtcNow, + }); + return response; + } + + /// + /// Partitions the incoming batch by and persists each + /// kind through its destination path. Events go through + /// for full pipeline parity with legacy /api/agent/ingest; Signals + Transitions land + /// directly in their new primary tables. The returned carries + /// both the per-kind counts and the control-signal / SignalR payload for the response. + /// + private async Task PersistItemsAsync( + IReadOnlyList items, + string tenantId, + string sessionId, + SecurityValidationResult validation) + { + var events = new List(); + var signals = new List(); + var transitions = new List(); + var unknown = 0; + + foreach (var item in items) + { + switch (item.Kind) + { + case "Event": + var evt = TelemetryPayloadParser.ParseEvent(item, tenantId, sessionId); + if (evt != null) events.Add(evt); + break; + case "Signal": + var sig = TelemetryPayloadParser.ParseSignal(item, tenantId, sessionId); + if (sig != null) signals.Add(sig); + break; + case "DecisionTransition": + var tr = TelemetryPayloadParser.ParseTransition(item, tenantId, sessionId); + if (tr != null) transitions.Add(tr); + break; + default: + unknown++; + _logger.LogWarning("IngestTelemetry: unknown Kind '{Kind}' (TelemetryItemId={Id})", item.Kind, item.TelemetryItemId); + break; + } + } + + // Signals + Transitions write directly; they don't feed into the event pipeline. + var signalCount = await _signalRepo.StoreBatchAsync(signals); + var transitionCount = await _transitionRepo.StoreBatchAsync(transitions); + + // M5.d.2: after the primary commit, fan out one envelope per row onto the + // telemetry-index-reconcile queue for the index-table writer (M5.d.3). + // The producer is feature-flag-gated (AdminConfiguration.EnableIndexDualWrite, + // default off) and never rethrows — see IIndexReconcileProducer contract. + if (signals.Count > 0 || transitions.Count > 0) + { + var envelopes = IndexReconcileEnvelopeFactory.BuildBatch(signals, transitions); + await _indexReconcileProducer.EnqueueBatchAsync(envelopes); + } + + int eventCount; + string? adminAction; + List? pendingActions; + SignalRMessageAction[] signalRMessages; + + if (events.Count > 0) + { + // Full legacy-parity pipeline (rule engine, app-install aggregation, SignalR, + // vulnerability correlation, webhooks, SLA breach, AdminAction detection, + // ServerAction delivery). See EventIngestProcessor for the M5.b.2 copy-rationale. + var eventRequest = new IngestEventsRequest + { + SessionId = sessionId, + TenantId = tenantId, + Events = events, + }; + var processed = await _eventProcessor.ProcessEventsAsync(eventRequest, validation); + + eventCount = processed.EventsProcessed; + adminAction = processed.AdminAction; + pendingActions = processed.PendingActions; + signalRMessages = processed.SignalRMessages; + } + else + { + // Signal/Transition-only batch: no events means no event pipeline. We still honour + // the control-signal contract (AdminAction + pending ServerActions) because the + // agent reads them from every 2xx response regardless of which items it sent. + eventCount = 0; + var (aa, pa) = await ReadControlSignalsAsync(tenantId, sessionId); + adminAction = aa; + pendingActions = pa; + signalRMessages = Array.Empty(); + } + + return new IngestOutcome + { + EventCount = eventCount, + SignalCount = signalCount, + TransitionCount = transitionCount, + UnknownCount = unknown, + AdminAction = adminAction, + PendingActions = pendingActions, + SignalRMessages = signalRMessages, + }; + } + + /// + /// Fetches AdminAction (session marked terminal out-of-band) and pending + /// ServerActions for Signal/Transition-only batches (no + /// invocation). Mirror of the legacy inline logic. + /// + private async Task<(string? adminAction, List? pendingActions)> + ReadControlSignalsAsync(string tenantId, string sessionId) + { + var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId); + if (session == null) return (null, null); + + string? adminAction = null; + if (session.Status == SessionStatus.Succeeded) adminAction = "Succeeded"; + else if (session.Status == SessionStatus.Failed) adminAction = "Failed"; + + List? pendingActions = null; + if (!string.IsNullOrEmpty(session.PendingActionsJson)) + { + var fetched = await _sessionRepo.FetchAndClearPendingActionsAsync(tenantId, sessionId); + if (fetched.Count > 0) pendingActions = fetched; + } + + return (adminAction, pendingActions); + } + + private sealed class IngestOutcome + { + public int EventCount; + public int SignalCount; + public int TransitionCount; + public int UnknownCount; + public string? AdminAction; + public List? PendingActions; + public SignalRMessageAction[] SignalRMessages = Array.Empty(); + } + + /// + /// Reads a stream into a string with a strict byte cap. Returns (true, "") as + /// soon as the cap would be exceeded — the stream isn't fully drained, which bounds + /// memory use even for a malicious sender. Matches the legacy NDJSON parser's guard + /// semantics (strict greater-than, so a payload equal to the cap is accepted). + /// + internal static async Task<(bool exceeded, string body)> ReadBodyWithSizeCapAsync( + Stream source, int maxBytes) + { + using var buffered = new MemoryStream(); + var buffer = new byte[8192]; + int read; + long total = 0; + while ((read = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + total += read; + if (total > maxBytes) return (true, string.Empty); + await buffered.WriteAsync(buffer, 0, read); + } + return (false, System.Text.Encoding.UTF8.GetString(buffered.ToArray())); + } + + /// + /// Scans the batch for any item whose differs + /// from the first item's. Returns true + the offending index/value on mismatch; false if + /// every item shares the same PartitionKey (or the batch has < 2 items). Pure so the + /// guard can be unit-tested without a live HTTP trigger. + /// + internal static bool FindMismatchingPartitionKey( + IReadOnlyList items, + out int mismatchIndex, + out string? mismatchedValue) + { + mismatchIndex = -1; + mismatchedValue = null; + if (items is null || items.Count < 2) return false; + + var expected = items[0].PartitionKey; + for (var i = 1; i < items.Count; i++) + { + if (!string.Equals(items[i].PartitionKey, expected, StringComparison.Ordinal)) + { + mismatchIndex = i; + mismatchedValue = items[i].PartitionKey; + return true; + } + } + return false; + } + + /// + /// PartitionKey convention is {tenantId}_{sessionId} — both GUIDs with dashes, so + /// splitting on the single underscore between them is unambiguous. Returns false for any + /// shape that doesn't match (malformed, extra parts, empty halves). + /// + internal static bool TryParsePartitionKey(string partitionKey, out string tenantId, out string sessionId) + { + tenantId = string.Empty; + sessionId = string.Empty; + if (string.IsNullOrEmpty(partitionKey)) return false; + + var parts = partitionKey.Split('_'); + if (parts.Length != 2) return false; + if (string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1])) return false; + + tenantId = parts[0]; + sessionId = parts[1]; + return true; + } + + private static async Task WriteErrorAsync( + HttpRequestData req, HttpStatusCode status, string message) + { + var response = req.CreateResponse(status); + await response.WriteAsJsonAsync(new IngestEventsResponse + { + Success = false, + EventsReceived = 0, + EventsProcessed = 0, + Message = message, + ProcessedAt = DateTime.UtcNow, + }); + return response; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/TelemetryPayloadParser.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/TelemetryPayloadParser.cs new file mode 100644 index 00000000..2c6183ea --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/TelemetryPayloadParser.cs @@ -0,0 +1,197 @@ +using System; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Functions.Functions.Ingest +{ + /// + /// Pure mapping layer from wire format to backend storage + /// records. All inputs are defensive — malformed + /// returns null rather than throwing, so a single poisonous item can't take down + /// the whole batch. Unit-tested against the canonical JSON shapes the agent produces. + /// + internal static class TelemetryPayloadParser + { + /// + /// Parses an Event item's payload into an . + /// TenantId/SessionId on the event are authoritative from the caller; any agent-supplied + /// values are overwritten by . + /// + public static EnrollmentEvent? ParseEvent(TelemetryItemDto item, string tenantId, string sessionId) + { + if (string.IsNullOrEmpty(item?.PayloadJson)) return null; + try + { + var evt = JsonConvert.DeserializeObject(item.PayloadJson!); + if (evt == null) return null; + // Tenant/Session stamped by the function after return; here we only deserialise. + return evt; + } + catch (JsonException) + { + return null; + } + } + + /// + /// Parses a Signal item's payload (agent-serialised DecisionSignal) into the + /// flat storage shape. Extracts typed columns used for queries + /// and indexes while preserving the full JSON in + /// for replay fidelity. + /// + public static SignalRecord? ParseSignal(TelemetryItemDto item, string tenantId, string sessionId) + { + if (string.IsNullOrEmpty(item?.PayloadJson)) return null; + JObject root; + try { root = JObject.Parse(item.PayloadJson!); } + catch (JsonException) { return null; } + + // SessionSignalOrdinal is the RowKey driver — a missing value would silently default + // to 0 and clobber a legitimate ordinal-0 row on UpsertReplace. Require explicit + // presence; 0 itself is a valid value the caller may intentionally send. + if (!TryGetLong(root, "SessionSignalOrdinal", out var ordinal)) return null; + + var traceOrdinal = (long?)root["SessionTraceOrdinal"] ?? item!.SessionTraceOrdinal ?? 0L; + var kind = (string?)root["Kind"] ?? string.Empty; + var schemaVersion = (int?)root["KindSchemaVersion"] ?? 0; + var occurredAt = (DateTime?)root["OccurredAtUtc"] ?? item!.EnqueuedAtUtc; + var sourceOrigin = (string?)root["SourceOrigin"] ?? string.Empty; + + return new SignalRecord + { + TenantId = tenantId, + SessionId = sessionId, + SessionSignalOrdinal = ordinal, + SessionTraceOrdinal = traceOrdinal, + Kind = kind, + KindSchemaVersion = schemaVersion, + OccurredAtUtc = occurredAt, + SourceOrigin = sourceOrigin, + PayloadJson = item.PayloadJson!, + }; + } + + /// + /// Parses a DecisionTransition item's payload into the flat + /// storage shape. Projects index-discriminator + /// columns eagerly so the queue-driven index fan-out (M5.b) can read them off the + /// primary row without re-parsing the JSON blob. + /// + public static DecisionTransitionRecord? ParseTransition(TelemetryItemDto item, string tenantId, string sessionId) + { + if (string.IsNullOrEmpty(item?.PayloadJson)) return null; + JObject root; + try { root = JObject.Parse(item.PayloadJson!); } + catch (JsonException) { return null; } + + // StepIndex is the RowKey driver — same defense as SessionSignalOrdinal above. + // A missing StepIndex must not silently default to 0 and clobber the real step-0 row. + if (!TryGetInt(root, "StepIndex", out var stepIndex)) return null; + + var traceOrdinal = (long?)root["SessionTraceOrdinal"] ?? item!.SessionTraceOrdinal ?? 0L; + var signalOrdinal = (long?)root["SignalOrdinalRef"] ?? 0L; + var occurredAt = (DateTime?)root["OccurredAtUtc"] ?? item!.EnqueuedAtUtc; + var trigger = (string?)root["Trigger"] ?? string.Empty; + var fromStage = (string?)root["FromStage"] ?? string.Empty; + var toStage = (string?)root["ToStage"] ?? string.Empty; + var taken = (bool?)root["Taken"] ?? false; + var deadEndReason = (string?)root["DeadEndReason"]; + var reducerVersion = (string?)root["ReducerVersion"] ?? string.Empty; + + // Classifier verdict — optional nested object. + string? verdictId = null, verdictLevel = null; + if (root["ClassifierVerdict"] is JObject verdict) + { + verdictId = (string?)verdict["ClassifierId"] ?? (string?)verdict["Id"]; + verdictLevel = (string?)verdict["HypothesisLevel"] ?? (string?)verdict["Level"]; + } + + return new DecisionTransitionRecord + { + TenantId = tenantId, + SessionId = sessionId, + StepIndex = stepIndex, + SessionTraceOrdinal = traceOrdinal, + SignalOrdinalRef = signalOrdinal, + OccurredAtUtc = occurredAt, + Trigger = trigger, + FromStage = fromStage, + ToStage = toStage, + Taken = taken, + DeadEndReason = deadEndReason, + ReducerVersion = reducerVersion, + IsTerminal = IsTerminalStage(toStage), + ClassifierVerdictId = verdictId, + ClassifierHypothesisLevel = verdictLevel, + PayloadJson = item.PayloadJson!, + }; + } + + /// + /// Terminal-stage classification mirrors the agent-side + /// SessionStageExtensions.IsTerminal() (DecisionCore) so backend index queries can + /// reach the same conclusion without linking DecisionCore. New terminal stages must be + /// added in both places — enforced by the SessionStageExtensions contract test on + /// the agent side. + /// + internal static bool IsTerminalStage(string toStage) + { + // Authoritative list mirrors DecisionCore SessionStageExtensions.IsTerminal(). + // Keep in sync — new terminal stages need both sites updated. + switch (toStage) + { + case "Completed": + case "Failed": + case "WhiteGloveSealed": + case "WhiteGloveCompletedPart2": + return true; + default: + return false; + } + } + + /// + /// Reads a property that MUST be present on the envelope. Distinguishes "missing key" + /// (returns false) from "present with value 0" (returns true, value=0). Used for + /// RowKey-driving fields where a silent default would cause row collisions. + /// + private static bool TryGetLong(JObject root, string key, out long value) + { + if (root.TryGetValue(key, StringComparison.Ordinal, out var token) + && token.Type != JTokenType.Null + && token.Type != JTokenType.Undefined) + { + try + { + value = token.Value(); + return true; + } + catch (System.FormatException) { } + catch (System.OverflowException) { } + catch (System.InvalidCastException) { } + } + value = 0; + return false; + } + + private static bool TryGetInt(JObject root, string key, out int value) + { + if (root.TryGetValue(key, StringComparison.Ordinal, out var token) + && token.Type != JTokenType.Null + && token.Type != JTokenType.Undefined) + { + try + { + value = token.Value(); + return true; + } + catch (System.FormatException) { } + catch (System.OverflowException) { } + catch (System.InvalidCastException) { } + } + value = 0; + return false; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs new file mode 100644 index 00000000..723c629a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDecisionGraphFunction.cs @@ -0,0 +1,88 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Sessions +{ + /// + /// GET /api/sessions/{sessionId}/decision-graph — returns a pre-projected DAG of + /// the session's reducer steps for the Inspector (Plan §M5 / §M6). Building the graph + /// backend-side means the UI receives one structured shape instead of rebuilding it from + /// the raw journal on every page open; it also keeps the "what counts as terminal" rule + /// authoritatively next to 's copy of that list. + /// + public class GetSessionDecisionGraphFunction + { + /// Sane upper bound — a V2 session journal shouldn't produce more steps than this. + internal const int MaxTransitionsToLoad = 5000; + + private readonly ILogger _logger; + private readonly IDecisionTransitionRepository _transitionRepo; + private readonly ISessionRepository _sessionRepo; + + public GetSessionDecisionGraphFunction( + ILogger logger, + IDecisionTransitionRepository transitionRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _transitionRepo = transitionRepo; + _sessionRepo = sessionRepo; + } + + [Function("GetSessionDecisionGraph")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/decision-graph")] HttpRequestData req, + string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "SessionId is required" }); + return badRequest; + } + + var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]"; + _logger.LogInformation("{Prefix} GetSessionDecisionGraph", sessionPrefix); + + try + { + var requestCtx = req.GetRequestContext(); + + var transitions = await _transitionRepo.QueryBySessionAsync( + requestCtx.TargetTenantId, sessionId, MaxTransitionsToLoad); + + // Global Admin cross-tenant fallback (same pattern as other session-detail reads). + var effectiveTenantId = requestCtx.TargetTenantId; + if (transitions.Count == 0 && requestCtx.IsGlobalAdmin) + { + var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId); + if (resolvedTenantId != null && + !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase)) + { + transitions = await _transitionRepo.QueryBySessionAsync( + resolvedTenantId, sessionId, MaxTransitionsToLoad); + effectiveTenantId = resolvedTenantId; + } + } + + var projection = DecisionGraphBuilder.Build(effectiveTenantId, sessionId, transitions); + + return await req.OkAsync(new + { + success = true, + truncated = transitions.Count >= MaxTransitionsToLoad, + graph = projection, + }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, $"Get decision graph for session '{sessionId}'"); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs new file mode 100644 index 00000000..74540af5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionReducerVerificationFunction.cs @@ -0,0 +1,114 @@ +using System.Net; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Sessions +{ + /// + /// GET /api/sessions/{sessionId}/reducer-verification — admin/ops endpoint that + /// runs structural verification on a session's persisted SignalLog + DecisionTransitions + /// journal (Plan §M5). Not tenant-exposed: gated by GlobalAdminOnly in the + /// EndpointAccessPolicyCatalog. + /// + /// Why structural only (not full engine replay)? A full replay requires deserialising + /// the polymorphic DecisionSignal.Evidence from SignalRecord.PayloadJson, + /// which is a non-trivial refactor beyond the scope of this commit. The structural checks + /// catch the realistic release-gate failure modes (corruption, ReducerVersion drift, orphan + /// references) and give ops a usable tool today; a dedicated follow-up can add engine + /// replay once the Evidence deserialisation contract is pinned down. + /// + /// + public class GetSessionReducerVerificationFunction + { + /// Hard caps — match the other M5 read endpoints. + internal const int MaxSignalsToLoad = 5000; + internal const int MaxTransitionsToLoad = 5000; + + private readonly ILogger _logger; + private readonly ISignalRepository _signalRepo; + private readonly IDecisionTransitionRepository _transitionRepo; + private readonly ISessionRepository _sessionRepo; + + public GetSessionReducerVerificationFunction( + ILogger logger, + ISignalRepository signalRepo, + IDecisionTransitionRepository transitionRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _signalRepo = signalRepo; + _transitionRepo = transitionRepo; + _sessionRepo = sessionRepo; + } + + [Function("GetSessionReducerVerification")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/reducer-verification")] HttpRequestData req, + string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "SessionId is required" }); + return badRequest; + } + + var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]"; + _logger.LogInformation("{Prefix} GetSessionReducerVerification", sessionPrefix); + + try + { + var requestCtx = req.GetRequestContext(); + var tenantIdForLoad = requestCtx.TargetTenantId; + + var signals = await _signalRepo.QueryBySessionAsync( + tenantIdForLoad, sessionId, MaxSignalsToLoad); + var transitions = await _transitionRepo.QueryBySessionAsync( + tenantIdForLoad, sessionId, MaxTransitionsToLoad); + + // Global Admin cross-tenant fallback — same pattern as the other session + // detail read endpoints. Resolve via SessionsIndex when the effective tenant + // has no data. (GlobalAdminOnly gate means we don't need to check IsGlobalAdmin + // again, but the SessionsIndex lookup is still the cleanest way to find the + // actual tenant for a loose sessionId.) + if (signals.Count == 0 && transitions.Count == 0) + { + var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId); + if (resolvedTenantId != null && + !string.Equals(resolvedTenantId, tenantIdForLoad, StringComparison.OrdinalIgnoreCase)) + { + signals = await _signalRepo.QueryBySessionAsync( + resolvedTenantId, sessionId, MaxSignalsToLoad); + transitions = await _transitionRepo.QueryBySessionAsync( + resolvedTenantId, sessionId, MaxTransitionsToLoad); + tenantIdForLoad = resolvedTenantId; + } + } + + // Read the live reducer's version via a transient DecisionEngine instance. + // The engine has no heavy construction cost (no DI, no state) so this is + // cheaper than threading a singleton through. + var currentReducerVersion = new DecisionEngine().ReducerVersion; + + var report = ReducerVerifier.Verify( + tenantIdForLoad, sessionId, signals, transitions, currentReducerVersion); + + return await req.OkAsync(new + { + success = true, + truncated = signals.Count >= MaxSignalsToLoad || transitions.Count >= MaxTransitionsToLoad, + report, + }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, $"Reducer verification for session '{sessionId}'"); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs new file mode 100644 index 00000000..68b45d88 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionSignalsFunction.cs @@ -0,0 +1,107 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Sessions +{ + /// + /// GET /api/sessions/{sessionId}/signals — returns the SignalLog for a V2 session + /// (Plan §M5 read endpoint, consumed by the Inspector's SignalStream panel and the MCP + /// get_decision_signals tool). + /// + /// Tenant scoping is resolved upstream by PolicyEnforcementMiddleware via + /// ; Global-Admin fallback mirrors the other + /// session-detail read endpoints (resolve actual tenant via SessionsIndex when the + /// effective tenant has no data). + /// + /// + public class GetSessionSignalsFunction + { + /// Hard upper bound on page size — protects against unbounded result sets. + internal const int MaxResultsCap = 5000; + /// Default result set size when the caller doesn't specify ?maxResults=. + internal const int DefaultMaxResults = 1000; + + private readonly ILogger _logger; + private readonly ISignalRepository _signalRepo; + private readonly ISessionRepository _sessionRepo; + + public GetSessionSignalsFunction( + ILogger logger, + ISignalRepository signalRepo, + ISessionRepository sessionRepo) + { + _logger = logger; + _signalRepo = signalRepo; + _sessionRepo = sessionRepo; + } + + [Function("GetSessionSignals")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/signals")] HttpRequestData req, + string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); + await badRequest.WriteAsJsonAsync(new { success = false, message = "SessionId is required" }); + return badRequest; + } + + var maxResults = ParseMaxResults(req.Url.Query); + var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]"; + _logger.LogInformation("{Prefix} GetSessionSignals: maxResults={Max}", sessionPrefix, maxResults); + + try + { + var requestCtx = req.GetRequestContext(); + + var signals = await _signalRepo.QueryBySessionAsync( + requestCtx.TargetTenantId, sessionId, maxResults); + + // Global Admin cross-tenant fallback — mirror GetSessionEventsFunction pattern. + if (signals.Count == 0 && requestCtx.IsGlobalAdmin) + { + var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId); + if (resolvedTenantId != null && + !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase)) + { + signals = await _signalRepo.QueryBySessionAsync(resolvedTenantId, sessionId, maxResults); + } + } + + return await req.OkAsync(new + { + success = true, + sessionId, + count = signals.Count, + truncated = signals.Count >= maxResults, + signals, + }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, $"Get signals for session '{sessionId}'"); + } + } + + /// + /// Reads ?maxResults= from the query string, clamping to [1, ]. + /// Returns when the parameter is missing or malformed. + /// Takes the raw query string so the helper is unit-testable without mocking + /// HttpRequestData (abstract, painful to fake). + /// + internal static int ParseMaxResults(string queryString) + { + var query = System.Web.HttpUtility.ParseQueryString(queryString ?? string.Empty); + var raw = query["maxResults"]; + if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var parsed)) return DefaultMaxResults; + if (parsed < 1) return 1; + if (parsed > MaxResultsCap) return MaxResultsCap; + return parsed; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 50cd8ff1..e4066287 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -161,6 +161,17 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// V2 Decision Engine index-table dual-write producer (Plan §2.8, §M5.d). Gated by +// AdminConfiguration.EnableIndexDualWrite (default false) inside the implementation. +builder.Services.AddSingleton< + AutopilotMonitor.Shared.DataAccess.IIndexReconcileProducer, + AutopilotMonitor.Functions.Services.Indexing.AzureQueueIndexReconcileProducer>(); + +// V2 Decision Engine index-table reconcile consumer (Plan §M5.d.3). Plain class, not +// interface-abstracted — Cosmos swap would reshape around IIndexTableRepository, not here. +builder.Services.AddSingleton< + AutopilotMonitor.Functions.Services.Indexing.IndexReconcileHandler>(); + // Programmatic SignalR push for background tasks (rule engine, vulnerability correlation) builder.Services.AddSingleton(); @@ -175,6 +186,7 @@ builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 204ba072..073db2da 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -105,6 +105,7 @@ public static class EndpointAccessPolicyCatalog // ── DeviceOrBootstrapAuth ─────────────────────────────────────── new("POST", "agent/register-session", EndpointPolicy.DeviceOrBootstrapAuth), new("POST", "agent/ingest", EndpointPolicy.DeviceOrBootstrapAuth), + new("POST", "agent/telemetry", EndpointPolicy.DeviceOrBootstrapAuth), new("GET", "agent/config", EndpointPolicy.DeviceOrBootstrapAuth), new("POST", "agent/upload-url", EndpointPolicy.DeviceOrBootstrapAuth), new("POST", "agent/error", EndpointPolicy.DeviceOrBootstrapAuth), @@ -138,6 +139,8 @@ public static class EndpointAccessPolicyCatalog new("GET", "sessions", EndpointPolicy.MemberRead), new("GET", "sessions/{sessionId}", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/events", EndpointPolicy.MemberRead, TenantScoping.QueryParam), + new("GET", "sessions/{sessionId}/signals", EndpointPolicy.MemberRead, TenantScoping.QueryParam), + new("GET", "sessions/{sessionId}/decision-graph", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam), new("GET", "metrics/app", EndpointPolicy.MemberRead), @@ -200,6 +203,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "metrics/mcp-usage/me", EndpointPolicy.AuthenticatedUser), // ── GlobalAdminOnly ──────────────────────────────────────────── + new("GET", "sessions/{sessionId}/reducer-verification", EndpointPolicy.GlobalAdminOnly), new("GET", "global/raw/sessions", EndpointPolicy.GlobalAdminOnly), new("GET", "global/raw/events", EndpointPolicy.GlobalAdminOnly), new("GET", "global/raw/events/search", EndpointPolicy.GlobalAdminOnly), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs index 9994e904..c55af9db 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs @@ -30,9 +30,13 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Gets global admin configuration (uses cache with 5-minute TTL) + /// Gets global admin configuration (uses cache with 5-minute TTL). + /// + /// Virtual so it can be mocked via Moq in consumer tests that need controlled + /// flag values (e.g. IndexReconcileTimer flag-gate tests). + /// /// - public async Task GetConfigurationAsync() + public virtual async Task GetConfigurationAsync() { if (_cache.TryGetValue(CacheKey, out AdminConfiguration? cachedConfig) && cachedConfig != null) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs new file mode 100644 index 00000000..120b0c52 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/DecisionGraphBuilder.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Pure projection from the persisted journal () to + /// the renderable the Inspector consumes (Plan §M5 / + /// §M6). No IO, no service dependencies — unit-testable against canned transition lists. + /// + internal static class DecisionGraphBuilder + { + public static DecisionGraphProjection Build( + string tenantId, + string sessionId, + IReadOnlyList transitions) + { + var projection = new DecisionGraphProjection + { + TenantId = tenantId, + SessionId = sessionId, + Nodes = new List(), + Edges = new List(), + }; + + if (transitions == null || transitions.Count == 0) return projection; + + // ReducerVersion is consistent across a session (enforced by the agent-side Journal + // contract); pick the first row's value. + projection.ReducerVersion = transitions[0].ReducerVersion ?? string.Empty; + + // Build nodes by de-duplicating From+To stages. Visit count = how many edges land on + // the node (in-degree). Also track terminal flag + outcome derived from the ToStage. + var nodeMap = new Dictionary(StringComparer.Ordinal); + + DecisionGraphNode EnsureNode(string stage) + { + if (!nodeMap.TryGetValue(stage, out var node)) + { + node = new DecisionGraphNode + { + Id = stage, + IsTerminal = IsTerminalStage(stage), + TerminalOutcome = DeriveTerminalOutcome(stage), + VisitCount = 0, + }; + nodeMap[stage] = node; + } + return node; + } + + // Edges come out in StepIndex order (repository already sorts). Record them as-is — + // preserving dead-ends because the Inspector renders them in a distinct style. + foreach (var t in transitions.OrderBy(x => x.StepIndex)) + { + // Lazy-init both endpoints so isolated nodes (never-visited stages that show up + // as FromStage only) are still rendered. + var fromNode = EnsureNode(t.FromStage ?? string.Empty); + var toNode = EnsureNode(t.ToStage ?? string.Empty); + + projection.Edges.Add(new DecisionGraphEdge + { + StepIndex = t.StepIndex, + FromStage = t.FromStage ?? string.Empty, + ToStage = t.ToStage ?? string.Empty, + Trigger = t.Trigger ?? string.Empty, + Taken = t.Taken, + DeadEndReason = t.DeadEndReason, + SignalOrdinalRef = t.SignalOrdinalRef, + OccurredAtUtc = t.OccurredAtUtc, + ClassifierVerdictId = t.ClassifierVerdictId, + ClassifierHypothesisLevel = t.ClassifierHypothesisLevel, + }); + + // VisitCount counts *taken* transitions — dead-ends don't advance the visit graph. + // Matches the UI semantics: heat-map should show where execution actually went. + if (t.Taken) toNode.VisitCount++; + } + + // Emit nodes in deterministic order: by first-seen StepIndex. Graph renderers use + // insertion order as a hint when they haven't computed a layout yet. + projection.Nodes = nodeMap.Values.ToList(); + return projection; + } + + /// + /// Mirror of the agent-side SessionStageExtensions.IsTerminal() (DecisionCore) + + /// the lookup. Three sources of + /// truth stay in sync via the commit-time contract call-out (commit message + + /// feedback_table_storage_serialization). + /// + internal static bool IsTerminalStage(string stage) + { + switch (stage) + { + case "Completed": + case "Failed": + case "WhiteGloveSealed": + case "WhiteGloveCompletedPart2": + return true; + default: + return false; + } + } + + /// + /// Maps a terminal stage to a coarse outcome label for the Inspector. WhiteGloveSealed + /// is Part-1 pause (reboot expected, agent exits but session is not finished) — labelled + /// distinctly so the UI can render it with a different colour than the other terminals. + /// + internal static string? DeriveTerminalOutcome(string stage) + { + switch (stage) + { + case "Completed": + case "WhiteGloveCompletedPart2": + return "Succeeded"; + case "Failed": + return "Failed"; + case "WhiteGloveSealed": + return "PausedForPart2"; + default: + return null; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs new file mode 100644 index 00000000..fe6b1a57 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs @@ -0,0 +1,171 @@ +using AutopilotMonitor.Functions.Functions.Ingest; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Per-app install summary aggregation — folds a batch of app_install_* + + /// download_progress + do_telemetry events into an + /// keyed by app name. Verbatim copy of the legacy helper; + /// see for the duplication rationale. + /// + public sealed partial class EventIngestProcessor + { + private void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary summaries) + { + bool isRelevant = + evt.EventType == "app_install_started" || evt.EventType == "app_install_start" || + evt.EventType == "app_install_completed" || evt.EventType == "app_install_complete" || + evt.EventType == "app_install_failed" || + evt.EventType == "app_download_started" || + evt.EventType == "app_install_skipped" || + evt.EventType == "download_progress" || + evt.EventType == "do_telemetry"; + + if (!isRelevant) return; + + var appName = evt.Data?.ContainsKey("appName") == true ? evt.Data["appName"]?.ToString()?.Trim() : null; + if (string.IsNullOrEmpty(appName)) return; + + if (!summaries.TryGetValue(appName, out var state)) + { + state = new AppInstallAggregationState + { + Summary = new AppInstallSummary + { + AppName = appName, + SessionId = sessionId, + TenantId = tenantId, + StartedAt = evt.Timestamp + } + }; + summaries[appName] = state; + } + + var summary = state.Summary; + + if (evt.Data != null) + { + if (evt.Data.TryGetValue("appVersion", out var appVersionObj)) + { + var appVersion = appVersionObj?.ToString(); + if (!string.IsNullOrWhiteSpace(appVersion)) + summary.AppVersion = appVersion.Trim(); + } + if (evt.Data.TryGetValue("appType", out var appTypeObj)) + { + var appType = appTypeObj?.ToString(); + if (!string.IsNullOrWhiteSpace(appType)) + summary.AppType = appType.Trim(); + } + if (evt.Data.TryGetValue("attemptNumber", out var attemptObj) && + int.TryParse(attemptObj?.ToString(), out var attempt) && attempt > 0) + { + summary.AttemptNumber = Math.Max(summary.AttemptNumber, attempt); + } + if (evt.Data.TryGetValue("installerPhase", out var phaseObj)) + { + var phase = phaseObj?.ToString(); + if (!string.IsNullOrWhiteSpace(phase)) + summary.InstallerPhase = phase.Trim(); + } + if (evt.Data.TryGetValue("exitCode", out var exitCodeObj) && + int.TryParse(exitCodeObj?.ToString(), out var exitCode)) + { + summary.ExitCode = exitCode; + } + if (evt.Data.TryGetValue("detectionResult", out var detectionObj)) + { + var detection = detectionObj?.ToString(); + if (!string.IsNullOrWhiteSpace(detection)) + summary.DetectionResult = detection.Trim(); + } + } + + switch (evt.EventType) + { + case "app_install_started": + case "app_install_start": + if (!state.InstallStartedAt.HasValue || evt.Timestamp < state.InstallStartedAt.Value) + state.InstallStartedAt = evt.Timestamp; + if (summary.Status == "InProgress" || summary.Status == string.Empty) + summary.Status = "InProgress"; + break; + + case "app_download_started": + if (!state.DownloadStartedAt.HasValue || evt.Timestamp < state.DownloadStartedAt.Value) + state.DownloadStartedAt = evt.Timestamp; + if (summary.Status == "InProgress" || summary.Status == string.Empty) + summary.Status = "InProgress"; + break; + + case "app_install_completed": + case "app_install_complete": + summary.Status = "Succeeded"; + summary.CompletedAt = evt.Timestamp; + if (summary.StartedAt != DateTime.MinValue) + summary.DurationSeconds = Math.Max(1, EventTimestampValidator.SafeDurationSeconds(summary.StartedAt, evt.Timestamp)); + break; + + case "app_install_failed": + summary.Status = "Failed"; + summary.CompletedAt = evt.Timestamp; + if (summary.StartedAt != DateTime.MinValue) + summary.DurationSeconds = Math.Max(1, EventTimestampValidator.SafeDurationSeconds(summary.StartedAt, evt.Timestamp)); + summary.FailureCode = evt.Data?.ContainsKey("errorCode") == true + ? evt.Data["errorCode"]?.ToString() ?? string.Empty : string.Empty; + summary.FailureMessage = evt.Data?.ContainsKey("errorMessage") == true + ? evt.Data["errorMessage"]?.ToString() ?? string.Empty : evt.Message ?? string.Empty; + break; + + case "app_install_skipped": + if (summary.Status == "InProgress") + summary.Status = "Succeeded"; + break; + + case "download_progress": + var bytesKey = evt.Data?.ContainsKey("bytesDownloaded") == true ? "bytesDownloaded" + : evt.Data?.ContainsKey("bytes_downloaded") == true ? "bytes_downloaded" : null; + if (bytesKey != null && long.TryParse(evt.Data![bytesKey]?.ToString(), out var bytes)) + summary.DownloadBytes = Math.Max(summary.DownloadBytes, bytes); + break; + + case "do_telemetry": + if (evt.Data != null) + { + if (evt.Data.ContainsKey("doFileSize") && long.TryParse(evt.Data["doFileSize"]?.ToString(), out var doFs)) + { + summary.DownloadBytes = Math.Max(summary.DownloadBytes, doFs); + summary.DoFileSize = doFs; + } + if (evt.Data.ContainsKey("doTotalBytesDownloaded") && long.TryParse(evt.Data["doTotalBytesDownloaded"]?.ToString(), out var doTotalDl)) + summary.DoTotalBytesDownloaded = doTotalDl; + if (evt.Data.ContainsKey("doBytesFromPeers") && long.TryParse(evt.Data["doBytesFromPeers"]?.ToString(), out var doPeers)) + summary.DoBytesFromPeers = doPeers; + if (evt.Data.ContainsKey("doBytesFromHttp") && long.TryParse(evt.Data["doBytesFromHttp"]?.ToString(), out var doHttp)) + summary.DoBytesFromHttp = doHttp; + if (evt.Data.ContainsKey("doPercentPeerCaching") && int.TryParse(evt.Data["doPercentPeerCaching"]?.ToString(), out var doPct)) + summary.DoPercentPeerCaching = doPct; + if (evt.Data.ContainsKey("doDownloadMode") && int.TryParse(evt.Data["doDownloadMode"]?.ToString(), out var doMode)) + summary.DoDownloadMode = doMode; + if (evt.Data.ContainsKey("doDownloadDuration")) + { + var doDurStr = evt.Data["doDownloadDuration"]?.ToString() ?? string.Empty; + summary.DoDownloadDuration = doDurStr; + if (TimeSpan.TryParse(doDurStr, out var doDurTs) && doDurTs.TotalSeconds >= 1) + summary.DownloadDurationSeconds = Math.Max(summary.DownloadDurationSeconds, (int)doDurTs.TotalSeconds); + } + if (evt.Data.ContainsKey("doBytesFromLanPeers") && long.TryParse(evt.Data["doBytesFromLanPeers"]?.ToString(), out var doLan)) + summary.DoBytesFromLanPeers = doLan; + if (evt.Data.ContainsKey("doBytesFromGroupPeers") && long.TryParse(evt.Data["doBytesFromGroupPeers"]?.ToString(), out var doGroup)) + summary.DoBytesFromGroupPeers = doGroup; + if (evt.Data.ContainsKey("doBytesFromInternetPeers") && long.TryParse(evt.Data["doBytesFromInternetPeers"]?.ToString(), out var doInet)) + summary.DoBytesFromInternetPeers = doInet; + } + break; + } + + IngestEventsFunction.RecalculateAppDurations(state); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs new file mode 100644 index 00000000..e97daec0 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs @@ -0,0 +1,212 @@ +using AutopilotMonitor.Functions.Functions.Ingest; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Event classification + session-status transitions. Verbatim copy of the legacy + /// IngestEventsFunction helpers (see the class-level comment on + /// for the copy-duplication rationale). + /// + public sealed partial class EventIngestProcessor + { + private EventClassification ClassifyEvents(List storedEvents) + { + var classification = new EventClassification(); + + foreach (var evt in storedEvents) + { + if (!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value) + classification.EarliestEventTimestamp = evt.Timestamp; + if (!classification.LatestEventTimestamp.HasValue || evt.Timestamp > classification.LatestEventTimestamp.Value) + classification.LatestEventTimestamp = evt.Timestamp; + + switch (evt.EventType) + { + case "phase_changed": + case "esp_phase_changed": + classification.LastPhaseChangeEvent = evt; + break; + case "enrollment_complete": + classification.CompletionEvent = evt; + break; + case "gather_rules_collection_completed": + classification.GatherCompletionEvent = evt; + break; + case "enrollment_failed": + classification.FailureEvent = evt; + break; + case "diagnostics_uploaded": + classification.DiagnosticsUploadedEvent = evt; + break; + case "whiteglove_complete": + classification.WhiteGloveEvent = evt; + break; + case "whiteglove_resumed": + classification.WhiteGloveResumedEvent = evt; + break; + case "whiteglove_started": + classification.WhiteGloveStartedEvent = evt; + break; + case "esp_failure": + classification.EspFailureEvent = evt; + break; + case "device_location": + classification.DeviceLocationEvent = evt; + break; + case "session_stalled": + classification.SessionStalledEvent = evt; + break; + case "script_completed": + case "script_failed": + var scriptType = evt.Data?.ContainsKey("scriptType") == true + ? evt.Data["scriptType"]?.ToString() : null; + if (string.Equals(scriptType, "platform", StringComparison.OrdinalIgnoreCase)) + classification.PlatformScriptCount++; + else if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)) + classification.RemediationScriptCount++; + break; + } + + if (!IsPeriodicOrStallEvent(evt.EventType)) + classification.HasNonPeriodicRealEvent = true; + + AggregateAppInstallEvent(evt, storedEvents[0].TenantId!, storedEvents[0].SessionId!, classification.AppInstallUpdates); + } + + return classification; + } + + private static bool IsPeriodicOrStallEvent(string? eventType) => eventType switch + { + "performance_snapshot" => true, + "agent_metrics_snapshot" => true, + "performance_collector_stopped" => true, + "agent_metrics_collector_stopped" => true, + "stall_probe_check" => true, + "stall_probe_result" => true, + "session_stalled" => true, + "modern_deployment_log" => true, + _ => false + }; + + private async Task<(bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason)> + UpdateSessionStatusAsync(IngestEventsRequest request, string sessionPrefix, EventClassification c) + { + bool statusTransitioned = false; + bool whiteGloveStatusTransitioned = false; + string? failureReason = null; + + if (c.CompletionEvent != null) + { + statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( + request.TenantId, request.SessionId, SessionStatus.Succeeded, c.CompletionEvent.Phase, + completedAt: c.CompletionEvent.Timestamp, + earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); + _logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned})", sessionPrefix, statusTransitioned); + } + else if (c.FailureEvent != null) + { + failureReason = c.FailureEvent.Data?.ContainsKey("errorCode") == true + ? $"{c.FailureEvent.Message} ({c.FailureEvent.Data["errorCode"]})" + : c.FailureEvent.Message; + + statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( + request.TenantId, request.SessionId, SessionStatus.Failed, c.FailureEvent.Phase, failureReason, + completedAt: c.FailureEvent.Timestamp, + earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); + _logger.LogWarning("{SessionPrefix} Status: Failed - {FailureReason} (transitioned={Transitioned})", sessionPrefix, failureReason, statusTransitioned); + } + else if (c.EspFailureEvent != null) + { + failureReason = c.EspFailureEvent.Message ?? "ESP failure (backend fallback)"; + statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( + request.TenantId, request.SessionId, SessionStatus.Failed, c.EspFailureEvent.Phase, failureReason, + completedAt: c.EspFailureEvent.Timestamp, + earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); + _logger.LogWarning("{SessionPrefix} Status: Failed via esp_failure fallback - {FailureReason} (transitioned={Transitioned})", + sessionPrefix, failureReason, statusTransitioned); + } + else if (c.GatherCompletionEvent != null) + { + await _sessionRepo.UpdateSessionStatusAsync( + request.TenantId, request.SessionId, SessionStatus.Succeeded, c.GatherCompletionEvent.Phase, + completedAt: c.GatherCompletionEvent.Timestamp, + earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); + _logger.LogInformation("{SessionPrefix} Status: Succeeded (gather_rules)", sessionPrefix); + } + else if (c.WhiteGloveEvent != null) + { + whiteGloveStatusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( + request.TenantId, request.SessionId, SessionStatus.Pending, EnrollmentPhase.AppsDevice, + earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, + isPreProvisioned: true, isUserDriven: false); + + if (!whiteGloveStatusTransitioned) + { + _logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix); + try + { + await _sessionRepo.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false); + whiteGloveStatusTransitioned = true; + _logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix); + } + catch (Exception fallbackEx) + { + _logger.LogError(fallbackEx, "{SessionPrefix} WhiteGlove fallback SetSessionPreProvisionedAsync also failed", sessionPrefix); + } + } + + _logger.LogInformation("{SessionPrefix} Status: Pending (WhiteGlove pre-provisioning complete, transitioned={Transitioned})", sessionPrefix, whiteGloveStatusTransitioned); + } + else if (c.WhiteGloveResumedEvent != null) + { + var currentSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); + if (currentSession?.Status == SessionStatus.Pending) + { + await _sessionRepo.UpdateSessionStatusAsync( + request.TenantId, request.SessionId, SessionStatus.InProgress, c.WhiteGloveResumedEvent.Phase, + earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, + isUserDriven: true, resumedAt: c.WhiteGloveResumedEvent.Timestamp); + _logger.LogInformation("{SessionPrefix} Status: InProgress (WhiteGlove Part 2 resumed, IsUserDriven=true)", sessionPrefix); + } + else + { + _logger.LogInformation("{SessionPrefix} WhiteGlove resumed skipped, session already {Status}", sessionPrefix, currentSession?.Status); + } + } + + if (c.WhiteGloveStartedEvent != null) + { + _logger.LogInformation("{SessionPrefix} whiteglove_started detected (soft signal — not setting IsPreProvisioned, awaiting whiteglove_complete)", sessionPrefix); + } + + if (c.SessionStalledEvent != null) + { + var stalledReason = "Agent reported stall after 60min without progress (stall_probe)"; + var stalledTransitioned = await _sessionRepo.UpdateSessionStatusAsync( + request.TenantId, request.SessionId, SessionStatus.Stalled, + earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, + stalledAt: c.SessionStalledEvent.Timestamp, failureReason: stalledReason); + if (stalledTransitioned) + _logger.LogWarning("{SessionPrefix} Status: Stalled (agent-reported via session_stalled event)", sessionPrefix); + } + else if (c.HasNonPeriodicRealEvent && !statusTransitioned && !whiteGloveStatusTransitioned) + { + var currentSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); + if (currentSession?.Status == SessionStatus.Stalled) + { + var healed = await _sessionRepo.UpdateSessionStatusAsync( + request.TenantId, request.SessionId, SessionStatus.InProgress, + earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, + clearStalledAt: true, clearFailureReason: true); + if (healed) + _logger.LogInformation("{SessionPrefix} Status: InProgress (healed from Stalled by new real event)", sessionPrefix); + } + } + + return (statusTransitioned, whiteGloveStatusTransitioned, failureReason); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs new file mode 100644 index 00000000..9412260c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs @@ -0,0 +1,168 @@ +using System.Linq; +using AutopilotMonitor.Functions.Functions.Ingest; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Notifications; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// External + real-time notification plumbing: outgoing webhook alerts (Teams/Slack-style) + /// and SignalR messages for the live UI push. Verbatim copy of the legacy helpers; see + /// for the duplication rationale. + /// + public sealed partial class EventIngestProcessor + { + private async Task SendWebhookNotificationsAsync( + IngestEventsRequest request, string sessionPrefix, EventClassification c, + SessionSummary? updatedSession, bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason, + List ruleResults) + { + var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); + var (webhookUrl, providerTypeInt) = tenantConfig.GetEffectiveWebhookConfig(); + + if (string.IsNullOrEmpty(webhookUrl) || providerTypeInt == 0) + return; + + var providerType = (WebhookProviderType)providerTypeInt; + var sessionUrl = updatedSession != null + ? $"https://www.autopilotmonitor.com/session/{request.TenantId}/{request.SessionId}" + : null; + + if (statusTransitioned && (c.CompletionEvent != null || c.FailureEvent != null)) + { + var notifySuccess = c.CompletionEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess(); + var notifyFailure = c.FailureEvent != null && tenantConfig.GetEffectiveNotifyOnFailure(); + if (notifySuccess || notifyFailure) + { + var duration = updatedSession?.DurationSeconds != null + ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) + : (TimeSpan?)null; + + if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null) + { + var completionTime = c.CompletionEvent?.Timestamp ?? c.FailureEvent?.Timestamp; + if (completionTime.HasValue) + duration = completionTime.Value - updatedSession.ResumedAt.Value; + } + + var alert = NotificationAlertBuilder.BuildEnrollmentAlert( + updatedSession?.DeviceName, + updatedSession?.SerialNumber, + updatedSession?.Manufacturer, + updatedSession?.Model, + success: c.CompletionEvent != null, + failureReason: failureReason, + duration: duration, + sessionUrl: sessionUrl); + NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); + + _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); + } + } + + if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess()) + { + var duration = updatedSession?.DurationSeconds != null + ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) + : (TimeSpan?)null; + + var alert = NotificationAlertBuilder.BuildWhiteGloveAlert( + updatedSession?.DeviceName, + updatedSession?.SerialNumber, + updatedSession?.Manufacturer, + updatedSession?.Model, + success: true, + duration: duration, + sessionUrl: sessionUrl); + NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); + + _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); + } + + if (c.EspFailureEvent != null && updatedSession?.IsPreProvisioned == true && tenantConfig.GetEffectiveNotifyOnFailure()) + { + var duration = updatedSession?.DurationSeconds != null + ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) + : (TimeSpan?)null; + + var alert = NotificationAlertBuilder.BuildWhiteGloveAlert( + updatedSession?.DeviceName, + updatedSession?.SerialNumber, + updatedSession?.Manufacturer, + updatedSession?.Model, + success: false, + duration: duration, + sessionUrl: sessionUrl); + NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); + + _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); + } + } + + private SignalRMessageAction[] BuildSignalRMessages( + IngestEventsRequest request, SessionSummary? updatedSession, int processedCount, + List newRuleResults) + { + object? sessionDelta = updatedSession != null ? new { + updatedSession.CurrentPhase, + updatedSession.CurrentPhaseDetail, + updatedSession.Status, + updatedSession.FailureReason, + updatedSession.EventCount, + updatedSession.DurationSeconds, + updatedSession.CompletedAt, + updatedSession.DiagnosticsBlobName, + updatedSession.IsPreProvisioned + } : null; + + var summaryMessage = new SignalRMessageAction("newevents") + { + GroupName = $"tenant-{request.TenantId}", + Arguments = new object[] { new { + sessionId = request.SessionId, + tenantId = request.TenantId, + eventCount = processedCount, + sessionUpdate = sessionDelta + } } + }; + + var slimRuleResults = newRuleResults.Count > 0 + ? newRuleResults.Select(r => new { + r.ResultId, + r.RuleId, + r.RuleTitle, + r.Severity, + r.Category, + r.ConfidenceScore, + r.Explanation, + r.Remediation, + r.RelatedDocs, + r.MatchedConditions, + r.DetectedAt + }).ToList() + : null; + + var eventsMessage = new SignalRMessageAction("eventStream") + { + GroupName = $"session-{request.TenantId}-{request.SessionId}", + Arguments = new object[] { new { + sessionId = request.SessionId, + tenantId = request.TenantId, + newEventCount = processedCount, + newRuleResults = slimRuleResults + } } + }; + + return new[] { summaryMessage, eventsMessage }; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RuleStats.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RuleStats.cs new file mode 100644 index 00000000..0b5040e5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RuleStats.cs @@ -0,0 +1,86 @@ +using System.Linq; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Daily rule-fire/evaluation telemetry (per-tenant + global aggregate rows). Verbatim + /// copy of the legacy helpers; see for the duplication + /// rationale. Both methods are fire-and-forget — failures are logged, never thrown. + /// + public sealed partial class EventIngestProcessor + { + private async Task RecordGatherRuleStatsAsync(string tenantId, List events) + { + try + { + var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); + + var gatherEvents = events.Where(e => + e.Data != null && + e.Data.ContainsKey("ruleId") && + e.Source == "GatherRuleExecutor").ToList(); + + if (gatherEvents.Count == 0) return; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var evt in gatherEvents) + { + var ruleId = evt.Data!["ruleId"]?.ToString(); + if (string.IsNullOrEmpty(ruleId) || !seen.Add(ruleId)) continue; + + var ruleTitle = evt.Data.ContainsKey("ruleTitle") ? evt.Data["ruleTitle"]?.ToString() ?? "" : ""; + + await _metricsRepo.IncrementRuleStatAsync( + today, tenantId, ruleId, "gather", + ruleTitle, "", "", + fired: true, confidenceScore: null); + + await _metricsRepo.IncrementRuleStatAsync( + today, "global", ruleId, "gather", + ruleTitle, "", "", + fired: true, confidenceScore: null); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to record gather rule stats (non-fatal)"); + } + } + + private async Task RecordAnalyzeRuleStatsAsync(string tenantId, AnalysisOutcome outcome) + { + try + { + var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); + var firedRuleIds = new HashSet(outcome.Results.Select(r => r.RuleId)); + + foreach (var rule in outcome.EvaluatedRules) + { + var fired = firedRuleIds.Contains(rule.RuleId); + int? confidence = null; + if (fired) + { + var result = outcome.Results.FirstOrDefault(r => r.RuleId == rule.RuleId); + confidence = result?.ConfidenceScore; + } + + await _metricsRepo.IncrementRuleStatAsync( + today, tenantId, rule.RuleId, "analyze", + rule.Title, rule.Category, rule.Severity, + fired, confidence); + + await _metricsRepo.IncrementRuleStatAsync( + today, "global", rule.RuleId, "analyze", + rule.Title, rule.Category, rule.Severity, + fired, confidence); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to record analyze rule stats (non-fatal)"); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs new file mode 100644 index 00000000..7844c238 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs @@ -0,0 +1,491 @@ +using System.Linq; +using AutopilotMonitor.Functions.Functions.Ingest; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services.Notifications; +using AutopilotMonitor.Functions.Services.Vulnerability; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.ApplicationInsights; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// M5.b.2 — Post-parse event-processing pipeline for the V2 /api/agent/telemetry + /// endpoint. Deliberate copy of 's post-body-parse + /// tail, extracted into a standalone service so the new endpoint has identical event + /// behaviour (rule engine, app-install aggregation, SignalR, vulnerability correlation, + /// webhooks, SLA breach evaluation, AdminAction detection, ServerAction delivery) without + /// touching the production-hot legacy path. + /// + /// Why a copy, not an extraction? /api/agent/ingest serves live traffic from every + /// deployed V1 agent. Refactoring it is a production risk we chose not to take. The + /// duplicate disappears when the legacy endpoint is decommissioned post-v11 GA (see + /// tasks/todo.md → Follow-Ups → Legacy-Agent-Ausmusterung). Until then: bugs fixed in + /// legacy must be ported here manually. + /// + /// + /// Pure static helpers (, + /// ) and the internal DTOs + /// EventClassification / AppInstallAggregationState are shared + /// (state-less, stable, no behaviour) — the duplication is scoped to the logic that + /// actually runs. + /// + /// + /// Split across partials for readability — this file owns the orchestrator (ctor + DI + + /// ); thematic helpers live in siblings: + /// .Classification.cs (ClassifyEvents, IsPeriodicOrStallEvent, + /// UpdateSessionStatusAsync), .Notifications.cs + /// (SendWebhookNotificationsAsync, BuildSignalRMessages), + /// .RuleStats.cs (RecordGatherRuleStatsAsync, + /// RecordAnalyzeRuleStatsAsync), .AppInstall.cs + /// (AggregateAppInstallEvent). + /// + /// + public sealed partial class EventIngestProcessor + { + private readonly ILogger _logger; + private readonly ISessionRepository _sessionRepo; + private readonly IMetricsRepository _metricsRepo; + private readonly IRuleRepository _ruleRepo; + private readonly IVulnerabilityRepository _vulnRepo; + private readonly TenantConfigurationService _configService; + private readonly AnalyzeRuleService _analyzeRuleService; + private readonly WebhookNotificationService _webhookNotificationService; + private readonly VulnerabilityCorrelationService _vulnerabilityCorrelation; + private readonly AdminConfigurationService _adminConfigService; + private readonly SignalRNotificationService _signalRNotification; + private readonly OpsEventService _opsEventService; + private readonly SlaBreachEvaluationService _slaBreachService; + private readonly TelemetryClient _telemetryClient; + + public EventIngestProcessor( + ILogger logger, + ISessionRepository sessionRepo, + IMetricsRepository metricsRepo, + IRuleRepository ruleRepo, + IVulnerabilityRepository vulnRepo, + TenantConfigurationService configService, + AnalyzeRuleService analyzeRuleService, + WebhookNotificationService webhookNotificationService, + VulnerabilityCorrelationService vulnerabilityCorrelation, + AdminConfigurationService adminConfigService, + SignalRNotificationService signalRNotification, + OpsEventService opsEventService, + SlaBreachEvaluationService slaBreachService, + TelemetryClient telemetryClient) + { + _logger = logger; + _sessionRepo = sessionRepo; + _metricsRepo = metricsRepo; + _ruleRepo = ruleRepo; + _vulnRepo = vulnRepo; + _configService = configService; + _analyzeRuleService = analyzeRuleService; + _webhookNotificationService = webhookNotificationService; + _vulnerabilityCorrelation = vulnerabilityCorrelation; + _adminConfigService = adminConfigService; + _signalRNotification = signalRNotification; + _opsEventService = opsEventService; + _slaBreachService = slaBreachService; + _telemetryClient = telemetryClient; + } + + /// + /// Runs the full event-processing pipeline on an already-parsed batch. Mirror of + /// 's ProcessIngestAsync tail starting at + /// timestamp sanitation (security checks, device/version kill-switches, NDJSON body + /// parse and tenant-mismatch check are the caller's responsibility — the V2 function + /// does them before it even knows the item is an Event). + /// + public async Task ProcessEventsAsync( + IngestEventsRequest request, + SecurityValidationResult validation) + { + var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]"; + _logger.LogInformation( + "{SessionPrefix} IngestTelemetry→EventProcessor: {Count} events (Device: {Cert}, Hardware: {Mfg} {Model}, Rate: {InWindow}/{MaxReq})", + sessionPrefix, request.Events.Count, + validation.CertificateThumbprint, + validation.Manufacturer, + validation.Model, + validation.RateLimitResult?.RequestsInWindow, + validation.RateLimitResult?.MaxRequests); + + var receivedAt = DateTime.UtcNow; + IngestEventsFunction.StampServerFields(request.Events, request.TenantId, request.SessionId, receivedAt); + IngestEventsFunction.SanitizeEventTimestamps(request.Events, receivedAt, _logger); + + var storedEvents = await _sessionRepo.StoreEventsBatchAsync(request.Events); + int processedCount = storedEvents.Count; + + var indexTenantId = request.TenantId; + var indexSessionId = request.SessionId; + var indexEvents = storedEvents.ToList(); + _ = Task.WhenAll( + _sessionRepo.UpsertEventTypeIndexBatchAsync(indexTenantId, indexSessionId, indexEvents), + _sessionRepo.UpsertDeviceSnapshotAsync(indexTenantId, indexSessionId, indexEvents) + ).ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Index update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); + + var imeVersionEvent = request.Events.FirstOrDefault(e => + e.EventType == "ime_agent_version" && e.Data?.ContainsKey("agentVersion") == true); + if (imeVersionEvent != null) + { + var imeVersion = imeVersionEvent.Data!["agentVersion"]?.ToString(); + if (!string.IsNullOrEmpty(imeVersion)) + { + _ = _sessionRepo.UpdateSessionImeAgentVersionAsync(request.TenantId, request.SessionId, imeVersion) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "ImeAgentVersion update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); + + _ = _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId) + .ContinueWith(async t => + { + if (t.IsFaulted) + { + _logger.LogWarning(t.Exception?.InnerException, + "ImeVersionHistory update failed (non-fatal)"); + } + else if (t.Result) + { + await _opsEventService.RecordNewImeVersionDetectedAsync( + imeVersion, request.TenantId, request.SessionId); + } + }, TaskScheduler.Default); + } + } + + var classification = ClassifyEvents(storedEvents); + + foreach (var summary in classification.AppInstallUpdates.Values) + { + await _metricsRepo.StoreAppInstallSummaryAsync(summary.Summary); + } + + if (classification.DeviceLocationEvent?.Data != null) + { + var geoData = classification.DeviceLocationEvent.Data; + var geoTenantId = request.TenantId; + var geoSessionId = request.SessionId; + _ = _sessionRepo.UpdateSessionGeoAsync( + geoTenantId, + geoSessionId, + geoData.ContainsKey("country") ? geoData["country"]?.ToString() : null, + geoData.ContainsKey("region") ? geoData["region"]?.ToString() : null, + geoData.ContainsKey("city") ? geoData["city"]?.ToString() : null, + geoData.ContainsKey("loc") ? geoData["loc"]?.ToString() : null + ).ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget UpdateSessionGeoAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + } + + var (statusTransitioned, whiteGloveStatusTransitioned, failureReason) = + await UpdateSessionStatusAsync(request, sessionPrefix, classification); + + if (processedCount > 0) + { + await _sessionRepo.IncrementSessionEventCountAsync( + request.TenantId, + request.SessionId, + processedCount, + classification.EarliestEventTimestamp, + classification.LatestEventTimestamp, + currentPhase: classification.LastPhaseChangeEvent?.Phase, + platformScriptIncrement: classification.PlatformScriptCount, + remediationScriptIncrement: classification.RemediationScriptCount); + } + + var newRuleResults = new List(); + if (classification.CompletionEvent != null || classification.FailureEvent != null) + { + var ruleSessionId = request.SessionId; + var ruleTenantId = request.TenantId; + var rulePrefix = sessionPrefix; + _ = Task.Run(async () => + { + try + { + var ruleEngine = new RuleEngine(_analyzeRuleService, _ruleRepo, _sessionRepo, _logger); + var outcome = await ruleEngine.AnalyzeSessionAsync(ruleTenantId, ruleSessionId); + + foreach (var result in outcome.Results) + { + await _ruleRepo.StoreRuleResultAsync(result); + } + + if (outcome.Results.Count > 0) + { + _logger.LogInformation( + "{Prefix} Enrollment-end analysis (async): {Count} issue(s) detected", + rulePrefix, outcome.Results.Count); + + await _signalRNotification.NotifyRuleResultsAvailableAsync( + ruleTenantId, ruleSessionId, outcome.Results.Count); + } + + if (outcome.Results.Count > 0) + { + _ = _metricsRepo.IncrementPlatformStatAsync("IssuesDetected", outcome.Results.Count) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + } + + _ = RecordAnalyzeRuleStatsAsync(ruleTenantId, outcome); + } + catch (Exception ruleEx) + { + _logger.LogWarning(ruleEx, "{Prefix} Enrollment-end analysis failed (async, non-fatal)", rulePrefix); + } + }); + } + + var shutdownInventoryDetected = storedEvents.Any(e => + e.EventType == Shared.Constants.EventTypes.SoftwareInventoryAnalysis && + e.Data != null && + e.Data.ContainsKey("triggered_at") && + e.Data["triggered_at"]?.ToString() == "shutdown" && + e.Data.ContainsKey("chunk_index") && + Convert.ToInt32(e.Data["chunk_index"]) == 0); + + if (shutdownInventoryDetected) + { + var capturedSessionId = request.SessionId; + var capturedTenantId = request.TenantId; + var capturedPrefix = sessionPrefix; + + var allInventoryItems = new List>(); + var inventoryChunks = storedEvents + .Where(e => e.EventType == Shared.Constants.EventTypes.SoftwareInventoryAnalysis && + e.Data != null && + e.Data.ContainsKey("triggered_at") && + e.Data["triggered_at"]?.ToString() == "shutdown" && + e.Data.ContainsKey("inventory")) + .OrderBy(e => Convert.ToInt32(e.Data!.GetValueOrDefault("chunk_index", 0))) + .ToList(); + + foreach (var chunk in inventoryChunks) + { + if (chunk.Data!["inventory"] is System.Collections.IEnumerable items) + { + foreach (var item in items) + { + if (item is Dictionary dict) + allInventoryItems.Add(dict); + } + } + } + + int? whiteGlovePart = null; + var firstShutdownChunk = inventoryChunks.FirstOrDefault(); + if (firstShutdownChunk?.Data != null && + firstShutdownChunk.Data.TryGetValue("whiteglove_part", out var wgPartObj)) + { + whiteGlovePart = Convert.ToInt32(wgPartObj); + } + + if (allInventoryItems.Count > 0) + { + var capturedWhiteGlovePart = whiteGlovePart; + + _ = Task.Run(async () => + { + try + { + var adminConfig = await _adminConfigService.GetConfigurationAsync(); + if (adminConfig?.VulnerabilityCorrelationEnabled != true) + return; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var reportData = await _vulnerabilityCorrelation.CorrelateAsync( + capturedSessionId, capturedTenantId, allInventoryItems, cts.Token); + + if (reportData != null) + { + var phaseLabel = capturedWhiteGlovePart == 1 ? "device_setup" + : capturedWhiteGlovePart == 2 ? "user_enrollment" + : (string?)null; + + if (phaseLabel != null && reportData.ContainsKey("findings") + && reportData["findings"] is List> tagFindings) + { + foreach (var f in tagFindings) + f.TryAdd("phase", phaseLabel); + } + + if (capturedWhiteGlovePart == 2) + { + try + { + var existingReport = await _vulnRepo.GetVulnerabilityReportAsync( + capturedTenantId, capturedSessionId); + if (existingReport != null) + { + reportData = VulnerabilityCorrelationService.MergeReports( + existingReport, reportData, + existingPhaseLabel: "device_setup", + newPhaseLabel: "user_enrollment"); + _logger.LogInformation( + "{Prefix} WhiteGlove Part 2: merged vulnerability report with Part 1 findings", + capturedPrefix); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "{Prefix} Failed to load Part 1 report for merge (storing Part 2 standalone)", + capturedPrefix); + } + } + + await _vulnRepo.StoreVulnerabilityReportAsync( + capturedTenantId, capturedSessionId, reportData); + _logger.LogInformation( + "{Prefix} Vulnerability correlation complete (async, whiteGlovePart={Part})", + capturedPrefix, capturedWhiteGlovePart?.ToString() ?? "none"); + + var findings = reportData.ContainsKey("findings") + ? reportData["findings"] as List> + : null; + if (findings != null && findings.Count > 0) + { + _ = _sessionRepo.UpsertCveIndexEntriesAsync(capturedTenantId, capturedSessionId, findings) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "CveIndex update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); + } + + var overallRisk = reportData.ContainsKey("scan_summary") + && reportData["scan_summary"] is Dictionary summary + && summary.ContainsKey("overall_risk") + ? summary["overall_risk"]?.ToString() ?? "unknown" + : "unknown"; + await _signalRNotification.NotifyVulnerabilityReportAvailableAsync( + capturedTenantId, capturedSessionId, overallRisk); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "{Prefix} Vulnerability correlation failed (async, non-fatal)", capturedPrefix); + } + }); + } + } + + _ = _metricsRepo.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + if (classification.CompletionEvent != null) + _ = _metricsRepo.IncrementPlatformStatAsync("SuccessfulEnrollments") + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + + _ = RecordGatherRuleStatsAsync(request.TenantId, storedEvents) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, + "Fire-and-forget RecordGatherRuleStatsAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + + if (classification.DiagnosticsUploadedEvent != null) + { + var blobName = classification.DiagnosticsUploadedEvent.Data?.ContainsKey("blobName") == true + ? classification.DiagnosticsUploadedEvent.Data["blobName"]?.ToString() + : null; + if (!string.IsNullOrEmpty(blobName)) + { + await _sessionRepo.UpdateSessionDiagnosticsBlobAsync( + request.TenantId, request.SessionId, blobName); + } + } + + var updatedSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); + + if (updatedSession != null && updatedSession.Status == SessionStatus.InProgress) + { + var sessionAge = DateTime.UtcNow - updatedSession.StartedAt; + if (sessionAge.TotalHours > 4) + { + _logger.LogWarning( + "Session {SessionId} (tenant {TenantId}) still InProgress after {Hours:F1}h — may be stuck", + request.SessionId, request.TenantId, sessionAge.TotalHours); + } + } + + if (classification.WhiteGloveEvent != null && updatedSession?.IsPreProvisioned != true) + { + _logger.LogError( + "{SessionPrefix} WhiteGlove status update not persisted after retries and fallback. " + + "IsPreProvisioned={IsPreProvisioned}, Status={Status}. " + + "Proceeding with 200 to allow agent spool drain.", + sessionPrefix, updatedSession?.IsPreProvisioned, updatedSession?.Status); + } + + await SendWebhookNotificationsAsync( + request, sessionPrefix, classification, updatedSession, + statusTransitioned, whiteGloveStatusTransitioned, failureReason, newRuleResults); + + if (statusTransitioned && updatedSession?.Status == SessionStatus.Failed) + { + _ = _slaBreachService.EvaluateSessionCompletionAsync(request.TenantId, updatedSession); + } + + string? adminAction = null; + if (updatedSession != null && + classification.CompletionEvent == null && classification.FailureEvent == null && + (updatedSession.Status == SessionStatus.Succeeded || updatedSession.Status == SessionStatus.Failed)) + { + adminAction = updatedSession.Status.ToString(); + _logger.LogInformation( + "{SessionPrefix} Admin override detected — signaling agent: AdminAction={AdminAction}", + sessionPrefix, adminAction); + } + + List? pendingActions = null; + if (updatedSession != null && !string.IsNullOrEmpty(updatedSession.PendingActionsJson)) + { + var fetched = await _sessionRepo.FetchAndClearPendingActionsAsync(request.TenantId, request.SessionId); + if (fetched.Count > 0) + { + pendingActions = fetched; + foreach (var a in fetched) + { + _telemetryClient.TrackEvent("ServerActionDelivered", new Dictionary + { + { "tenantId", request.TenantId }, + { "sessionId", request.SessionId }, + { "actionType", a.Type ?? string.Empty }, + { "reason", a.Reason ?? string.Empty }, + { "ruleId", a.RuleId ?? string.Empty }, + { "queuedAt", a.QueuedAt.ToString("O") }, + { "ageSeconds", ((int)(DateTime.UtcNow - a.QueuedAt).TotalSeconds).ToString() } + }); + } + _logger.LogInformation( + "{SessionPrefix} Delivering {Count} server action(s): [{Types}]", + sessionPrefix, fetched.Count, string.Join(",", fetched.Select(a => a.Type))); + } + } + + var signalRMessages = BuildSignalRMessages(request, updatedSession, processedCount, newRuleResults); + + return new EventIngestResult + { + EventsProcessed = processedCount, + AdminAction = adminAction, + PendingActions = pendingActions, + SignalRMessages = signalRMessages, + }; + } + + } + + /// + /// Result shape returned by . Mirrors + /// the control-signal fields the V2 agent's UploadResult parser reads from the 2xx body + /// (Plan §M4.6.ε) plus the SignalR messages for the real-time UI push. + /// + public sealed class EventIngestResult + { + public int EventsProcessed { get; set; } + public string? AdminAction { get; set; } + public List? PendingActions { get; set; } + public SignalRMessageAction[] SignalRMessages { get; set; } = Array.Empty(); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Indexing/AzureQueueIndexReconcileProducer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Indexing/AzureQueueIndexReconcileProducer.cs new file mode 100644 index 00000000..b018d95c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Indexing/AzureQueueIndexReconcileProducer.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.Identity; +using Azure.Storage.Queues; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Services.Indexing +{ + /// + /// Azure Storage Queue implementation of + /// (Plan §2.8, §M5.d). Writes one Base64-encoded JSON message per envelope onto + /// the telemetry-index-reconcile queue. + /// + /// Gate: feature flag AdminConfiguration.EnableIndexDualWrite is + /// consulted via (5-min memory cache). + /// When false, the producer no-ops — the primary commit has already happened + /// and pre-M5.d behaviour stays bit-exact. + /// + /// + /// Fault tolerance: queue-side exceptions (network, throttling, queue + /// missing) are logged but NOT rethrown. Primary rows are the source of truth; + /// missed envelopes get recovered by the 2h IndexReconcileTimer (M5.d.4). + /// + /// + public sealed class AzureQueueIndexReconcileProducer : IIndexReconcileProducer + { + private readonly QueueClient _queueClient; + private readonly AdminConfigurationService _adminConfig; + private readonly ILogger _logger; + + private int _queueEnsured; // 0 = not yet ensured, 1 = CreateIfNotExistsAsync has run + + public AzureQueueIndexReconcileProducer( + IConfiguration configuration, + AdminConfigurationService adminConfig, + ILogger logger) + { + _adminConfig = adminConfig; + _logger = logger; + + var storageAccountName = configuration["AzureStorageAccountName"]; + var connectionString = configuration["AzureTableStorageConnectionString"]; + + // Match Azure Functions QueueTrigger default (Base64) so the M5.d.3 consumer + // binding can decode our messages without an explicit QueueMessageEncoding override. + var options = new QueueClientOptions + { + MessageEncoding = QueueMessageEncoding.Base64, + }; + + if (!string.IsNullOrEmpty(storageAccountName)) + { + var queueUri = new Uri( + $"https://{storageAccountName}.queue.core.windows.net/{Constants.QueueNames.TelemetryIndexReconcile}"); + _queueClient = new QueueClient(queueUri, new DefaultAzureCredential(), options); + _logger.LogInformation( + "IndexReconcile producer initialized with Managed Identity (account: {Account})", + storageAccountName); + } + else if (!string.IsNullOrEmpty(connectionString)) + { + _queueClient = new QueueClient( + connectionString, Constants.QueueNames.TelemetryIndexReconcile, options); + _logger.LogInformation("IndexReconcile producer initialized with connection string"); + } + else + { + throw new InvalidOperationException( + "Queue Storage not configured. Set either 'AzureStorageAccountName' (for Managed Identity) or 'AzureTableStorageConnectionString'."); + } + } + + public async Task EnqueueBatchAsync( + IReadOnlyList envelopes, + CancellationToken cancellationToken = default) + { + if (envelopes is null || envelopes.Count == 0) return 0; + + // Flag check — cached 5min inside AdminConfigurationService, cheap per call. + var config = await _adminConfig.GetConfigurationAsync().ConfigureAwait(false); + if (!config.EnableIndexDualWrite) + { + return 0; + } + + await EnsureQueueExistsAsync(cancellationToken).ConfigureAwait(false); + + var sent = 0; + foreach (var envelope in envelopes) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var body = JsonConvert.SerializeObject(envelope); + await _queueClient.SendMessageAsync(body, cancellationToken).ConfigureAwait(false); + sent++; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "IndexReconcile enqueue failed (source={Source} tenant={Tenant} session={Session}) — timer reconciliation will recover", + envelope.SourceKind, envelope.TenantId, envelope.SessionId); + } + } + + if (sent > 0) + { + _logger.LogDebug("IndexReconcile: enqueued {Count} envelope(s)", sent); + } + return sent; + } + + private async Task EnsureQueueExistsAsync(CancellationToken cancellationToken) + { + // Cheap double-check: if CreateIfNotExistsAsync has already succeeded once we skip + // the round trip. Interlocked flag keeps concurrent first-callers exclusive without + // introducing a lock on the fast path. + if (_queueEnsured == 1) return; + + try + { + await _queueClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + System.Threading.Interlocked.Exchange(ref _queueEnsured, 1); + } + catch (Exception ex) + { + // Leave _queueEnsured=0 so a later call retries — we'll still attempt SendMessageAsync + // below; if the queue genuinely doesn't exist that call fails + gets logged per-envelope. + _logger.LogWarning(ex, "IndexReconcile queue CreateIfNotExists failed — will retry next batch"); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Indexing/IndexReconcileEnvelopeFactory.cs b/src/Backend/AutopilotMonitor.Functions/Services/Indexing/IndexReconcileEnvelopeFactory.cs new file mode 100644 index 00000000..917eb747 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Indexing/IndexReconcileEnvelopeFactory.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Services.Indexing +{ + /// + /// Pure factory that lifts committed primary rows into s + /// for the telemetry-index-reconcile queue (Plan §2.8, §M5.d). State-less + zero-IO + /// so callers can unit-test mapping behaviour against canned inputs without a live queue. + /// + /// One envelope per primary row. The handler (M5.d.3) decides which of the 0–3 index + /// tables to write, so the decision here is shape-only: copy discriminators + back-refs. + /// + /// + internal static class IndexReconcileEnvelopeFactory + { + public static IndexReconcileEnvelope FromSignal(SignalRecord record) + { + return new IndexReconcileEnvelope + { + EnvelopeVersion = "1", + SourceKind = "Signal", + TenantId = record.TenantId, + SessionId = record.SessionId, + OccurredAtUtc = record.OccurredAtUtc, + SessionSignalOrdinal = record.SessionSignalOrdinal, + SignalKind = record.Kind, + SourceOrigin = record.SourceOrigin, + }; + } + + public static IndexReconcileEnvelope FromDecisionTransition(DecisionTransitionRecord record) + { + return new IndexReconcileEnvelope + { + EnvelopeVersion = "1", + SourceKind = "DecisionTransition", + TenantId = record.TenantId, + SessionId = record.SessionId, + OccurredAtUtc = record.OccurredAtUtc, + StepIndex = record.StepIndex, + FromStage = record.FromStage, + ToStage = record.ToStage, + Taken = record.Taken, + IsTerminal = record.IsTerminal, + DeadEndReason = record.DeadEndReason, + ClassifierVerdictId = record.ClassifierVerdictId, + ClassifierHypothesisLevel = record.ClassifierHypothesisLevel, + }; + } + + /// + /// Builds envelopes for both kinds in one pass, preserving input order. Convenience for + /// , + /// whose per-request batch mixes signals + transitions. + /// + public static List BuildBatch( + IReadOnlyList signals, + IReadOnlyList transitions) + { + var result = new List( + (signals?.Count ?? 0) + (transitions?.Count ?? 0)); + + if (signals != null) + { + foreach (var s in signals) result.Add(FromSignal(s)); + } + if (transitions != null) + { + foreach (var t in transitions) result.Add(FromDecisionTransition(t)); + } + return result; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Indexing/IndexReconcileHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Indexing/IndexReconcileHandler.cs new file mode 100644 index 00000000..9284827f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Indexing/IndexReconcileHandler.cs @@ -0,0 +1,203 @@ +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services.Indexing +{ + /// + /// Fan-out logic for s consumed from the + /// telemetry-index-reconcile queue (Plan §2.8, §M5.d.3). Projects a single + /// envelope into the 0–3 applicable index-table rows via . + /// + /// Routing: + /// + /// Signal → SignalsByKind (always, 1 row). + /// DecisionTransition, IsTerminal=true → SessionsByTerminal. + /// DecisionTransition, Taken=true → SessionsByStage + /// (session entered a new stage; older rows for the same SessionId in other stage + /// PKs remain until the 2h reconcile timer prunes or the next stage entry supersedes + /// them in query-time ordering). + /// DecisionTransition, Taken=false + non-null DeadEndReason + /// → DeadEndsByReason. + /// DecisionTransition with non-null ClassifierVerdictId + /// → ClassifierVerdictsByIdLevel. + /// + /// + /// + /// Failure semantics: malformed envelopes (unknown , + /// missing required discriminators) are logged + dropped — retrying them against a poison + /// queue won't help. Transient Table-Storage errors rethrow, so the QueueTrigger platform + /// retry + poison-queue machinery kicks in. + /// + /// + /// Plain class (no I-interface): repo layer is already DAL-abstracted via + /// ; wrapping the handler too would be Cosmos-swap bloat. + /// Testability comes from constructor-injecting a fake repo. + /// + /// + public class IndexReconcileHandler + { + private readonly IIndexTableRepository _indexRepo; + private readonly ILogger _logger; + + public IndexReconcileHandler( + IIndexTableRepository indexRepo, + ILogger logger) + { + _indexRepo = indexRepo; + _logger = logger; + } + + public Task HandleAsync(IndexReconcileEnvelope envelope, CancellationToken cancellationToken = default) + { + if (envelope is null) + { + _logger.LogWarning("IndexReconcile: null envelope — dropping"); + return Task.CompletedTask; + } + + return envelope.SourceKind switch + { + "Signal" => HandleSignalAsync(envelope, cancellationToken), + "DecisionTransition" => HandleDecisionTransitionAsync(envelope, cancellationToken), + _ => LogAndDrop(envelope), + }; + } + + private Task LogAndDrop(IndexReconcileEnvelope envelope) + { + _logger.LogWarning( + "IndexReconcile: unknown SourceKind '{Kind}' (envelopeVersion={Version}) — dropping", + envelope.SourceKind, envelope.EnvelopeVersion); + return Task.CompletedTask; + } + + // ============================================================ Signal path + + private async Task HandleSignalAsync(IndexReconcileEnvelope e, CancellationToken ct) + { + if (e.SessionSignalOrdinal is not long ordinal || string.IsNullOrEmpty(e.SignalKind)) + { + _logger.LogWarning( + "IndexReconcile Signal envelope missing SessionSignalOrdinal or SignalKind (tenant={Tenant} session={Session}) — dropping", + e.TenantId, e.SessionId); + return; + } + + await _indexRepo.StoreSignalsByKindAsync( + new[] + { + new SignalsByKindRecord + { + TenantId = e.TenantId, + SessionId = e.SessionId, + SignalKind = e.SignalKind, + SessionSignalOrdinal = ordinal, + OccurredAtUtc = e.OccurredAtUtc, + SourceOrigin = e.SourceOrigin ?? string.Empty, + } + }, + ct).ConfigureAwait(false); + } + + // ============================================================ DecisionTransition path + + private async Task HandleDecisionTransitionAsync(IndexReconcileEnvelope e, CancellationToken ct) + { + if (e.StepIndex is not int stepIndex) + { + _logger.LogWarning( + "IndexReconcile DecisionTransition envelope missing StepIndex (tenant={Tenant} session={Session}) — dropping", + e.TenantId, e.SessionId); + return; + } + + var writes = 0; + + // SessionsByTerminal — only if a terminal stage was reached. + if (e.IsTerminal == true && !string.IsNullOrEmpty(e.ToStage)) + { + await _indexRepo.StoreSessionsByTerminalAsync( + new[] + { + new SessionsByTerminalRecord + { + TenantId = e.TenantId, + SessionId = e.SessionId, + TerminalStage = e.ToStage!, + OccurredAtUtc = e.OccurredAtUtc, + StepIndex = stepIndex, + } + }, + ct).ConfigureAwait(false); + writes++; + } + + // SessionsByStage — every Taken transition advances the "current stage" row. + if (e.Taken == true && !string.IsNullOrEmpty(e.ToStage)) + { + await _indexRepo.StoreSessionsByStageAsync( + new[] + { + new SessionsByStageRecord + { + TenantId = e.TenantId, + SessionId = e.SessionId, + Stage = e.ToStage!, + LastUpdatedUtc = e.OccurredAtUtc, + StepIndex = stepIndex, + } + }, + ct).ConfigureAwait(false); + writes++; + } + + // DeadEndsByReason — blocked transitions with an explained reason. + if (e.Taken == false && !string.IsNullOrEmpty(e.DeadEndReason)) + { + await _indexRepo.StoreDeadEndsByReasonAsync( + new[] + { + new DeadEndsByReasonRecord + { + TenantId = e.TenantId, + SessionId = e.SessionId, + DeadEndReason = e.DeadEndReason!, + StepIndex = stepIndex, + FromStage = e.FromStage ?? string.Empty, + AttemptedToStage = e.ToStage ?? string.Empty, + OccurredAtUtc = e.OccurredAtUtc, + } + }, + ct).ConfigureAwait(false); + writes++; + } + + // ClassifierVerdictsByIdLevel — any transition step that recorded a classifier verdict. + if (!string.IsNullOrEmpty(e.ClassifierVerdictId)) + { + await _indexRepo.StoreClassifierVerdictsByIdLevelAsync( + new[] + { + new ClassifierVerdictsByIdLevelRecord + { + TenantId = e.TenantId, + SessionId = e.SessionId, + ClassifierId = e.ClassifierVerdictId!, + HypothesisLevel = e.ClassifierHypothesisLevel ?? string.Empty, + StepIndex = stepIndex, + OccurredAtUtc = e.OccurredAtUtc, + } + }, + ct).ConfigureAwait(false); + writes++; + } + + _logger.LogDebug( + "IndexReconcile DecisionTransition handled (tenant={Tenant} session={Session} step={Step}): {Writes} index row(s)", + e.TenantId, e.SessionId, stepIndex, writes); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/ReducerVerifier.cs b/src/Backend/AutopilotMonitor.Functions/Services/ReducerVerifier.cs new file mode 100644 index 00000000..b7ef9543 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/ReducerVerifier.cs @@ -0,0 +1,174 @@ +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Structural verification of a session's persisted SignalLog + DecisionTransitions + /// journal. Pure function — takes loaded records, produces a report. No IO, no DI. + /// See for scope boundary vs. full engine replay. + /// + internal static class ReducerVerifier + { + public static ReducerVerificationReport Verify( + string tenantId, + string sessionId, + IReadOnlyList signals, + IReadOnlyList transitions, + string currentReducerVersion) + { + var report = new ReducerVerificationReport + { + TenantId = tenantId, + SessionId = sessionId, + SignalCount = signals?.Count ?? 0, + TransitionCount = transitions?.Count ?? 0, + CurrentReducerVersion = currentReducerVersion ?? string.Empty, + Issues = new List(), + }; + + if ((signals == null || signals.Count == 0) && (transitions == null || transitions.Count == 0)) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Info", + Kind = "empty_session", + Message = "Session has no signals or transitions persisted.", + }); + return report; + } + + // ---- Signal ordinal contiguity --------------------------------------------- + if (signals != null && signals.Count > 0) + { + var ordered = signals.OrderBy(s => s.SessionSignalOrdinal).ToList(); + report.SignalOrdinalFirst = ordered[0].SessionSignalOrdinal; + report.SignalOrdinalLast = ordered[ordered.Count - 1].SessionSignalOrdinal; + + var gaps = new List<(long Prev, long Next)>(); + for (var i = 1; i < ordered.Count; i++) + { + var prev = ordered[i - 1].SessionSignalOrdinal; + var next = ordered[i].SessionSignalOrdinal; + if (next != prev + 1) gaps.Add((prev, next)); + } + report.SignalOrdinalsContiguous = gaps.Count == 0; + + // Report gaps individually up to a cap — an attacker could fill the report + // with thousands of issues otherwise. + const int maxGapIssues = 20; + foreach (var (prev, next) in gaps.Take(maxGapIssues)) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "signal_ordinal_gap", + Message = $"Signal ordinal jumps from {prev} to {next} (missing {next - prev - 1} ordinal(s)).", + }); + } + if (gaps.Count > maxGapIssues) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "signal_ordinal_gap", + Message = $"... {gaps.Count - maxGapIssues} additional ordinal gaps not listed.", + }); + } + } + + // ---- Transition step-index contiguity -------------------------------------- + if (transitions != null && transitions.Count > 0) + { + var ordered = transitions.OrderBy(t => t.StepIndex).ToList(); + report.StepIndexFirst = ordered[0].StepIndex; + report.StepIndexLast = ordered[ordered.Count - 1].StepIndex; + + var gaps = new List<(int Prev, int Next)>(); + for (var i = 1; i < ordered.Count; i++) + { + var prev = ordered[i - 1].StepIndex; + var next = ordered[i].StepIndex; + if (next != prev + 1) gaps.Add((prev, next)); + } + report.StepIndicesContiguous = gaps.Count == 0; + + const int maxGapIssues = 20; + foreach (var (prev, next) in gaps.Take(maxGapIssues)) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "step_index_gap", + Message = $"Transition StepIndex jumps from {prev} to {next} (missing {next - prev - 1} step(s)).", + }); + } + if (gaps.Count > maxGapIssues) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "step_index_gap", + Message = $"... {gaps.Count - maxGapIssues} additional step-index gaps not listed.", + }); + } + + // StoredReducerVersion + drift check (first transition is representative; the + // agent-side Journal contract guarantees per-session consistency). + report.StoredReducerVersion = ordered[0].ReducerVersion; + report.ReducerVersionDrift = !string.IsNullOrEmpty(report.CurrentReducerVersion) && + !string.IsNullOrEmpty(report.StoredReducerVersion) && + !string.Equals(report.StoredReducerVersion, report.CurrentReducerVersion, StringComparison.Ordinal); + if (report.ReducerVersionDrift) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Info", + Kind = "reducer_version_drift", + Message = $"Stored ReducerVersion {report.StoredReducerVersion} differs from current {report.CurrentReducerVersion}. " + + "Session was journaled under an older reducer build — structural checks still valid, but " + + "a replay would not reproduce the stored transitions exactly.", + }); + } + + // ---- Cross-reference: SignalOrdinalRef → existing signal -------------- + if (signals != null && signals.Count > 0) + { + var signalOrdinals = new HashSet(signals.Select(s => s.SessionSignalOrdinal)); + var orphanCount = 0; + const int maxOrphanIssues = 20; + + foreach (var t in ordered) + { + if (!signalOrdinals.Contains(t.SignalOrdinalRef)) + { + orphanCount++; + if (orphanCount <= maxOrphanIssues) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "orphaned_transition", + Message = $"Transition StepIndex={t.StepIndex} references SignalOrdinal={t.SignalOrdinalRef} which is not in the loaded signal set.", + }); + } + } + } + if (orphanCount > maxOrphanIssues) + { + report.Issues.Add(new VerificationIssue + { + Severity = "Warning", + Kind = "orphaned_transition", + Message = $"... {orphanCount - maxOrphanIssues} additional orphaned transitions not listed.", + }); + } + report.OrphanedTransitionCount = orphanCount; + } + } + + return report; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj new file mode 100644 index 00000000..71b5018a --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj @@ -0,0 +1,34 @@ + + + + netstandard2.0 + 9.0 + enable + true + 2.0.0.0 + 2.0.0.0 + + Decision Core shared reducer library for Autopilot-Monitor Agent v2.0. + Consumed by Agent (net48) and Backend (net8.0). Pure, dependency-minimal. + AssemblyVersion is the ReducerVersion source (plan §2.10). + + + + + + + + + + + <_Parameter1>AutopilotMonitor.DecisionCore.Tests + + + + + + + + + diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdict.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdict.cs new file mode 100644 index 00000000..27bfb7fc --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdict.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Classifiers +{ + /// + /// Immutable verdict record produced by an . Plan §2.4. + /// + /// Emitted back into the signal log as a synthetic + /// ClassifierVerdictIssued signal (L.8) and attached to the corresponding + /// DecisionTransition.ClassifierVerdictJson. + /// + /// + public sealed class ClassifierVerdict + { + public ClassifierVerdict( + string classifierId, + HypothesisLevel level, + int score, + IReadOnlyList contributingFactors, + string reason, + string inputHash) + { + if (string.IsNullOrEmpty(classifierId)) + { + throw new ArgumentException("ClassifierId is mandatory.", nameof(classifierId)); + } + + if (string.IsNullOrEmpty(inputHash)) + { + throw new ArgumentException("InputHash is mandatory.", nameof(inputHash)); + } + + if (score < 0 || score > 100) + { + throw new ArgumentOutOfRangeException( + nameof(score), + "Score must be in [0, 100]."); + } + + ClassifierId = classifierId; + Level = level; + Score = score; + ContributingFactors = contributingFactors ?? Array.Empty(); + Reason = reason ?? string.Empty; + InputHash = inputHash; + } + + public string ClassifierId { get; } + + public HypothesisLevel Level { get; } + + public int Score { get; } + + public IReadOnlyList ContributingFactors { get; } + + public string Reason { get; } + + /// SHA256-based hex hash (16 chars) over classifier-specific snapshot. Plan §2.4 — anti-loop. + public string InputHash { get; } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdictLookup.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdictLookup.cs new file mode 100644 index 00000000..6650ba42 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/ClassifierVerdictLookup.cs @@ -0,0 +1,36 @@ +using System; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Classifiers +{ + /// + /// Maps a classifier id to the hypothesis slot in that holds + /// its last verdict. Plan §2.4 anti-loop — the InputHash of the last verdict is + /// compared against the new snapshot hash to decide whether to skip a re-run. + /// + /// Shared between the harness (ClassifierAwareReplayHarness) and the production + /// EffectRunner so both use identical anti-loop semantics. Adding a new classifier + /// = one new case here. + /// + /// + public static class ClassifierVerdictLookup + { + /// + /// Returns the LastClassifierVerdictId of the hypothesis corresponding to + /// , or null if the classifier id is unknown + /// or the hypothesis hasn't been touched yet. + /// + public static string? LookupLastVerdictId(DecisionState state, string classifierId) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + if (classifierId == null) throw new ArgumentNullException(nameof(classifierId)); + + return classifierId switch + { + WhiteGloveSealingClassifier.ClassifierId => state.WhiteGloveSealing.LastClassifierVerdictId, + WhiteGlovePart2CompletionClassifier.ClassifierId => state.WhiteGlovePart2Completion.LastClassifierVerdictId, + _ => null, + }; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifier.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifier.cs new file mode 100644 index 00000000..437064c8 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifier.cs @@ -0,0 +1,29 @@ +using System; + +namespace AutopilotMonitor.DecisionCore.Classifiers +{ + /// + /// Kernel classifier service. Plan §2.4 / L.8. + /// + /// Implementations are pure: Classify(snapshot) → ClassifierVerdict. + /// Anti-loop via — the reducer does not re-invoke + /// a classifier whose last verdict matches the current snapshot hash. + /// + /// + /// First implementations shipped in M3: WhiteGloveSealingClassifier, + /// WhiteGlovePart2CompletionClassifier. Future (v11.1+): EnrollmentFailureCauseClassifier, + /// AppInstallOutcomeClassifier. + /// + /// + public interface IClassifier + { + /// Stable identifier used in verdict records and journal references. + string Id { get; } + + /// Concrete snapshot type the classifier expects. Enforced by the reducer. + Type SnapshotType { get; } + + /// Run the classifier against a snapshot. + ClassifierVerdict Classify(object snapshot); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifierSnapshot.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifierSnapshot.cs new file mode 100644 index 00000000..396ceee3 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/IClassifierSnapshot.cs @@ -0,0 +1,16 @@ +namespace AutopilotMonitor.DecisionCore.Classifiers +{ + /// + /// A classifier-input snapshot that exposes a deterministic hash. Plan §2.4 anti-loop. + /// + /// Implementations must produce the same hash byte-for-byte for equivalent snapshots, + /// across process restarts, regardless of hashtable ordering or culture. The hash is + /// used by the effect runner / replay harness to skip classifier invocations whose + /// input matches the last verdict's . + /// + /// + public interface IClassifierSnapshot + { + string ComputeInputHash(); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionClassifier.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionClassifier.cs new file mode 100644 index 00000000..e3e67942 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionClassifier.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Classifiers +{ + /// + /// Scoring classifier for WhiteGlove Part 2 completion. Plan §2.4 / §M3.4. + /// + /// Scoring rules (new — no Legacy equivalent): each of the four Part-2 post-reboot user + /// signals contributes +25; all four together cross the Confirmed threshold of 100. + /// Missing one signal gives 75 -> Weak. This matches the plan's requirement that Part-2 + /// completion needs the full UserSignIn + Hello + Desktop + AccountSetup set. + /// + /// + public sealed class WhiteGlovePart2CompletionClassifier : IClassifier + { + public const string ClassifierId = "whiteglove-part2-completion"; + + internal const int HighThreshold = 100; // exactly-all-four -> Confirmed + internal const int LowThreshold = 25; // any single signal -> Weak + + internal const int WeightUserAadSignIn = 25; + internal const int WeightHelloResolved = 25; + internal const int WeightDesktopArrived = 25; + internal const int WeightAccountSetup = 25; + + public string Id => ClassifierId; + public Type SnapshotType => typeof(WhiteGlovePart2CompletionSnapshot); + + public ClassifierVerdict Classify(object snapshot) + { + if (snapshot == null) throw new ArgumentNullException(nameof(snapshot)); + if (!(snapshot is WhiteGlovePart2CompletionSnapshot s)) + { + throw new ArgumentException( + $"Expected {nameof(WhiteGlovePart2CompletionSnapshot)}, got {snapshot.GetType().Name}.", + nameof(snapshot)); + } + + var factors = new List(); + int score = 0; + + if (s.UserAadSignInComplete) { score += WeightUserAadSignIn; factors.Add($"user_aad_signin:{WeightUserAadSignIn:+#;-#;0}"); } + if (s.HelloResolvedPart2) { score += WeightHelloResolved; factors.Add($"hello_part2:{WeightHelloResolved:+#;-#;0}"); } + if (s.DesktopArrivedPart2) { score += WeightDesktopArrived; factors.Add($"desktop_part2:{WeightDesktopArrived:+#;-#;0}"); } + if (s.AccountSetupCompletedPart2) { score += WeightAccountSetup; factors.Add($"account_setup_part2:{WeightAccountSetup:+#;-#;0}"); } + + if (score < 0) score = 0; + if (score > 100) score = 100; + + HypothesisLevel level; + if (score >= HighThreshold) level = HypothesisLevel.Confirmed; + else if (score >= LowThreshold) level = HypothesisLevel.Weak; + else level = HypothesisLevel.Unknown; + + return new ClassifierVerdict( + classifierId: ClassifierId, + level: level, + score: score, + contributingFactors: factors, + reason: $"confidence={level} score={score}", + inputHash: s.ComputeInputHash()); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionSnapshot.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionSnapshot.cs new file mode 100644 index 00000000..c402c92b --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGlovePart2CompletionSnapshot.cs @@ -0,0 +1,58 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Classifiers +{ + /// + /// Input snapshot for . + /// Plan §2.4 / §M3.4 — the four Part-2 post-reboot user signals drive the score. + /// + public sealed class WhiteGlovePart2CompletionSnapshot : IClassifierSnapshot + { + public WhiteGlovePart2CompletionSnapshot( + bool userAadSignInComplete, + bool helloResolvedPart2, + bool desktopArrivedPart2, + bool accountSetupCompletedPart2, + EnrollmentPhase? currentEnrollmentPhase, + DateTime? systemRebootUtc) + { + UserAadSignInComplete = userAadSignInComplete; + HelloResolvedPart2 = helloResolvedPart2; + DesktopArrivedPart2 = desktopArrivedPart2; + AccountSetupCompletedPart2 = accountSetupCompletedPart2; + CurrentEnrollmentPhase = currentEnrollmentPhase; + SystemRebootUtc = systemRebootUtc; + } + + public bool UserAadSignInComplete { get; } + public bool HelloResolvedPart2 { get; } + public bool DesktopArrivedPart2 { get; } + public bool AccountSetupCompletedPart2 { get; } + public EnrollmentPhase? CurrentEnrollmentPhase { get; } + public DateTime? SystemRebootUtc { get; } + + public string ComputeInputHash() + { + var canonical = string.Join( + "|", + "whiteglove-part2-completion-v1", + UserAadSignInComplete ? "1" : "0", + HelloResolvedPart2 ? "1" : "0", + DesktopArrivedPart2 ? "1" : "0", + AccountSetupCompletedPart2 ? "1" : "0", + CurrentEnrollmentPhase?.ToString() ?? "null", + SystemRebootUtc?.ToString("O") ?? "null"); + + using (var sha = SHA256.Create()) + { + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical)); + var sb = new StringBuilder(16); + for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2")); + return sb.ToString(); + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs new file mode 100644 index 00000000..8fe1ee5d --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Classifiers +{ + /// + /// Scoring-based WhiteGlove Part 1 classifier. Plan §2.4 / §M3.3. + /// + /// Scoring rules ported 1:1 from the Legacy WhiteGloveClassifier + /// (src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs) + /// — same weights, same thresholds, same asymmetric-conservative decision rule. The + /// refactor change is the contract, the snapshot input type, + /// and the deterministic anti-loop. + /// + /// + /// Asymmetric-conservative: only drives the + /// WhiteGloveSealed stage. verdicts are recorded for + /// observability but treated as NOT WhiteGlove — false-positive WG keeps the agent + /// pending when it should exit; false-negative emits completion early and the later + /// user sign-in triggers a new session (much lower cost). + /// + /// + public sealed class WhiteGloveSealingClassifier : IClassifier + { + public const string ClassifierId = "whiteglove-sealing"; + + // Thresholds (tunable after production data — preserve Legacy baseline) + internal const int HighThreshold = 70; // >= 70 -> Confirmed + internal const int LowThreshold = 30; // 30..69 -> Weak (observability only) + + // Positive-signal weights (port from Legacy) + internal const int WeightShellCoreWhiteGloveSuccess = 80; + internal const int WeightWhiteGloveSealingPattern = 40; + internal const int WeightDeviceOnlyDeployment = 15; + internal const int WeightSystemRebootObserved = 15; + + // Negative-signal weights (hard excluders) + internal const int WeightAadJoinedWithUser = -100; + internal const int WeightDesktopArrived = -100; + internal const int WeightHelloResolved = -100; + internal const int WeightAccountSetupActive = -40; + + public string Id => ClassifierId; + public Type SnapshotType => typeof(WhiteGloveSealingSnapshot); + + public ClassifierVerdict Classify(object snapshot) + { + if (snapshot == null) throw new ArgumentNullException(nameof(snapshot)); + if (!(snapshot is WhiteGloveSealingSnapshot s)) + { + throw new ArgumentException( + $"Expected {nameof(WhiteGloveSealingSnapshot)}, got {snapshot.GetType().Name}.", + nameof(snapshot)); + } + + var factors = new List(); + int score = 0; + + // Hard excluders — any one flips the verdict to definitive NOT WhiteGlove. + if (s.AadJoinedWithUser) { score += WeightAadJoinedWithUser; factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}"); } + if (s.DesktopArrived) { score += WeightDesktopArrived; factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}"); } + if (s.HelloResolved) { score += WeightHelloResolved; factors.Add($"hello_resolved:{WeightHelloResolved:+#;-#;0}"); } + if (s.HasAccountSetupActivity) + { + score += WeightAccountSetupActive; + factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}"); + } + + // Positive signals. + if (s.ShellCoreWhiteGloveSuccessSeen) + { + score += WeightShellCoreWhiteGloveSuccess; + factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}"); + } + if (s.WhiteGloveSealingPatternSeen) + { + score += WeightWhiteGloveSealingPattern; + factors.Add($"sealing_pattern:{WeightWhiteGloveSealingPattern:+#;-#;0}"); + } + if (s.IsDeviceOnlyDeploymentHypothesis) + { + score += WeightDeviceOnlyDeployment; + factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}"); + } + if (s.SystemRebootUtc.HasValue) + { + score += WeightSystemRebootObserved; + factors.Add($"system_reboot:{WeightSystemRebootObserved:+#;-#;0}"); + } + + // Cap to [0, 100]. + if (score < 0) score = 0; + if (score > 100) score = 100; + + HypothesisLevel level; + if (score >= HighThreshold) level = HypothesisLevel.Confirmed; + else if (score >= LowThreshold) level = HypothesisLevel.Weak; + else level = HypothesisLevel.Rejected; + + return new ClassifierVerdict( + classifierId: ClassifierId, + level: level, + score: score, + contributingFactors: factors, + reason: $"confidence={level} score={score}", + inputHash: s.ComputeInputHash()); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs new file mode 100644 index 00000000..928fb38d --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingSnapshot.cs @@ -0,0 +1,79 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Classifiers +{ + /// + /// Input snapshot for . Plan §2.4 input-hash. + /// + /// Mirrors the WG-relevant fields from DecisionState that participate in scoring. + /// Extra fields (unrelated facts, deadlines, etc.) intentionally excluded — including them + /// would cause unnecessary re-runs on the anti-loop guard. + /// + /// + public sealed class WhiteGloveSealingSnapshot : IClassifierSnapshot + { + public WhiteGloveSealingSnapshot( + bool shellCoreWhiteGloveSuccessSeen, + bool whiteGloveSealingPatternSeen, + bool aadJoinedWithUser, + bool desktopArrived, + bool helloResolved, + bool hasAccountSetupActivity, + bool isDeviceOnlyDeploymentHypothesis, + DateTime? systemRebootUtc, + EnrollmentPhase? currentEnrollmentPhase) + { + ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen; + WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen; + AadJoinedWithUser = aadJoinedWithUser; + DesktopArrived = desktopArrived; + HelloResolved = helloResolved; + HasAccountSetupActivity = hasAccountSetupActivity; + IsDeviceOnlyDeploymentHypothesis = isDeviceOnlyDeploymentHypothesis; + SystemRebootUtc = systemRebootUtc; + CurrentEnrollmentPhase = currentEnrollmentPhase; + } + + public bool ShellCoreWhiteGloveSuccessSeen { get; } + public bool WhiteGloveSealingPatternSeen { get; } + public bool AadJoinedWithUser { get; } + public bool DesktopArrived { get; } + public bool HelloResolved { get; } + public bool HasAccountSetupActivity { get; } + public bool IsDeviceOnlyDeploymentHypothesis { get; } + public DateTime? SystemRebootUtc { get; } + public EnrollmentPhase? CurrentEnrollmentPhase { get; } + + /// + /// Deterministic 16-hex-char SHA256 prefix over the snapshot. Anti-loop guard: + /// the classifier is skipped if this hash equals the last recorded + /// . Plan §2.4. + /// + public string ComputeInputHash() + { + var canonical = string.Join( + "|", + "whiteglove-sealing-v1", + ShellCoreWhiteGloveSuccessSeen ? "1" : "0", + WhiteGloveSealingPatternSeen ? "1" : "0", + AadJoinedWithUser ? "1" : "0", + DesktopArrived ? "1" : "0", + HelloResolved ? "1" : "0", + HasAccountSetupActivity ? "1" : "0", + IsDeviceOnlyDeploymentHypothesis ? "1" : "0", + SystemRebootUtc?.ToString("O") ?? "null", + CurrentEnrollmentPhase?.ToString() ?? "null"); + + using (var sha = SHA256.Create()) + { + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical)); + var sb = new StringBuilder(16); + for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2")); + return sb.ToString(); + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs new file mode 100644 index 00000000..24068436 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs @@ -0,0 +1,57 @@ +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Canonical deadline identifiers. Plan §2.6 pflicht-deadlines. + /// + /// Every decision-relevant timer in the engine uses one of these names. The + /// record is keyed on , + /// so a re-scheduled deadline with the same name replaces the prior entry (see + /// DecisionStateBuilder.AddDeadline). The name also appears in the + /// DeadlineFired signal payload under . + /// + /// + public static class DeadlineNames + { + /// Post-ESP-exit grace period for Hello resolution. Plan §2.7 (300 s). + public const string HelloSafety = "hello_safety"; + + /// Brief settle window after ESP exit before we emit completion. + public const string EspSettle = "esp_settle"; + + /// Detects "no ESP at all" for self-deploying / device-only paths. + public const string DeviceOnlyEspDetection = "device_only_esp_detection"; + + /// Safety net for device-only sessions that never arrive at a terminal signal. + public const string DeviceOnlySafety = "device_only_safety"; + + /// Secondary Hello wait window (separate from HelloSafety; plan §2.7). + public const string HelloWait = "hello_wait"; + + /// Periodic classifier-tick every 30 s — replaces legacy signal-correlated loop. + public const string ClassifierTick = "classifier_tick"; + + /// 24 h watchdog after White-Glove Part 1 → reboot → awaiting user sign-in. Plan §2.3. + public const string WhiteGlovePart2Safety = "whiteglove_part2_safety"; + } + + /// + /// Well-known payload keys on a . + /// + public static class SignalPayloadKeys + { + /// On DeadlineFired: the deadline name from . + public const string Deadline = "deadline"; + + /// On EspPhaseChanged: the raw phase name as observed by the collector. + public const string EspPhase = "phase"; + + /// On HelloResolved / HelloResolvedPart2: outcome string (e.g. Success, Timeout, Skipped). + public const string HelloOutcome = "outcome"; + + /// On AadUserJoinedLate: user presence indicator ("true" / "false"). + public const string AadJoinedWithUser = "aadJoinedWithUser"; + + /// On ImeUserSessionCompleted: matched pattern id. + public const string ImePatternId = "patternId"; + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEffect.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEffect.cs new file mode 100644 index 00000000..f4f6630f --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEffect.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Action kind an requests the EffectRunner to execute. Plan §2.5 / §2.7b. + /// + public enum DecisionEffectKind + { + ScheduleDeadline, + CancelDeadline, + RunClassifier, + EmitEventTimelineEntry, + PersistSnapshot, + } + + /// + /// Immutable effect record. Plan §2.5. + /// + /// Effects are the only side-channel through which the reducer talks to the outside world. + /// The reducer itself is pure ((old, signal) → (new, transition, effects)); the EffectRunner + /// performs I/O (Deadlines, Classifier, Event-Emit, Persist) per plan §2.7b error strategy: + /// + /// + /// Transient (retry, 100/400/1600 ms): , + /// Critical (session abort on register failure): , + /// Optional (best-effort, no abort): + /// + /// + public sealed class DecisionEffect + { + public DecisionEffect( + DecisionEffectKind kind, + IReadOnlyDictionary? parameters = null, + ActiveDeadline? deadline = null, + string? cancelDeadlineName = null, + string? classifierId = null, + object? classifierSnapshot = null) + { + Kind = kind; + Parameters = parameters; + Deadline = deadline; + CancelDeadlineName = cancelDeadlineName; + ClassifierId = classifierId; + ClassifierSnapshot = classifierSnapshot; + } + + public DecisionEffectKind Kind { get; } + + public IReadOnlyDictionary? Parameters { get; } + + /// Set for . + public ActiveDeadline? Deadline { get; } + + /// Set for . + public string? CancelDeadlineName { get; } + + /// Set for . + public string? ClassifierId { get; } + + /// Set for — classifier-specific snapshot object. + public object? ClassifierSnapshot { get; } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs new file mode 100644 index 00000000..c9f01fb8 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // Classic UserDriven-v1 enrollment handlers. Plan §2.5 partial-class layout. + public sealed partial class DecisionEngine + { + // Post-ESP Hello-safety grace period per plan §2.7. + private static readonly TimeSpan s_helloSafetyWindow = TimeSpan.FromSeconds(300); + + /// + /// Handle . Drives the + /// → + /// progression and updates the user-visible + /// fact. + /// + private DecisionStep HandleEspPhaseChangedV1(DecisionState state, DecisionSignal signal) + { + var rawPhase = signal.Payload != null && signal.Payload.TryGetValue(SignalPayloadKeys.EspPhase, out var p) + ? p + : string.Empty; + var enrollmentPhase = MapEspPhaseToEnrollmentPhase(rawPhase); + + var newStage = enrollmentPhase switch + { + EnrollmentPhase.DeviceSetup => SessionStage.EspDeviceSetup, + EnrollmentPhase.AccountSetup => SessionStage.EspAccountSetup, + EnrollmentPhase.FinalizingSetup => SessionStage.AwaitingHello, + _ => state.Stage, + }; + + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStage(newStage) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + if (enrollmentPhase != EnrollmentPhase.Unknown) + { + builder.WithCurrentEnrollmentPhase(enrollmentPhase, signal.SessionSignalOrdinal); + } + + if (enrollmentPhase == EnrollmentPhase.DeviceSetup && state.DeviceSetupEnteredUtc == null) + { + builder.DeviceSetupEnteredUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + } + if (enrollmentPhase == EnrollmentPhase.AccountSetup && state.AccountSetupEnteredUtc == null) + { + builder.AccountSetupEnteredUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + } + if (enrollmentPhase == EnrollmentPhase.FinalizingSetup && state.FinalizingEnteredUtc == null) + { + builder.FinalizingEnteredUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + } + + // Weak UserDriven-v1 hypothesis — seeing AccountSetup is the canonical tell. + if (enrollmentPhase == EnrollmentPhase.AccountSetup && + state.EnrollmentType.Level == HypothesisLevel.Unknown) + { + builder.EnrollmentType = state.EnrollmentType.With( + level: HypothesisLevel.Weak, + reason: "account_setup_observed", + score: 40, + lastUpdatedUtc: signal.OccurredAtUtc); + } + + // Device-Only ESP detection (plan §2.7): arm on first DeviceSetup, cancel on AccountSetup. + // See DecisionEngine.SelfDeploying.cs for the deadline-fired handler. + var effectsList = new List(); + if (enrollmentPhase == EnrollmentPhase.DeviceSetup && + state.DeviceSetupEnteredUtc == null) + { + var devOnlyDl = BuildDeviceOnlyEspDetectionDeadline(signal.OccurredAtUtc); + builder.AddDeadline(devOnlyDl); + effectsList.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: devOnlyDl)); + } + else if (enrollmentPhase == EnrollmentPhase.AccountSetup) + { + builder.CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); + effectsList.Add(new DecisionEffect( + DecisionEffectKind.CancelDeadline, + cancelDeadlineName: DeadlineNames.DeviceOnlyEspDetection)); + } + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: newStage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.EspPhaseChanged)); + + return new DecisionStep(newState, transition, effectsList.ToArray()); + } + + /// + /// Handle . Records + /// , transitions to + /// , and schedules the Hello-safety deadline + /// so a hang on Hello doesn't strand the session. + /// + private DecisionStep HandleEspExitingV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var dueAtUtc = signal.OccurredAtUtc.Add(s_helloSafetyWindow); + + var helloSafety = new ActiveDeadline( + name: DeadlineNames.HelloSafety, + dueAtUtc: dueAtUtc, + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary + { + [SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety, + }); + + var builder = state.ToBuilder() + .WithStage(SessionStage.AwaitingHello) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .AddDeadline(helloSafety); + builder.EspFinalExitUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.AwaitingHello, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.EspExiting)); + + var effects = new[] + { + new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: helloSafety), + }; + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Handle . Records the resolution fact + /// and cancels the Hello-safety deadline. If Desktop has already arrived the session + /// completes here; otherwise stage transitions to . + /// + private DecisionStep HandleHelloResolvedV1(DecisionState state, DecisionSignal signal) + { + var outcome = signal.Payload != null && signal.Payload.TryGetValue(SignalPayloadKeys.HelloOutcome, out var o) + ? o + : "Success"; + + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.HelloSafety); + builder.HelloResolvedUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + builder.HelloOutcome = new SignalFact(outcome, signal.SessionSignalOrdinal); + + var desktopAlreadyArrived = state.DesktopArrivedUtc != null; + var toStage = desktopAlreadyArrived ? SessionStage.Completed : SessionStage.AwaitingDesktop; + builder.WithStage(toStage); + + if (desktopAlreadyArrived) + { + builder.WithOutcome(SessionOutcome.EnrollmentComplete); + builder.ClearDeadlines(); + } + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: toStage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.HelloResolved)); + + var effects = desktopAlreadyArrived + ? new[] { BuildEnrollmentCompleteEffect() } + : Array.Empty(); + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Handle . Mirror of the Hello handler: + /// records , and if Hello has already + /// resolved the session completes here. + /// + private DecisionStep HandleDesktopArrivedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + builder.DesktopArrivedUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + + var helloAlreadyResolved = state.HelloResolvedUtc != null; + var toStage = helloAlreadyResolved ? SessionStage.Completed : state.Stage; + builder.WithStage(toStage); + + if (helloAlreadyResolved) + { + builder.WithOutcome(SessionOutcome.EnrollmentComplete); + builder.ClearDeadlines(); + } + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: toStage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.DesktopArrived)); + + var effects = helloAlreadyResolved + ? new[] { BuildEnrollmentCompleteEffect() } + : Array.Empty(); + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Handle . Primarily a + /// EnrollmentType-hypothesis strengthener (IME's user-session-complete pattern + /// is a strong UserDriven-v1 indicator), and records the matched pattern id. + /// + private DecisionStep HandleImeUserSessionCompletedV1(DecisionState state, DecisionSignal signal) + { + var patternId = signal.Payload != null && signal.Payload.TryGetValue(SignalPayloadKeys.ImePatternId, out var pid) + ? pid + : null; + + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + if (!string.IsNullOrEmpty(patternId)) + { + builder.ImeMatchedPatternId = new SignalFact(patternId!, signal.SessionSignalOrdinal); + } + + // Strengthen UserDriven-v1 hypothesis on user-session-complete. + if (state.EnrollmentType.Level < HypothesisLevel.Strong) + { + builder.EnrollmentType = state.EnrollmentType.With( + level: HypothesisLevel.Strong, + reason: "ime_user_session_completed", + score: 75, + lastUpdatedUtc: signal.OccurredAtUtc); + } + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.ImeUserSessionCompleted)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + /// + /// Handle — hypothesis-only update. + /// + /// Per project memory feedback_aad_joined_late_not_completion: this signal is a + /// classifier-state update ONLY — never a completion trigger. Stage is unchanged, no + /// terminal event is emitted, but is set + /// and the hypothesis records the late-AADJ + /// reason so downstream classifiers (WhiteGlovePart2CompletionClassifier in M3.4) can + /// factor it in. + /// + /// + private DecisionStep HandleAadUserJoinedLateV1(DecisionState state, DecisionSignal signal) + { + var withUser = signal.Payload != null && + signal.Payload.TryGetValue(SignalPayloadKeys.AadJoinedWithUser, out var raw) && + bool.TryParse(raw, out var parsed) && parsed; + + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + builder.AadJoinedWithUser = new SignalFact(withUser, signal.SessionSignalOrdinal); + + // Annotate the EnrollmentType hypothesis reason without bumping level up. + builder.EnrollmentType = state.EnrollmentType.With( + reason: $"late_aadj_observed:withUser={withUser.ToString().ToLowerInvariant()}", + lastUpdatedUtc: signal.OccurredAtUtc); + + var newState = builder.Build(); + + // Explicit "taken but stage unchanged" transition — the Inspector should see the + // hypothesis annotation but nobody should mistake it for a completion step. + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.AadUserJoinedLate)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + // ============================================================== internal helpers + + private static DecisionEffect BuildEnrollmentCompleteEffect() => + new DecisionEffect( + kind: DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary + { + ["eventType"] = "enrollment_complete", + }); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs new file mode 100644 index 00000000..c414e942 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // Edge-case handlers: hybrid reboot, ESP terminal failure, system reboot observation. + // Plan §2.5 / §M3.5. + public sealed partial class DecisionEngine + { + /// + /// Handle . Emitted by + /// EspAndHelloTracker after a reboot mid-ESP. The session's stage is preserved + /// (we were already somewhere in the ESP phase sequence), but the reboot fact is + /// recorded so downstream classifiers (WhiteGloveSealingClassifier) can factor it in. + /// + private DecisionStep HandleEspResumedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + // Record the reboot fact on first observation. Subsequent EspResumed signals + // keep the original timestamp so the hash over SystemRebootUtc is stable. + if (state.SystemRebootUtc == null) + { + builder.SystemRebootUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + } + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.EspResumed)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + /// + /// Handle . Directly transitions + /// to with , + /// clears deadlines, and emits enrollment_failed. No classifier re-run — + /// ESP terminal failure is definitive. + /// + private DecisionStep HandleEspTerminalFailureV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + + var reason = signal.Payload != null && signal.Payload.TryGetValue("reason", out var r) + ? r + : "esp_terminal_failure"; + + var builder = state.ToBuilder() + .WithStage(SessionStage.Failed) + .WithOutcome(SessionOutcome.EnrollmentFailed) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .ClearDeadlines(); + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.Failed, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.EspTerminalFailure)); + + var effects = new[] + { + new DecisionEffect( + DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary + { + ["eventType"] = "enrollment_failed", + ["reason"] = reason, + }), + }; + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Handle . Records the fact so + /// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight. + /// + private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + if (state.SystemRebootUtc == null) + { + builder.SystemRebootUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + } + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.SystemRebootObserved)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs new file mode 100644 index 00000000..26d17e67 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // SelfDeploying-v1 + Device-Only handlers. Plan §2.5 partial-class layout. + public sealed partial class DecisionEngine + { + // Plan §2.7: "5min timer after DeviceSetup; if no AccountSetup -> classified as device-only". + internal static readonly TimeSpan s_deviceOnlyEspDetectionWindow = TimeSpan.FromMinutes(5); + + /// + /// Hypothesis-reason tokens for . + /// Plan §2.3 values UserPresent / DeviceOnly / Ambiguous — expressed through the + /// field so we do not need a new enum. + /// + internal static class DeviceOnlyReasons + { + public const string UserPresent = "user_present"; + public const string DeviceOnly = "device_only"; + public const string Ambiguous = "ambiguous"; + } + + /// + /// Handle . + /// + /// The canonical SelfDeploying / Device-Only terminal signal. Cancels the + /// deadline if still armed, + /// finalizes the hypothesis based + /// on whether any user-presence fact was observed, and transitions the session to + /// with . + /// + /// + private DecisionStep HandleDeviceSetupProvisioningCompleteV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + + // Determine DeviceOnlyDeployment verdict. + var hasUserPresence = + (state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) || + state.HelloResolvedUtc != null || + state.DesktopArrivedUtc != null; + + var deviceOnlyReason = hasUserPresence + ? DeviceOnlyReasons.UserPresent + : DeviceOnlyReasons.DeviceOnly; + + var updatedHypothesis = state.DeviceOnlyDeployment.With( + level: HypothesisLevel.Confirmed, + reason: deviceOnlyReason, + score: 100, + lastUpdatedUtc: signal.OccurredAtUtc); + + var builder = state.ToBuilder() + .WithStage(SessionStage.Completed) + .WithOutcome(SessionOutcome.EnrollmentComplete) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .ClearDeadlines(); + builder.DeviceOnlyDeployment = updatedHypothesis; + + // Also strengthen EnrollmentType: observing DeviceSetupProvisioningComplete + // without preceding AccountSetup strongly implies SelfDeploying-v1. + if (state.AccountSetupEnteredUtc == null && + state.EnrollmentType.Level < HypothesisLevel.Strong) + { + builder.EnrollmentType = state.EnrollmentType.With( + level: HypothesisLevel.Strong, + reason: "selfdeploying_provisioning_complete", + score: 80, + lastUpdatedUtc: signal.OccurredAtUtc); + } + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.Completed, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete)); + + var effects = new[] { BuildEnrollmentCompleteEffect() }; + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Called by the shared DeadlineFired router when + /// elapses without an AccountSetup + /// phase having arrived. Confirms the + /// hypothesis as DeviceOnly but does NOT complete the session — the terminal still + /// needs an explicit . + /// + private DecisionStep HandleDeviceOnlyEspDetectionDeadlineFired(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.DeviceOnlyEspDetection); + + builder.DeviceOnlyDeployment = state.DeviceOnlyDeployment.With( + level: HypothesisLevel.Strong, + reason: DeviceOnlyReasons.DeviceOnly, + score: 70, + lastUpdatedUtc: signal.OccurredAtUtc); + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: $"DeadlineFired:{DeadlineNames.DeviceOnlyEspDetection}"); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + // ============================================================== internal helpers + + /// + /// Called from the Classic handler when + /// DeviceSetup is observed for the first time — arms the + /// deadline. The Classic handler + /// adds the deadline effect to its own effect list. + /// + internal ActiveDeadline BuildDeviceOnlyEspDetectionDeadline(DateTime fromUtc) => + new ActiveDeadline( + name: DeadlineNames.DeviceOnlyEspDetection, + dueAtUtc: fromUtc.Add(s_deviceOnlyEspDetectionWindow), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary + { + [SignalPayloadKeys.Deadline] = DeadlineNames.DeviceOnlyEspDetection, + }); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs new file mode 100644 index 00000000..d2a55719 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // Lifecycle + cross-scenario shared handlers. Plan §2.5 partial-class layout. + public sealed partial class DecisionEngine + { + /// + /// Handle . Plan §2.7 / §4.x M4.4.4. + /// + /// For a fresh session this is the first signal and the state already equals + /// . The handler still runs + /// through the pipeline so the start is recorded as a journal transition (step 0) — + /// this anchors the Inspector timeline. + /// + /// + /// Also arms the deadline up-front + /// (Plan §4.x M4.4 re-trigger-lücke fix): the legacy reactive arming in + /// AttachWhiteGloveClassifierEffects only fired on the first WG-relevant + /// signal, which meant non-WG or late-WG sessions never re-evaluated the classifier. + /// Arming from SessionStarted guarantees a periodic classifier pass; the existing + /// hasTick dedup in AttachWhiteGloveClassifierEffects makes the reactive + /// arm a no-op when a tick is already present. + /// + /// + /// If the engine sees SessionStarted on a state whose stage is already + /// something other than , we treat it as a + /// defensive no-op (dead-end) rather than silently reinitializing — replay of a + /// truncated log should fail visibly, not reset hard-won hypotheses. + /// + /// + private DecisionStep HandleSessionStartedV1(DecisionState state, DecisionSignal signal) + { + if (state.Stage != SessionStage.SessionStarted && state.StepIndex != 0) + { + var bookkeptDead = BumpStepBookkeeping(state, signal); + return new DecisionStep( + bookkeptDead, + BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: bookkeptDead.StepIndex, + trigger: nameof(DecisionSignalKind.SessionStarted), + deadEndReason: $"session_started_in_active_state:{state.Stage}"), + Array.Empty()); + } + + // Arm ClassifierTick up-front so the White-Glove classifier re-evaluates + // periodically from the very start of the session — Plan §4.x M4.4.4. + var classifierTick = BuildClassifierTickDeadline(signal.OccurredAtUtc); + + // Stage stays SessionStarted; this transition is the "we saw the start" anchor. + var newState = state.ToBuilder() + .WithStage(SessionStage.SessionStarted) + .WithStepIndex(state.StepIndex + 1) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .AddDeadline(classifierTick) + .Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.SessionStarted, + nextStepIndex: newState.StepIndex, + trigger: nameof(DecisionSignalKind.SessionStarted)); + + var effects = new DecisionEffect[] + { + new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: classifierTick), + }; + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Handle . Plan §2.7 sonder-case 1. + /// + /// In M3.0 scope this is a generic bookkeeping handler. The White-Glove Part-1 → + /// Part-2 post-reboot transition is implemented in DecisionEngine.WhiteGlovePart2.cs + /// (M3.4) and takes precedence there when the prior stage was + /// . + /// + /// + private DecisionStep HandleSessionRecoveredV1(DecisionState state, DecisionSignal signal) + { + // Plan §2.7 sonder-case 1: WhiteGlove Part 1 -> Reboot -> Part 2. + // If the recovered session was sealed, transition into the Part 2 awaiting-user + // stage and arm the 24h safety deadline. See DecisionEngine.WhiteGlovePart2.cs. + if (state.Stage == SessionStage.WhiteGloveSealed) + { + return HandleWhiteGlovePart1To2Bridge(state, signal); + } + + // Otherwise the recovered state is already mid-flight elsewhere; the handler is + // a neutral "observed a restart" step — stage unchanged, bookkeeping advanced. + var newState = BumpStepBookkeeping(state, signal); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: newState.StepIndex, + trigger: nameof(DecisionSignalKind.SessionRecovered)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + /// + /// Handle . + /// + /// Emitted by the orchestrator, never by a collector. Stage transitions to + /// with . + /// This is a terminal event; the orchestrator uses it to record admin-kill / + /// override actions cleanly without going through the regular completion paths + /// (plan §2.7 admin-action audit). + /// + /// + private DecisionStep HandleSessionAbortedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var newState = state.ToBuilder() + .WithStage(SessionStage.Failed) + .WithOutcome(SessionOutcome.Aborted) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .ClearDeadlines() + .Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.Failed, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.SessionAborted)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + // ============================================================== shared helpers + // Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers + // as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies + // grow with each sub-milestone. + + /// + /// Handle . Plan §2.6. + /// + /// The payload carries = the deadline name + /// (from ). The handler removes the corresponding + /// from state and dispatches to a deadline-specific body. + /// Deadlines for stages that don't yet exist in this sub-milestone (e.g. Part-2 + /// safety) land in the Unknown-Deadline path — they complete bookkeeping without + /// changing state, which lets M3.0 replay logs that contain future deadline names. + /// + /// + private DecisionStep HandleDeadlineFiredV1(DecisionState state, DecisionSignal signal) + { + var deadlineName = signal.Payload != null && signal.Payload.TryGetValue(SignalPayloadKeys.Deadline, out var n) + ? n + : null; + + if (string.IsNullOrEmpty(deadlineName)) + { + var bookkeptDead = BumpStepBookkeeping(state, signal); + return new DecisionStep( + bookkeptDead, + BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: bookkeptDead.StepIndex, + trigger: nameof(DecisionSignalKind.DeadlineFired), + deadEndReason: "deadline_fired_without_name"), + Array.Empty()); + } + + switch (deadlineName) + { + case DeadlineNames.HelloSafety: + return HandleHelloSafetyDeadlineFired(state, signal); + case DeadlineNames.DeviceOnlyEspDetection: + return HandleDeviceOnlyEspDetectionDeadlineFired(state, signal); + case DeadlineNames.ClassifierTick: + return HandleClassifierTickDeadlineFired(state, signal); + case DeadlineNames.WhiteGlovePart2Safety: + return HandleWhiteGlovePart2SafetyDeadlineFired(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 + // ClassifierTick, M3.4 for WhiteGlovePart2Safety, etc. + var nextStepIgnored = state.StepIndex + 1; + var cancelled = state.ToBuilder() + .WithStepIndex(nextStepIgnored) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(deadlineName!) + .Build(); + var transitionIgnored = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStepIgnored, + trigger: $"DeadlineFired:{deadlineName}"); + return new DecisionStep(cancelled, transitionIgnored, Array.Empty()); + } + } + + /// + /// Hello-safety deadline fired: the post-ESP grace window expired without a + /// . Treat as a Hello timeout — the + /// session completes with =Timeout + /// if Desktop has also arrived; otherwise we stay in + /// and the downstream DesktopArrived handler completes the session. + /// + private DecisionStep HandleHelloSafetyDeadlineFired(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.HelloSafety); + + // If Hello already resolved before the deadline fired (race), the fact is already + // set; don't overwrite it. Otherwise record the synthetic timeout. + if (state.HelloResolvedUtc == null) + { + builder.HelloResolvedUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + builder.HelloOutcome = new SignalFact("Timeout", signal.SessionSignalOrdinal); + } + + var desktopAlreadyArrived = state.DesktopArrivedUtc != null; + var toStage = desktopAlreadyArrived ? SessionStage.Completed : SessionStage.AwaitingDesktop; + builder.WithStage(toStage); + + if (desktopAlreadyArrived) + { + builder.WithOutcome(SessionOutcome.EnrollmentComplete); + builder.ClearDeadlines(); + } + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: toStage, + nextStepIndex: nextStep, + trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}"); + + DecisionEffect[] effects = desktopAlreadyArrived + ? new[] { BuildEnrollmentCompleteEffect() } + : Array.Empty(); + + return new DecisionStep(newState, transition, effects); + } + + // ============================================================== diagnostic signals + // Plan §4.x M4.4.3 — close the reducer-handler gap for signals that carry useful + // telemetry but do NOT influence the state machine. Previously these fell through to + // HandleUnhandledSignal, which wrote a DeadEnd transition every time — noise in the + // journal. Now they record as neutral taken transitions; full payload/evidence + // remains in the SignalLog for Inspector analysis. + + /// + /// Handle . Diagnostic-only — + /// carries hardware inventory in payload, does not drive stage or hypothesis. + /// + private DecisionStep HandleDeviceInfoCollectedV1(DecisionState state, DecisionSignal signal) => + RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.DeviceInfoCollected)); + + /// + /// Handle . Diagnostic-only — + /// carries Autopilot profile registry contents, does not drive stage or hypothesis. + /// + private DecisionStep HandleAutopilotProfileReadV1(DecisionState state, DecisionSignal signal) => + RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.AutopilotProfileRead)); + + private DecisionStep RecordDiagnosticObservation(DecisionState state, DecisionSignal signal, string trigger) + { + var newState = BumpStepBookkeeping(state, signal); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: newState.StepIndex, + trigger: trigger); + return new DecisionStep(newState, transition, Array.Empty()); + } + + /// + /// Determine the user-visible enrollment phase implied by an ESP phase-change signal. + /// Plan §2.3 phase-fact mapping. Populated in M3.1 as Classic handlers come online. + /// + internal static EnrollmentPhase MapEspPhaseToEnrollmentPhase(string rawPhase) + { + if (string.IsNullOrEmpty(rawPhase)) return EnrollmentPhase.Unknown; + return rawPhase switch + { + "DeviceSetup" => EnrollmentPhase.DeviceSetup, + "AccountSetup" => EnrollmentPhase.AccountSetup, + "FinalizingSetup" => EnrollmentPhase.FinalizingSetup, + "Finalizing" => EnrollmentPhase.FinalizingSetup, + "Complete" => EnrollmentPhase.Complete, + _ => EnrollmentPhase.Unknown, + }; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs new file mode 100644 index 00000000..ca7c01c5 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlove.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // WhiteGlove Part 1 handlers + classifier-verdict routing. Plan §2.5 / §2.4. + public sealed partial class DecisionEngine + { + // Plan §2.6 — ClassifierTick recurrence. 30 s is the legacy polling cadence. + internal static readonly TimeSpan s_classifierTickInterval = TimeSpan.FromSeconds(30); + + /// + /// Handle . Records the + /// fact and emits a + /// effect for the WhiteGlove-sealing + /// classifier. Also arms the deadline + /// if not already armed. + /// + private DecisionStep HandleWhiteGloveShellCoreSuccessV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + builder.ShellCoreWhiteGloveSuccessSeen = new SignalFact(true, signal.SessionSignalOrdinal); + + var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: newState.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.WhiteGloveShellCoreSuccess)); + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Handle . Signal- + /// correlated WhiteGlove path (IME-pattern based). Records the fact and emits a + /// effect. + /// + private DecisionStep HandleWhiteGloveSealingPatternDetectedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + builder.WhiteGloveSealingPatternSeen = new SignalFact(true, signal.SessionSignalOrdinal); + + var (newState, effects) = AttachWhiteGloveClassifierEffects(builder.Build(), signal); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: newState.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.WhiteGloveSealingPatternDetected)); + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Handle . Produced by the + /// effect runner after it executed a + /// effect. The payload carries the verdict; the handler updates + /// (plan §2.3 hypothesis) and, on + /// , transitions to + /// + + /// and emits the whiteglove_complete event. + /// + private DecisionStep HandleClassifierVerdictIssuedV1(DecisionState state, DecisionSignal signal) + { + var p = signal.Payload ?? new Dictionary(); + var classifier = GetPayload(p, "classifier", "unknown"); + var levelRaw = GetPayload(p, "level", HypothesisLevel.Unknown.ToString()); + var scoreRaw = GetPayload(p, "score", "0"); + var reason = GetPayload(p, "reason", classifier); + var inputHash = GetPayload(p, "inputHash", string.Empty); + + if (!Enum.TryParse(levelRaw, ignoreCase: true, out var level)) + { + level = HypothesisLevel.Unknown; + } + if (!int.TryParse(scoreRaw, out var score)) + { + score = 0; + } + + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + // M3.4 Part-2 classifier runs through its own applier. + if (classifier == WhiteGlovePart2CompletionClassifier.ClassifierId) + { + return ApplyWhiteGlovePart2Verdict(state, signal, level, score, reason, inputHash); + } + + if (classifier == WhiteGloveSealingClassifier.ClassifierId) + { + builder.WhiteGloveSealing = state.WhiteGloveSealing.With( + level: level, + reason: reason, + score: score, + lastUpdatedUtc: signal.OccurredAtUtc, + lastClassifierVerdictId: inputHash); + + if (level == HypothesisLevel.Confirmed) + { + builder + .WithStage(SessionStage.WhiteGloveSealed) + .WithOutcome(SessionOutcome.WhiteGlovePart1Sealed) + .ClearDeadlines(); + + var sealedState = builder.Build(); + var sealedTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.WhiteGloveSealed, + nextStepIndex: nextStep, + trigger: $"ClassifierVerdictIssued:{classifier}:Confirmed"); + + var sealedEffects = new[] + { + new DecisionEffect( + DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary { ["eventType"] = "whiteglove_complete" }), + }; + + return new DecisionStep(sealedState, sealedTransition, sealedEffects); + } + } + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: $"ClassifierVerdictIssued:{classifier}:{level}"); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + // ============================================================ classifier-tick support + + /// + /// Classifier-tick deadline fired. Re-evaluates the WhiteGlove classifier with the + /// current snapshot (picks up late-arriving facts the original handlers did not see) + /// and re-arms itself unless a terminal stage has been reached. + /// + private DecisionStep HandleClassifierTickDeadlineFired(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.ClassifierTick); + + var effects = new List + { + BuildRunClassifierEffect(state), + }; + + var reachedTerminal = state.Stage == SessionStage.Completed + || state.Stage == SessionStage.Failed + || state.Stage == SessionStage.WhiteGloveSealed + || state.Stage == SessionStage.WhiteGloveCompletedPart2; + + if (!reachedTerminal) + { + var rearm = BuildClassifierTickDeadline(signal.OccurredAtUtc); + builder.AddDeadline(rearm); + effects.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: rearm)); + } + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: $"DeadlineFired:{DeadlineNames.ClassifierTick}"); + + return new DecisionStep(newState, transition, effects.ToArray()); + } + + // ============================================================ internal helpers + + /// + /// Attach WhiteGlove-classifier effects (RunClassifier + optional ClassifierTick arm) + /// to a just-built state. Shared between + /// and . + /// + private (DecisionState NewState, DecisionEffect[] Effects) AttachWhiteGloveClassifierEffects( + DecisionState state, + DecisionSignal triggerSignal) + { + var effects = new List { BuildRunClassifierEffect(state) }; + + // Arm ClassifierTick on first WG-relevant signal if not already scheduled. + var hasTick = false; + foreach (var d in state.Deadlines) + { + if (d.Name == DeadlineNames.ClassifierTick) { hasTick = true; break; } + } + + if (!hasTick) + { + var tick = BuildClassifierTickDeadline(triggerSignal.OccurredAtUtc); + state = state.ToBuilder().AddDeadline(tick).Build(); + effects.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: tick)); + } + + return (state, effects.ToArray()); + } + + private static DecisionEffect BuildRunClassifierEffect(DecisionState state) => + new DecisionEffect( + kind: DecisionEffectKind.RunClassifier, + classifierId: WhiteGloveSealingClassifier.ClassifierId, + classifierSnapshot: BuildWhiteGloveSealingSnapshot(state)); + + private static ActiveDeadline BuildClassifierTickDeadline(DateTime fromUtc) => + new ActiveDeadline( + name: DeadlineNames.ClassifierTick, + dueAtUtc: fromUtc.Add(s_classifierTickInterval), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary + { + [SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick, + }); + + /// + /// Build a from the current . + /// Exposed to the effect runner so the verdict snapshot carried with the + /// effect is deterministic from state. + /// + internal static WhiteGloveSealingSnapshot BuildWhiteGloveSealingSnapshot(DecisionState state) => + new WhiteGloveSealingSnapshot( + shellCoreWhiteGloveSuccessSeen: state.ShellCoreWhiteGloveSuccessSeen?.Value == true, + whiteGloveSealingPatternSeen: state.WhiteGloveSealingPatternSeen?.Value == true, + aadJoinedWithUser: state.AadJoinedWithUser?.Value == true, + desktopArrived: state.DesktopArrivedUtc != null, + helloResolved: state.HelloResolvedUtc != null, + hasAccountSetupActivity: state.AccountSetupEnteredUtc != null, + isDeviceOnlyDeploymentHypothesis: + state.DeviceOnlyDeployment.Level >= HypothesisLevel.Strong && + state.DeviceOnlyDeployment.Reason == DeviceOnlyReasons.DeviceOnly, + systemRebootUtc: state.SystemRebootUtc?.Value, + currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value); + + private static string GetPayload(IReadOnlyDictionary p, string key, string fallback) => + p.TryGetValue(key, out var v) ? v : fallback; + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs new file mode 100644 index 00000000..585b8043 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // WhiteGlove Part 2 (post-reboot user sign-in) handlers. Plan §2.5 / §M3.4. + public sealed partial class DecisionEngine + { + // Plan §2.3: "Part-2-Safety-Timeout (z.B. 24h nach Reboot)" watches for the user + // never signing in. Firing yields Outcome=EnrollmentFailed reason=part2_user_absent. + internal static readonly TimeSpan s_whiteGlovePart2SafetyWindow = TimeSpan.FromHours(24); + + /// + /// WhiteGlove Part 1 → Part 2 bridge. Called from + /// when the recovered state is . Transitions + /// into and arms the Part-2 + /// safety deadline. + /// + private DecisionStep HandleWhiteGlovePart1To2Bridge(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var safety = new ActiveDeadline( + name: DeadlineNames.WhiteGlovePart2Safety, + dueAtUtc: signal.OccurredAtUtc.Add(s_whiteGlovePart2SafetyWindow), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary + { + [SignalPayloadKeys.Deadline] = DeadlineNames.WhiteGlovePart2Safety, + }); + + var builder = state.ToBuilder() + .WithStage(SessionStage.WhiteGloveAwaitingUserSignIn) + .WithOutcome(null) // leaving the Part-1 Sealed outcome aside until Part-2 concludes. + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .AddDeadline(safety); + builder.SystemRebootUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.WhiteGloveAwaitingUserSignIn, + nextStepIndex: nextStep, + trigger: "SessionRecovered:WhiteGloveSealed->AwaitingUserSignIn"); + + var effects = new[] + { + new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety), + }; + + return new DecisionStep(newState, transition, effects); + } + + private DecisionStep HandleUserAadSignInCompleteV1(DecisionState state, DecisionSignal signal) + { + return UpdatePart2Fact( + state, + signal, + applyFact: b => { b.UserAadSignInCompleteUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); }, + trigger: nameof(DecisionSignalKind.UserAadSignInComplete)); + } + + private DecisionStep HandleHelloResolvedPart2V1(DecisionState state, DecisionSignal signal) + { + return UpdatePart2Fact( + state, + signal, + applyFact: b => { b.HelloResolvedPart2Utc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); }, + trigger: nameof(DecisionSignalKind.HelloResolvedPart2)); + } + + private DecisionStep HandleDesktopArrivedPart2V1(DecisionState state, DecisionSignal signal) + { + return UpdatePart2Fact( + state, + signal, + applyFact: b => { b.DesktopArrivedPart2Utc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); }, + trigger: nameof(DecisionSignalKind.DesktopArrivedPart2)); + } + + private DecisionStep HandleAccountSetupCompletedPart2V1(DecisionState state, DecisionSignal signal) + { + return UpdatePart2Fact( + state, + signal, + applyFact: b => { b.AccountSetupCompletedPart2Utc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); }, + trigger: nameof(DecisionSignalKind.AccountSetupCompletedPart2)); + } + + /// + /// Part-2 safety deadline fired — user never signed in within 24 h. Terminates the + /// session as with reason + /// part2_user_absent. + /// + private DecisionStep HandleWhiteGlovePart2SafetyDeadlineFired(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + + var builder = state.ToBuilder() + .WithStage(SessionStage.Failed) + .WithOutcome(SessionOutcome.EnrollmentFailed) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .ClearDeadlines(); + + builder.WhiteGlovePart2Completion = state.WhiteGlovePart2Completion.With( + level: HypothesisLevel.Rejected, + reason: "part2_user_absent", + score: 0, + lastUpdatedUtc: signal.OccurredAtUtc); + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.Failed, + nextStepIndex: nextStep, + trigger: $"DeadlineFired:{DeadlineNames.WhiteGlovePart2Safety}"); + + var effects = new[] + { + new DecisionEffect( + DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary + { + ["eventType"] = "enrollment_failed", + ["reason"] = "part2_user_absent", + }), + }; + + return new DecisionStep(newState, transition, effects); + } + + // ============================================================ internal helpers + + private DecisionStep UpdatePart2Fact( + DecisionState state, + DecisionSignal signal, + Action applyFact, + string trigger) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + applyFact(builder); + + // Part-2 only matters once we're in the WhiteGloveAwaitingUserSignIn stage; + // if signals arrive on other stages, still record the fact but do not emit + // a Part-2 classifier run. + var emitClassifier = + state.Stage == SessionStage.WhiteGloveAwaitingUserSignIn + || state.Stage == SessionStage.WhiteGloveCompletedPart2; + + var stateAfterFact = builder.Build(); + + var effects = emitClassifier + ? new[] { BuildRunPart2ClassifierEffect(stateAfterFact) } + : Array.Empty(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: stateAfterFact.Stage, + nextStepIndex: nextStep, + trigger: trigger); + + return new DecisionStep(stateAfterFact, transition, effects); + } + + private static DecisionEffect BuildRunPart2ClassifierEffect(DecisionState state) => + new DecisionEffect( + kind: DecisionEffectKind.RunClassifier, + classifierId: WhiteGlovePart2CompletionClassifier.ClassifierId, + classifierSnapshot: BuildWhiteGlovePart2CompletionSnapshot(state)); + + internal static WhiteGlovePart2CompletionSnapshot BuildWhiteGlovePart2CompletionSnapshot(DecisionState state) => + new WhiteGlovePart2CompletionSnapshot( + userAadSignInComplete: state.UserAadSignInCompleteUtc != null, + helloResolvedPart2: state.HelloResolvedPart2Utc != null, + desktopArrivedPart2: state.DesktopArrivedPart2Utc != null, + accountSetupCompletedPart2: state.AccountSetupCompletedPart2Utc != null, + currentEnrollmentPhase: state.CurrentEnrollmentPhase?.Value, + systemRebootUtc: state.SystemRebootUtc?.Value); + + /// + /// Apply a Part-2 classifier verdict to . + /// Called from the extended router when + /// the verdict's classifier id is the Part-2 completion classifier. On Confirmed, + /// transitions to + Outcome + /// and emits the + /// whiteglove_part2_complete event. + /// + private DecisionStep ApplyWhiteGlovePart2Verdict( + DecisionState state, + DecisionSignal signal, + HypothesisLevel level, + int score, + string reason, + string inputHash) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + builder.WhiteGlovePart2Completion = state.WhiteGlovePart2Completion.With( + level: level, + reason: reason, + score: score, + lastUpdatedUtc: signal.OccurredAtUtc, + lastClassifierVerdictId: inputHash); + + if (level == HypothesisLevel.Confirmed) + { + builder + .WithStage(SessionStage.WhiteGloveCompletedPart2) + .WithOutcome(SessionOutcome.WhiteGlovePart2Complete) + .ClearDeadlines(); + + var sealedState = builder.Build(); + var sealedTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.WhiteGloveCompletedPart2, + nextStepIndex: nextStep, + trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:Confirmed"); + + var effects = new[] + { + new DecisionEffect( + DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary { ["eventType"] = "whiteglove_part2_complete" }), + }; + + return new DecisionStep(sealedState, sealedTransition, effects); + } + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: $"ClassifierVerdictIssued:{WhiteGlovePart2CompletionClassifier.ClassifierId}:{level}"); + + return new DecisionStep(newState, transition, Array.Empty()); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs new file mode 100644 index 00000000..72c62d73 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Decision Engine kernel. Plan §2.5 / L.2 / L.3. + /// + /// Dispatches on (, ) + /// to partial-class handlers. All handlers live in sibling files named + /// DecisionEngine.Classic.cs, DecisionEngine.SelfDeploying.cs, + /// DecisionEngine.WhiteGlove.cs, DecisionEngine.WhiteGlovePart2.cs, + /// DecisionEngine.Shared.cs. + /// + /// + /// Exception fail-safe (L.16): a handler exception never corrupts state; the kernel + /// catches it, advances , applies the signal ordinal, + /// and emits a dead-end tagged + /// DeadEndReason="reducer_exception". + /// + /// + public sealed partial class DecisionEngine : IDecisionEngine + { + private static readonly string s_reducerVersion = + typeof(DecisionEngine).GetTypeInfo().Assembly.GetName().Version?.ToString() ?? "0.0.0.0"; + + public string ReducerVersion => s_reducerVersion; + + public DecisionStep Reduce(DecisionState oldState, DecisionSignal signal) + { + if (oldState == null) throw new ArgumentNullException(nameof(oldState)); + if (signal == null) throw new ArgumentNullException(nameof(signal)); + + try + { + return Dispatch(oldState, signal); + } + catch (Exception ex) + { + return BuildReducerExceptionStep(oldState, signal, ex); + } + } + + /// + /// Pure dispatch on (Kind, SchemaVersion) — no mutation here, no try/catch + /// (outer provides the fail-safe wrapper). Handlers return fully + /// constructed instances. + /// + private DecisionStep Dispatch(DecisionState state, DecisionSignal signal) + { + return (signal.Kind, signal.KindSchemaVersion) switch + { + // ----- Lifecycle (DecisionEngine.Shared.cs) ----- + (DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal), + (DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal), + (DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal), + (DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal), + + // ----- Classic UserDriven-v1 (DecisionEngine.Classic.cs) ----- + (DecisionSignalKind.EspPhaseChanged, 1) => HandleEspPhaseChangedV1(state, signal), + (DecisionSignalKind.EspExiting, 1) => HandleEspExitingV1(state, signal), + (DecisionSignalKind.HelloResolved, 1) => HandleHelloResolvedV1(state, signal), + (DecisionSignalKind.DesktopArrived, 1) => HandleDesktopArrivedV1(state, signal), + (DecisionSignalKind.ImeUserSessionCompleted, 1) => HandleImeUserSessionCompletedV1(state, signal), + (DecisionSignalKind.AadUserJoinedLate, 1) => HandleAadUserJoinedLateV1(state, signal), + + // ----- SelfDeploying + Device-Only (DecisionEngine.SelfDeploying.cs) ----- + (DecisionSignalKind.DeviceSetupProvisioningComplete, 1) => HandleDeviceSetupProvisioningCompleteV1(state, signal), + + // ----- WhiteGlove Part 1 (DecisionEngine.WhiteGlove.cs) ----- + (DecisionSignalKind.WhiteGloveShellCoreSuccess, 1) => HandleWhiteGloveShellCoreSuccessV1(state, signal), + (DecisionSignalKind.WhiteGloveSealingPatternDetected, 1) => HandleWhiteGloveSealingPatternDetectedV1(state, signal), + (DecisionSignalKind.ClassifierVerdictIssued, 1) => HandleClassifierVerdictIssuedV1(state, signal), + + // ----- WhiteGlove Part 2 (DecisionEngine.WhiteGlovePart2.cs) ----- + (DecisionSignalKind.UserAadSignInComplete, 1) => HandleUserAadSignInCompleteV1(state, signal), + (DecisionSignalKind.HelloResolvedPart2, 1) => HandleHelloResolvedPart2V1(state, signal), + (DecisionSignalKind.DesktopArrivedPart2, 1) => HandleDesktopArrivedPart2V1(state, signal), + (DecisionSignalKind.AccountSetupCompletedPart2, 1) => HandleAccountSetupCompletedPart2V1(state, signal), + + // ----- Edge (DecisionEngine.Edge.cs) ----- + (DecisionSignalKind.EspResumed, 1) => HandleEspResumedV1(state, signal), + (DecisionSignalKind.EspTerminalFailure, 1) => HandleEspTerminalFailureV1(state, signal), + (DecisionSignalKind.SystemRebootObserved, 1) => HandleSystemRebootObservedV1(state, signal), + + // ----- Diagnostic observations (DecisionEngine.Shared.cs) — Plan §4.x M4.4.3 ----- + (DecisionSignalKind.DeviceInfoCollected, 1) => HandleDeviceInfoCollectedV1(state, signal), + (DecisionSignalKind.AutopilotProfileRead, 1) => HandleAutopilotProfileReadV1(state, signal), + + // ----- Still unwired (AppInstall — v11.1+) ----- + _ => HandleUnhandledSignal(state, signal), + }; + } + + // ================================================================== helpers + + /// + /// Advance StepIndex / LastAppliedSignalOrdinal without touching the engine-visible + /// stage or facts. Reducer handlers call this when they record a step but don't + /// progress the state machine (e.g. hypothesis-only update, or dead-end). + /// + internal DecisionState BumpStepBookkeeping(DecisionState state, DecisionSignal signal) => + state.ToBuilder() + .WithStepIndex(state.StepIndex + 1) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .Build(); + + /// + /// Build a that records a state-changing step. Guards + /// default to empty (for handler-initiated changes that aren't guard-driven); callers + /// pass explicit guard evaluations when they matter to the Inspector. + /// + internal DecisionTransition BuildTakenTransition( + DecisionState before, + DecisionSignal signal, + SessionStage toStage, + int nextStepIndex, + string trigger, + IReadOnlyList? guards = null) + { + return new DecisionTransition( + stepIndex: nextStepIndex, + sessionTraceOrdinal: signal.SessionTraceOrdinal, + signalOrdinalRef: signal.SessionSignalOrdinal, + occurredAtUtc: signal.OccurredAtUtc, + trigger: trigger, + fromStage: before.Stage, + toStage: toStage, + taken: true, + deadEndReason: null, + reducerVersion: ReducerVersion, + guards: guards, + emittedEventSequences: null, + classifierVerdict: null, + errorMessage: null, + stackTraceHash: null); + } + + internal DecisionTransition BuildDeadEndTransition( + DecisionState state, + DecisionSignal signal, + int nextStepIndex, + string trigger, + string deadEndReason, + IReadOnlyList? guards = null) + { + return new DecisionTransition( + stepIndex: nextStepIndex, + sessionTraceOrdinal: signal.SessionTraceOrdinal, + signalOrdinalRef: signal.SessionSignalOrdinal, + occurredAtUtc: signal.OccurredAtUtc, + trigger: trigger, + fromStage: state.Stage, + toStage: state.Stage, + taken: false, + deadEndReason: deadEndReason, + reducerVersion: ReducerVersion, + guards: guards, + emittedEventSequences: null, + classifierVerdict: null, + errorMessage: null, + stackTraceHash: null); + } + + /// + /// Plan §2.5 L.16 — exception fail-safe. State is unchanged (no builder copy); only + /// bookkeeping (StepIndex, LastAppliedSignalOrdinal) advances so the signal counts as + /// processed. holds the original message + /// and a short SHA256 of the trace — + /// enough for an Inspector to link recurring-exception rows without dumping full PII. + /// + private DecisionStep BuildReducerExceptionStep( + DecisionState state, + DecisionSignal signal, + Exception exception) + { + var bookkept = BumpStepBookkeeping(state, signal); + var transition = new DecisionTransition( + stepIndex: bookkept.StepIndex, + sessionTraceOrdinal: signal.SessionTraceOrdinal, + signalOrdinalRef: signal.SessionSignalOrdinal, + occurredAtUtc: signal.OccurredAtUtc, + trigger: signal.Kind.ToString(), + fromStage: state.Stage, + toStage: state.Stage, + taken: false, + deadEndReason: "reducer_exception", + reducerVersion: ReducerVersion, + guards: null, + emittedEventSequences: null, + classifierVerdict: null, + errorMessage: exception.Message, + stackTraceHash: HashStackTrace(exception)); + + return new DecisionStep(bookkept, transition, Array.Empty()); + } + + private DecisionStep HandleUnhandledSignal(DecisionState state, DecisionSignal signal) + { + var bookkept = BumpStepBookkeeping(state, signal); + var transition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: bookkept.StepIndex, + trigger: signal.Kind.ToString(), + deadEndReason: $"unhandled_signal_kind:{signal.Kind}:v{signal.KindSchemaVersion}"); + + return new DecisionStep(bookkept, transition, Array.Empty()); + } + + private static string HashStackTrace(Exception exception) + { + var raw = exception.StackTrace ?? exception.GetType().FullName ?? "unknown"; + using (var sha = SHA256.Create()) + { + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(raw)); + var sb = new StringBuilder(16); + for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2")); + return sb.ToString(); + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStep.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStep.cs new file mode 100644 index 00000000..a2e3749c --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStep.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Return value of a single reducer call. Plan §2.3 contract: + /// (newState, transition, effects) = engine.Reduce(oldState, signal). + /// + public sealed class DecisionStep + { + public DecisionStep( + DecisionState newState, + DecisionTransition transition, + IReadOnlyList effects) + { + NewState = newState ?? throw new ArgumentNullException(nameof(newState)); + Transition = transition ?? throw new ArgumentNullException(nameof(transition)); + Effects = effects ?? throw new ArgumentNullException(nameof(effects)); + } + + public DecisionState NewState { get; } + + public DecisionTransition Transition { get; } + + public IReadOnlyList Effects { get; } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionTransition.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionTransition.cs new file mode 100644 index 00000000..d4ec8d5f --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionTransition.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Immutable journal record for a single reducer step. Plan §2.8. + /// + /// Taken=true: a state-changing transition. Taken=false: a dead-end + /// (trigger arrived, at least one guard failed — recorded so the Inspector can show the + /// blocked path in the decision graph, §2.9). + /// + /// + /// Cross-type correlation: links to the triggering signal, + /// lists Event.Sequence values of any timeline events + /// this step emitted, and is the session-wide monotonic + /// counter shared across Event / Signal / Transition (plan §2.2 correlation IDs). + /// + /// + public sealed class DecisionTransition + { + public DecisionTransition( + int stepIndex, + long sessionTraceOrdinal, + long signalOrdinalRef, + DateTime occurredAtUtc, + string trigger, + SessionStage fromStage, + SessionStage toStage, + bool taken, + string? deadEndReason, + string reducerVersion, + IReadOnlyList? guards = null, + IReadOnlyList? emittedEventSequences = null, + ClassifierVerdict? classifierVerdict = null, + string? errorMessage = null, + string? stackTraceHash = null) + { + if (stepIndex < 0) + { + throw new ArgumentOutOfRangeException( + nameof(stepIndex), + "StepIndex must be non-negative."); + } + + if (string.IsNullOrEmpty(trigger)) + { + throw new ArgumentException("Trigger is mandatory.", nameof(trigger)); + } + + if (string.IsNullOrEmpty(reducerVersion)) + { + throw new ArgumentException("ReducerVersion is mandatory.", nameof(reducerVersion)); + } + + StepIndex = stepIndex; + SessionTraceOrdinal = sessionTraceOrdinal; + SignalOrdinalRef = signalOrdinalRef; + OccurredAtUtc = occurredAtUtc; + Trigger = trigger; + FromStage = fromStage; + ToStage = toStage; + Taken = taken; + DeadEndReason = deadEndReason; + ReducerVersion = reducerVersion; + Guards = guards ?? Array.Empty(); + EmittedEventSequences = emittedEventSequences ?? Array.Empty(); + ClassifierVerdict = classifierVerdict; + ErrorMessage = errorMessage; + StackTraceHash = stackTraceHash; + } + + public int StepIndex { get; } + + public long SessionTraceOrdinal { get; } + + /// Plan §2.2 cross-reference — which signal caused this transition. + public long SignalOrdinalRef { get; } + + public DateTime OccurredAtUtc { get; } + + public string Trigger { get; } + + public SessionStage FromStage { get; } + + public SessionStage ToStage { get; } + + public bool Taken { get; } + + /// + /// Non-null when is false. Plan §2.5 also uses well-known reasons: + /// "reducer_exception", "effect_failure", "hybrid_reboot_gate_blocking", … + /// + public string? DeadEndReason { get; } + + public string ReducerVersion { get; } + + public IReadOnlyList Guards { get; } + + /// Plan §2.2 cross-reference — Event.Sequence values this transition emitted as effects. + public IReadOnlyList EmittedEventSequences { get; } + + public ClassifierVerdict? ClassifierVerdict { get; } + + /// Set when == "reducer_exception" (plan §2.5 fail-safe). + public string? ErrorMessage { get; } + + /// SHA256-short hash of the original stack trace. Prevents unbounded PII in the journal. + public string? StackTraceHash { get; } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/GuardReport.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/GuardReport.cs new file mode 100644 index 00000000..675113d5 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/GuardReport.cs @@ -0,0 +1,31 @@ +using System; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Guard-evaluation record emitted during a reducer step. Plan §2.5 / §2.8 (Journal + /// GuardsJson). A taken transition may have zero or more guard evaluations; + /// a dead-end transition has at least one Passed=false guard that explains + /// the rejection. + /// + public sealed class GuardReport + { + public GuardReport(string guardId, bool passed, string? reason = null) + { + if (string.IsNullOrEmpty(guardId)) + { + throw new ArgumentException("GuardId is mandatory.", nameof(guardId)); + } + + GuardId = guardId; + Passed = passed; + Reason = reason; + } + + public string GuardId { get; } + + public bool Passed { get; } + + public string? Reason { get; } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/IClock.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IClock.cs new file mode 100644 index 00000000..fed19cf0 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IClock.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Pluggable clock abstraction. Plan §2.6 L.7. + /// + /// Time is consumed exclusively through this interface by every time-dependent + /// component in DecisionCore and Agent.V2.Core (Deadline-Scheduler, + /// ClassifierTick-Timer, recovery logic, …). Agent runtime uses ; + /// the replay harness uses VirtualClock so 420-second timeouts run in + /// milliseconds and are deterministic. + /// + /// + public interface IClock + { + /// Current UTC time. + DateTime UtcNow { get; } + + /// Async delay; virtualized in tests. + Task Delay(TimeSpan delay, CancellationToken cancellationToken = default); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/IDecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IDecisionEngine.cs new file mode 100644 index 00000000..fab6ab1f --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IDecisionEngine.cs @@ -0,0 +1,26 @@ +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Pure reducer interface. Plan §2.5 / L.2 / L.3. + /// + /// Contract: must be treated as immutable and is never + /// modified. The returned is a fresh instance. + /// Signal-log determinism (L.5): the same persisted signal stream always produces the + /// same journal and the same terminal outcome. + /// + /// + public interface IDecisionEngine + { + /// ReducerVersion stamp written into each journal transition. Drift-detection in §2.10. + string ReducerVersion { get; } + + /// + /// Apply a single signal to the state and return (newState, transition, effects). + /// Stubbed in M1; full handler dispatch (partial classes §2.5) comes online in M3. + /// + DecisionStep Reduce(DecisionState oldState, DecisionSignal signal); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/SystemClock.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/SystemClock.cs new file mode 100644 index 00000000..6d6ecba1 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/SystemClock.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Default implementation for the agent runtime — wraps + /// and . + /// Tests must use a virtual clock implementation, never this one. + /// + public sealed class SystemClock : IClock + { + public static readonly SystemClock Instance = new SystemClock(); + + public DateTime UtcNow => DateTime.UtcNow; + + public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default) => + Task.Delay(delay, cancellationToken); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs new file mode 100644 index 00000000..de7e539d --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs @@ -0,0 +1,57 @@ +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.DecisionCore.Serialization +{ + /// + /// Centralized factory for DecisionCore DTOs. + /// Plan §2.8 / §2.15 L.14. + /// + /// Produces a instance with: + /// + /// All DecisionCore enums registered via + /// so unknown values land on Unknown instead of throwing. + /// — no polymorphism loopholes over the wire. + /// — preserve nullable fact/hypothesis fields. + /// UTC round-trip for DateTime — , + /// ISO-8601 format. + /// + /// + /// + public static class DecisionCoreJsonSettings + { + /// + /// Create a fresh settings instance. Not a static singleton — Newtonsoft mutates + /// internal state during serialization (binder caches, etc.), so handing out + /// shared instances across threads is a well-known footgun. + /// + public static JsonSerializerSettings Create() + { + var settings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.None, + NullValueHandling = NullValueHandling.Include, + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + DateParseHandling = DateParseHandling.DateTime, + Formatting = Formatting.None, + MissingMemberHandling = MissingMemberHandling.Ignore, + }; + + // Enum converters with Unknown-fallback — plan §2.15 L.14. + settings.Converters.Add(new UnknownFallbackEnumConverter(DecisionSignalKind.SessionStarted)); + // ^ SessionStarted is a neutral fallback; unknown signal kinds should be rare (SchemaVersion gate), + // and when they do occur we prefer a benign no-op over a crash. + settings.Converters.Add(new UnknownFallbackEnumConverter(EvidenceKind.Synthetic)); + settings.Converters.Add(new UnknownFallbackEnumConverter(HypothesisLevel.Unknown)); + settings.Converters.Add(new UnknownFallbackEnumConverter(SessionStage.Unknown)); + settings.Converters.Add(new UnknownFallbackEnumConverter(SessionOutcome.Unknown)); + settings.Converters.Add(new UnknownFallbackEnumConverter(DecisionEffectKind.PersistSnapshot)); + settings.Converters.Add(new UnknownFallbackEnumConverter(EnrollmentPhase.Unknown)); + + return settings; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs new file mode 100644 index 00000000..14990a02 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.DecisionCore.Serialization +{ + /// + /// Serialize / deserialize JSONL records. + /// Plan §2.7 / §4 M2 harness integration. + /// + /// Uses Newtonsoft.Json with . The reader + /// tolerates missing optional fields but enforces the evidence-kind mandatory-field + /// contract (plan §2.2) — a line that would fail new Evidence(...) also fails + /// here with the same . + /// + /// + public static class SignalSerializer + { + public static string Serialize(DecisionSignal signal) + { + if (signal == null) throw new ArgumentNullException(nameof(signal)); + return JsonConvert.SerializeObject(signal, DecisionCoreJsonSettings.Create()); + } + + public static DecisionSignal Deserialize(string line) + { + if (line == null) throw new ArgumentNullException(nameof(line)); + + var settings = DecisionCoreJsonSettings.Create(); + var obj = JsonConvert.DeserializeObject(line, settings) + ?? throw new JsonReaderException($"Failed to parse line as JSON object: {line}"); + + // Evidence is required. + var evidenceTok = obj["Evidence"] ?? throw new JsonSerializationException("Signal is missing Evidence block."); + var evidenceKind = evidenceTok.Value("Kind") ?? throw new JsonSerializationException("Evidence.Kind missing."); + var evidenceIdentifier = evidenceTok.Value("Identifier") + ?? throw new JsonSerializationException("Evidence.Identifier missing."); + var evidenceSummary = evidenceTok.Value("Summary") ?? string.Empty; + var rawPointer = evidenceTok.Value("RawPointer"); + + IReadOnlyDictionary? derivationInputs = null; + if (evidenceTok["DerivationInputs"] is JObject diObj) + { + var d = new Dictionary(); + foreach (var prop in diObj.Properties()) + { + d[prop.Name] = prop.Value?.ToString() ?? string.Empty; + } + derivationInputs = d; + } + + var evidence = new Evidence( + kind: ParseEnum(evidenceKind, EvidenceKind.Synthetic), + identifier: evidenceIdentifier, + summary: evidenceSummary, + rawPointer: rawPointer, + derivationInputs: derivationInputs); + + IReadOnlyDictionary? payload = null; + if (obj["Payload"] is JObject payloadObj) + { + var p = new Dictionary(); + foreach (var prop in payloadObj.Properties()) + { + p[prop.Name] = prop.Value?.ToString() ?? string.Empty; + } + payload = p; + } + + return new DecisionSignal( + sessionSignalOrdinal: obj.Value("SessionSignalOrdinal"), + sessionTraceOrdinal: obj.Value("SessionTraceOrdinal"), + kind: ParseEnum(obj.Value("Kind"), DecisionSignalKind.SessionStarted), + kindSchemaVersion: obj.Value("KindSchemaVersion") ?? 1, + occurredAtUtc: obj.Value("OccurredAtUtc"), + sourceOrigin: obj.Value("SourceOrigin") ?? "unknown", + evidence: evidence, + payload: payload); + } + + private static T ParseEnum(string? raw, T fallback) where T : struct, Enum + { + if (string.IsNullOrEmpty(raw)) return fallback; + return Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed) + ? parsed + : fallback; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/StateSerializer.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/StateSerializer.cs new file mode 100644 index 00000000..2df2d2c9 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/StateSerializer.cs @@ -0,0 +1,46 @@ +using System; +using AutopilotMonitor.DecisionCore.State; +using Newtonsoft.Json; + +namespace AutopilotMonitor.DecisionCore.Serialization +{ + /// + /// Serialize / deserialize snapshots. + /// Plan §2.7 (snapshot.json) — fast-start optimization, jederzeit verwerfbar bei + /// Checksum-Mismatch (§2.7c: Snapshot ist Cache, SignalLog ist Wahrheit). + /// + /// Newtonsoft binds JSON properties onto the single public constructor via + /// case-insensitive parameter matching. Hypothesen-Instanzen, SignalFact<T>-Werte + /// und ActiveDeadline-Listen werden automatisch rekursiv deserialisiert, weil ihre + /// ctor-Signaturen zu den Property-Namen passen. + /// + /// + public static class StateSerializer + { + public static string Serialize(DecisionState state) + { + if (state == null) throw new ArgumentNullException(nameof(state)); + return JsonConvert.SerializeObject(state, DecisionCoreJsonSettings.Create()); + } + + public static DecisionState Deserialize(string payload) + { + if (payload == null) throw new ArgumentNullException(nameof(payload)); + + try + { + var result = JsonConvert.DeserializeObject(payload, DecisionCoreJsonSettings.Create()); + if (result == null) + { + throw new JsonSerializationException("State deserialization produced null."); + } + return result; + } + catch (Exception ex) when (ex is ArgumentException || ex is ArgumentOutOfRangeException) + { + throw new JsonSerializationException( + $"DecisionState constructor rejected payload: {ex.Message}", ex); + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/TransitionSerializer.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/TransitionSerializer.cs new file mode 100644 index 00000000..becc832a --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/TransitionSerializer.cs @@ -0,0 +1,47 @@ +using System; +using AutopilotMonitor.DecisionCore.Engine; +using Newtonsoft.Json; + +namespace AutopilotMonitor.DecisionCore.Serialization +{ + /// + /// Serialize / deserialize JSONL records. + /// Plan §2.7 (journal.jsonl) + §2.8 (DecisionTransitions Azure table row). + /// + /// Uses Newtonsoft.Json with . Newtonsoft binds + /// JSON properties onto the single public constructor via case-insensitive parameter + /// matching. Any ctor-validation exception surfaces as + /// so a corrupt tail is detectable during recovery without crashing the load loop. + /// + /// + public static class TransitionSerializer + { + public static string Serialize(DecisionTransition transition) + { + if (transition == null) throw new ArgumentNullException(nameof(transition)); + return JsonConvert.SerializeObject(transition, DecisionCoreJsonSettings.Create()); + } + + public static DecisionTransition Deserialize(string line) + { + if (line == null) throw new ArgumentNullException(nameof(line)); + + try + { + var result = JsonConvert.DeserializeObject(line, DecisionCoreJsonSettings.Create()); + if (result == null) + { + throw new JsonSerializationException($"Transition deserialization produced null for line: {line}"); + } + return result; + } + catch (Exception ex) when (ex is ArgumentException || ex is ArgumentOutOfRangeException) + { + // Ctor validation rejects malformed records — wrap so callers see a + // serialization failure instead of a raw argument exception. + throw new JsonSerializationException( + $"Transition constructor rejected payload: {ex.Message}", ex); + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs new file mode 100644 index 00000000..c807e3b4 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs @@ -0,0 +1,82 @@ +using System; +using Newtonsoft.Json; + +namespace AutopilotMonitor.DecisionCore.Serialization +{ + /// + /// Forward-compatibility converter for enums in DecisionCore DTOs. Plan §2.15 L.14. + /// + /// Serializes as its name. + /// Deserialization maps unknown / unrecognized string values to a caller-provided + /// default — typically Unknown — instead of throwing. This lets an older + /// consumer (e.g. a backend built against an older DecisionCore version) read rows + /// written by a newer producer (new enum values) without crashing; the consumer + /// flags those rows as VerificationStatus=drift_tolerated per plan §2.10. + /// + /// + public sealed class UnknownFallbackEnumConverter : JsonConverter + where T : struct, Enum + { + private readonly T _unknownFallback; + + public UnknownFallbackEnumConverter(T unknownFallback) + { + _unknownFallback = unknownFallback; + } + + public override bool CanConvert(Type objectType) + { + var t = Nullable.GetUnderlyingType(objectType) ?? objectType; + return t == typeof(T); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + writer.WriteValue(value.ToString()); + } + + public override object? ReadJson( + JsonReader reader, + Type objectType, + object? existingValue, + JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return Nullable.GetUnderlyingType(objectType) != null ? (object?)null : _unknownFallback; + } + + if (reader.TokenType == JsonToken.Integer) + { + // Legacy / numeric payloads. Try to cast; fallback if out of range. + var numeric = Convert.ToInt32(reader.Value); + if (Enum.IsDefined(typeof(T), numeric)) + { + return (T)Enum.ToObject(typeof(T), numeric); + } + return _unknownFallback; + } + + if (reader.TokenType == JsonToken.String) + { + var raw = reader.Value as string; + if (string.IsNullOrEmpty(raw)) + { + return _unknownFallback; + } + if (Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed)) + { + return parsed; + } + return _unknownFallback; + } + + return _unknownFallback; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignal.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignal.cs new file mode 100644 index 00000000..78301c0a --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignal.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.DecisionCore.Signals +{ + /// + /// Immutable input unit to the Decision Engine. Plan §2.2. + /// + /// The SignalLog is append-only and is the single source of input truth (plan §2.7c L.1). + /// Ordinals are assigned exclusively by SignalIngress (single-writer serialization, §2.1a) + /// — never by collectors or adapters directly. + /// + /// + public sealed class DecisionSignal + { + public DecisionSignal( + long sessionSignalOrdinal, + long sessionTraceOrdinal, + DecisionSignalKind kind, + int kindSchemaVersion, + DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + IReadOnlyDictionary? payload = null) + { + if (kindSchemaVersion < 1) + { + throw new ArgumentOutOfRangeException( + nameof(kindSchemaVersion), + "SchemaVersion must be >= 1."); + } + + if (string.IsNullOrEmpty(sourceOrigin)) + { + throw new ArgumentException( + "SourceOrigin is mandatory.", + nameof(sourceOrigin)); + } + + SessionSignalOrdinal = sessionSignalOrdinal; + SessionTraceOrdinal = sessionTraceOrdinal; + Kind = kind; + KindSchemaVersion = kindSchemaVersion; + OccurredAtUtc = occurredAtUtc; + SourceOrigin = sourceOrigin; + Evidence = evidence ?? throw new ArgumentNullException(nameof(evidence)); + Payload = payload; + } + + /// Monotonic per SignalLog; RowKey for Azure Signals table. + public long SessionSignalOrdinal { get; } + + /// Session-wide monotonic across all telemetry types (Event + Signal + Transition). Inspector correlation only. + public long SessionTraceOrdinal { get; } + + public DecisionSignalKind Kind { get; } + + /// Plan §2.2 L.6 — reducer dispatches on (Kind, KindSchemaVersion). + public int KindSchemaVersion { get; } + + public DateTime OccurredAtUtc { get; } + + public string SourceOrigin { get; } + + public Evidence Evidence { get; } + + public IReadOnlyDictionary? Payload { get; } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs new file mode 100644 index 00000000..a704411f --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs @@ -0,0 +1,45 @@ +namespace AutopilotMonitor.DecisionCore.Signals +{ + /// + /// All signal kinds consumed by the Decision Engine. + /// Plan §2.2. Every kind is versioned via ; + /// reducer handlers dispatch on (Kind, SchemaVersion). New kinds or version bumps + /// require a replay fixture in tests/fixtures/signal-kinds/{kind}-v{n}.json + /// — missing fixture = merge block. + /// + public enum DecisionSignalKind + { + // --- Raw — Part 1 --- + EspPhaseChanged, + EspExiting, + EspResumed, + EspTerminalFailure, + DesktopArrived, + HelloResolved, + ImeUserSessionCompleted, + DeviceSetupProvisioningComplete, + AppInstallCompleted, + AppInstallFailed, + WhiteGloveShellCoreSuccess, + WhiteGloveSealingPatternDetected, + AadUserJoinedLate, + SystemRebootObserved, + DeviceInfoCollected, + AutopilotProfileRead, + + // --- Raw — WhiteGlove Part 2 (Post-Reboot User-Sign-In) --- + UserAadSignInComplete, + HelloResolvedPart2, + DesktopArrivedPart2, + AccountSetupCompletedPart2, + + // --- Synthetic --- + DeadlineFired, + ClassifierVerdictIssued, + + // --- Lifecycle --- + SessionStarted, + SessionAborted, + SessionRecovered, + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/Evidence.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/Evidence.cs new file mode 100644 index 00000000..ec89a244 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/Evidence.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.DecisionCore.Signals +{ + /// + /// Immutable evidence record attached to every . + /// Plan §2.2 — pflichtfeld-validation in the signal adapter; signal without + /// evidence = bug. Per-kind mandatory fields: + /// + /// Raw: , , + /// Derived: all Raw fields + non-empty + /// Synthetic: , , + /// + /// + public sealed class Evidence + { + public Evidence( + EvidenceKind kind, + string identifier, + string summary, + string? rawPointer = null, + IReadOnlyDictionary? derivationInputs = null) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("Evidence.Identifier is mandatory.", nameof(identifier)); + } + + if (summary == null) + { + throw new ArgumentNullException(nameof(summary)); + } + + if (kind == EvidenceKind.Derived && (derivationInputs == null || derivationInputs.Count == 0)) + { + throw new ArgumentException( + "Evidence.Derived requires at least one DerivationInputs entry.", + nameof(derivationInputs)); + } + + Kind = kind; + Identifier = identifier; + Summary = summary.Length > 256 ? summary.Substring(0, 256) : summary; + RawPointer = rawPointer; + DerivationInputs = derivationInputs; + } + + public EvidenceKind Kind { get; } + + /// + /// Raw: RecordId / RegistryPath / LogLineHash. + /// Derived: detectorId + version (e.g. "desktop-arrival-detector-v1"). + /// Synthetic: correlationId (e.g. "deadline:hello_safety:fired"). + /// + public string Identifier { get; } + + /// UI-friendly summary, ≤256 chars (truncated on construction). + public string Summary { get; } + + /// Optional blob key for full raw payload retrieval. + public string? RawPointer { get; } + + /// Derived-only: which raw observations fed the detector. + public IReadOnlyDictionary? DerivationInputs { get; } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/EvidenceKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/EvidenceKind.cs new file mode 100644 index 00000000..d250840e --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/EvidenceKind.cs @@ -0,0 +1,17 @@ +namespace AutopilotMonitor.DecisionCore.Signals +{ + /// + /// Classification of a signal's evidence origin. Plan §2.2. + /// + public enum EvidenceKind + { + /// Direct observation from a raw source (event log, registry, IME line). + Raw, + + /// Produced by a detector from multiple raw inputs. Requires DerivationInputs. + Derived, + + /// Engine-internal synthesis (deadline fired, classifier verdict, session recovered). + Synthetic, + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/ActiveDeadline.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/ActiveDeadline.cs new file mode 100644 index 00000000..a4ee6b5e --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/ActiveDeadline.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Immutable persisted deadline record. Plan §2.6. + /// Timer-Monopol (L.7): only the Deadline-Scheduler (EffectRunner-role) holds + /// decision-relevant timers. On restart, deadlines are re-hydrated from the + /// persisted ; remaining = DueAtUtc - clock.UtcNow. + /// + public sealed class ActiveDeadline + { + public ActiveDeadline( + string name, + DateTime dueAtUtc, + DecisionSignalKind firesSignalKind, + IReadOnlyDictionary? firesPayload = null) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("Deadline Name is mandatory.", nameof(name)); + } + + Name = name; + DueAtUtc = dueAtUtc; + FiresSignalKind = firesSignalKind; + FiresPayload = firesPayload; + } + + /// Unique deadline name within a session (e.g. "hello_safety", "part2_safety"). + public string Name { get; } + + public DateTime DueAtUtc { get; } + + public DecisionSignalKind FiresSignalKind { get; } + + public IReadOnlyDictionary? FiresPayload { get; } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs new file mode 100644 index 00000000..bc6cc2b0 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Immutable snapshot of the engine-visible session state. Plan §2.3. + /// + /// Invariant: all DTOs in DecisionCore are immutable value objects. + /// "Change" is a new instance via With…-methods — no in-place mutation. + /// Reducer contract: (newState, transition, effects) = engine.Reduce(oldState, signal). + /// + /// + /// Agent-process lifecycle flags (crash, admin actions, boot-time, heartbeat) live + /// in agent-lifecycle.json, not here (L.11 separation). + /// + /// + public sealed class DecisionState + { + public const string CurrentSchemaVersion = "v2"; + + public DecisionState( + string sessionId, + string tenantId, + SessionStage stage, + SessionOutcome? outcome, + Hypothesis enrollmentType, + Hypothesis whiteGloveSealing, + Hypothesis whiteGlovePart2Completion, + Hypothesis deviceOnlyDeployment, + SignalFact? currentEnrollmentPhase, + SignalFact? deviceSetupEnteredUtc, + SignalFact? accountSetupEnteredUtc, + SignalFact? finalizingEnteredUtc, + SignalFact? espFinalExitUtc, + SignalFact? desktopArrivedUtc, + SignalFact? helloResolvedUtc, + SignalFact? systemRebootUtc, + SignalFact? helloOutcome, + SignalFact? aadJoinedWithUser, + SignalFact? imeMatchedPatternId, + SignalFact? shellCoreWhiteGloveSuccessSeen, + SignalFact? whiteGloveSealingPatternSeen, + SignalFact? userAadSignInCompleteUtc, + SignalFact? helloResolvedPart2Utc, + SignalFact? desktopArrivedPart2Utc, + SignalFact? accountSetupCompletedPart2Utc, + IReadOnlyList deadlines, + long lastAppliedSignalOrdinal, + int stepIndex, + string? schemaVersion = null) + { + if (string.IsNullOrEmpty(sessionId)) + { + throw new ArgumentException("SessionId is mandatory.", nameof(sessionId)); + } + + if (string.IsNullOrEmpty(tenantId)) + { + throw new ArgumentException("TenantId is mandatory.", nameof(tenantId)); + } + + SessionId = sessionId; + TenantId = tenantId; + Stage = stage; + Outcome = outcome; + EnrollmentType = enrollmentType ?? throw new ArgumentNullException(nameof(enrollmentType)); + WhiteGloveSealing = whiteGloveSealing ?? throw new ArgumentNullException(nameof(whiteGloveSealing)); + WhiteGlovePart2Completion = whiteGlovePart2Completion ?? throw new ArgumentNullException(nameof(whiteGlovePart2Completion)); + DeviceOnlyDeployment = deviceOnlyDeployment ?? throw new ArgumentNullException(nameof(deviceOnlyDeployment)); + CurrentEnrollmentPhase = currentEnrollmentPhase; + DeviceSetupEnteredUtc = deviceSetupEnteredUtc; + AccountSetupEnteredUtc = accountSetupEnteredUtc; + FinalizingEnteredUtc = finalizingEnteredUtc; + EspFinalExitUtc = espFinalExitUtc; + DesktopArrivedUtc = desktopArrivedUtc; + HelloResolvedUtc = helloResolvedUtc; + SystemRebootUtc = systemRebootUtc; + HelloOutcome = helloOutcome; + AadJoinedWithUser = aadJoinedWithUser; + ImeMatchedPatternId = imeMatchedPatternId; + ShellCoreWhiteGloveSuccessSeen = shellCoreWhiteGloveSuccessSeen; + WhiteGloveSealingPatternSeen = whiteGloveSealingPatternSeen; + UserAadSignInCompleteUtc = userAadSignInCompleteUtc; + HelloResolvedPart2Utc = helloResolvedPart2Utc; + DesktopArrivedPart2Utc = desktopArrivedPart2Utc; + AccountSetupCompletedPart2Utc = accountSetupCompletedPart2Utc; + Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines)); + LastAppliedSignalOrdinal = lastAppliedSignalOrdinal; + StepIndex = stepIndex; + SchemaVersion = schemaVersion ?? CurrentSchemaVersion; + } + + public string SessionId { get; } + + public string TenantId { get; } + + /// Engine-stage — what the reducer is currently waiting on. + public SessionStage Stage { get; } + + /// Null when the session is non-terminal. + public SessionOutcome? Outcome { get; } + + // --- Hypothesen --- + public Hypothesis EnrollmentType { get; } + + public Hypothesis WhiteGloveSealing { get; } + + public Hypothesis WhiteGlovePart2Completion { get; } + + public Hypothesis DeviceOnlyDeployment { get; } + + // --- Enrollment-Phase (end-user reality, separate from Stage) --- + public SignalFact? CurrentEnrollmentPhase { get; } + + public SignalFact? DeviceSetupEnteredUtc { get; } + + public SignalFact? AccountSetupEnteredUtc { get; } + + public SignalFact? FinalizingEnteredUtc { get; } + + // --- Signal-induced facts (with source ordinal for evidence trace) --- + public SignalFact? EspFinalExitUtc { get; } + + public SignalFact? DesktopArrivedUtc { get; } + + public SignalFact? HelloResolvedUtc { get; } + + public SignalFact? SystemRebootUtc { get; } + + public SignalFact? HelloOutcome { get; } + + public SignalFact? AadJoinedWithUser { get; } + + public SignalFact? ImeMatchedPatternId { get; } + + /// True once has fired. WG Part-1 indicator. + public SignalFact? ShellCoreWhiteGloveSuccessSeen { get; } + + /// True once has fired. Signal-correlated WG path. + public SignalFact? WhiteGloveSealingPatternSeen { get; } + + // --- WhiteGlove Part 2 (post-reboot user sign-in) facts --- + public SignalFact? UserAadSignInCompleteUtc { get; } + public SignalFact? HelloResolvedPart2Utc { get; } + public SignalFact? DesktopArrivedPart2Utc { get; } + public SignalFact? AccountSetupCompletedPart2Utc { get; } + + public IReadOnlyList Deadlines { get; } + + public long LastAppliedSignalOrdinal { get; } + + public int StepIndex { get; } + + public string SchemaVersion { get; } + + /// + /// Produce a mutable builder pre-populated with this state's values. + /// Reducer handlers call state.ToBuilder().WithStage(...).Build() to + /// express immutable "copy with changes" ergonomically (plan §2.3 / L.3). + /// + public DecisionStateBuilder ToBuilder() => new DecisionStateBuilder(this); + + /// + /// Construct the initial non-terminal state for a new session. + /// Used by the reducer's SessionStarted handler in M3. + /// + public static DecisionState CreateInitial(string sessionId, string tenantId) => + new DecisionState( + sessionId: sessionId, + tenantId: tenantId, + stage: SessionStage.SessionStarted, + outcome: null, + enrollmentType: Hypothesis.UnknownInstance, + whiteGloveSealing: Hypothesis.UnknownInstance, + whiteGlovePart2Completion: Hypothesis.UnknownInstance, + deviceOnlyDeployment: Hypothesis.UnknownInstance, + currentEnrollmentPhase: null, + deviceSetupEnteredUtc: null, + accountSetupEnteredUtc: null, + finalizingEnteredUtc: null, + espFinalExitUtc: null, + desktopArrivedUtc: null, + helloResolvedUtc: null, + systemRebootUtc: null, + helloOutcome: null, + aadJoinedWithUser: null, + imeMatchedPatternId: null, + shellCoreWhiteGloveSuccessSeen: null, + whiteGloveSealingPatternSeen: null, + userAadSignInCompleteUtc: null, + helloResolvedPart2Utc: null, + desktopArrivedPart2Utc: null, + accountSetupCompletedPart2Utc: null, + deadlines: Array.Empty(), + lastAppliedSignalOrdinal: -1, + stepIndex: 0); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs new file mode 100644 index 00000000..b21eff61 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Mutable construction helper for producing a new immutable . + /// + /// Plan §2.3 / L.3 — DecisionState itself is immutable; reducer handlers use the + /// builder to express "copy with changes" without typing all 20+ constructor arguments. + /// Call to materialize a new . The original + /// state is never mutated. + /// + /// + public sealed class DecisionStateBuilder + { + public DecisionStateBuilder(DecisionState source) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + + SessionId = source.SessionId; + TenantId = source.TenantId; + Stage = source.Stage; + Outcome = source.Outcome; + EnrollmentType = source.EnrollmentType; + WhiteGloveSealing = source.WhiteGloveSealing; + WhiteGlovePart2Completion = source.WhiteGlovePart2Completion; + DeviceOnlyDeployment = source.DeviceOnlyDeployment; + CurrentEnrollmentPhase = source.CurrentEnrollmentPhase; + DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc; + AccountSetupEnteredUtc = source.AccountSetupEnteredUtc; + FinalizingEnteredUtc = source.FinalizingEnteredUtc; + EspFinalExitUtc = source.EspFinalExitUtc; + DesktopArrivedUtc = source.DesktopArrivedUtc; + HelloResolvedUtc = source.HelloResolvedUtc; + SystemRebootUtc = source.SystemRebootUtc; + HelloOutcome = source.HelloOutcome; + AadJoinedWithUser = source.AadJoinedWithUser; + ImeMatchedPatternId = source.ImeMatchedPatternId; + ShellCoreWhiteGloveSuccessSeen = source.ShellCoreWhiteGloveSuccessSeen; + WhiteGloveSealingPatternSeen = source.WhiteGloveSealingPatternSeen; + UserAadSignInCompleteUtc = source.UserAadSignInCompleteUtc; + HelloResolvedPart2Utc = source.HelloResolvedPart2Utc; + DesktopArrivedPart2Utc = source.DesktopArrivedPart2Utc; + AccountSetupCompletedPart2Utc = source.AccountSetupCompletedPart2Utc; + Deadlines = new List(source.Deadlines); + LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal; + StepIndex = source.StepIndex; + SchemaVersion = source.SchemaVersion; + } + + public string SessionId { get; set; } + public string TenantId { get; set; } + public SessionStage Stage { get; set; } + public SessionOutcome? Outcome { get; set; } + public Hypothesis EnrollmentType { get; set; } + public Hypothesis WhiteGloveSealing { get; set; } + public Hypothesis WhiteGlovePart2Completion { get; set; } + public Hypothesis DeviceOnlyDeployment { get; set; } + public SignalFact? CurrentEnrollmentPhase { get; set; } + public SignalFact? DeviceSetupEnteredUtc { get; set; } + public SignalFact? AccountSetupEnteredUtc { get; set; } + public SignalFact? FinalizingEnteredUtc { get; set; } + public SignalFact? EspFinalExitUtc { get; set; } + public SignalFact? DesktopArrivedUtc { get; set; } + public SignalFact? HelloResolvedUtc { get; set; } + public SignalFact? SystemRebootUtc { get; set; } + public SignalFact? HelloOutcome { get; set; } + public SignalFact? AadJoinedWithUser { get; set; } + public SignalFact? ImeMatchedPatternId { get; set; } + public SignalFact? ShellCoreWhiteGloveSuccessSeen { get; set; } + public SignalFact? WhiteGloveSealingPatternSeen { get; set; } + public SignalFact? UserAadSignInCompleteUtc { get; set; } + public SignalFact? HelloResolvedPart2Utc { get; set; } + public SignalFact? DesktopArrivedPart2Utc { get; set; } + public SignalFact? AccountSetupCompletedPart2Utc { get; set; } + public List Deadlines { get; set; } + public long LastAppliedSignalOrdinal { get; set; } + public int StepIndex { get; set; } + public string SchemaVersion { get; set; } + + // ---------- fluent helpers for the most common reducer operations ---------- + + public DecisionStateBuilder WithStage(SessionStage stage) { Stage = stage; return this; } + + public DecisionStateBuilder WithOutcome(SessionOutcome? outcome) { Outcome = outcome; return this; } + + public DecisionStateBuilder WithStepIndex(int stepIndex) { StepIndex = stepIndex; return this; } + + public DecisionStateBuilder WithLastAppliedSignalOrdinal(long ordinal) { LastAppliedSignalOrdinal = ordinal; return this; } + + public DecisionStateBuilder WithCurrentEnrollmentPhase(EnrollmentPhase phase, long sourceSignalOrdinal) + { + CurrentEnrollmentPhase = new SignalFact(phase, sourceSignalOrdinal); + return this; + } + + public DecisionStateBuilder AddDeadline(ActiveDeadline deadline) + { + if (deadline == null) throw new ArgumentNullException(nameof(deadline)); + // Replace-if-same-name semantic: deadlines identified by Name. Plan §2.6. + for (int i = 0; i < Deadlines.Count; i++) + { + if (string.Equals(Deadlines[i].Name, deadline.Name, StringComparison.Ordinal)) + { + Deadlines[i] = deadline; + return this; + } + } + Deadlines.Add(deadline); + return this; + } + + public DecisionStateBuilder CancelDeadline(string name) + { + if (name == null) throw new ArgumentNullException(nameof(name)); + Deadlines.RemoveAll(d => string.Equals(d.Name, name, StringComparison.Ordinal)); + return this; + } + + public DecisionStateBuilder ClearDeadlines() { Deadlines.Clear(); return this; } + + public DecisionState Build() => + new DecisionState( + sessionId: SessionId, + tenantId: TenantId, + stage: Stage, + outcome: Outcome, + enrollmentType: EnrollmentType, + whiteGloveSealing: WhiteGloveSealing, + whiteGlovePart2Completion: WhiteGlovePart2Completion, + deviceOnlyDeployment: DeviceOnlyDeployment, + currentEnrollmentPhase: CurrentEnrollmentPhase, + deviceSetupEnteredUtc: DeviceSetupEnteredUtc, + accountSetupEnteredUtc: AccountSetupEnteredUtc, + finalizingEnteredUtc: FinalizingEnteredUtc, + espFinalExitUtc: EspFinalExitUtc, + desktopArrivedUtc: DesktopArrivedUtc, + helloResolvedUtc: HelloResolvedUtc, + systemRebootUtc: SystemRebootUtc, + helloOutcome: HelloOutcome, + aadJoinedWithUser: AadJoinedWithUser, + imeMatchedPatternId: ImeMatchedPatternId, + shellCoreWhiteGloveSuccessSeen: ShellCoreWhiteGloveSuccessSeen, + whiteGloveSealingPatternSeen: WhiteGloveSealingPatternSeen, + userAadSignInCompleteUtc: UserAadSignInCompleteUtc, + helloResolvedPart2Utc: HelloResolvedPart2Utc, + desktopArrivedPart2Utc: DesktopArrivedPart2Utc, + accountSetupCompletedPart2Utc: AccountSetupCompletedPart2Utc, + deadlines: Deadlines.ToArray(), + lastAppliedSignalOrdinal: LastAppliedSignalOrdinal, + stepIndex: StepIndex, + schemaVersion: SchemaVersion); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/Hypothesis.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/Hypothesis.cs new file mode 100644 index 00000000..883ed58e --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/Hypothesis.cs @@ -0,0 +1,65 @@ +using System; + +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Immutable engine hypothesis record. Plan §2.3. + /// Replaces boolean flag state in trackers with an explicit + /// level/score/reason/evidence-trace tuple. + /// + public sealed class Hypothesis + { + public static readonly Hypothesis UnknownInstance = new Hypothesis( + HypothesisLevel.Unknown, + reason: null, + score: 0, + lastUpdatedUtc: DateTime.MinValue, + lastClassifierVerdictId: null); + + public Hypothesis( + HypothesisLevel level, + string? reason, + int score, + DateTime lastUpdatedUtc, + string? lastClassifierVerdictId) + { + if (score < 0 || score > 100) + { + throw new ArgumentOutOfRangeException( + nameof(score), + "Score must be in [0, 100]."); + } + + Level = level; + Reason = reason; + Score = score; + LastUpdatedUtc = lastUpdatedUtc; + LastClassifierVerdictId = lastClassifierVerdictId; + } + + public HypothesisLevel Level { get; } + + public string? Reason { get; } + + /// 0..100 — classifier score or reducer-assigned confidence. + public int Score { get; } + + public DateTime LastUpdatedUtc { get; } + + /// Journal reference to the classifier verdict that last touched this hypothesis, if any. + public string? LastClassifierVerdictId { get; } + + public Hypothesis With( + HypothesisLevel? level = null, + string? reason = null, + int? score = null, + DateTime? lastUpdatedUtc = null, + string? lastClassifierVerdictId = null) => + new Hypothesis( + level ?? Level, + reason ?? Reason, + score ?? Score, + lastUpdatedUtc ?? LastUpdatedUtc, + lastClassifierVerdictId ?? LastClassifierVerdictId); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/HypothesisLevel.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/HypothesisLevel.cs new file mode 100644 index 00000000..63595c08 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/HypothesisLevel.cs @@ -0,0 +1,20 @@ +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Confidence levels for an engine hypothesis. Plan §2.3. + /// + public enum HypothesisLevel + { + Unknown = 0, + Weak = 1, + Strong = 2, + Confirmed = 3, + Rejected = 4, + + /// + /// Plan §2.7b — Classifier exception / best-effort failure. + /// Hypothesis keeps its prior level; verdict recorded as Inconclusive, no abort. + /// + Inconclusive = 5, + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs new file mode 100644 index 00000000..11694d36 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs @@ -0,0 +1,17 @@ +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Terminal outcome of a session. Plan §2.3. + /// Non-null only when is a terminal stage + /// (Completed, Failed, or WhiteGloveCompletedPart2). + /// + public enum SessionOutcome + { + Unknown = 0, + EnrollmentComplete, + EnrollmentFailed, + WhiteGlovePart1Sealed, + WhiteGlovePart2Complete, + Aborted, + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStage.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStage.cs new file mode 100644 index 00000000..660d7913 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStage.cs @@ -0,0 +1,41 @@ +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Engine stage — what the engine is currently waiting on. Plan §2.3. + /// Distinct from EnrollmentPhase (end-user reality) and from Hypothesis (engine guesses). + /// + /// Placeholder stages will be extended as partial-class reducers (§2.5) come online in M3. + /// This enum is extended additively; new values are forward-compatible via + /// UnknownFallbackEnumConverter (§2.15 L.14). + /// + /// + public enum SessionStage + { + Unknown = 0, + SessionStarted, + + // Classic (UserDriven-v1) + AwaitingEspPhaseChange, + EspDeviceSetup, + EspAccountSetup, + AwaitingHello, + AwaitingDesktop, + DesktopArrivedEspBlocking, + + // SelfDeploying-v1 / Device-Only + AwaitingDeviceSetupProvisioning, + AwaitingDeviceOnlyEsp, + + // WhiteGlove Part 1 + WhiteGloveCandidate, + WhiteGloveSealed, + + // WhiteGlove Part 2 (post-reboot user sign-in) + WhiteGloveAwaitingUserSignIn, + WhiteGloveCompletedPart2, + + // Terminal + Completed, + Failed, + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStageExtensions.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStageExtensions.cs new file mode 100644 index 00000000..61db7484 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStageExtensions.cs @@ -0,0 +1,42 @@ +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Classification helpers for . Plan §4.x M4.6.β. + /// + public static class SessionStageExtensions + { + /// + /// true when the stage represents a session-terminal state — the orchestrator + /// must stop, peripheral termination handlers (CleanupService, SummaryDialog, + /// DiagnosticsPackageService) run, and no further signals are expected. + /// + /// is terminal for Part 1 only — the agent + /// exits but the session is paused (Part 2 resumes after reboot). The distinction + /// matters for CleanupService: Part-1 exit must NOT self-destruct; Part-2 exit + /// () and the classic terminals + /// ( / ) do. + /// + /// + public static bool IsTerminal(this SessionStage stage) + { + switch (stage) + { + case SessionStage.Completed: + case SessionStage.Failed: + case SessionStage.WhiteGloveSealed: + case SessionStage.WhiteGloveCompletedPart2: + return true; + default: + return false; + } + } + + /// + /// true when the stage terminates the current agent run but leaves the session + /// paused for a future restart (WhiteGlove Part 1 → post-reboot Part 2 resume). Caller + /// (typically Program.cs) must skip CleanupService.ExecuteSelfDestruct in this case. + /// + public static bool IsPauseBeforePart2(this SessionStage stage) + => stage == SessionStage.WhiteGloveSealed; + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SignalFact.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SignalFact.cs new file mode 100644 index 00000000..507e3da5 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SignalFact.cs @@ -0,0 +1,32 @@ +using System; + +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Immutable value-with-provenance record. Plan §2.3. + /// points back at the DecisionSignal + /// that set the fact, enabling evidence trace in the Inspector. + /// + public sealed class SignalFact + { + public SignalFact(T value, long sourceSignalOrdinal) + { + if (sourceSignalOrdinal < 0) + { + throw new ArgumentOutOfRangeException( + nameof(sourceSignalOrdinal), + "SourceSignalOrdinal must be non-negative."); + } + + Value = value; + SourceSignalOrdinal = sourceSignalOrdinal; + } + + public T Value { get; } + + public long SourceSignalOrdinal { get; } + + public SignalFact With(T value, long sourceSignalOrdinal) => + new SignalFact(value, sourceSignalOrdinal); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 5ca3ae7c..83694baa 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -139,6 +139,13 @@ namespace AutopilotMonitor.Shared public const string ReseedFromGitHub = "/api/rules/reseed-from-github"; public const string ValidateBootstrapCode = "/api/bootstrap/validate/{code}"; + // V2 Agent generic telemetry transport (Plan §2.7a). + // Sibling of IngestEvents under /api/agent/* — same Agent-writes namespace, heterogeneous + // TelemetryItem[] payload (Event + Signal + DecisionTransition). Backend routes per + // TelemetryItem.Kind to Events / Signals / DecisionTransitions tables. Replaces the + // per-type IngestEvents path once the Legacy Agent is decommissioned (v11.1+). + public const string IngestTelemetry = "/api/agent/telemetry"; + // MCP/Agent API search endpoints public const string SearchSessions = "/api/search/sessions"; public const string SearchSessionsByEvent = "/api/search/sessions-by-event"; @@ -336,6 +343,22 @@ namespace AutopilotMonitor.Shared // Lightweight index of sessions that have events (for orphan detection) public const string EventSessionIndex = "EventSessionIndex"; + // V2 Decision Engine primary tables (Plan §M5). + // SignalLog (input-truth) and Journal (decision-truth) projected to the backend for + // the Inspector + Reducer-Verifier. Both partitioned by {TenantId}_{SessionId}. + public const string Signals = "Signals"; + public const string DecisionTransitions = "DecisionTransitions"; + + // V2 Decision Engine index tables (Plan §2.8 query matrix + §M5.d). Secondary + // projections written asynchronously via the `telemetry-index-reconcile` queue + // after the primary Signals / DecisionTransitions row has committed. Eventual + // consistency; a 2h timer re-indexes the last 4h as a safety-net on queue failures. + public const string SessionsByTerminal = "SessionsByTerminal"; + public const string SessionsByStage = "SessionsByStage"; + public const string DeadEndsByReason = "DeadEndsByReason"; + public const string ClassifierVerdictsByIdLevel = "ClassifierVerdictsByIdLevel"; + public const string SignalsByKind = "SignalsByKind"; + /// /// Returns all table names for initialization /// @@ -377,8 +400,30 @@ namespace AutopilotMonitor.Shared OpsEvents, ImeVersionHistory, RuleStats, - EventSessionIndex + EventSessionIndex, + Signals, + DecisionTransitions, + SessionsByTerminal, + SessionsByStage, + DeadEndsByReason, + ClassifierVerdictsByIdLevel, + SignalsByKind }; } + + /// + /// Azure Storage Queue names. Queues feed async fan-out / reconciliation paths + /// that would otherwise block the hot ingest loop. + /// + public static class QueueNames + { + /// + /// V2 Decision Engine index-table fan-out (Plan §2.8, §M5.d). One message per + /// committed primary row (Signal or DecisionTransition); consumer writes the + /// 0–3 applicable index rows. Eventual consistency; the 2h reconcile timer is + /// the safety net on queue failures. + /// + public const string TelemetryIndexReconcile = "telemetry-index-reconcile"; + } } } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs new file mode 100644 index 00000000..77a0b766 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDecisionTransitionRepository.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for the DecisionTransitions primary table (Plan §M5). Writes projected + /// s derived from agent Journal entries. + /// + /// Writes are idempotent via (PartitionKey, RowKey): a retried batch replays without + /// duplicating rows. Records within a single batch must share the same (TenantId, SessionId). + /// + /// + public interface IDecisionTransitionRepository + { + /// + /// Upserts a batch of transition records. Returns the number of rows committed. + /// + Task StoreBatchAsync(IReadOnlyList records, CancellationToken cancellationToken = default); + + /// + /// Reads up to transition records for a single session, + /// ordered by ascending. Used by the + /// Inspector decision-graph endpoint (GET /api/sessions/{id}/decision-graph, Plan §M5). + /// + Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default); + + /// + /// Cross-partition scan of all decision transitions whose Azure Tables server-side + /// Timestamp is at or after . Used by the + /// IndexReconcileTimer (Plan §M5.d.4) to re-enqueue the last 4h of primary + /// rows as a safety-net against queue failures. + /// + /// Perf note: Azure Tables has no PK-bound filter here, so this is a scan; + /// bounds memory. If the cap is reached the caller + /// should log + narrow the window rather than trust completeness. + /// + /// + Task> QueryByTimestampAtOrAfterAsync( + DateTime cutoffUtc, int maxResults = 50_000, CancellationToken cancellationToken = default); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IIndexReconcileProducer.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IIndexReconcileProducer.cs new file mode 100644 index 00000000..56ea478e --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IIndexReconcileProducer.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Enqueues s onto the + /// telemetry-index-reconcile queue (Plan §2.8, §M5.d). Implementations MUST + /// no-op when the AdminConfiguration.EnableIndexDualWrite flag is false — + /// this is the rollout gate that keeps pre-M5.d behaviour bit-exact by default. + /// + /// Exceptions from the underlying queue should NOT propagate back to the ingest + /// path. The primary-row write is the source of truth; index rows are eventually + /// consistent and the 2h reconcile timer (M5.d.4) is the safety net for failures. + /// + /// + public interface IIndexReconcileProducer + { + /// + /// Enqueues one queue message per envelope. Returns the number of messages actually + /// sent (0 if the feature flag is off or the batch is empty). + /// + Task EnqueueBatchAsync( + IReadOnlyList envelopes, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IIndexTableRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IIndexTableRepository.cs new file mode 100644 index 00000000..7b713608 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IIndexTableRepository.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Write-only repository for the 5 V2 Decision Engine index tables + /// (Plan §2.8 query matrix, §M5.d). Drives async fan-out from the primary + /// Signals / DecisionTransitions rows after they've committed, via the + /// telemetry-index-reconcile queue and the 2h safety-net timer. + /// + /// All writes are idempotent via (PartitionKey, RowKey): a retried reconcile + /// replays without duplicating rows. + /// batches require a shared PartitionKey, so the implementation groups + chunks input + /// per the 100-op transaction limit. Callers should partition by target table before + /// invoking the corresponding Store…Async method. + /// + /// + public interface IIndexTableRepository + { + Task StoreSessionsByTerminalAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default); + + Task StoreSessionsByStageAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default); + + Task StoreDeadEndsByReasonAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default); + + Task StoreClassifierVerdictsByIdLevelAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default); + + Task StoreSignalsByKindAsync( + IReadOnlyList records, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs new file mode 100644 index 00000000..edf88c8f --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISignalRepository.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for the Signals primary table (Plan §M5). Writes projected + /// s derived from agent-emitted DecisionSignals. + /// + /// Writes are idempotent via (PartitionKey, RowKey): a retried batch replays + /// without duplicating rows. Records within a single batch must share the same + /// (TenantId, SessionId) — the repository groups/chunks per the Azure Tables 100-op + /// transaction limit. + /// + /// + public interface ISignalRepository + { + /// + /// Upserts a batch of signal records. Returns the number of rows committed. + /// + Task StoreBatchAsync(IReadOnlyList records, CancellationToken cancellationToken = default); + + /// + /// Reads up to signal records for a single session, + /// ordered by ascending. Used by the + /// Inspector read endpoint (GET /api/sessions/{id}/signals, Plan §M5). + /// + Task> QueryBySessionAsync( + string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default); + + /// + /// Cross-partition scan of all signals whose Azure Tables server-side Timestamp + /// is at or after . Used by the + /// IndexReconcileTimer (Plan §M5.d.4) to re-enqueue the last 4h of primary + /// rows as a safety-net against queue failures. + /// + /// Perf note: Azure Tables has no PK-bound filter here, so this is a scan; + /// bounds memory. If the cap is reached the caller + /// should log + narrow the window rather than trust completeness. + /// + /// + Task> QueryByTimestampAtOrAfterAsync( + DateTime cutoffUtc, int maxResults = 50_000, CancellationToken cancellationToken = default); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 97da1224..344b3855 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -307,6 +307,41 @@ namespace AutopilotMonitor.Shared.Models } } + // ===== WHITEGLOVE SEALING PATTERN IDS (Plan §M5, M4.4.4 / M4.4.5.e) ===== + + /// + /// JSON-serialized list of IDs whose match is re-emitted by + /// the V2 agent as a WhiteGloveSealingPatternDetected DecisionSignal (in addition + /// to the normal ime_pattern_match event). Example: "[\"wg-seal-1\",\"wg-seal-2\"]". + /// + /// Default null/empty = feature off (M3-compatible, no regression risk). Only IDs in + /// this list count as sealing signals; other IME pattern matches follow the regular + /// event path. Global-only — no per-tenant override (plan §M5 M4.4.5.e decision). + /// + /// + public string WhiteGloveSealingPatternIdsJson { get; set; } = default!; + + /// + /// Returns the deserialized list of WhiteGlove sealing pattern IDs. Falls back to an + /// empty list for null/empty/invalid JSON so the feature stays off by default + /// (M3-compatible). + /// + public List GetWhiteGloveSealingPatternIds() + { + if (string.IsNullOrWhiteSpace(WhiteGloveSealingPatternIdsJson)) + return new List(); + try + { + return JsonSerializer.Deserialize>(WhiteGloveSealingPatternIdsJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new List(); + } + catch + { + return new List(); + } + } + /// /// Whether vulnerability correlation is globally enabled. /// When false, agents still collect inventory but backend skips correlation. @@ -314,6 +349,16 @@ namespace AutopilotMonitor.Shared.Models /// public bool VulnerabilityCorrelationEnabled { get; set; } = true; + /// + /// Feature flag for V2 Decision Engine index-table dual-write (Plan §M5.d). + /// When true, IngestTelemetryFunction enqueues telemetry-index-reconcile + /// envelopes after committing each primary Signals / DecisionTransitions + /// row; a queue-triggered handler (M5.d.3) then writes the 0–3 applicable index rows. + /// Default: false — enables controlled rollout. The 2h reconcile timer (M5.d.4) is + /// the safety-net for queue failures even once the flag is on. + /// + public bool EnableIndexDualWrite { get; set; } = false; + /// /// Last successful vulnerability data sync timestamp (UTC ISO 8601). /// Updated by VulnerabilityDataSyncFunction. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs index fc112d93..e9246f86 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs @@ -53,6 +53,18 @@ namespace AutopilotMonitor.Shared.Models /// public List ImeLogPatterns { get; set; } = new List(); + /// + /// Pattern-IDs (aus ), deren Match vom V2-Agent als + /// WhiteGloveSealingPatternDetected-DecisionSignal re-emittiert wird + /// (zusätzlich zum normalen ime_pattern_match-Event). Plan §4.x M4.4.4 / M4.4.5.e. + /// + /// Default null/leer = Feature off (M3-kompatibel, kein Regressions-Risiko). + /// Nur Pattern-IDs in dieser Liste werden als Sealing-Signal gewertet — alle anderen + /// IME-Pattern-Matches laufen den normalen Event-Pfad. + /// + /// + public List WhiteGloveSealingPatternIds { get; set; } = new List(); + /// /// Maximum consecutive authentication failures (401/403) before the agent shuts down. /// Prevents endless retry traffic when the device is not authorized. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 8eaf453b..2517453c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -149,9 +149,11 @@ namespace AutopilotMonitor.Shared.Models // ===== PAYLOAD SETTINGS ===== /// - /// Maximum decompressed NDJSON payload size in MB - /// Protects against memory exhaustion and denial-of-service attacks - /// Default: 5 MB + /// Maximum decompressed ingest-batch payload size in MB. Applies to both the legacy + /// NDJSON path (/api/agent/ingest) and the V2 JSON-array path + /// (/api/agent/telemetry) — same DoS/memory-exhaustion protection, different + /// wire shapes. The property name is historical; scope is shape-agnostic. Default: 5 MB. + /// Table-only setting (not editable via the tenant-config public API). /// public int MaxNdjsonPayloadSizeMB { get; set; } = 5; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs b/src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs new file mode 100644 index 00000000..0d088d04 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/DecisionGraphProjection.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Server-side projection of a session's s into a + /// renderable DAG for the Inspector (Plan §M5, §M6). Pre-computed on the backend so the + /// UI receives one structured shape instead of rebuilding the graph from the raw journal. + /// + public sealed class DecisionGraphProjection + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// Unique stages reached in the session (de-duplicated from Transition From/To stages). + public List Nodes { get; set; } = new List(); + + /// One entry per transition — edges preserve chronological order via . + public List Edges { get; set; } = new List(); + + /// Plan §2.10 — lets the UI flag sessions running on an older ReducerVersion than current. + public string ReducerVersion { get; set; } = string.Empty; + } + + /// + /// One stage the session visited. Identified by the enum name so + /// JSON payloads stay forward-compatible with new stages (string, not int ordinal). + /// + public sealed class DecisionGraphNode + { + /// Stage enum name (e.g. "EspInProgress"). Also used as the graph-node ID. + public string Id { get; set; } = string.Empty; + + public bool IsTerminal { get; set; } + + /// + /// Outcome label derived from the terminal — "Succeeded", + /// "Failed", "PausedForPart2", or null for non-terminal nodes. + /// The richer termination metadata (TerminationReason + TerminationOutcome + /// from the enrollment_terminated event in M4.6.β) is not inlined here — + /// a future revision can enrich terminal nodes by joining the Events table if the UI + /// needs it. Today's Inspector gets the high-level label. + /// + public string? TerminalOutcome { get; set; } + + /// Number of edges that target this node (for UI sizing / heat-map rendering). + public int VisitCount { get; set; } + } + + /// + /// One reducer step. false = dead-end edge (guard blocked the transition) + /// so the Inspector can render the blocked path in a different style. + /// + public sealed class DecisionGraphEdge + { + public int StepIndex { get; set; } + public string FromStage { get; set; } = string.Empty; + public string ToStage { get; set; } = string.Empty; + public string Trigger { get; set; } = string.Empty; + public bool Taken { get; set; } + public string? DeadEndReason { get; set; } + public long SignalOrdinalRef { get; set; } + public DateTime OccurredAtUtc { get; set; } + public string? ClassifierVerdictId { get; set; } + public string? ClassifierHypothesisLevel { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/DecisionTransitionRecord.cs b/src/Shared/AutopilotMonitor.Shared/Models/DecisionTransitionRecord.cs new file mode 100644 index 00000000..c3518482 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/DecisionTransitionRecord.cs @@ -0,0 +1,59 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Backend storage record for a single DecisionTransition journal step (Plan §M5). + /// Flat shape projected from the agent's DecisionTransition — see + /// for the overall projection pattern. + /// + /// PK = {TenantId}_{SessionId}, RK = {StepIndex:D10}. + /// + /// + /// The index-discriminator columns (, , + /// ) are projected eagerly so index-table fan-out + /// (SessionsByTerminal / ClassifierVerdictsByIdLevel / …) can happen without re-parsing + /// . The primary write stores them inline; the queue-driven + /// index writer reads them straight off the primary row. + /// + /// + public sealed class DecisionTransitionRecord + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// Monotonic per Journal. Drives table RowKey ordering. + public int StepIndex { get; set; } + + /// Session-wide monotonic across Event + Signal + Transition. + public long SessionTraceOrdinal { get; set; } + + /// Plan §2.2 cross-reference — which signal caused this transition. + public long SignalOrdinalRef { get; set; } + + public DateTime OccurredAtUtc { get; set; } + + public string Trigger { get; set; } = string.Empty; + + /// SessionStage enum name. + public string FromStage { get; set; } = string.Empty; + public string ToStage { get; set; } = string.Empty; + + /// false = dead-end (blocked path, non-null). + public bool Taken { get; set; } + + public string? DeadEndReason { get; set; } + + public string ReducerVersion { get; set; } = string.Empty; + + /// Projected index discriminator — true when is a terminal stage. + public bool IsTerminal { get; set; } + + /// Projected index discriminator — non-null when this step embeds a classifier verdict. + public string? ClassifierVerdictId { get; set; } + public string? ClassifierHypothesisLevel { get; set; } + + /// Agent-serialized DecisionTransition JSON (Guards + EmittedEventSequences + ClassifierVerdict included). + public string PayloadJson { get; set; } = string.Empty; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/IndexReconcileEnvelope.cs b/src/Shared/AutopilotMonitor.Shared/Models/IndexReconcileEnvelope.cs new file mode 100644 index 00000000..c592871f --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/IndexReconcileEnvelope.cs @@ -0,0 +1,56 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Queue-message envelope for the telemetry-index-reconcile queue + /// (Plan §2.8, §M5.d). Carries all discriminators needed for the queue-triggered + /// handler (M5.d.3) to fan-out one primary row into the 0–3 applicable index tables, + /// without needing to re-read the primary row from storage. + /// + /// Flat shape (nullable fields per ) deliberately avoids + /// polymorphic Newtonsoft deserialization — see M5.c.3 finding that abstract-type + /// round-trips through JSON need bespoke type-handling configuration, which isn't + /// worth the complexity for a two-variant envelope. + /// + /// + public sealed class IndexReconcileEnvelope + { + /// Schema version — bump on breaking envelope changes so consumers can reject or migrate. + public string EnvelopeVersion { get; set; } = "1"; + + /// Either "Signal" or "DecisionTransition"; drives which nullable fields carry data. + public string SourceKind { get; set; } = string.Empty; + + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// When the source event happened on the agent clock; primary RK is derived from this. + public DateTime OccurredAtUtc { get; set; } + + // ---------- SourceKind="Signal" fields ---------- + + /// Back-ref to Signals primary row (its RK). + public long? SessionSignalOrdinal { get; set; } + + public string? SignalKind { get; set; } + + public string? SourceOrigin { get; set; } + + // ---------- SourceKind="DecisionTransition" fields ---------- + + /// Back-ref to DecisionTransitions primary row (its RK). + public int? StepIndex { get; set; } + + public string? FromStage { get; set; } + public string? ToStage { get; set; } + + public bool? Taken { get; set; } + public bool? IsTerminal { get; set; } + + public string? DeadEndReason { get; set; } + + public string? ClassifierVerdictId { get; set; } + public string? ClassifierHypothesisLevel { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/IndexRecords.cs b/src/Shared/AutopilotMonitor.Shared/Models/IndexRecords.cs new file mode 100644 index 00000000..92b15ec8 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/IndexRecords.cs @@ -0,0 +1,125 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Index-table record projecting a terminal onto the + /// SessionsByTerminal table (Plan §2.8 query matrix, §M5.d). Written async via the + /// telemetry-index-reconcile queue after the primary DecisionTransitions + /// row has committed; enables "which sessions ended in {TerminalStage}?" queries. + /// + /// PK = {TenantId}_{TerminalStage}, RK = {OccurredAtUtc:yyyyMMddHHmmss}_{SessionId}. + /// + /// + public sealed class SessionsByTerminalRecord + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// The terminal SessionStage name (e.g. Completed, Failed, WhiteGloveSealed). + public string TerminalStage { get; set; } = string.Empty; + + public DateTime OccurredAtUtc { get; set; } + + /// Back-ref into the primary DecisionTransitions row so queries can hop to full detail. + public int StepIndex { get; set; } + } + + /// + /// Index-table record tracking a session's stage history on the SessionsByStage table + /// (Plan §2.8 query matrix, §M5.d). Written per stage-entering transition; older stage rows + /// for the same session remain until queue-side or reconciliation logic removes them. + /// + /// PK = {TenantId}_{Stage}, RK = {LastUpdatedUtcTicks:D19}_{SessionId} — + /// ticks zero-padded to 19 digits so lexicographic RK ordering matches numeric time order. + /// + /// + public sealed class SessionsByStageRecord + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// The SessionStage name the session entered with this row. + public string Stage { get; set; } = string.Empty; + + public DateTime LastUpdatedUtc { get; set; } + + /// Back-ref into the primary DecisionTransitions row that moved the session into this stage. + public int StepIndex { get; set; } + } + + /// + /// Index-table record for dead-end transitions on the DeadEndsByReason table + /// (Plan §2.8 query matrix, §M5.d). Written for + /// rows with Taken=false and non-null DeadEndReason. + /// + /// PK = {TenantId}_{DeadEndReason}, RK = {OccurredAtUtc:yyyyMMddHHmmss}_{SessionId}_{StepIndex:D6}. + /// + /// + public sealed class DeadEndsByReasonRecord + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + public string DeadEndReason { get; set; } = string.Empty; + + /// Back-ref into the primary DecisionTransitions row. + public int StepIndex { get; set; } + + public string FromStage { get; set; } = string.Empty; + + /// The ToStage the transition attempted but was blocked from reaching. + public string AttemptedToStage { get; set; } = string.Empty; + + public DateTime OccurredAtUtc { get; set; } + } + + /// + /// Index-table record projecting classifier verdicts onto the + /// ClassifierVerdictsByIdLevel table (Plan §2.8 query matrix, §M5.d). Written for + /// rows carrying a non-null ClassifierVerdictId. + /// + /// PK = {TenantId}_{ClassifierId}_{HypothesisLevel}, + /// RK = {OccurredAtUtc:yyyyMMddHHmmss}_{SessionId}_{StepIndex:D6}. + /// + /// + public sealed class ClassifierVerdictsByIdLevelRecord + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + public string ClassifierId { get; set; } = string.Empty; + + /// Hypothesis level name (e.g. Strong, Weak, None, Inconclusive). + public string HypothesisLevel { get; set; } = string.Empty; + + /// Back-ref into the primary DecisionTransitions row. + public int StepIndex { get; set; } + + public DateTime OccurredAtUtc { get; set; } + } + + /// + /// Index-table record projecting signals onto the SignalsByKind table + /// (Plan §2.8 query matrix, §M5.d). Written per ; enables + /// cross-session "all signals of kind {SignalKind} for tenant {TenantId}" queries. + /// + /// PK = {TenantId}_{SignalKind}, + /// RK = {OccurredAtUtc:yyyyMMddHHmmss}_{SessionId}_{SessionSignalOrdinal:D10}. + /// + /// + public sealed class SignalsByKindRecord + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + public string SignalKind { get; set; } = string.Empty; + + /// Back-ref into the primary Signals row. + public long SessionSignalOrdinal { get; set; } + + public DateTime OccurredAtUtc { get; set; } + + public string SourceOrigin { get; set; } = string.Empty; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ReducerVerificationReport.cs b/src/Shared/AutopilotMonitor.Shared/Models/ReducerVerificationReport.cs new file mode 100644 index 00000000..be58732c --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/ReducerVerificationReport.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Structural health report for a session's persisted SignalLog + DecisionTransitions + /// journal. Produced by GET /api/sessions/{id}/reducer-verification (Plan §M5, + /// admin/ops endpoint, not tenant-exposed). + /// + /// Scope: this report covers structural invariants that can be checked without + /// running the reducer — ordinal contiguity, cross-references, ReducerVersion drift, counts. + /// A full engine replay with per-step diff would require polymorphic deserialisation of + /// the DecisionSignal.Evidence payload and is a dedicated follow-up. + /// + /// + public sealed class ReducerVerificationReport + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + public int SignalCount { get; set; } + public int TransitionCount { get; set; } + + /// First transition's ReducerVersion (null when no transitions present). + public string? StoredReducerVersion { get; set; } + + /// The current live backend DecisionEngine.ReducerVersion. + public string CurrentReducerVersion { get; set; } = string.Empty; + + /// + /// True when stored ≠ current — the session was journaled under a different reducer + /// build. Plan §2.10 calls this out as a known drift signal rather than a bug; the + /// report surfaces it so ops can decide if replay is still meaningful. + /// + public bool ReducerVersionDrift { get; set; } + + // ---- Signal ordinal contiguity --------------------------------------------------- + public bool SignalOrdinalsContiguous { get; set; } + public long SignalOrdinalFirst { get; set; } + public long SignalOrdinalLast { get; set; } + + // ---- Transition step-index contiguity -------------------------------------------- + public bool StepIndicesContiguous { get; set; } + public int StepIndexFirst { get; set; } + public int StepIndexLast { get; set; } + + /// + /// Transitions whose SignalOrdinalRef does not match any loaded signal row. + /// A non-zero count indicates either a corrupted journal or — more likely — truncated + /// data on the query (the verifier loaded a subset of transitions and the referenced + /// signals fell outside the slice). + /// + public int OrphanedTransitionCount { get; set; } + + /// Human-readable issue stream for the Inspector's verification panel. + public List Issues { get; set; } = new List(); + } + + public sealed class VerificationIssue + { + /// Info / Warning / Error. + public string Severity { get; set; } = "Info"; + + /// + /// Discriminator string: reducer_version_drift, signal_ordinal_gap, + /// step_index_gap, orphaned_transition, empty_session, … + /// + public string Kind { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SignalRecord.cs b/src/Shared/AutopilotMonitor.Shared/Models/SignalRecord.cs new file mode 100644 index 00000000..28185153 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/SignalRecord.cs @@ -0,0 +1,43 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Backend storage record for a single DecisionSignal (Plan §M5). Flat shape projected + /// from the agent's DecisionSignal so the Backend doesn't need to reference DecisionCore + /// just to persist and serve it back out. + /// + /// Keys (authoritative, supplied by the agent for idempotent upsert via + /// (PartitionKey, RowKey)): + /// PK = {TenantId}_{SessionId}, RK = {SessionSignalOrdinal:D19}. + /// + /// + /// Fidelity: carries the complete agent-serialized + /// DecisionSignal (including Evidence + Payload dictionary). Typed columns exist only + /// for query/projection; replay is driven off the JSON blob. + /// + /// + public sealed class SignalRecord + { + public string TenantId { get; set; } = string.Empty; + public string SessionId { get; set; } = string.Empty; + + /// Monotonic per SignalLog. Drives table RowKey ordering. + public long SessionSignalOrdinal { get; set; } + + /// Session-wide monotonic across Event + Signal + Transition. Inspector correlation only. + public long SessionTraceOrdinal { get; set; } + + /// DecisionSignalKind enum name — stored as string for forward-compat. + public string Kind { get; set; } = string.Empty; + + public int KindSchemaVersion { get; set; } + + public DateTime OccurredAtUtc { get; set; } + + public string SourceOrigin { get; set; } = string.Empty; + + /// Agent-serialized DecisionSignal JSON (Evidence + Payload included). + public string PayloadJson { get; set; } = string.Empty; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/TelemetryItemDto.cs b/src/Shared/AutopilotMonitor.Shared/Models/TelemetryItemDto.cs new file mode 100644 index 00000000..9c001843 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/TelemetryItemDto.cs @@ -0,0 +1,44 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Wire-format DTO for a single telemetry item sent by the V2 agent to + /// POST /api/agent/telemetry (Plan §2.7a / §M5). The agent's in-memory class + /// AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItem is ctor-validated + /// and immutable; this DTO mirrors the JSON shape so the backend can deserialise without + /// depending on agent assemblies. + /// + /// Serialisation: Newtonsoft.Json, PascalCase, as string + /// ("Event"/"Signal"/"DecisionTransition") for forward-compat. + /// + /// + public sealed class TelemetryItemDto + { + /// + /// Item type — routes to the destination table. Values: Event, Signal, + /// DecisionTransition. Unknown values are rejected at parse time. + /// + public string Kind { get; set; } = string.Empty; + + /// Azure-Table PartitionKey, typically {tenantId}_{sessionId}. + public string PartitionKey { get; set; } = string.Empty; + + public string RowKey { get; set; } = string.Empty; + + /// Transport-cursor — monotonic per session across all item kinds. + public long TelemetryItemId { get; set; } + + /// Null for agent-global items (no session). + public long? SessionTraceOrdinal { get; set; } + + /// Already-serialised JSON of the wrapped payload (EnrollmentEvent / DecisionSignal / DecisionTransition). + public string PayloadJson { get; set; } = string.Empty; + + public bool RequiresImmediateFlush { get; set; } + + public DateTime EnqueuedAtUtc { get; set; } + + public int RetryCount { get; set; } + } +} diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index 6c3faf82..0508af5d 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -34,6 +34,8 @@ interface AdminConfigContextValue { setAllowAgentDowngrade: (value: boolean) => void; modernDeploymentHarmlessEventIds: string; setModernDeploymentHarmlessEventIds: (value: string) => void; + enableIndexDualWrite: boolean; + setEnableIndexDualWrite: (value: boolean) => void; // Diagnostics log paths globalDiagPaths: DiagnosticsLogPath[]; @@ -110,6 +112,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) const [opsEventRetentionDays, setOpsEventRetentionDays] = useState(90); const [allowAgentDowngrade, setAllowAgentDowngrade] = useState(false); const [modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds] = useState("100, 1005"); + const [enableIndexDualWrite, setEnableIndexDualWrite] = useState(false); // Diagnostics Log Paths state const [globalDiagPaths, setGlobalDiagPaths] = useState([]); @@ -162,6 +165,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setOpsEventRetentionDays(data.opsEventRetentionDays ?? 90); setAllowAgentDowngrade(data.allowAgentDowngrade ?? false); setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(data.modernDeploymentHarmlessEventIdsJson)); + setEnableIndexDualWrite(data.enableIndexDualWrite ?? false); try { setGlobalDiagPaths(data.diagnosticsGlobalLogPathsJson ? JSON.parse(data.diagnosticsGlobalLogPathsJson) : []); } catch { @@ -255,6 +259,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) opsEventRetentionDays, allowAgentDowngrade, modernDeploymentHarmlessEventIdsJson: serializeHarmlessEventIds(modernDeploymentHarmlessEventIds), + enableIndexDualWrite, }; const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, { @@ -281,7 +286,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) } finally { setSavingConfig(false); } - }, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, allowAgentDowngrade, modernDeploymentHarmlessEventIds, getAccessToken]); + }, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, getAccessToken]); // Reset admin config const handleResetAdminConfig = useCallback(() => { @@ -294,6 +299,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90); setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false); setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(adminConfig.modernDeploymentHarmlessEventIdsJson)); + setEnableIndexDualWrite(adminConfig.enableIndexDualWrite ?? false); try { setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []); } catch { @@ -418,6 +424,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) opsEventRetentionDays, setOpsEventRetentionDays, allowAgentDowngrade, setAllowAgentDowngrade, modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds, + enableIndexDualWrite, setEnableIndexDualWrite, globalDiagPaths, setGlobalDiagPaths, savingDiagPaths, opsAlertRules, setOpsAlertRules, opsAlertTelegramEnabled, setOpsAlertTelegramEnabled, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx index 218522b8..cc68e884 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx @@ -16,6 +16,8 @@ interface AdminConfigSettingsSectionProps { setAllowAgentDowngrade: (value: boolean) => void; modernDeploymentHarmlessEventIds: string; setModernDeploymentHarmlessEventIds: (value: string) => void; + enableIndexDualWrite: boolean; + setEnableIndexDualWrite: (value: boolean) => void; onSave: () => Promise; onReset: () => void; } @@ -34,6 +36,8 @@ export function AdminConfigSettingsSection({ setAllowAgentDowngrade, modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds, + enableIndexDualWrite, + setEnableIndexDualWrite, onSave, onReset, }: AdminConfigSettingsSectionProps) { @@ -152,6 +156,34 @@ export function AdminConfigSettingsSection({ +
+ +
+ {adminConfig && (
diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx index 30add519..bc4cff07 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionGlobalSettings.tsx @@ -12,6 +12,7 @@ export function SectionGlobalSettings() { collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes, allowAgentDowngrade, setAllowAgentDowngrade, modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds, + enableIndexDualWrite, setEnableIndexDualWrite, handleSaveAdminConfig, handleResetAdminConfig, } = useAdminConfig(); @@ -32,6 +33,8 @@ export function SectionGlobalSettings() { setAllowAgentDowngrade={setAllowAgentDowngrade} modernDeploymentHarmlessEventIds={modernDeploymentHarmlessEventIds} setModernDeploymentHarmlessEventIds={setModernDeploymentHarmlessEventIds} + enableIndexDualWrite={enableIndexDualWrite} + setEnableIndexDualWrite={setEnableIndexDualWrite} onSave={handleSaveAdminConfig} onReset={handleResetAdminConfig} /> diff --git a/src/Web/autopilot-monitor-web/types/adminConfig.ts b/src/Web/autopilot-monitor-web/types/adminConfig.ts index f5305ece..0f2e6475 100644 --- a/src/Web/autopilot-monitor-web/types/adminConfig.ts +++ b/src/Web/autopilot-monitor-web/types/adminConfig.ts @@ -24,6 +24,13 @@ export interface AdminConfiguration { opsAlertSlackWebhookUrl?: string; allowAgentDowngrade?: boolean; modernDeploymentHarmlessEventIdsJson?: string; + /** + * Feature flag for the V2 Decision Engine index-table dual-write (Plan §M5.d). + * When true, IngestTelemetryFunction enqueues telemetry-index-reconcile envelopes after + * committing each primary row, and the 2h IndexReconcileTimer re-scans the last 4h as + * a safety net. Default false — keeps pre-M5.d behaviour bit-exact until explicitly flipped. + */ + enableIndexDualWrite?: boolean; } export interface OpsAlertRule { diff --git a/tasks/lessons.md b/tasks/lessons.md deleted file mode 100644 index d49c1de3..00000000 --- a/tasks/lessons.md +++ /dev/null @@ -1,31 +0,0 @@ -# Lessons Learned - - - -## Rules - - - -## Code Style - -## Architecture - -## Testing - -## Common Mistakes - -### Timeline sequence must NEVER regress in the curated UI view (2026-04-04) -**What went wrong:** `groupEventsByPhase` was called without `preventPhaseRegression: true` for normal sessions and WhiteGlove pre-provisioning. A mid-enrollment reboot emits a new `agent_started` (Phase 0 = "Start"), which regressed the active phase back to Start, dumping post-reboot events (seq 173+) into the Start section — breaking the sequential appearance. -**Rule:** ALL `groupEventsByPhase` calls MUST use `{ preventPhaseRegression: true }`. The curated timeline sequence must appear continuous within each phase section. Reboots and agent restarts must NOT create phase regression — events stay in whatever phase was active before the reboot. -**Severity:** UX — confuses users, violates core timeline principle. - -### Table Storage mapping defaults must match domain defaults (2026-03-31) -**What went wrong:** Added `NtpServer` mapping with `?? ""` fallback. Existing tenants had no value stored → got empty string instead of `"time.windows.com"` → agent NTP check broke. -**Rule:** When adding missing Table Storage read mappings, the `??` fallback MUST match the domain/model default (check `AgentConfigResponse`, `GetAgentConfigFunction`, or the property initializer). Never use empty string as fallback for fields that have a meaningful default. -**Severity:** PRODUCTION — silently breaks agent functionality for all existing tenants. - -### PowerShell scripts deployed via Intune MUST be pure ASCII (2026-03-30) -**What went wrong:** Em-dash characters `—` (U+2014) and Unicode symbols (✓, →, ✗) in Bootstrap .ps1 scripts caused cascading PowerShell parse errors in production. The IME (Intune Management Extension) uses PowerShell 5.1 which reads scripts without BOM as ANSI — multi-byte UTF-8 chars get corrupted. -**Rule:** ALL `.ps1` files under `scripts/Bootstrap/` (and any other scripts deployed via Intune) MUST contain only ASCII characters (0x00-0x7F). No em-dashes, no Unicode symbols, no special quotes. Use `-` instead of `—`, `[OK]` instead of `✓`, etc. -**How to check:** `LC_ALL=en_US.UTF-8 grep -Pn '[^\x00-\x7F]' ` — must return no matches. -**Severity:** PRODUCTION BREAKING — agent installation fails completely on all devices. diff --git a/tests/fixtures/enrollment-sessions-local/.gitkeep b/tests/fixtures/enrollment-sessions-local/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/enrollment-sessions-local/README.md b/tests/fixtures/enrollment-sessions-local/README.md new file mode 100644 index 00000000..8e9de63c --- /dev/null +++ b/tests/fixtures/enrollment-sessions-local/README.md @@ -0,0 +1,20 @@ +# Local Enrollment Session Fixtures — NOT committed + +This directory holds **raw** or **partially-anonymized** session exports used during the +M3 reducer bring-up. Files here are **excluded from git** — see root `.gitignore`. + +## Workflow (plan §4 M3) + +1. Export a session via `tools/export-session-fixture.mjs` (pipes through MCP + `get_session_events`). +2. Inspect the resulting `{sessionId}.jsonl` here. Adjust / anonymize as needed. +3. When a fixture is stable and anonymized, **move** it to + `tests/fixtures/enrollment-sessions/` and commit it with a short README entry + (see that directory's README for the commit policy). + +## Why this split + +Plan: "Lokal Rohdaten arbeiten, nicht committen bis stabil + anonymisiert." +Prod-session exports contain tenant identifiers, device IDs, and user names that must +never enter the repo history. The two-directory pattern forces an explicit "promote" +step with review. diff --git a/tests/fixtures/enrollment-sessions/README.md b/tests/fixtures/enrollment-sessions/README.md new file mode 100644 index 00000000..027d2bf3 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/README.md @@ -0,0 +1,47 @@ +# Enrollment Session Fixtures — committed, anonymized + +Each `.jsonl` file in this directory is an anonymized DecisionSignal fixture used by +the Replay Harness and the reducer scenario tests (plan §4 M3 gate + §R6 test +discipline). + +## Commit policy + +A fixture may only land here when **all** of the following hold: + +1. **Anonymized.** No tenant IDs, device IDs, serial numbers, user names, UPN prefixes, + or raw IP addresses. Use the placeholder tokens `tenant-anon-NNNN`, `device-anon-NNNN`, + `user-anon-NNNN`. Identifiers are consistent within a fixture but never reused across + fixtures. +2. **Pflicht-Evidence.** Every signal has non-empty `Evidence.Identifier` and `Summary`. + `Derived` signals carry non-empty `DerivationInputs`. `Synthetic` signals carry a + correlation ID. Plan §2.2 signal adapter rules apply. +3. **Category tag.** Filename prefix marks the scenario category: + - `userdriven-happy-*` — ESP → AccountSetup → Hello → Desktop → Complete + - `userdriven-hello-timeout-*` + - `selfdeploying-*` + - `whiteglove-inline-*` + - `whiteglove-signal-correlated-*` + - `whiteglove-part2-happy-*` + - `whiteglove-part2-stuck-*` + - `whiteglove-false-positive-*` + - `hybrid-reboot-*` + - `esp-terminal-failure-*` + - `hello-timeout-*` + - `late-aadj-*` +4. **README entry.** Add a line to the `Fixtures` table below with the expected terminal + state + hypotheses. +5. **Pair with scenario test.** At least one xUnit test in + `src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/` consumes the fixture and + asserts its terminal state (plan R6 flow-test priority). + +## Fixtures + +| Filename | Category | Terminal Stage | EnrollmentType | WhiteGlove | Notes | +|---|---|---|---|---|---| +| _(empty — populated during M3 bring-up)_ | | | | | | + +## Ordering + +Fixtures preserve the capture `SessionSignalOrdinal`. If you edit a fixture (e.g., remove +a noisy signal), renumber ordinals contiguous from 0 and re-hash the harness output — +then update the paired scenario test's expected hash. diff --git a/tests/fixtures/enrollment-sessions/esp-terminal-failure-v1.jsonl b/tests/fixtures/enrollment-sessions/esp-terminal-failure-v1.jsonl new file mode 100644 index 00000000..0f5fe10e --- /dev/null +++ b/tests/fixtures/enrollment-sessions/esp-terminal-failure-v1.jsonl @@ -0,0 +1,9 @@ +# Scenario: ESP Terminal Failure +# ESP reports a non-recoverable failure (e.g. Event 62411 failure) during AccountSetup. +# Reducer transitions to Failed directly with reason carried from the signal payload. +# enrollment_failed event emitted. +# Plan: §4 M3.5 ESP-Terminal-Failure. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:esp-fail:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"EspTerminalFailure","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:12:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62411:recordId:900","Summary":"ESP terminal failure (Event 62411)","RawPointer":"blob://events/shell-core/900"},"Payload":{"reason":"esp_terminal_failure_account_setup"}} diff --git a/tests/fixtures/enrollment-sessions/hybrid-reboot-v1.jsonl b/tests/fixtures/enrollment-sessions/hybrid-reboot-v1.jsonl new file mode 100644 index 00000000..5324f290 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/hybrid-reboot-v1.jsonl @@ -0,0 +1,15 @@ +# Scenario: Hybrid-Reboot UserDriven (ESP mid-phase reboot, session resumes cleanly) +# The device enters DeviceSetup, reboots before AccountSetup, agent restarts and emits +# EspResumed + SystemRebootObserved. The existing stage is preserved, reboot fact is +# recorded. Session then continues through AccountSetup, ESP exit, Hello, Desktop -> +# Completed. Hybrid Reboot DOES NOT cause WhiteGlove sealing (no WG facts). +# Plan: §4 M3.5 hybrid reboot scenario. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:hybrid-reboot:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"SystemRebootObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:05:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Derived","Identifier":"boot-time-delta-detector-v1","Summary":"System boot time advanced since session start","DerivationInputs":{"priorBoot":"2026-04-24T07:59:00Z","currentBoot":"2026-04-24T08:05:00Z"}}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"EspResumed","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:05:30Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-resume-detector-v1","Summary":"ESP resumed after reboot","DerivationInputs":{"previousPhase":"DeviceSetup"}}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"ImeUserSessionCompleted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:12:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Raw","Identifier":"ime-log-line-hash:ghi789","Summary":"User session completed pattern"},"Payload":{"patternId":"ime-pattern-user-session-complete-v1"}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-exit-detector-v1","Summary":"ESP exit observed","DerivationInputs":{"rawValue":"exit"}}} +{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:14:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:77","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}} +{"SessionSignalOrdinal":8,"SessionTraceOrdinal":8,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:15:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0030","explorerPid":"4500"}}} diff --git a/tests/fixtures/enrollment-sessions/late-aadj-v1.jsonl b/tests/fixtures/enrollment-sessions/late-aadj-v1.jsonl new file mode 100644 index 00000000..51d42ed4 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/late-aadj-v1.jsonl @@ -0,0 +1,14 @@ +# Scenario: Late AADJ observed mid-enrollment +# AadUserJoinedLate must NEVER trigger completion (project memory: +# feedback_aad_joined_late_not_completion). This fixture proves that the Late-AADJ signal +# updates the EnrollmentType hypothesis and AadJoinedWithUser fact, but stage is unchanged +# and no terminal event is emitted. Session completes regularly via Hello + Desktop afterwards. +# Plan: §4 M3.1. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T12:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:late-aadj:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T12:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T12:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"AadUserJoinedLate","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T12:11:00Z","SourceOrigin":"AadJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"aad-join-watcher-v1","Summary":"AADJ observed with user present","DerivationInputs":{"registryKey":"HKLM\\...CloudDomainJoin\\JoinInfo","user":"user-anon-0003"}},"Payload":{"aadJoinedWithUser":"true"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"ImeUserSessionCompleted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T12:12:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Raw","Identifier":"ime-log-line-hash:789abc","Summary":"User session completed pattern matched"},"Payload":{"patternId":"ime-pattern-user-session-complete-v1"}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T12:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-exit-detector-v1","Summary":"ESP exit observed","DerivationInputs":{"rawValue":"exit"}}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T12:14:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:43","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}} +{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T12:15:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0003","explorerPid":"4244"}}} diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl new file mode 100644 index 00000000..b76a531f --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-esp-exit-unknown-v1.jsonl @@ -0,0 +1,11 @@ +# Scenario: SelfDeploying Device-Only with ESP-Exit-Unknown +# DeviceSetup phase arrives, but no AccountSetup follows within the 5-minute +# DeviceOnlyEspDetection window — the deadline fires and classifies the session +# as DeviceOnly. Terminal comes via DeviceSetupProvisioningComplete afterwards. +# Expected: Stage=Completed, Outcome=EnrollmentComplete, +# DeviceOnlyDeployment=Confirmed/Strong progression, no user-presence facts. +# Plan: §4 M3.2 — "SelfDeploying-v1 mit ESP-Exit-Unknown -> Device-Only-ESP-Timer -> Complete". +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:device-only:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:06:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:device_only_esp_detection:fired","Summary":"Device-only ESP detection window elapsed without AccountSetup"},"Payload":{"deadline":"device_only_esp_detection"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T10:08:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete","DerivationInputs":{"registryKey":"HKLM\\...Provisioning\\Status","rawValue":"Complete"}}} diff --git a/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl b/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl new file mode 100644 index 00000000..ae0dbcc8 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/selfdeploying-happy-v1.jsonl @@ -0,0 +1,10 @@ +# Scenario: SelfDeploying-v1 Happy (SkipUserStatusPage) +# Device-level ESP only, no AccountSetup phase, no Hello, no Desktop. +# Terminal signal is DeviceSetupProvisioningComplete. +# Expected: Stage=Completed, Outcome=EnrollmentComplete, +# DeviceOnlyDeployment=Confirmed(reason=device_only) (no user-presence facts), +# EnrollmentType=Strong(selfdeploying_provisioning_complete). +# Plan: §4 M3.2 canonical SelfDeploying scenario. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:selfdeploying-happy:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup (SkipUserStatusPage)","DerivationInputs":{"rawValue":"1","skipUserStatusPage":"true"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-21T09:03:00Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"Device setup provisioning complete","DerivationInputs":{"registryKey":"HKLM\\...Provisioning\\Status","rawValue":"Complete"}}} diff --git a/tests/fixtures/enrollment-sessions/userdriven-happy-v1.jsonl b/tests/fixtures/enrollment-sessions/userdriven-happy-v1.jsonl new file mode 100644 index 00000000..b6e6cfa0 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/userdriven-happy-v1.jsonl @@ -0,0 +1,11 @@ +# Scenario: UserDriven-v1 Happy Path (ESP -> AccountSetup -> ESP-Exit -> Hello -> Desktop -> Complete) +# Expected terminal: Stage=Completed, Outcome=EnrollmentComplete, HelloOutcome=Success +# EnrollmentType hypothesis ends at Strong (IME user-session-complete signal). +# Plan: §4 M3.1 canonical scenario. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T10:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:userdriven-happy:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T10:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"registryKey":"HKLM\\...Autopilot\\EspStatus","rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T10:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"registryKey":"HKLM\\...Autopilot\\EspStatus","rawValue":"2"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"ImeUserSessionCompleted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T10:12:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Raw","Identifier":"ime-log-line-hash:abc123","Summary":"User session completed pattern matched"},"Payload":{"patternId":"ime-pattern-user-session-complete-v1"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T10:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-exit-detector-v1","Summary":"ESP exit observed","DerivationInputs":{"rawValue":"exit"}}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T10:14:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:42","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T10:14:30Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0001","explorerPid":"4242"}}} diff --git a/tests/fixtures/enrollment-sessions/userdriven-hello-timeout-v1.jsonl b/tests/fixtures/enrollment-sessions/userdriven-hello-timeout-v1.jsonl new file mode 100644 index 00000000..ce7b77b4 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/userdriven-hello-timeout-v1.jsonl @@ -0,0 +1,11 @@ +# Scenario: UserDriven-v1 with Hello-Timeout +# ESP -> AccountSetup -> ESP-Exit schedules HelloSafety deadline -> Desktop arrives first -> +# HelloSafety deadline fires -> synthetic Hello timeout -> Completed (HelloOutcome=Timeout). +# Plan: §4 M3.1. The deadline-driven path protects against Hello never resolving. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T11:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:hello-timeout:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T11:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T11:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"ImeUserSessionCompleted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T11:12:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Raw","Identifier":"ime-log-line-hash:def456","Summary":"User session completed pattern matched"},"Payload":{"patternId":"ime-pattern-user-session-complete-v1"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T11:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-exit-detector-v1","Summary":"ESP exit observed","DerivationInputs":{"rawValue":"exit"}}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T11:14:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0002","explorerPid":"4243"}}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-20T11:18:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:hello_safety:fired","Summary":"Hello safety deadline fired"},"Payload":{"deadline":"hello_safety"}} diff --git a/tests/fixtures/enrollment-sessions/whiteglove-antiloop-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-antiloop-v1.jsonl new file mode 100644 index 00000000..5c4bb636 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/whiteglove-antiloop-v1.jsonl @@ -0,0 +1,12 @@ +# Scenario: Classifier Anti-Loop — same snapshot produces a single verdict +# SealingPattern fires once (snapshot A -> classifier runs -> Weak). Two subsequent +# ClassifierTick deadlines fire without any intervening fact change — the harness must +# skip both (anti-loop via InputHash match). End state WhiteGloveSealing=Weak. +# Expected: ClassifierRunStats["whiteglove-sealing:run"] == 1, +# ClassifierRunStats["whiteglove-sealing:skipped_by_antiloop"] == 2. +# Plan: §4 M3.3 — Classifier-Anti-Loop / §2.4 InputHash dedup. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-antiloop:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveSealingPatternDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"whiteglove-sealing-pattern-v1","Summary":"IME WG sealing pattern matched","DerivationInputs":{"patternId":"wg-sealing-v1"}}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:02:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:1","Summary":"Classifier tick 1 — same snapshot"},"Payload":{"deadline":"classifier_tick"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T12:03:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:2","Summary":"Classifier tick 2 — same snapshot"},"Payload":{"deadline":"classifier_tick"}} diff --git a/tests/fixtures/enrollment-sessions/whiteglove-false-positive-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-false-positive-v1.jsonl new file mode 100644 index 00000000..cb0f26d7 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/whiteglove-false-positive-v1.jsonl @@ -0,0 +1,18 @@ +# Scenario: WhiteGlove False-Positive defense — Late-AADJ rejects WG +# SealingPattern gives +40 (Weak), then AadUserJoinedLate fires (hard excluder -100). +# A ClassifierTick re-runs the classifier with the updated snapshot -> score capped to 0 +# -> Rejected. The session continues through the Classic UserDriven-v1 completion path, +# ending in EnrollmentComplete (NOT WhiteGloveSealed). +# Expected: Final Stage=Completed, Outcome=EnrollmentComplete, +# WhiteGloveSealing.Level=Rejected. +# Plan: §4 M3.3 — WG-False-Positive-Abwehr; project memory +# feedback_aad_joined_late_not_completion (Late-AADJ never completes prematurely). +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-false-positive:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveSealingPatternDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"whiteglove-sealing-pattern-v1","Summary":"IME WG sealing pattern matched","DerivationInputs":{"patternId":"wg-sealing-v1"}}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"AadUserJoinedLate","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:03:00Z","SourceOrigin":"AadJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"aad-join-watcher-v1","Summary":"AADJ observed with user present","DerivationInputs":{"user":"user-anon-0006"}},"Payload":{"aadJoinedWithUser":"true"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:03:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:1","Summary":"Classifier tick re-runs after AadUserJoinedLate"},"Payload":{"deadline":"classifier_tick"}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-exit-detector-v1","Summary":"ESP exit observed","DerivationInputs":{"rawValue":"exit"}}} +{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:14:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:55","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}} +{"SessionSignalOrdinal":8,"SessionTraceOrdinal":8,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T11:15:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0006","explorerPid":"4245"}}} diff --git a/tests/fixtures/enrollment-sessions/whiteglove-inline-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-inline-v1.jsonl new file mode 100644 index 00000000..5c1a305c --- /dev/null +++ b/tests/fixtures/enrollment-sessions/whiteglove-inline-v1.jsonl @@ -0,0 +1,9 @@ +# Scenario: WhiteGlove Part 1 — Inline Classifier (ShellCore success alone is decisive) +# ShellCoreWhiteGloveSuccess (Event 62407) fires; classifier sees one strong positive +# signal, no hard-excluders -> score 80 -> Confirmed -> WhiteGloveSealed. +# Expected: Stage=WhiteGloveSealed, Outcome=WhiteGlovePart1Sealed, +# WhiteGloveSealing.Level=Confirmed (score >= 70). +# Plan: §4 M3.3 — WG-Inline-Classifier. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-inline:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T09:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T09:02:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:104","Summary":"Shell Core white-glove success (Event 62407)","RawPointer":"blob://events/shell-core/104"}} diff --git a/tests/fixtures/enrollment-sessions/whiteglove-part2-happy-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-part2-happy-v1.jsonl new file mode 100644 index 00000000..4d84cc6c --- /dev/null +++ b/tests/fixtures/enrollment-sessions/whiteglove-part2-happy-v1.jsonl @@ -0,0 +1,16 @@ +# Scenario: WhiteGlove Part 2 Happy (Post-Reboot user sign-in) +# Session picks up from a WhiteGloveSealed Part 1 state (simulated here by driving +# ShellCoreWhiteGloveSuccess first, which confirms Part 1 inline). SessionRecovered +# after a reboot transitions the session to WhiteGloveAwaitingUserSignIn and arms the +# 24h Part-2 safety deadline. All four Part-2 user signals arrive within minutes -> +# Part2 completion classifier returns Confirmed -> Stage=WhiteGloveCompletedPart2 + +# Outcome=WhiteGlovePart2Complete, whiteglove_part2_complete event emitted. +# Plan: §4 M3.4. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-happy:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:02:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:310","Summary":"Shell Core white-glove success"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"SessionRecovered","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:07:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-happy:recovered","Summary":"Agent started after reboot; Part 1 sealed state persisted"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"UserAadSignInComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:10:00Z","SourceOrigin":"AadJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"aad-join-watcher-v1","Summary":"AAD user sign-in complete","DerivationInputs":{"user":"user-anon-0015"}}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"HelloResolvedPart2","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:11:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:211","Summary":"Hello for Business enrolled (Part 2)"},"Payload":{"outcome":"Success"}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"DesktopArrivedPart2","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:12:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user (Part 2)","DerivationInputs":{"user":"user-anon-0015","explorerPid":"4300"}}} +{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"AccountSetupCompletedPart2","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T08:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"account-setup-complete-detector-v1","Summary":"Account setup phase marked complete (Part 2)","DerivationInputs":{"registryKey":"HKLM\\...EspStatus\\AccountSetup","rawValue":"Complete"}}} diff --git a/tests/fixtures/enrollment-sessions/whiteglove-part2-stuck-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-part2-stuck-v1.jsonl new file mode 100644 index 00000000..fac0a843 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/whiteglove-part2-stuck-v1.jsonl @@ -0,0 +1,11 @@ +# Scenario: WhiteGlove Part 2 Stuck (user never signs in; 24h safety deadline fires) +# Session seals Part 1, reboots, and waits for the user — who never signs in. The +# 24h WhiteGlovePart2Safety deadline fires -> Stage=Failed, Outcome=EnrollmentFailed, +# Part2Completion hypothesis Rejected with reason "part2_user_absent", +# enrollment_failed event emitted with reason="part2_user_absent". +# Plan: §4 M3.4. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-stuck:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:02:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:320","Summary":"Shell Core white-glove success"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"SessionRecovered","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-23T09:07:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-part2-stuck:recovered","Summary":"Agent started after reboot"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:07:00Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:whiteglove_part2_safety:fired","Summary":"Part 2 safety deadline fired — no user sign-in within 24 h"},"Payload":{"deadline":"whiteglove_part2_safety"}} diff --git a/tests/fixtures/enrollment-sessions/whiteglove-signal-correlated-v1.jsonl b/tests/fixtures/enrollment-sessions/whiteglove-signal-correlated-v1.jsonl new file mode 100644 index 00000000..5357b442 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/whiteglove-signal-correlated-v1.jsonl @@ -0,0 +1,13 @@ +# Scenario: WhiteGlove Part 1 — Signal-correlated (sealing-pattern + ShellCore together) +# SealingPattern alone scores 40 (Weak). A subsequent ShellCoreWhiteGloveSuccess changes the +# snapshot so the classifier re-runs (not anti-loop-skipped) and hits the confirmed threshold. +# Between the two WG signals a ClassifierTick fires with no new facts — proves the tick is +# safely idempotent under anti-loop. +# Expected: Stage=WhiteGloveSealed after ShellCore step. Classifier runs twice (SealingPattern +# then ShellCore); one ClassifierTick is anti-loop-skipped. +# Plan: §4 M3.3 — WG-Signal-Correlated. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:wg-signal-correlated:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"WhiteGloveSealingPatternDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:02:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Derived","Identifier":"whiteglove-sealing-pattern-v1","Summary":"IME WG sealing pattern matched","DerivationInputs":{"patternId":"wg-sealing-v1","imeLogLineHash":"0xabcd"}}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DeadlineFired","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:02:30Z","SourceOrigin":"DeadlineScheduler","Evidence":{"Kind":"Synthetic","Identifier":"deadline:classifier_tick:fired:1","Summary":"Classifier tick fired (no snapshot change)"},"Payload":{"deadline":"classifier_tick"}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"WhiteGloveShellCoreSuccess","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:05:00Z","SourceOrigin":"ShellCoreTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62407:recordId:201","Summary":"Shell Core white-glove success (Event 62407)","RawPointer":"blob://events/shell-core/201"}} diff --git a/tools/export-session-fixture.mjs b/tools/export-session-fixture.mjs new file mode 100644 index 00000000..e7cabd5e --- /dev/null +++ b/tools/export-session-fixture.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +// ========================================================================= +// export-session-fixture.mjs +// +// Plan: plans/REFACTOR_AGENT_V2.md §4 M2. +// +// Exports a Prod session's event stream (via MCP `get_session_events`) into a +// DecisionSignal-shaped JSONL fixture. During the v11 transition period each +// signal is emitted with `Evidence.Kind = "Derived"` and +// `Evidence.Identifier = "legacy-export-v1"` because the Legacy event stream is +// not a true raw signal stream — the export is a derivation. Once a real V2 +// Agent runs and writes native SignalLog JSONL, fixtures can be captured with +// `Evidence.Kind = "Raw"` and this tool's role shrinks to anonymization only. +// +// M2 scope: scaffolding + schema validation. The MCP call is a placeholder — +// wire it to the real MCP client in M3 when the first fixture-driven reducer +// test needs Prod input. +// +// Usage: +// node tools/export-session-fixture.mjs --help +// node tools/export-session-fixture.mjs --dry-run --session-id= --out=tests/fixtures/enrollment-sessions-local/.jsonl +// node tools/export-session-fixture.mjs --validate= +// ========================================================================= + +import { argv, exit } from "node:process"; +import { writeFileSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { mkdirSync, existsSync } from "node:fs"; + +const args = parseArgs(argv.slice(2)); + +if (args.help) { + printUsage(); + exit(0); +} + +if (args.validate) { + exit(validateJsonl(args.validate) ? 0 : 2); +} + +if (!args.sessionId) { + console.error("ERROR: --session-id= is required. See --help."); + exit(1); +} + +if (args.dryRun) { + // Produce a tiny synthetic fixture so downstream tooling can be wired up + // without a live MCP connection. Each signal has the full required shape + // per DecisionCore.Signals.DecisionSignal + Evidence. + const fixture = buildDryRunFixture(args.sessionId); + const outPath = args.out ?? `tests/fixtures/enrollment-sessions-local/${args.sessionId}.dryrun.jsonl`; + writeJsonl(outPath, fixture); + console.log(`Wrote ${fixture.length} dry-run signals to ${outPath}`); + console.log("Next: fill in MCP client wiring for real session export (M3 bring-up)."); + exit(0); +} + +// TODO(v2-m3): wire up real MCP `get_session_events` call here. +// The MCP client integration lives in src/McpServer/autopilot-monitor-mcp/; +// this script will import the client, authenticate with the admin credentials +// in the local environment, fetch the full event stream for the session, and +// convert each row via mapLegacyEventToDecisionSignal() below. +console.error( + "ERROR: Real MCP export is not wired up yet (plan §4 M3). " + + "Use --dry-run for the scaffolding path, or --validate to check an existing fixture." +); +exit(3); + +// --------------------------------------------------------------------- helpers + +function parseArgs(argv) { + const out = { dryRun: false, help: false }; + for (const arg of argv) { + if (arg === "--help" || arg === "-h") { out.help = true; continue; } + if (arg === "--dry-run") { out.dryRun = true; continue; } + const match = arg.match(/^--([\w-]+)(?:=(.*))?$/); + if (match) { + const [, key, value] = match; + const k = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + out[k] = value ?? true; + } + } + return out; +} + +function printUsage() { + console.log( + ` +export-session-fixture.mjs — export a Prod session to DecisionSignal JSONL + +Arguments: + --session-id= Required for export. Legacy session ID as seen in the Sessions table. + --out= Output path. Default: tests/fixtures/enrollment-sessions-local/.jsonl. + --dry-run Produce a small synthetic fixture (no MCP call). For tooling scaffolding. + --validate= Validate an existing JSONL file's DecisionSignal shape + Pflicht-Evidence. No output. + --help | -h Print this text. + +Evidence policy (plan §2.2 / §4 M2): + During the Legacy->V2 transition, exported signals are emitted as Evidence.Kind="Derived" + with Identifier="legacy-export-v1" and DerivationInputs populated from the source event row. + This preserves replay determinism at the signal-log level while the raw event row gets + discarded after ingest. Raw-Evidence exports become available once V2 Agents run on the + session (they write native SignalLog JSONL directly). + +Output: + Each line = one JSON-serialized DecisionSignal record. Comment lines starting with '#' + and blank lines are allowed and ignored by the Replay Harness. +` + ); +} + +function buildDryRunFixture(sessionId) { + // Minimal three-signal synthetic session: start, one phase change, recover. + // Reducer handlers are not implemented yet (M3); the harness parses and counts. + const t0 = new Date("2026-04-20T10:00:00Z").toISOString(); + const t1 = new Date("2026-04-20T10:01:00Z").toISOString(); + const t2 = new Date("2026-04-20T10:02:00Z").toISOString(); + return [ + { + SessionSignalOrdinal: 0, + SessionTraceOrdinal: 0, + Kind: "SessionStarted", + KindSchemaVersion: 1, + OccurredAtUtc: t0, + SourceOrigin: "export-session-fixture/dry-run", + Evidence: { + Kind: "Synthetic", + Identifier: `session:${sessionId}:started`, + Summary: "Dry-run session-started marker", + }, + Payload: { sessionId }, + }, + { + SessionSignalOrdinal: 1, + SessionTraceOrdinal: 1, + Kind: "EspPhaseChanged", + KindSchemaVersion: 1, + OccurredAtUtc: t1, + SourceOrigin: "export-session-fixture/dry-run", + Evidence: { + Kind: "Derived", + Identifier: "legacy-export-v1", + Summary: "phase=AccountSetup (dry-run)", + DerivationInputs: { + legacyEventType: "esp_phase_changed", + legacyPhase: "AccountSetup", + }, + }, + Payload: { phase: "AccountSetup" }, + }, + { + SessionSignalOrdinal: 2, + SessionTraceOrdinal: 2, + Kind: "SessionRecovered", + KindSchemaVersion: 1, + OccurredAtUtc: t2, + SourceOrigin: "export-session-fixture/dry-run", + Evidence: { + Kind: "Synthetic", + Identifier: `session:${sessionId}:recovered`, + Summary: "Dry-run reboot/recover marker", + }, + Payload: {}, + }, + ]; +} + +function writeJsonl(path, records) { + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const body = records.map((r) => JSON.stringify(r)).join("\n") + "\n"; + writeFileSync(path, body, { encoding: "utf-8" }); +} + +function validateJsonl(path) { + const text = readFileSync(path, { encoding: "utf-8" }); + let ok = true; + let n = 0; + for (const [i, rawLine] of text.split(/\r?\n/).entries()) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + let rec; + try { + rec = JSON.parse(line); + } catch (err) { + console.error(`Line ${i + 1}: JSON parse error — ${err.message}`); + ok = false; + continue; + } + n++; + const problems = validateSignalShape(rec); + if (problems.length) { + console.error(`Line ${i + 1}: ${problems.join("; ")}`); + ok = false; + } + } + console.log(`Validated ${n} signals from ${path}: ${ok ? "OK" : "FAIL"}`); + return ok; +} + +function validateSignalShape(rec) { + const errs = []; + for (const key of ["SessionSignalOrdinal", "SessionTraceOrdinal", "Kind", "KindSchemaVersion", "OccurredAtUtc", "SourceOrigin", "Evidence"]) { + if (!(key in rec)) errs.push(`missing ${key}`); + } + if (rec.KindSchemaVersion !== undefined && rec.KindSchemaVersion < 1) errs.push("KindSchemaVersion must be >= 1"); + if (rec.Evidence) { + const ev = rec.Evidence; + for (const key of ["Kind", "Identifier", "Summary"]) { + if (!(key in ev) || ev[key] === "") errs.push(`Evidence.${key} missing/empty`); + } + if (ev.Kind === "Derived" && (!ev.DerivationInputs || Object.keys(ev.DerivationInputs).length === 0)) { + errs.push("Evidence.Derived requires DerivationInputs"); + } + } + return errs; +}