mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
Merge pull request #43 — V2 Agent refactor + M5 backend + Codex review fixes
Merges branch Agent-v2.0 onto main: complete V1 to V2 agent kernel refactor (M1 to M5), admin UI for EnableIndexDualWrite flag, plus the three Codex review fixes and the SessionTraceOrdinal recovery-seed follow-up. 60 sub-milestone commits preserved via merge-commit strategy for future git-bisect traceability.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
+11
-1
@@ -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
|
||||
|
||||
+77
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
V2-Agent-Core test project. Plan §4 M4 gates.
|
||||
TargetFramework must be net48 to reference the net48-only V2.Core library.
|
||||
-->
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- V2.Core + V2 exe (for Program.Guards startup helpers, M4.6.α) + dependencies.
|
||||
Plan §2.16 isolation: NO reference to Agent.Core. -->
|
||||
<ProjectReference Include="..\AutopilotMonitor.Agent.V2.Core\AutopilotMonitor.Agent.V2.Core.csproj" />
|
||||
<ProjectReference Include="..\AutopilotMonitor.Agent.V2\AutopilotMonitor.Agent.V2.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// Disposable per-test temp directory. Deleted on <see cref="Dispose"/> even if
|
||||
/// sub-files are still open (best-effort).
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// Builder helpers for <see cref="DecisionSignal"/> in tests — keeps call sites compact.
|
||||
/// </summary>
|
||||
internal static class TestSignals
|
||||
{
|
||||
public static DecisionSignal Raw(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind = DecisionSignalKind.EspPhaseChanged,
|
||||
DateTime? occurredAtUtc = null,
|
||||
long? traceOrdinal = null,
|
||||
string origin = "UnitTest",
|
||||
IReadOnlyDictionary<string, string>? 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// Deterministic <see cref="IClock"/> for V2.Core tests.
|
||||
/// 1:1-Copy aus <c>DecisionCore.Tests/Harness/VirtualClock.cs</c> — Plan §2.16
|
||||
/// Copy-Not-Share-Prinzip zwischen Testprojekten (konsistent zur V2-Isolation).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end integration-test fixture for the V2 Agent runtime.
|
||||
/// Plan §4.x M4.4.5.g.
|
||||
/// <para>
|
||||
/// <b>Drives the real pipeline</b>: DecisionEngine + DecisionStepProcessor + SignalIngress +
|
||||
/// EffectRunner + Persistence (SignalLog/Journal/Snapshot) + TelemetryTransport + production
|
||||
/// Classifiers (WhiteGloveSealingClassifier + WhiteGlovePart2CompletionClassifier).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>System-boundary fakes only</b>: HTTP upload (<see cref="FakeBackendTelemetryUploader"/>),
|
||||
/// wall-clock (<see cref="VirtualClock"/>), file-system (isolated <see cref="TempDirectory"/>).
|
||||
/// No collectors are wired — tests drive signals directly via
|
||||
/// <see cref="PostFixture"/> / <see cref="Post"/>, which mirrors the Collector→Adapter→Ingress
|
||||
/// path but lets the test control timing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deadline caveat</b>: the real <see cref="DeadlineScheduler"/> runs. Effect-scheduled
|
||||
/// deadlines use wall-clock timers; fixture signals with <c>DeadlineFired</c>-Kind are
|
||||
/// posted manually (same as the M3 <c>ClassifierAwareReplayHarness</c>) — the real timers
|
||||
/// would fire in the wall-clock future, long after test shutdown.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<IClassifier>
|
||||
{
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Post one signal via the IngressSink, mirroring the Collector→Adapter path.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load an M3 fixture (e.g. <c>"userdriven-happy-v1.jsonl"</c>) and replay every signal
|
||||
/// in order. After each post, wait until:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>processor.CurrentState.StepIndex</c> has advanced — ApplyStep fully
|
||||
/// completed (journal + effects + snapshot + state-forward)</item>
|
||||
/// <item>Ingress queue is empty — any <c>ClassifierVerdictIssued</c> posted by the
|
||||
/// EffectRunner has been processed</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Why not <see cref="ISignalLogWriter.LastOrdinal"/>?</b> That counter is updated
|
||||
/// inside <c>SignalIngress.ProcessItem</c> BEFORE the EffectRunner runs — so a wait
|
||||
/// on LastOrdinal can release before any synthetic signal (e.g. a classifier verdict)
|
||||
/// has even been posted. <c>StepIndex</c> on the other hand is only incremented after
|
||||
/// <c>DecisionStepProcessor.ApplyStep</c> completes, by which time the EffectRunner has
|
||||
/// synchronously posted any <c>RunClassifier</c>-triggered synthetic.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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)!;
|
||||
}
|
||||
|
||||
/// <summary>Waits for <see cref="DecisionState.Stage"/> to match any of <paramref name="stages"/>.</summary>
|
||||
public bool WaitForStage(int timeoutMs, params SessionStage[] stages)
|
||||
{
|
||||
var target = new HashSet<SessionStage>(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)!;
|
||||
}
|
||||
|
||||
/// <summary>Returns the last journal transition (or null if empty).</summary>
|
||||
public AutopilotMonitor.DecisionCore.Engine.DecisionTransition? LastTransition()
|
||||
{
|
||||
var all = GetJournal().ReadAll();
|
||||
return all.Count == 0 ? null : all[all.Count - 1];
|
||||
}
|
||||
|
||||
/// <summary>Returns all event-kind items that were enqueued for the backend (post-drain or not).</summary>
|
||||
public IReadOnlyList<TelemetryItem> AllEventItemsInSpool()
|
||||
{
|
||||
return GetSpool()
|
||||
.Peek(int.MaxValue)
|
||||
.Where(i => i.Kind == TelemetryItemKind.Event)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Orchestrator.Dispose(); }
|
||||
catch { /* best-effort */ }
|
||||
Tmp.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Load a DecisionSignal JSONL fixture from <c>tests/fixtures/enrollment-sessions/</c>.
|
||||
/// 1:1-Copy aus <c>DecisionCore.Tests/Harness/FixtureLoader.cs</c> — Plan §2.16 Copy-Not-Share-
|
||||
/// Prinzip für V2-Isolation.
|
||||
/// <para>
|
||||
/// Comment lines starting with '#' and blank lines are ignored.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class FixtureLoader
|
||||
{
|
||||
public static IReadOnlyList<DecisionSignal> LoadJsonl(string path)
|
||||
{
|
||||
var signals = new List<DecisionSignal>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate the <c>tests/fixtures/enrollment-sessions</c> directory relative to the test binary.
|
||||
/// Walks up from <see cref="AppContext.BaseDirectory"/> looking for the solution file.
|
||||
/// </summary>
|
||||
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<DecisionSignal> Load(string filename) =>
|
||||
LoadJsonl(Path.Combine(FixtureRoot(), filename));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end runtime tests that drive the V2 Agent orchestrator through the same fixtures
|
||||
/// the M3 <c>ClassifierAwareReplayHarness</c> uses. Release-Gate M4.4 (Plan §4.x M4.4.5.g).
|
||||
/// <para>
|
||||
/// Each test loads a JSONL fixture from <c>tests/fixtures/enrollment-sessions/</c>, replays
|
||||
/// all signals through <see cref="Orchestration.SignalIngress"/>, and asserts the final
|
||||
/// <see cref="DecisionState"/> + journal tail against the expected terminal shape. Unlike
|
||||
/// the M3 harness, this exercises Persistence + EffectRunner + Classifiers + Telemetry in
|
||||
/// a single live pipeline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Scenario coverage mirrors <c>AutopilotMonitor.DecisionCore.Tests/Scenarios/*</c>:
|
||||
/// 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).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -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<ActiveDeadline>();
|
||||
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<ObjectDisposedException>(() =>
|
||||
sched.Schedule(Deadline("x", DateTime.UtcNow.AddSeconds(1))));
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// M4.6.β — <see cref="DecisionStepProcessor"/> fires the terminal-stage callback exactly
|
||||
/// once, at the transition from a non-terminal to a terminal <see cref="SessionStage"/>.
|
||||
/// </summary>
|
||||
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<DecisionState>? 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<DecisionEffect>());
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+247
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Rig wires the processor against in-memory fakes + a real <see cref="DecisionEngine"/>
|
||||
/// so we get genuine <see cref="DecisionStep"/> instances (ctor-validated transition +
|
||||
/// effects) without duplicating the reducer shape in test builders.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Builds a real DecisionStep by Reducing SessionStarted against the current state.</summary>
|
||||
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<ArgumentNullException>(() =>
|
||||
new DecisionStepProcessor(null!, rig.Journal, rig.Effects, rig.Snapshot, rig.Quarantine, rig.Logger));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new DecisionStepProcessor(rig.InitialState, null!, rig.Effects, rig.Snapshot, rig.Quarantine, rig.Logger));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new DecisionStepProcessor(rig.InitialState, rig.Journal, null!, rig.Snapshot, rig.Quarantine, rig.Logger));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new DecisionStepProcessor(rig.InitialState, rig.Journal, rig.Effects, null!, rig.Quarantine, rig.Logger));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new DecisionStepProcessor(rig.InitialState, rig.Journal, rig.Effects, rig.Snapshot, null!, rig.Logger));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
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<ArgumentOutOfRangeException>(() => rig.Build(threshold: 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => sut.ApplyStep(step, signal));
|
||||
Assert.Throws<InvalidOperationException>(() => sut.ApplyStep(step, signal));
|
||||
Assert.Empty(rig.Quarantine.Reasons);
|
||||
|
||||
// Failure 3 crosses the threshold.
|
||||
Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<AutopilotMonitor.Agent.V2.Core.Orchestration.EffectFailure>(),
|
||||
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<ArgumentNullException>(() => sut.ApplyStep(null!, signal));
|
||||
Assert.Throws<ArgumentNullException>(() => sut.ApplyStep(step, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Codex Finding 1 regression gate for <see cref="DecisionStepProcessor"/>: verifies that
|
||||
/// every successfully-journaled <see cref="DecisionTransition"/> is also projected onto
|
||||
/// the telemetry transport for backend upload, and that a throwing emitter does NOT
|
||||
/// abort the step.
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(() => 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<DrainResult> DrainAllAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(DrainResult.Empty());
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, IClassifier> Classifiers { get; } = new Dictionary<string, IClassifier>();
|
||||
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<string, string> { ["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<string, string> { ["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<DecisionEffect>(), 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<DecisionStateBuilder> mutate)
|
||||
{
|
||||
mutate(b);
|
||||
return b;
|
||||
}
|
||||
}
|
||||
}
|
||||
+294
@@ -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);
|
||||
|
||||
/// <summary>Test host that lets a test manually fire enrollment events.</summary>
|
||||
private sealed class FakeCollectorHost : ICollectorHost
|
||||
{
|
||||
private readonly Action<EnrollmentEvent> _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<EnrollmentEvent> 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<EnrollmentEvent>? CapturedSink { get; private set; }
|
||||
public readonly List<FakeCollectorHost> Hosts = new List<FakeCollectorHost>();
|
||||
public readonly IReadOnlyList<string> HostNames;
|
||||
|
||||
public FakeComponentFactory(params string[] hostNames)
|
||||
{
|
||||
HostNames = hostNames.Length == 0
|
||||
? new[] { "HelloTracker", "ShellCoreTracker", "ProvisioningStatusTracker", "StallProbeCollector", "ModernDeploymentTracker" }
|
||||
: hostNames;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string>? CapturedWhiteGloveSealingPatternIds { get; private set; }
|
||||
|
||||
public ISignalIngressSink? CapturedIngress { get; private set; }
|
||||
public IClock? CapturedClock { get; private set; }
|
||||
|
||||
public IReadOnlyList<ICollectorHost> CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
IReadOnlyCollection<string> 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<IClassifier> Classifiers { get; } = new List<IClassifier>();
|
||||
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<IClassifier>(),
|
||||
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)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// M4.6.α — max-lifetime watchdog. When the configured lifetime elapses without a
|
||||
/// decision-terminal stage, the orchestrator raises <see cref="EnrollmentOrchestrator.Terminated"/>
|
||||
/// with reason <see cref="EnrollmentTerminationReason.MaxLifetimeExceeded"/> and outcome
|
||||
/// <see cref="EnrollmentTerminationOutcome.TimedOut"/>.
|
||||
/// </summary>
|
||||
[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<IClassifier>(),
|
||||
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<ArgumentOutOfRangeException>(() => Build(tmp, agentMaxLifetime: TimeSpan.Zero));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+330
@@ -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<IClassifier> Classifiers { get; } = new List<IClassifier>();
|
||||
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<ArgumentException>(() => new EnrollmentOrchestrator(
|
||||
"", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers));
|
||||
Assert.Throws<ArgumentException>(() => new EnrollmentOrchestrator(
|
||||
"S1", "", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers));
|
||||
Assert.Throws<ArgumentException>(() => new EnrollmentOrchestrator(
|
||||
"S1", "T1", "", rig.TransportDir, rig.Clock, rig.Logger, rig.Uploader, rig.Classifiers));
|
||||
Assert.Throws<ArgumentException>(() => 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<ArgumentNullException>(() => new EnrollmentOrchestrator(
|
||||
"S1", "T1", rig.StateDir, rig.TransportDir, null!, rig.Logger, rig.Uploader, rig.Classifiers));
|
||||
Assert.Throws<ArgumentNullException>(() => new EnrollmentOrchestrator(
|
||||
"S1", "T1", rig.StateDir, rig.TransportDir, rig.Clock, null!, rig.Uploader, rig.Classifiers));
|
||||
Assert.Throws<ArgumentNullException>(() => new EnrollmentOrchestrator(
|
||||
"S1", "T1", rig.StateDir, rig.TransportDir, rig.Clock, rig.Logger, null!, rig.Classifiers));
|
||||
Assert.Throws<ArgumentNullException>(() => 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<InvalidOperationException>(() => sut.CurrentState);
|
||||
Assert.Throws<InvalidOperationException>(() => sut.IngressSink);
|
||||
Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ObjectDisposedException>(() => 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)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The orchestrator owns its <see cref="DeadlineScheduler"/> 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.
|
||||
/// </summary>
|
||||
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)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -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<string>? CapturedPatternIds { get; private set; }
|
||||
|
||||
public IReadOnlyList<ICollectorHost> CreateCollectorHosts(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
Action<EnrollmentEvent> onEnrollmentEvent,
|
||||
IReadOnlyCollection<string> whiteGloveSealingPatternIds,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock)
|
||||
{
|
||||
CapturedPatternIds = whiteGloveSealingPatternIds;
|
||||
return Array.Empty<ICollectorHost>();
|
||||
}
|
||||
}
|
||||
|
||||
private EnrollmentOrchestrator Build(
|
||||
TempDirectory tmp,
|
||||
IComponentFactory? factory = null,
|
||||
IReadOnlyCollection<string>? 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<IClassifier>(),
|
||||
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<string>());
|
||||
|
||||
sut.Start();
|
||||
|
||||
Assert.NotNull(factory.CapturedPatternIds);
|
||||
Assert.Empty(factory.CapturedPatternIds!);
|
||||
|
||||
sut.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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<BackPressureCall> _calls = new List<BackPressureCall>();
|
||||
|
||||
public int CallCount
|
||||
{
|
||||
get { lock (_lock) return _calls.Count; }
|
||||
}
|
||||
|
||||
public IReadOnlyList<BackPressureCall> 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Test fake <see cref="IClassifier"/> — returns a canned verdict or throws on demand.
|
||||
/// </summary>
|
||||
internal sealed class FakeClassifier : IClassifier
|
||||
{
|
||||
public FakeClassifier(string id, Func<object, ClassifierVerdict> classifyFn)
|
||||
{
|
||||
Id = id;
|
||||
_classifyFn = classifyFn;
|
||||
SnapshotType = typeof(object);
|
||||
}
|
||||
|
||||
private readonly Func<object, ClassifierVerdict> _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<string>(),
|
||||
reason: "fake",
|
||||
inputHash: inputHash);
|
||||
}
|
||||
|
||||
internal sealed class HashSnapshot : IClassifierSnapshot
|
||||
{
|
||||
private readonly string _hash;
|
||||
public HashSnapshot(string hash) => _hash = hash;
|
||||
public string ComputeInputHash() => _hash;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Test fake — records Schedule/Cancel calls and can be scripted to throw on the next call.
|
||||
/// Avoids the real <see cref="DeadlineScheduler"/>'s timer plumbing for tests that don't
|
||||
/// need it.
|
||||
/// </summary>
|
||||
internal sealed class FakeDeadlineScheduler : IDeadlineScheduler
|
||||
{
|
||||
private readonly List<ActiveDeadline> _scheduled = new List<ActiveDeadline>();
|
||||
private readonly List<string> _cancelled = new List<string>();
|
||||
|
||||
public Exception? ThrowOnSchedule { get; set; }
|
||||
public Exception? ThrowOnCancel { get; set; }
|
||||
|
||||
public IReadOnlyList<ActiveDeadline> Scheduled => _scheduled;
|
||||
public IReadOnlyList<string> Cancelled => _cancelled;
|
||||
|
||||
public event EventHandler<DeadlineFiredEventArgs>? Fired;
|
||||
|
||||
public IReadOnlyList<ActiveDeadline> 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<ActiveDeadline> deadlines)
|
||||
{
|
||||
foreach (var d in deadlines) Schedule(d);
|
||||
}
|
||||
|
||||
public void Dispose() { /* no real timer to dispose */ }
|
||||
|
||||
/// <summary>Test helper — raises the <see cref="Fired"/> event with given args.</summary>
|
||||
public void RaiseFired(ActiveDeadline deadline, DateTime firedAtUtc) =>
|
||||
Fired?.Invoke(this, new DeadlineFiredEventArgs(deadline, firedAtUtc));
|
||||
}
|
||||
}
|
||||
+97
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Test fake for <see cref="IDecisionStepProcessor"/>.
|
||||
/// Captures ApplyStep calls, advances <see cref="CurrentState"/> to the step's new state,
|
||||
/// and supports scripted exceptions + a blocking handle for back-pressure tests.
|
||||
/// </summary>
|
||||
internal sealed class FakeDecisionStepProcessor : IDecisionStepProcessor
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private readonly Queue<Exception?> _script = new Queue<Exception?>();
|
||||
private readonly List<ApplyCall> _calls = new List<ApplyCall>();
|
||||
private DecisionState _currentState;
|
||||
private int _failedApplyCount;
|
||||
|
||||
public FakeDecisionStepProcessor(string sessionId = "S1", string tenantId = "T1")
|
||||
{
|
||||
_currentState = DecisionState.CreateInitial(sessionId, tenantId);
|
||||
}
|
||||
|
||||
/// <summary>Wenn gesetzt, wartet <see cref="ApplyStep"/> darauf bevor es weiterläuft.</summary>
|
||||
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<ApplyCall> 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Scripted <see cref="IEffectRunner"/> for processor-level unit tests.
|
||||
/// Default behavior returns <see cref="EffectRunResult.Empty"/>; use <c>ScriptResult</c>
|
||||
/// or <c>ScriptThrow</c> to script per-call responses.
|
||||
/// </summary>
|
||||
internal sealed class FakeEffectRunner : IEffectRunner
|
||||
{
|
||||
private readonly Queue<object> _script = new Queue<object>();
|
||||
private readonly List<RunCall> _calls = new List<RunCall>();
|
||||
|
||||
public IReadOnlyList<RunCall> 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<EffectRunResult> RunAsync(
|
||||
IReadOnlyList<DecisionEffect> 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<DecisionEffect> effects, DecisionState state, DateTime at)
|
||||
{
|
||||
Effects = effects;
|
||||
State = state;
|
||||
OccurredAtUtc = at;
|
||||
}
|
||||
|
||||
public IReadOnlyList<DecisionEffect> Effects { get; }
|
||||
public DecisionState State { get; }
|
||||
public DateTime OccurredAtUtc { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Test fake — records every <see cref="Emit"/> call and optionally throws a scripted
|
||||
/// sequence of exceptions (to exercise retry behavior).
|
||||
/// </summary>
|
||||
internal sealed class FakeEventTimelineEmitter : IEventTimelineEmitter
|
||||
{
|
||||
private readonly Queue<Func<int, Exception?>> _throwScript = new Queue<Func<int, Exception?>>();
|
||||
private readonly List<EmitCall> _calls = new List<EmitCall>();
|
||||
|
||||
public IReadOnlyList<EmitCall> Calls => _calls;
|
||||
public int CallCount => _calls.Count;
|
||||
|
||||
/// <summary>Next <paramref name="count"/> calls throw <paramref name="ex"/>.</summary>
|
||||
public FakeEventTimelineEmitter ScriptThrow(Exception ex, int count = 1)
|
||||
{
|
||||
for (int i = 0; i < count; i++) _throwScript.Enqueue(_ => ex);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Next <paramref name="count"/> calls succeed (no-op).</summary>
|
||||
public FakeEventTimelineEmitter ScriptOk(int count = 1)
|
||||
{
|
||||
for (int i = 0; i < count; i++) _throwScript.Enqueue(_ => null);
|
||||
return this;
|
||||
}
|
||||
|
||||
public void Emit(IReadOnlyDictionary<string, string>? 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<string, string>? parameters, DecisionState state, DateTime occurredAtUtc)
|
||||
{
|
||||
Parameters = parameters;
|
||||
State = state;
|
||||
OccurredAtUtc = occurredAtUtc;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Parameters { get; }
|
||||
public DecisionState State { get; }
|
||||
public DateTime OccurredAtUtc { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IJournalWriter"/> fake with scripted exceptions.
|
||||
/// Mirrors the <see cref="FakeSnapshotPersistence"/> pattern.
|
||||
/// </summary>
|
||||
internal sealed class FakeJournalWriter : IJournalWriter
|
||||
{
|
||||
private readonly Queue<Exception?> _appendScript = new Queue<Exception?>();
|
||||
private readonly List<DecisionTransition> _appended = new List<DecisionTransition>();
|
||||
private int _failedAppends;
|
||||
|
||||
public IReadOnlyList<DecisionTransition> 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<DecisionTransition> ReadAll() => _appended.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>Captures <see cref="IQuarantineSink.TriggerQuarantine"/> calls for assertions.</summary>
|
||||
internal sealed class FakeQuarantineSink : IQuarantineSink
|
||||
{
|
||||
private readonly List<string> _reasons = new List<string>();
|
||||
|
||||
public IReadOnlyList<string> Reasons => _reasons;
|
||||
public int CallCount => _reasons.Count;
|
||||
|
||||
public void TriggerQuarantine(string reason) => _reasons.Add(reason);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Test fake — captures every synthetic signal posted by the EffectRunner.
|
||||
/// </summary>
|
||||
internal sealed class FakeSignalIngressSink : ISignalIngressSink
|
||||
{
|
||||
private readonly List<PostedSignal> _posted = new List<PostedSignal>();
|
||||
|
||||
public IReadOnlyList<PostedSignal> Posted => _posted;
|
||||
|
||||
public void Post(
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
string sourceOrigin,
|
||||
Evidence evidence,
|
||||
IReadOnlyDictionary<string, string>? 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<string, string>? 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<string, string>? Payload { get; }
|
||||
public int KindSchemaVersion { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Test fake — in-memory snapshot persistence with scripted exception sequence.
|
||||
/// </summary>
|
||||
internal sealed class FakeSnapshotPersistence : ISnapshotPersistence
|
||||
{
|
||||
private readonly Queue<Exception?> _saveScript = new Queue<Exception?>();
|
||||
private readonly List<DecisionState> _saved = new List<DecisionState>();
|
||||
|
||||
public IReadOnlyList<DecisionState> 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 */ }
|
||||
}
|
||||
}
|
||||
+125
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an in-memory self-signed cert so the factory has something real to attach
|
||||
/// without touching the Windows cert store. <see cref="CertificateRequest"/> is
|
||||
/// available on net48 (4.7.2+).
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(
|
||||
() => 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<ArgumentNullException>(
|
||||
() => MtlsHttpClientFactory.Create(null!, logger));
|
||||
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => MtlsHttpClientFactory.Create(new FakeCertificateResolver(cert), null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IClassifier>(),
|
||||
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<DrainResult>)method!.Invoke(sut, new object?[] { CancellationToken.None })!;
|
||||
return task.GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Serialises threading-sensitive test classes against each other so xUnit does not
|
||||
/// run them in parallel. Plan §4.x M4.5.c.
|
||||
/// <para>
|
||||
/// The affected tests use <see cref="System.Threading.SpinWait.SpinUntil"/> /
|
||||
/// <see cref="System.Threading.ManualResetEventSlim.Wait"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Classes carrying <c>[Collection("SerialThreading")]</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[CollectionDefinition("SerialThreading", DisableParallelization = true)]
|
||||
public sealed class SerialThreadingCollection
|
||||
{
|
||||
// Marker class — xUnit reads only the attribute. Intentionally empty.
|
||||
}
|
||||
}
|
||||
+66
@@ -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<long[]>[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<long>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
/// <summary>
|
||||
/// Konstruktionshelfer — baut eine SignalIngress gegen In-Memory-Writer + Fake-Engine.
|
||||
/// </summary>
|
||||
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<bool> 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<InvalidOperationException>(() => 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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<DecisionStep, DecisionSignal, long> _inspect;
|
||||
private DecisionCore.State.DecisionState _state = DecisionCore.State.DecisionState.CreateInitial("S1", "T1");
|
||||
|
||||
public ApplyInspectingProcessor(ISignalLogWriter log, Action<DecisionStep, DecisionSignal, long> 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<string, string> { ["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<ArgumentNullException>(() =>
|
||||
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<ArgumentException>(() =>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts <paramref name="signalCount"/>-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).
|
||||
/// </summary>
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Codex Finding 1 regression gate: without the emitter hook in
|
||||
/// <see cref="SignalIngress"/>, no signal ever reached the telemetry transport and the
|
||||
/// backend <c>/api/sessions/{id}/signals</c> 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.
|
||||
/// </summary>
|
||||
[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<bool> 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<DrainResult> DrainAllAsync(CancellationToken cancellationToken = default)
|
||||
=> System.Threading.Tasks.Task.FromResult(DrainResult.Empty());
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() => w.Append(Step(5)));
|
||||
Assert.Throws<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() => writer.Append(TestSignals.Raw(5)));
|
||||
Assert.Throws<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -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<DateTime>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Codex Finding 2 regression gate: verifies that the <c>terminate_session</c> ServerAction's
|
||||
/// <c>adminOutcome</c> param is mapped onto <see cref="EnrollmentTerminationOutcome"/>
|
||||
/// instead of being silently hard-coded to <see cref="EnrollmentTerminationOutcome.Failed"/>.
|
||||
/// Portal Mark-Succeeded traffic is directly exercised here.
|
||||
/// </summary>
|
||||
public sealed class AdminActionOutcomeMappingTests
|
||||
{
|
||||
[Fact]
|
||||
public void adminOutcome_Succeeded_maps_to_Succeeded()
|
||||
{
|
||||
var result = global::AutopilotMonitor.Agent.V2.Program.MapAdminOutcome(
|
||||
new Dictionary<string, string> { ["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<string, string> { ["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<string, string> { ["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<string, string> { ["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<string, string> { ["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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the M4.6.α startup guards in <c>Program.Guards.cs</c>. These helpers are
|
||||
/// <c>internal static</c>; the V2 exe exposes its internals to this test assembly via
|
||||
/// <c>[InternalsVisibleTo]</c>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ArgumentNullException>(() =>
|
||||
new AgentAnalyzerManager(null!, logger, _ => { }, null));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new AgentAnalyzerManager(Config(), null!, _ => { }, null));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<IAgentAnalyzer>)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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentNullException>(() => new SessionIdPersistence(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new SessionIdPersistence(""));
|
||||
Assert.Throws<ArgumentNullException>(() => 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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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<ArgumentNullException>(() => new AadJoinWatcherAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new AadJoinWatcherAdapter(f.Watcher, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new AadJoinWatcherAdapter(f.Watcher, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -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<ArgumentNullException>(
|
||||
() => new DesktopArrivalDetectorAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => new DesktopArrivalDetectorAdapter(f.Detector, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => new DesktopArrivalDetectorAdapter(f.Detector, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan §4.x M4.4.4 — the adapter's <c>ReadHelloOutcome</c> now forwards from
|
||||
/// <see cref="EspAndHelloTracker.HelloOutcome"/> (which itself delegates to the inner
|
||||
/// <c>HelloTracker</c>) instead of hardcoded <c>"unknown"</c>. Verified via an internal
|
||||
/// test hook that exercises the full event-handler emit path without driving the inner
|
||||
/// HelloTracker's live event sources.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -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<ArgumentNullException>(() => new EspAndHelloTrackerAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new EspAndHelloTrackerAdapter(f.Coordinator, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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<ArgumentNullException>(() => new HelloTrackerAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new HelloTrackerAdapter(f.Tracker, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new HelloTrackerAdapter(f.Tracker, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan §4.x M4.4.4 — the <c>OnPatternMatched</c> hook on <see cref="ImeLogTracker"/>
|
||||
/// and the <c>WhiteGloveSealingPatternDetected</c> emission it enables on
|
||||
/// <see cref="ImeLogTrackerAdapter"/>.
|
||||
/// </summary>
|
||||
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<ImeLogPattern>(),
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+232
@@ -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<ImeLogPattern>(),
|
||||
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<string> 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<ArgumentNullException>(() => new ImeLogTrackerAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new ImeLogTrackerAdapter(f.Tracker, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new ImeLogTrackerAdapter(f.Tracker, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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<ArgumentNullException>(
|
||||
() => new ProvisioningStatusTrackerAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => new ProvisioningStatusTrackerAdapter(f.Tracker, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => new ProvisioningStatusTrackerAdapter(f.Tracker, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -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<ArgumentNullException>(() => new ShellCoreTrackerAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new ShellCoreTrackerAdapter(f.Tracker, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new ShellCoreTrackerAdapter(f.Tracker, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -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<ArgumentNullException>(() => new StallProbeCollectorAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new StallProbeCollectorAdapter(f.Collector, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new StallProbeCollectorAdapter(f.Collector, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -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<ArgumentNullException>(() => new BackPressureEventObserver(null!, new VirtualClock(At)));
|
||||
Assert.Throws<ArgumentNullException>(() => new BackPressureEventObserver(inner, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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<long[]>[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<long>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -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<string, string> { ["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<string, string> { ["eventType"] = "enrollment_complete" };
|
||||
|
||||
r.Sut.Emit(parameters, State(), deterministicTime);
|
||||
|
||||
var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson);
|
||||
var ts = parsed["Timestamp"]!.ToObject<DateTime>();
|
||||
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<string, string> { ["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<string, string> { ["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<string, string>
|
||||
{
|
||||
["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<string, string> { ["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<string, string> { ["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<ArgumentException>(() => r.Sut.Emit(null, State(), At));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
r.Sut.Emit(new Dictionary<string, string>(), State(), At));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
r.Sut.Emit(new Dictionary<string, string> { ["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<ArgumentNullException>(() =>
|
||||
r.Sut.Emit(new Dictionary<string, string> { ["eventType"] = "x" }, null!, At));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Source_is_decision_engine()
|
||||
{
|
||||
using var r = new Rig();
|
||||
r.Sut.Emit(
|
||||
new Dictionary<string, string> { ["eventType"] = "enrollment_complete" },
|
||||
State(),
|
||||
At);
|
||||
|
||||
var parsed = JObject.Parse(r.Transport.Enqueued[0].PayloadJson);
|
||||
Assert.Equal("decision_engine", (string?)parsed["Source"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory ITelemetryTransport that captures every Enqueue call. Builds a fully-populated
|
||||
/// <see cref="TelemetryItem"/> (assigned TelemetryItemId, SessionTraceOrdinal when session-scoped),
|
||||
/// so callers can verify field propagation without disk-backed plumbing.
|
||||
/// </summary>
|
||||
internal sealed class FakeTelemetryTransport : ITelemetryTransport
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private readonly List<TelemetryItem> _enqueued = new List<TelemetryItem>();
|
||||
private long _nextItemId = 0;
|
||||
|
||||
public IReadOnlyList<TelemetryItem> 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<DrainResult> DrainAllAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(DrainResult.Empty());
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
+178
@@ -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<ArgumentNullException>(() => emitter.Emit(null!));
|
||||
|
||||
var evt = NewTestEvent();
|
||||
evt.EventType = string.Empty;
|
||||
Assert.Throws<ArgumentException>(() => 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<string, object> { ["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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure emitter tests for <see cref="TelemetrySignalEmitter"/> (Codex Finding 1 fix).
|
||||
/// Verifies the agent-side wire format and transport projection; the cross-layer
|
||||
/// round-trip with the backend parser lives in <c>TelemetrySignalRoundTripTests</c>.
|
||||
/// </summary>
|
||||
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<ArgumentNullException>(() => emitter.Emit(null!));
|
||||
Assert.Equal(0, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_rejects_null_or_empty_identifiers()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
Assert.Throws<ArgumentException>(() => new TelemetrySignalEmitter(transport, "", TenantId));
|
||||
Assert.Throws<ArgumentException>(() => new TelemetrySignalEmitter(transport, SessionId, ""));
|
||||
Assert.Throws<ArgumentNullException>(() => new TelemetrySignalEmitter(null!, SessionId, TenantId));
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure emitter tests for <see cref="TelemetryTransitionEmitter"/> (Codex Finding 1 fix).
|
||||
/// Cross-layer round-trip with the backend parser lives in
|
||||
/// <c>TelemetryTransitionRoundTripTests</c>.
|
||||
/// </summary>
|
||||
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<string> { "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<ArgumentNullException>(() => emitter.Emit(null!));
|
||||
Assert.Equal(0, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_rejects_null_or_empty_identifiers()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
Assert.Throws<ArgumentException>(() => new TelemetryTransitionEmitter(transport, "", TenantId));
|
||||
Assert.Throws<ArgumentException>(() => new TelemetryTransitionEmitter(transport, SessionId, ""));
|
||||
Assert.Throws<ArgumentNullException>(() => new TelemetryTransitionEmitter(null!, SessionId, TenantId));
|
||||
}
|
||||
}
|
||||
}
|
||||
+221
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// CleanupService is virtual for testability. <c>ExecuteSelfDestruct</c> is a best-effort
|
||||
/// fire-and-forget in production — in tests we just count invocations.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
@@ -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<string>(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<DateTime>(StartUtc.AddMinutes(5), 1),
|
||||
DesktopArrivedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(10), 2),
|
||||
AadJoinedWithUser = new SignalFact<bool>(true, 3),
|
||||
ImeMatchedPatternId = new SignalFact<string>("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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mini helper for reflection-based field setters — AppPackageState.Targeted is a private-set property.</summary>
|
||||
internal static class TestHelpers
|
||||
{
|
||||
public static void SetTargeted(AppPackageState pkg, AppTargeted targeted)
|
||||
{
|
||||
var prop = typeof(AppPackageState).GetProperty(nameof(AppPackageState.Targeted))!;
|
||||
prop.SetValue(pkg, targeted);
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// M4.6.ε — BackendTelemetryUploader parses backend-to-agent control signals out of the
|
||||
/// 2xx response body (<see cref="UploadResult.DeviceBlocked"/> / <see cref="UploadResult.UnblockAt"/> /
|
||||
/// <see cref="UploadResult.DeviceKillSignal"/> / <see cref="UploadResult.AdminAction"/> /
|
||||
/// <see cref="UploadResult.Actions"/>). Malformed bodies degrade cleanly to a plain
|
||||
/// <see cref="UploadResult.Ok"/>.
|
||||
/// </summary>
|
||||
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<UploadResult> UploadWithBody(string body)
|
||||
{
|
||||
var (sut, handler) = Build();
|
||||
handler.QueueStatus(HttpStatusCode.OK, body);
|
||||
return await sut.UploadBatchAsync(new List<TelemetryItem> { 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<TelemetryItem> { 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+348
@@ -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<TelemetryItem>();
|
||||
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<TelemetryItem>(), 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<OperationCanceledException>(() =>
|
||||
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<ArgumentNullException>(() =>
|
||||
new BackendTelemetryUploader(null!, BaseUrl, "T1"));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new BackendTelemetryUploader(http, "", "T1"));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new BackendTelemetryUploader(http, BaseUrl, ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadBatchAsync_rejects_null_items()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
rig.Sut.UploadBatchAsync(null!, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Scripted test double for <see cref="IBackendTelemetryUploader"/>. Feed it a sequence of
|
||||
/// <see cref="UploadResult"/>s and it returns them in order. Tracks every batch it was called with.
|
||||
/// </summary>
|
||||
internal sealed class FakeBackendTelemetryUploader : IBackendTelemetryUploader
|
||||
{
|
||||
private readonly Queue<Func<IReadOnlyList<TelemetryItem>, UploadResult>> _script =
|
||||
new Queue<Func<IReadOnlyList<TelemetryItem>, UploadResult>>();
|
||||
|
||||
private readonly ConcurrentQueue<IReadOnlyList<TelemetryItem>> _received =
|
||||
new ConcurrentQueue<IReadOnlyList<TelemetryItem>>();
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public IReadOnlyList<IReadOnlyList<TelemetryItem>> Received
|
||||
{
|
||||
get
|
||||
{
|
||||
var list = new List<IReadOnlyList<TelemetryItem>>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a 2xx success carrying backend-to-agent control signals (DeviceBlocked /
|
||||
/// DeviceKillSignal / AdminAction / Actions). M4.6.ε.
|
||||
/// </summary>
|
||||
public FakeBackendTelemetryUploader QueueOkWithSignals(
|
||||
bool deviceBlocked = false,
|
||||
DateTime? unblockAt = null,
|
||||
bool deviceKillSignal = false,
|
||||
string? reason_AdminAction = null,
|
||||
IReadOnlyList<AutopilotMonitor.Shared.Models.ServerAction>? actions = null)
|
||||
{
|
||||
_script.Enqueue(_ => UploadResult.OkWithSignals(
|
||||
deviceBlocked: deviceBlocked,
|
||||
unblockAt: unblockAt,
|
||||
deviceKillSignal: deviceKillSignal,
|
||||
adminAction: reason_AdminAction,
|
||||
actions: actions));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Task<UploadResult> UploadBatchAsync(
|
||||
IReadOnlyList<TelemetryItem> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Test <see cref="HttpMessageHandler"/> that captures every outgoing request (including the
|
||||
/// body string, because the default handler disposes content after <c>SendAsync</c> returns)
|
||||
/// and returns responses from a scripted queue.
|
||||
/// </summary>
|
||||
internal sealed class RecordingHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _script =
|
||||
new Queue<Func<HttpRequestMessage, HttpResponseMessage>>();
|
||||
private readonly List<CapturedRequest> _captured = new List<CapturedRequest>();
|
||||
|
||||
public IReadOnlyList<CapturedRequest> 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<HttpResponseMessage> 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<string, string[]>(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<string, string[]> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// M4.6.ε — <see cref="TelemetryUploadOrchestrator.ServerResponseReceived"/> hook plus the
|
||||
/// internal <see cref="TelemetryUploadOrchestrator.IsPaused"/> DeviceBlocked pause logic.
|
||||
/// </summary>
|
||||
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<ServerAction>
|
||||
{
|
||||
new ServerAction { Type = ServerActionTypes.RotateConfig, Reason = "periodic" },
|
||||
};
|
||||
rig.Uploader.QueueOkWithSignals(actions: actions);
|
||||
|
||||
IReadOnlyList<ServerAction>? 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+288
@@ -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<OperationCanceledException>(
|
||||
() => 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<ObjectDisposedException>(() => sut.Enqueue(Draft("x")));
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<RootNamespace>AutopilotMonitor.Agent.V2.Core</RootNamespace>
|
||||
<!--
|
||||
V2-Agent-Core library. Plan §2.16 strict separation rule:
|
||||
references ONLY AutopilotMonitor.Shared + AutopilotMonitor.DecisionCore.
|
||||
NEVER AutopilotMonitor.Agent.Core or AutopilotMonitor.Agent.
|
||||
Infra + collectors are copied in M2 from Legacy and pflegen sich ab dann hier eigenständig.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>AutopilotMonitor.Agent.V2</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>AutopilotMonitor.DecisionCore.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>AutopilotMonitor.Agent.V2.Core.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
|
||||
<Import Project="V2Isolation.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Embedded guardrails.json — identical source path as Legacy Agent.Core; LogicalName
|
||||
carries the V2 assembly namespace so GatherRuleGuards locates it under
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json". -->
|
||||
<EmbeddedResource Include="..\..\..\rules\guardrails.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Management" />
|
||||
<Reference Include="System.Management.Automation">
|
||||
<HintPath>$(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System.ServiceProcess" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent configuration settings
|
||||
/// </summary>
|
||||
public class AgentConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Backend API base URL
|
||||
/// </summary>
|
||||
public string ApiBaseUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Session identifier for this enrollment
|
||||
/// </summary>
|
||||
public string SessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier
|
||||
/// </summary>
|
||||
public string TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Local spool directory path
|
||||
/// </summary>
|
||||
public string SpoolDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Agent log directory path
|
||||
/// </summary>
|
||||
public string LogDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload interval in seconds
|
||||
/// </summary>
|
||||
public int UploadIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum batch size for events
|
||||
/// </summary>
|
||||
public int MaxBatchSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use client certificate authentication
|
||||
/// </summary>
|
||||
public bool UseClientCertAuth { get; set; } = true;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Log verbosity level: Info (default), Debug, Verbose.
|
||||
/// Overridable via remote config.
|
||||
/// </summary>
|
||||
public AgentLogLevel LogLevel { get; set; } = AgentLogLevel.Info;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry attempts for failed uploads
|
||||
/// </summary>
|
||||
public int MaxRetryAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to self-destruct when enrollment completes (remove Scheduled Task and all files).
|
||||
/// This is the only cleanup mode — SelfDestruct always removes task + files.
|
||||
/// </summary>
|
||||
public bool SelfDestructOnComplete { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the Scheduled Task to remove during self-destruct
|
||||
/// </summary>
|
||||
public string ScheduledTaskName { get; set; } = Constants.ScheduledTaskName;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to reboot the device after enrollment completes
|
||||
/// </summary>
|
||||
public bool RebootOnComplete { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Delay in seconds before the reboot is initiated (shutdown.exe /r /t X).
|
||||
/// Default: 10 seconds
|
||||
/// </summary>
|
||||
public int RebootDelaySeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the log directory is preserved after self-destruct/cleanup.
|
||||
/// All other files (binaries, config, spool, scheduled task) are still removed.
|
||||
/// </summary>
|
||||
public bool KeepLogFile { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable geo-location detection (queries external IP services)
|
||||
/// </summary>
|
||||
public bool EnableGeoLocation { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// NTP server address for time check during enrollment.
|
||||
/// Default: "time.windows.com"
|
||||
/// </summary>
|
||||
public string NtpServer { get; set; } = "time.windows.com";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically set the device timezone based on IP geolocation.
|
||||
/// Requires EnableGeoLocation to be true. Uses tzutil /s to apply.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool EnableTimezoneAutoSet { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom path to IME logs directory for testing.
|
||||
/// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path.
|
||||
/// </summary>
|
||||
public string ImeLogPathOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public string ImeMatchLogPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string ReplayLogDir { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time compression factor for log replay.
|
||||
/// Higher values = faster replay. Default: 50 (e.g., 50 minutes of real logs = 1 minute of replay).
|
||||
/// </summary>
|
||||
public double ReplaySpeedFactor { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int MaxAuthFailures { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in minutes the agent keeps retrying after the first auth failure.
|
||||
/// 0 = disabled (no time limit, only MaxAuthFailures applies). Default: 0.
|
||||
/// </summary>
|
||||
public int AuthFailureTimeoutMinutes { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public int AgentMaxLifetimeMinutes { get; set; } = 360;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int AbsoluteMaxSessionHours { get; set; } = 48;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string BootstrapToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use bootstrap token auth (true until MDM cert becomes available).
|
||||
/// </summary>
|
||||
public bool UseBootstrapTokenAuth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to show a visual enrollment summary dialog to the end user.
|
||||
/// </summary>
|
||||
public bool ShowEnrollmentSummary { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-close timeout in seconds for the enrollment summary dialog.
|
||||
/// 0 = no auto-close. Default: 60
|
||||
/// </summary>
|
||||
public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Optional URL to a branding image for the enrollment summary dialog banner.
|
||||
/// </summary>
|
||||
public string EnrollmentSummaryBrandingImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool DiagnosticsUploadEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
|
||||
/// Default: "Off"
|
||||
/// </summary>
|
||||
public string DiagnosticsUploadMode { get; set; } = "Off";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public List<DiagnosticsLogPath> DiagnosticsLogPaths { get; set; } = new List<DiagnosticsLogPath>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether the agent should send Trace-severity events to the backend.
|
||||
/// Controlled per tenant via remote config. Default: true (on in preview).
|
||||
/// </summary>
|
||||
public bool SendTraceEvents { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, agent guardrails are relaxed: all registry, WMI, and command targets are allowed.
|
||||
/// File/diagnostics paths allow everything except C:\Users.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool UnrestrictedMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Sanitized command-line arguments for the agent_started event.
|
||||
/// Secrets (e.g. bootstrap token) are redacted before assignment.
|
||||
/// </summary>
|
||||
public string CommandLineArgs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool AwaitEnrollment { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in minutes to wait for the MDM certificate in await-enrollment mode.
|
||||
/// 0 = wait indefinitely. Default: 480 (8 hours).
|
||||
/// </summary>
|
||||
public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configuration
|
||||
/// </summary>
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrEmpty(ApiBaseUrl) &&
|
||||
!string.IsNullOrEmpty(SessionId) &&
|
||||
!string.IsNullOrEmpty(TenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Persisted await-enrollment configuration. Plan §4.x M4.6.α.
|
||||
/// <para>
|
||||
/// Saved to <c>%ProgramData%\AutopilotMonitor\await-enrollment.json</c> by <c>--install</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AwaitEnrollmentConfigFile
|
||||
{
|
||||
public int TimeoutMinutes { get; set; } = 480;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Persisted bootstrap configuration for OOBE-bootstrapped agents. Plan §4.x M4.6.α.
|
||||
/// <para>
|
||||
/// Saved to <c>%ProgramData%\AutopilotMonitor\bootstrap-config.json</c> by <c>--install</c> 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BootstrapConfigFile
|
||||
{
|
||||
public string BootstrapToken { get; set; }
|
||||
public string TenantId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches and caches remote configuration from the backend API
|
||||
/// Provides collector toggles and gather rules to the agent
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current configuration (thread-safe)
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the initial configuration from the backend
|
||||
/// Falls back to cached config if the API is unreachable
|
||||
/// </summary>
|
||||
public async Task<AgentConfigResponse> 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<AgentConfigResponse>(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<GatherRule>(),
|
||||
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing to dispose
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
public enum AgentLogLevel
|
||||
{
|
||||
Info = 0,
|
||||
Debug = 1,
|
||||
Verbose = 2,
|
||||
Trace = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple file-based logger for the agent.
|
||||
/// Optionally mirrors output to the console when EnableConsoleOutput is set.
|
||||
/// </summary>
|
||||
public class AgentLogger
|
||||
{
|
||||
private readonly string _logFilePath;
|
||||
private AgentLogLevel _logLevel;
|
||||
private readonly object _lockObject = new object();
|
||||
|
||||
/// <summary>
|
||||
/// When true, log entries are also written to Console.Out (same format as the log file).
|
||||
/// Set via --console flag or programmatically.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the active log level at runtime (e.g. after remote config is applied).
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Installation state of an app package during enrollment.
|
||||
/// Order matters: states are compared numerically for upgrade-only transitions.
|
||||
/// </summary>
|
||||
public enum AppInstallationState
|
||||
{
|
||||
Unknown = 0,
|
||||
NotInstalled = 1,
|
||||
InProgress = 2,
|
||||
Downloading = 3,
|
||||
Installing = 4,
|
||||
Installed = 5,
|
||||
Skipped = 6,
|
||||
Postponed = 7,
|
||||
Error = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the app should run
|
||||
/// </summary>
|
||||
public enum AppRunAs
|
||||
{
|
||||
Unknown = -1,
|
||||
User = 0,
|
||||
System = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// App install intent from Intune policy
|
||||
/// </summary>
|
||||
public enum AppIntent
|
||||
{
|
||||
Unknown = -1,
|
||||
NotTargeted = 0,
|
||||
Available = 1,
|
||||
Install = 3,
|
||||
Uninstall = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the app is targeted
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum AppTargeted
|
||||
{
|
||||
Unknown = 128,
|
||||
Dependency = 0,
|
||||
User = 1,
|
||||
Device = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Win32 app state values from IME
|
||||
/// </summary>
|
||||
public enum Win32AppState
|
||||
{
|
||||
Unknown = 0,
|
||||
NotInstalled = 1,
|
||||
InProgress = 2,
|
||||
Completed = 3,
|
||||
Error = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a single app package during enrollment.
|
||||
/// Adapted from EspOverlay PackageState with state transition protection.
|
||||
/// </summary>
|
||||
public class AppPackageState : IComparable<AppPackageState>
|
||||
{
|
||||
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<string> DependsOn { get; private set; } = new HashSet<string>();
|
||||
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)
|
||||
/// <summary>Product version detected after install (e.g. "11.2.1787.0"). From ReportingManager DetectedIdentityVersion.</summary>
|
||||
public string AppVersion { get; private set; }
|
||||
/// <summary>App installer type: "Win32", "MSI", "WinGet", "Store", "LOB". Derived from [WinGetApp]/MSI markers.</summary>
|
||||
public string AppType { get; private set; }
|
||||
/// <summary>Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle.</summary>
|
||||
public int AttemptNumber { get; private set; }
|
||||
/// <summary>Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines.</summary>
|
||||
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<Win32AppState, AppInstallationState> Win32StateMap =
|
||||
new Dictionary<Win32AppState, AppInstallationState>
|
||||
{
|
||||
{ Win32AppState.Unknown, AppInstallationState.Unknown },
|
||||
{ Win32AppState.InProgress, AppInstallationState.InProgress },
|
||||
{ Win32AppState.Completed, AppInstallationState.Installed },
|
||||
{ Win32AppState.Error, AppInstallationState.Error }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<AppInstallationState, int> SortOrderOverrides =
|
||||
new Dictionary<AppInstallationState, int>
|
||||
{
|
||||
{ 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores an AppPackageState from persisted data (used on agent restart).
|
||||
/// </summary>
|
||||
internal static AppPackageState Restore(
|
||||
string id, int listPos, string name,
|
||||
AppRunAs runAs, AppIntent intent, AppTargeted targeted,
|
||||
HashSet<string> 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<string>(),
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the app name. Only updates if the new name is not a truncated version of the current name.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates RunAs property
|
||||
/// </summary>
|
||||
public bool UpdateRunAs(AppRunAs newRunAs)
|
||||
{
|
||||
if (RunAs == newRunAs) return false;
|
||||
RunAs = newRunAs;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates Intent property
|
||||
/// </summary>
|
||||
public bool UpdateIntent(AppIntent newIntent)
|
||||
{
|
||||
if (Intent == newIntent) return false;
|
||||
Intent = newIntent;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates Targeted property
|
||||
/// </summary>
|
||||
public bool UpdateTargeted(AppTargeted newTargeted)
|
||||
{
|
||||
if (Targeted == newTargeted) return false;
|
||||
Targeted = newTargeted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates DependsOn set
|
||||
/// </summary>
|
||||
public bool UpdateDependsOn(HashSet<string> newDependsOn)
|
||||
{
|
||||
if (newDependsOn == null) newDependsOn = new HashSet<string>();
|
||||
if (DependsOn.SetEquals(newDependsOn)) return false;
|
||||
DependsOn = newDependsOn;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the error context for the app (called when an error pattern is matched).
|
||||
/// </summary>
|
||||
public void SetErrorContext(string patternId, string detail, string errorCode = null)
|
||||
{
|
||||
ErrorPatternId = patternId;
|
||||
ErrorDetail = detail;
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the installer exit code (lpExitCode) for the app.
|
||||
/// </summary>
|
||||
public void UpdateExitCode(string code)
|
||||
{
|
||||
ExitCode = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the Win32 HRESULT (hResultFromWin32) for the app.
|
||||
/// </summary>
|
||||
public void UpdateHResult(string code)
|
||||
{
|
||||
HResultFromWin32 = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool UpdateAppVersion(string version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version)) return false;
|
||||
if (AppVersion == version) return false;
|
||||
AppVersion = version.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the install attempt number. Keeps the highest observed value
|
||||
/// so it is monotonic across log batches.
|
||||
/// </summary>
|
||||
public bool UpdateAttemptNumber(int attempt)
|
||||
{
|
||||
if (attempt <= AttemptNumber) return false;
|
||||
AttemptNumber = attempt;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the detection rule result ("Detected" / "NotDetected").
|
||||
/// Applies any non-empty value so the latest detection wins.
|
||||
/// </summary>
|
||||
public bool UpdateDetectionResult(string result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(result)) return false;
|
||||
if (DetectionResult == result) return false;
|
||||
DetectionResult = result.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates Delivery Optimization telemetry data for this app package.
|
||||
/// Called when a [DO TEL] log entry is matched and linked to this app.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the installation state with transition protection.
|
||||
/// Returns true if the state actually changed.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool UpdateStateFromWin32AppState(string win32AppStateStringOrIntString)
|
||||
{
|
||||
Win32AppState win32State;
|
||||
if (Enum.TryParse(win32AppStateStringOrIntString, true, out win32State))
|
||||
return UpdateStateFromWin32AppState(win32State);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates state from Win32AppState enum value.
|
||||
/// </summary>
|
||||
public bool UpdateStateFromWin32AppState(Win32AppState win32State)
|
||||
{
|
||||
AppInstallationState newState;
|
||||
if (Win32StateMap.TryGetValue(win32State, out newState))
|
||||
return UpdateState(newState, upgradeOnly: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this app requires installation (Install or Uninstall intent)
|
||||
/// </summary>
|
||||
public bool IsRequired => Intent == AppIntent.Install || Intent == AppIntent.Uninstall;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the app is currently being processed (InProgress, Downloading, or Installing)
|
||||
/// </summary>
|
||||
public bool IsActive => InstallationState >= AppInstallationState.InProgress &&
|
||||
InstallationState <= AppInstallationState.Installing;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the app has reached a terminal state (Installed, Skipped, Postponed, Error)
|
||||
/// </summary>
|
||||
public bool IsCompleted => InstallationState >= AppInstallationState.Installed;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the app is in an error state
|
||||
/// </summary>
|
||||
public bool IsError => InstallationState == AppInstallationState.Error;
|
||||
|
||||
/// <summary>
|
||||
/// Comparison for sorting: active apps first, then by state change time, then by list position.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a summary dictionary for event data
|
||||
/// </summary>
|
||||
public Dictionary<string, object> ToEventData()
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+432
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages a collection of app package states during enrollment.
|
||||
/// Tracks current app, ignore list, and dependency cascading.
|
||||
/// Adapted from EspOverlay PackageStatesList.
|
||||
/// </summary>
|
||||
public class AppPackageStateList : List<AppPackageState>
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of the app currently being processed by IME
|
||||
/// </summary>
|
||||
public string CurrentPackageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public HashSet<string> IgnoreList { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
// States that should also cascade to dependents
|
||||
private static readonly HashSet<AppInstallationState> StatesToCascade =
|
||||
new HashSet<AppInstallationState>
|
||||
{
|
||||
AppInstallationState.Postponed,
|
||||
AppInstallationState.Error
|
||||
};
|
||||
|
||||
public AppPackageStateList(AgentLogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a package by ID, optionally creating it if not found.
|
||||
/// Returns null if the ID is in the ignore list.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds/updates packages from a JSON policy list (from IME "Get policies" log line).
|
||||
/// Returns true if any package was updated.
|
||||
/// </summary>
|
||||
public bool AddUpdateFromJsonPolicies(List<Dictionary<string, object>> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the name of a specific package
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the installation state of a specific package.
|
||||
/// Returns true if state actually changed.
|
||||
/// Optionally cascades to dependent packages for error/postpone states.
|
||||
/// </summary>
|
||||
public bool UpdateState(string id, AppInstallationState newState, int? progressPercent = null)
|
||||
{
|
||||
var packageIds = new HashSet<string> { 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates state to Downloading with optional progress tracking
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates state from a Win32AppState string
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current package being processed
|
||||
/// </summary>
|
||||
public void SetCurrent(string id)
|
||||
{
|
||||
CurrentPackageId = id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an ID to the ignore list
|
||||
/// </summary>
|
||||
public bool AddToIgnoreList(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return false;
|
||||
|
||||
return IgnoreList.Add(id); // HashSet.Add returns false if already present (case-insensitive)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if all required apps are completed
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first non-completed package (for display)
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Gets summary data for event emission
|
||||
/// </summary>
|
||||
public Dictionary<string, object> GetSummaryData()
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "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() }
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all package states as a list of dictionaries for final status dump.
|
||||
/// Reuses AppPackageState.ToEventData() which includes state, error info, DO telemetry.
|
||||
/// </summary>
|
||||
public List<Dictionary<string, object>> ToFinalStatusList()
|
||||
{
|
||||
return this.Select(pkg => pkg.ToEventData()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively gets all dependent package IDs (packages that depend on the given parent)
|
||||
/// </summary>
|
||||
private HashSet<string> GetDependentIdsDeep(string parentId, int level = 0)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
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<string, object> 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<string, object> 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<Dictionary<string, object>>(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<string> ExtractDependencies(object flatDepsObj, string appId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (flatDepsObj == null) return new HashSet<string>();
|
||||
|
||||
var deps = flatDepsObj as List<object>;
|
||||
if (deps == null) return new HashSet<string>();
|
||||
|
||||
var result = new HashSet<string>();
|
||||
foreach (var dep in deps)
|
||||
{
|
||||
var depDict = dep as Dictionary<string, object>;
|
||||
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<string>();
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single parsed line from a CMTrace-format log file
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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="">
|
||||
/// </summary>
|
||||
public static class CmTraceLogParser
|
||||
{
|
||||
// Pre-compiled regex for the CMTrace format
|
||||
private static readonly Regex CmTraceRegex = new Regex(
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a single line of CMTrace-formatted log.
|
||||
/// Returns true if parsing succeeded, false if the line doesn't match the format.
|
||||
/// </summary>
|
||||
public static bool TryParseLine(string line, out CmTraceLogEntry entry)
|
||||
{
|
||||
entry = null;
|
||||
|
||||
if (string.IsNullOrEmpty(line) || !line.StartsWith("<![LOG["))
|
||||
return false;
|
||||
|
||||
var match = CmTraceRegex.Match(line);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
var message = match.Groups["message"].Value;
|
||||
var timeStr = match.Groups["time"].Value;
|
||||
var dateStr = match.Groups["date"].Value;
|
||||
var component = match.Groups["component"].Value;
|
||||
var typeStr = match.Groups["type"].Value;
|
||||
var thread = match.Groups["thread"].Value;
|
||||
|
||||
// Parse timestamp: date is "M-d-yyyy", time is "HH:mm:ss.ticks"
|
||||
DateTime timestamp;
|
||||
if (!TryParseTimestamp(dateStr, timeStr, out timestamp))
|
||||
{
|
||||
timestamp = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
int type;
|
||||
int.TryParse(typeStr, out type);
|
||||
|
||||
entry = new CmTraceLogEntry
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
Message = message,
|
||||
Component = component,
|
||||
Type = type,
|
||||
Thread = thread
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseTimestamp(string dateStr, string timeStr, out DateTime result)
|
||||
{
|
||||
result = DateTime.MinValue;
|
||||
|
||||
// Date format: "M-d-yyyy" (e.g., "2-8-2026")
|
||||
// Time format: "HH:mm:ss.ticks" (e.g., "06:08:04.8834397")
|
||||
// Truncate time to 7 fractional digits max for DateTime parsing
|
||||
var timeParts = timeStr.Split('.');
|
||||
string normalizedTime;
|
||||
if (timeParts.Length == 2)
|
||||
{
|
||||
var fraction = timeParts[1];
|
||||
if (fraction.Length > 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+580
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: Event handlers for pattern matches (app state, scripts, ESP phases),
|
||||
/// telemetry processing, and utility methods.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts app GUID from DO FileId string.
|
||||
/// Format: ...intunewin-bin_{appGuid}_{number}
|
||||
/// Falls back to trying the second-to-last GUID-like segment.
|
||||
/// </summary>
|
||||
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<string, string> 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<string, string> 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<string, string> 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<string, string> 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<string, string> 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<string, string> 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<List<Dictionary<string, object>>>(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<CompiledPattern>(_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 { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A compiled regex pattern with its action and parameters
|
||||
/// </summary>
|
||||
private class CompiledPattern
|
||||
{
|
||||
public string PatternId { get; set; }
|
||||
public Regex Regex { get; set; }
|
||||
public string Action { get; set; }
|
||||
public Dictionary<string, string> Parameters { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+411
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: Log file polling and pattern matching logic.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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: <![LOG[message]LOG]!><time=...>
|
||||
// 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("<![LOG[") && !line.Contains("]LOG]!>"))
|
||||
{
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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}
|
||||
/// </summary>
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<CompiledPattern> _patternsAlways = new List<CompiledPattern>();
|
||||
private List<CompiledPattern> _patternsCurrentPhase = new List<CompiledPattern>();
|
||||
private List<CompiledPattern> _patternsOtherPhases = new List<CompiledPattern>();
|
||||
|
||||
// Active matchers (changes based on ESP phase)
|
||||
private List<CompiledPattern> _activePatterns = new List<CompiledPattern>();
|
||||
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<string> _seenAppIds = new HashSet<string>(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<string, int> EspPhaseOrder = new Dictionary<string, int>(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<string, List<Dictionary<string, object>>> _phasePackageSnapshots =
|
||||
new Dictionary<string, List<Dictionary<string, object>>>(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 = @"(?<id>[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<string, ScriptExecutionState> _pendingPlatformScripts =
|
||||
new Dictionary<string, ScriptExecutionState>(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<string> OnEspPhaseChanged { get; set; }
|
||||
public Action<string> OnImeAgentVersion { get; set; }
|
||||
public Action OnImeStarted { get; set; }
|
||||
public Action<AppPackageState, AppInstallationState, AppInstallationState> OnAppStateChanged { get; set; }
|
||||
public Action<string> OnPoliciesDiscovered { get; set; }
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
|
||||
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires on every pattern match with the matched <c>PatternId</c>. Plan §4.x M4.4.4.
|
||||
/// Invoked by <c>HandlePatternMatch</c> after <see cref="LastMatchedPatternId"/> is
|
||||
/// set, before action-specific callbacks — callers can read <see cref="LastMatchedPatternId"/>
|
||||
/// / <see cref="LastMatchedLogTimestamp"/> synchronously inside the handler.
|
||||
/// Added to enable the <c>WhiteGloveSealingPatternDetected</c> signal path in
|
||||
/// <c>ImeLogTrackerAdapter</c> without the legacy polling-on-LastMatchedPatternId anti-pattern.
|
||||
/// </summary>
|
||||
public Action<string> OnPatternMatched { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Access to the tracked package states
|
||||
/// </summary>
|
||||
public AppPackageStateList PackageStates => _packageStates;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots of package states from completed ESP phases (keyed by phase name, e.g. "DeviceSetup").
|
||||
/// Captured before package states are cleared on phase transition.
|
||||
/// </summary>
|
||||
public Dictionary<string, List<Dictionary<string, object>>> PhasePackageSnapshots => _phasePackageSnapshots;
|
||||
|
||||
public ImeLogTracker(string logFolder, List<ImeLogPattern> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles ImeLogPattern list from backend into Regex objects grouped by category.
|
||||
/// </summary>
|
||||
public void CompilePatterns(List<ImeLogPattern> patterns)
|
||||
{
|
||||
_patternsAlways = new List<CompiledPattern>();
|
||||
_patternsCurrentPhase = new List<CompiledPattern>();
|
||||
_patternsOtherPhases = new List<CompiledPattern>();
|
||||
|
||||
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<string, string>()
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background polling task
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the background polling task
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
try { _pollingTask?.Wait(TimeSpan.FromSeconds(5)); } catch { }
|
||||
_pollingTask = null;
|
||||
}
|
||||
|
||||
#region State Persistence
|
||||
|
||||
/// <summary>
|
||||
/// Loads persisted state from disk. Called on Start() to restore tracker state
|
||||
/// after an agent restart, so parsing continues exactly where it left off.
|
||||
/// </summary>
|
||||
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<string>(p.DependsOn) : new HashSet<string>(),
|
||||
(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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists current tracker state to disk as JSON.
|
||||
/// Called after each polling cycle when state has changed.
|
||||
/// </summary>
|
||||
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<string>(),
|
||||
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<string, FilePositionData>()
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes persisted state file. Called on enrollment complete to ensure
|
||||
/// a fresh state on the next enrollment cycle.
|
||||
/// </summary>
|
||||
public void DeleteState()
|
||||
{
|
||||
_statePersistence?.Delete();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
}
|
||||
+146
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<EnrollmentEvent> _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<EnrollmentEvent> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to find and attach to IntuneManagementExtension.exe.
|
||||
/// Stops the discovery timer once attached — switches to pure event-driven from there.
|
||||
/// </summary>
|
||||
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<string, object>
|
||||
{
|
||||
{ "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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles persistence of ImeLogTracker state to disk (JSON serialization).
|
||||
/// Extracted from ImeLogTracker to separate I/O concerns from log parsing.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Loads persisted state from disk. Returns null if no state file exists or on error.
|
||||
/// </summary>
|
||||
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<ImeTrackerStateData>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists tracker state to disk as JSON (atomic write via temp file).
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes persisted state file.
|
||||
/// </summary>
|
||||
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<string> SeenAppIds { get; set; }
|
||||
public List<string> IgnoreList { get; set; }
|
||||
public string CurrentPackageId { get; set; }
|
||||
public List<PackageStateData> Packages { get; set; }
|
||||
public Dictionary<string, FilePositionData> 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<string> 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; }
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks file read positions for incremental log file reading.
|
||||
/// In-memory only - resets on agent restart, which is acceptable since
|
||||
/// we start fresh per enrollment session.
|
||||
/// </summary>
|
||||
public class LogFilePositionTracker
|
||||
{
|
||||
private readonly Dictionary<string, FilePositionState> _positions
|
||||
= new Dictionary<string, FilePositionState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the current read position for a file after successful reading.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stored position for a file, or 0 if not tracked.
|
||||
/// Does not perform rollover detection.
|
||||
/// </summary>
|
||||
public long GetPosition(string filePath)
|
||||
{
|
||||
FilePositionState state;
|
||||
if (_positions.TryGetValue(filePath, out state))
|
||||
return state.Position;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all tracked positions for state persistence.
|
||||
/// Keys are full file paths.
|
||||
/// </summary>
|
||||
public Dictionary<string, FilePositionState> GetAllPositions()
|
||||
{
|
||||
return new Dictionary<string, FilePositionState>(_positions, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores a previously persisted position for a file.
|
||||
/// Used on agent restart to continue reading from the last known position.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates multi-line script execution data from IME logs before emitting
|
||||
/// a single consolidated event. Used for both platform scripts and remediation scripts.
|
||||
/// </summary>
|
||||
public class ScriptExecutionState
|
||||
{
|
||||
/// <summary>Intune policy GUID identifying the script.</summary>
|
||||
public string PolicyId { get; set; }
|
||||
|
||||
/// <summary>"platform" or "remediation".</summary>
|
||||
public string ScriptType { get; set; }
|
||||
|
||||
/// <summary>"detection" or "remediation" (remediation scripts only).</summary>
|
||||
public string ScriptPart { get; set; }
|
||||
|
||||
/// <summary>"System" or "User" execution context.</summary>
|
||||
public string RunContext { get; set; }
|
||||
|
||||
/// <summary>PowerShell exit code.</summary>
|
||||
public int? ExitCode { get; set; }
|
||||
|
||||
/// <summary>Standard output (truncated).</summary>
|
||||
public string Stdout { get; set; }
|
||||
|
||||
/// <summary>Standard error output (truncated).</summary>
|
||||
public string Stderr { get; set; }
|
||||
|
||||
/// <summary>"Success" or "Failed" (platform scripts).</summary>
|
||||
public string Result { get; set; }
|
||||
|
||||
/// <summary>"True" or "False" compliance result (remediation detection only).</summary>
|
||||
public string ComplianceResult { get; set; }
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 (<see cref="IsPlaceholderUserEmail"/>) appear transiently during
|
||||
/// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join.
|
||||
/// </summary>
|
||||
internal static class AadJoinInfo
|
||||
{
|
||||
internal const string JoinInfoRegistryPath =
|
||||
@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="userEmail">Raw UserEmail value (may be null/empty).</param>
|
||||
/// <param name="thumbprint">The certificate thumbprint sub-key name (may be null).</param>
|
||||
/// <param name="isPlaceholderUser">
|
||||
/// True when <paramref name="userEmail"/> matches a known provisioning-placeholder
|
||||
/// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join.
|
||||
/// </param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static bool IsPlaceholderUserEmail(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return false;
|
||||
|
||||
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|
||||
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
+251
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches <c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo</c> for a
|
||||
/// late AAD user join. Uses <see cref="RegistryWatcher"/> (RegNotifyChangeKeyValue) for
|
||||
/// instant, low-overhead change detection — no polling.
|
||||
///
|
||||
/// Two distinct events are raised:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="PlaceholderUserDetected"/> — first time a transient
|
||||
/// provisioning account (<c>foouser@*</c>, <c>autopilot@*</c>) appears. Fire-once.
|
||||
/// The watcher keeps running afterwards, as the placeholder is expected to be
|
||||
/// replaced by a real user.</description></item>
|
||||
/// <item><description><see cref="AadUserJoined"/> — first time a REAL user e-mail
|
||||
/// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// Retry semantics: the <c>JoinInfo</c> 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.
|
||||
/// </summary>
|
||||
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<AadPlaceholderUserDetectedEventArgs> PlaceholderUserDetected;
|
||||
public event EventHandler<AadUserJoinedEventArgs> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// System/service account names that should NOT be considered real users.
|
||||
/// </summary>
|
||||
private static readonly string[] ExcludedUserNames =
|
||||
{
|
||||
"SYSTEM",
|
||||
"LOCAL SERVICE",
|
||||
"NETWORK SERVICE",
|
||||
"DefaultUser0",
|
||||
"DefaultUser1",
|
||||
"defaultuser0",
|
||||
"defaultuser1"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fired exactly once when a real user desktop is detected (explorer.exe under a real user).
|
||||
/// </summary>
|
||||
public event EventHandler DesktopArrived;
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback for trace events (decision, reason, context).
|
||||
/// Wired by MonitoringService to emit agent_trace events to the backend.
|
||||
/// </summary>
|
||||
public Action<string, string, Dictionary<string, object>> 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<string, object> { { "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<string, object> { { "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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the owner of a process using WMI (Win32_Process.GetOwner).
|
||||
/// Returns "DOMAIN\User" or "User" string, or null on failure.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Thin coordinator that composes four focused sub-trackers to deliver the ESP + Hello
|
||||
/// monitoring surface expected by <c>CollectorCoordinator</c> and <c>EnrollmentTracker</c>:
|
||||
///
|
||||
/// - <see cref="HelloTracker"/> — WHfB policy, UDR 300/301/358/360/362/376,
|
||||
/// HelloForBusiness 3024/6045, Hello timers
|
||||
/// - <see cref="ShellCoreTracker"/> — Shell-Core 62404/62407 (ESP exit / failure,
|
||||
/// WhiteGlove success, Hello wizard start)
|
||||
/// - <see cref="ProvisioningStatusTracker"/> — Registry-driven ESP provisioning category
|
||||
/// tracking + DeviceSetup fallback
|
||||
/// - <see cref="ModernDeploymentTracker"/> — 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.
|
||||
/// </summary>
|
||||
public sealed class EspAndHelloTracker : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public event EventHandler HelloCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when ESP exit or Hello wizard start is detected.
|
||||
/// Triggers transition to FinalizingSetup phase in <c>EnrollmentTracker</c>.
|
||||
/// </summary>
|
||||
public event EventHandler<string> FinalizingSetupPhaseTriggered;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when WhiteGlove (Pre-Provisioning) completes successfully.
|
||||
/// The device will shut down; the agent should terminate gracefully.
|
||||
/// </summary>
|
||||
public event EventHandler WhiteGloveCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort,
|
||||
/// WhiteGlove_Failed, Provisioning_*_Failed, etc.). The string is the structured failure type.
|
||||
/// </summary>
|
||||
public event EventHandler<string> EspFailureDetected;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public event EventHandler DeviceSetupProvisioningComplete;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public string HelloOutcome => _helloTracker?.HelloOutcome;
|
||||
|
||||
/// <summary>
|
||||
/// True when Windows Hello for Business policy is configured (enabled or disabled).
|
||||
/// </summary>
|
||||
public bool IsPolicyConfigured => _helloTracker?.IsPolicyConfigured ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True when Windows Hello provisioning has completed (successfully, failed, or skipped).
|
||||
/// </summary>
|
||||
public bool IsHelloCompleted => _helloTracker?.IsHelloCompleted ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR the fallback confirmed.
|
||||
/// </summary>
|
||||
public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
|
||||
/// </summary>
|
||||
public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True once WhiteGlove start has been detected (EventID 509 or persisted from prior run).
|
||||
/// Forwarded from ModernDeploymentTracker.
|
||||
/// </summary>
|
||||
public bool IsWhiteGloveStartDetected => _modernDeploymentTracker?.IsWhiteGloveStartDetected ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetup contains a SaveWhiteGloveSuccessResult subcategory with state "succeeded".
|
||||
/// This is a definitive WhiteGlove (Pre-Provisioning) confirmation signal from the ESP registry.
|
||||
/// </summary>
|
||||
public bool HasSaveWhiteGloveSuccessResult => _provisioningTracker?.HasSaveWhiteGloveSuccessResult ?? false;
|
||||
|
||||
public EspAndHelloTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> 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<string, bool?> GetProvisioningCategorySnapshot()
|
||||
=> _provisioningTracker?.GetProvisioningCategorySnapshot()
|
||||
?? new System.Collections.Generic.Dictionary<string, bool?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public EspProvisioningSnapshot GetProvisioningSnapshot() => _provisioningTracker?.GetProvisioningSnapshot();
|
||||
|
||||
// =====================================================================
|
||||
// Forwarded Hello coordination API
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void ForceMarkHelloCompleted(string reason) => _helloTracker?.ForceMarkHelloCompleted(reason);
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits.
|
||||
/// </summary>
|
||||
public void StartHelloWaitTimer() => _helloTracker?.StartHelloWaitTimer();
|
||||
|
||||
/// <summary>
|
||||
/// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join).
|
||||
/// </summary>
|
||||
public void ResetForEspResumption() => _helloTracker?.ResetForEspResumption();
|
||||
|
||||
/// <summary>
|
||||
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
|
||||
/// Secondary recovery mechanism when state persistence is unavailable.
|
||||
/// </summary>
|
||||
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<T>(ref T tracker, string name, Action<T> 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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+997
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
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<int> TrackedUdrEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_NgcKeyRegistered,
|
||||
EventId_NgcKeyRegistrationFailed,
|
||||
EventId_ProvisioningWillLaunch,
|
||||
EventId_ProvisioningWillNotLaunch,
|
||||
EventId_ProvisioningBlocked,
|
||||
EventId_PinStatus
|
||||
};
|
||||
|
||||
private static readonly HashSet<int> TrackedHelloForBusinessEventIds = new HashSet<int>
|
||||
{
|
||||
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<EnrollmentEvent> _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<EnrollmentEvent> 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)
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void ForceMarkHelloCompleted(string reason)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_isHelloCompleted) return;
|
||||
_isHelloCompleted = true;
|
||||
HelloOutcome = reason;
|
||||
StopHelloCompletionTimerLocked();
|
||||
_logger.Warning($"Hello force-completed by external caller: {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits.
|
||||
/// Waits for Hello wizard to start (Shell-Core event 62404, reported via
|
||||
/// <see cref="NotifyHelloWizardStarted"/>) within the configured timeout.
|
||||
/// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>Test-only: simulate policy detection without a real registry read.</summary>
|
||||
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<string, object>
|
||||
{
|
||||
{ "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; } } }
|
||||
}
|
||||
}
|
||||
+480
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal const int EventId_WhiteGloveStart = 509;
|
||||
|
||||
internal static readonly HashSet<int> TargetedManagementServiceEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_WhiteGloveStart
|
||||
};
|
||||
|
||||
internal const string WhiteGloveBackfillStateFileName = "whiteglove-backfill.json";
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly int _logLevelMax;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly HashSet<int> _harmlessEventIds;
|
||||
|
||||
private EventLogWatcher _autopilotWatcher;
|
||||
private EventLogWatcher _managementWatcher;
|
||||
private bool _whiteGloveStartDetected;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
public ModernDeploymentTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> 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<int>(harmlessEventIds)
|
||||
: new HashSet<int> { 100, 1005 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True once WhiteGlove start has been detected (either live Event 509 or persisted from prior run).
|
||||
/// Exposed primarily for diagnostics/tests.
|
||||
/// </summary>
|
||||
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<int> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the XPath filter for the watcher. Exposed as internal for tests.
|
||||
/// </summary>
|
||||
internal static string BuildXPath(int logLevelMax, HashSet<int> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core event-processing logic, extracted to primitive inputs so tests can drive it
|
||||
/// without needing to synthesize an EventRecord (which is abstract + Windows-only).
|
||||
/// </summary>
|
||||
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<string, object>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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<WhiteGloveBackfillState>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only: force the in-memory WhiteGlove-seen guard (simulates successful backfill-dedup).
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1086
File diff suppressed because it is too large
Load Diff
+382
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="FinalizingSetupPhaseTriggered"/>, <see cref="WhiteGloveCompleted"/>,
|
||||
/// and <see cref="EspFailureDetected"/>. Cross-notifies the <see cref="HelloTracker"/>
|
||||
/// on Hello wizard start and ESP exit so Hello timers can react.
|
||||
/// </summary>
|
||||
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<int> TrackedShellCoreEventIds = new HashSet<int>
|
||||
{
|
||||
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<EnrollmentEvent> _onEventCollected;
|
||||
private readonly HelloTracker _helloTracker;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
private bool _espExitDetected;
|
||||
private bool _whiteGloveDetected;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
public event EventHandler<string> FinalizingSetupPhaseTriggered;
|
||||
public event EventHandler WhiteGloveCompleted;
|
||||
public event EventHandler<string> EspFailureDetected;
|
||||
|
||||
public ShellCoreTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core event-processing logic. Exposed as internal so tests can drive it without
|
||||
/// needing to synthesize an <see cref="EventRecord"/> (abstract + Windows-only).
|
||||
/// </summary>
|
||||
internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill)
|
||||
{
|
||||
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<string, object>
|
||||
{
|
||||
{ "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)
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
|
||||
/// Secondary recovery mechanism when state persistence is unavailable.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal backfill record handler — extracted for testability and to keep the
|
||||
/// backfill loop free of direct event-processing logic.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+551
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="Core.MonitoringService.IdleCheckCallback"/> (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 → <c>stall_probe_result</c> (Warning) + optional <c>EspFailureDetected</c>
|
||||
/// - 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 <c>stall_probe_check</c> Trace event on quiet/activeInstalls.
|
||||
/// - Non-trace probes: emit nothing when no anomaly is found (agent log only).
|
||||
/// - Anomaly found: always emit <c>stall_probe_result</c> Warning (plus EspFailureDetected if terminal).
|
||||
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning.
|
||||
/// </summary>
|
||||
public class StallProbeCollector
|
||||
{
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly int[] _thresholdsMinutes;
|
||||
private readonly HashSet<int> _traceIndices;
|
||||
private readonly HashSet<string> _sources;
|
||||
private readonly int _sessionStalledAfterProbeIndex;
|
||||
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
|
||||
|
||||
private readonly object _stateLock = new object();
|
||||
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
|
||||
private bool _sessionStalledFired;
|
||||
|
||||
public event EventHandler<string> 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<int> TerminalModernDeploymentEventIds = new HashSet<int>
|
||||
{
|
||||
// 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<EnrollmentEvent> 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<int>(traceIndices ?? new int[0]);
|
||||
_sources = new HashSet<string>(sources ?? new string[0], StringComparer.OrdinalIgnoreCase);
|
||||
_sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex;
|
||||
_harmlessModernDeploymentEventIds = new HashSet<int>(
|
||||
harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0
|
||||
? harmlessModernDeploymentEventIds
|
||||
: DefaultHarmlessModernDeploymentEventIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void ResetProbes()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_firedProbeIndices.Count > 0)
|
||||
{
|
||||
_logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes");
|
||||
_firedProbeIndices.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from the 60-s idle check tick. Decides whether any probe threshold has been
|
||||
/// newly crossed and runs the corresponding probe.
|
||||
/// </summary>
|
||||
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<string, object> BuildDataJson(ProbeResult r)
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "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<string> Anomalies = new List<string>();
|
||||
public readonly List<string> ActiveInstalls = new List<string>();
|
||||
public readonly List<string> SourcesFailed = new List<string>();
|
||||
public readonly List<string> RawSamples = new List<string>();
|
||||
public bool IsTerminal;
|
||||
public string TerminalReason;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user