Compare commits

...

41 Commits

Author SHA1 Message Date
Oliver Kieselbach 93de130cd1 feat(diagnostics): built-in collection catalog visible in the portal, RealmJoin + Device Preparation sections gated
The diagnostics package's built-in collection is now ONE catalog in Shared
(DiagnosticsBuiltInSections.All): the agent iterates it when building the ZIP and
the backend serves the same list to the portal (GET /api/diagnostics/paths,
MemberRead), so what administrators see is exactly what the agent collects.

Agent
- New conditional sections: RealmJoin client/package/Chocolatey/per-user logs
  behind the tenant's RealmJoin Watcher toggle (mirrored onto
  AgentConfiguration.EnableRealmJoinWatcher by RemoteConfigMerger) and the Device
  Preparation bootstrapper event log behind the deterministic WDP marker (probe
  once per build, fail toward Classic). ZIP layout mirrors the disk
  (RealmJoinLogs/Windows|Choco|User). Every skip is a package-manifest line.
- Built-ins bypass DiagnosticsPathGuards by design (reviewed code); caps,
  reparse-point skip and evtx export still apply. No ConfigVersion bump.

Backend
- GET diagnostics/paths (MemberRead, JWT-scoped) with the catalog + global paths.
- MergeDiagnosticsLogPaths: global ∪ tenant dedup (trimmed, case-insensitive) —
  duplicates used to produce duplicate ZIP entry names.

Web
- Collapsed read-only built-in list (RealmJoin on/off in the tenant header,
  neutral in the Global-Admin view), one-line DiagnosticsPathRow for every entry,
  add-row above the lists; tenant context no longer fetches /api/global/config.

Tests: agent RealmJoin on/off, no user session, bootstrapper evtx, probe failure,
merger mirror; backend catalog invariants, payload shape, dedup, policy; web
builtInSectionDisplay. Docs: docs/agent/diagnostics-package.md (+index/log).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:09:50 +02:00
Oliver Kieselbach b8b95c7e76 refactor(web): move RealmJoin Watcher and Keep Awake from Agent Analyzers to Agent Settings
Both toggles gate collector hosts / device behavior, not IAgentAnalyzers, and
tenants looked for them under Agent Settings. RealmJoin Watcher now sits in the
Agent Collectors card, Keep Awake During User-ESP in Agent Parameters; OOBE
console detection stays with the security analyzers. Section ownership in
SECTION_FIELD_MAP and the per-section reset handlers follow, the Agent Settings
Save/Reset bar moves below both cards it saves, and the Analyzers sidebar
description names all four checks. Wire contract (AnalyzerConfiguration) and
agent are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:06:39 +02:00
Oliver Kieselbach e57e081cec docs(web): Discord in notification-provider copy on about, privacy, settings and rule card
Privacy page last-updated bumped to 23 August 2026.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:47:52 +02:00
Oliver Kieselbach 3a60186824 revert(email): drop the gradient slant row and corner cell — solid fallbacks made non-gradient clients worse
Back to the 838da4cc header: one facet gradient, three-row brand mark, 113px
band, clip-path edge for clients that support it. Clients without gradient
support get a flat band instead of band-coloured blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:30:40 +02:00
Oliver Kieselbach c35595503f feat(email): header slant and light corner facet without clip-path — gradients Outlook can draw
Row 1 keeps the single facet gradient and adds a top-right cell with its own
dark/light split (the corner facet); row 2 draws the slanted bottom edge as a
green/white gradient in three band-coloured cells. Pixel-measured against the
reference render: band boundaries identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:22:44 +02:00
Oliver Kieselbach 838da4cc8a fix(email): single Outlook-safe facet gradient, no bgcolor inside the brand mark
Outlook web/iOS render exactly one linear-gradient with hex colours and % stops
(multiple layers / rgba stops are dropped), and dark mode inverts cell
backgrounds but not border colours — the white cell in the mark became a black
bar. Facets are now one gradient (top-right light corner dropped), the mark's
middle row is a white-bordered div.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:11:17 +02:00
Oliver Kieselbach a9937e0f5d fix(email): brand mark as three non-overlapping rows — Outlook ignores negative margins
Triangle / white cell + triangle / triangle, no overlays, no transparent td
borders; matches the reference mark in a side-by-side zoom render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:02:14 +02:00
Oliver Kieselbach 1dc2cc9a54 fix(email): header rebuilt 1:1 from the approved banner — faceted gradients and four-edge brand mark
Verified side-by-side against the reference render (scratch header-render.html):
three layered half-plane gradients reproduce the light/dark facets, the mark is
a CSS trapezoid plus two green wedges for the slanted right edge and the
descending bottom edge. Still no images in the mail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:44:26 +02:00
Oliver Kieselbach dd7ae299a8 fix(email): header restored to the approved rendering (e1de5a89 geometry)
112deg stripe gradient, 13/20/2 border-drawn mark, clip-path edge — the version
the user signed off on; later 'improvements' drifted from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:23:22 +02:00
Oliver Kieselbach d96d10ccec fix(email): header matches the reference banner — mark 12/21/0 trapezoid, stripes thin/wide/thin in the proven 8-stop gradient form
Verified against the original SVG mark in a Chromium render; the gradient keeps
the exact stop count that rendered in Outlook earlier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:05:12 +02:00
Oliver Kieselbach 457fff65f5 fix(email): brand mark back to the div border trick (Outlook paints transparent td borders white)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:56:09 +02:00
Oliver Kieselbach dc45694611 docs(okf): email template overrides and image-free mail header
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:50:41 +02:00
Oliver Kieselbach 7569061983 feat(admin): editable email templates — preview, test send and HTML override in Settings
- EmailTemplateService: override-or-built-in resolution per send (PreviewConfig
  table, partition EmailTemplates, 5-min cache, {{domainName}} placeholder,
  30k limit); send path fail-soft, admin read path strict
- GA endpoints global/email-templates/{kind} (GET/PUT/DELETE) and /test, which
  mails the effective template or an unsaved draft to the caller's tenant
  contact address
- portal: Settings → Email Templates with preview frame, test send, HTML editor
- mail header: brand mark drawn with CSS borders, no images left in the mails

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:50:10 +02:00
Oliver Kieselbach 0f5e36db94 fix(email): exact brand mark as PNG again, stripes direction and widths match the reference
The CSS border trapezoid did not reproduce the mark's shape across clients; the
mark is brand identity, so it is the one image in the mail (empty alt). Stripes
now run top-left to bottom-right, thin/wide/thin as in the reference banner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:40:15 +02:00
Oliver Kieselbach e1de5a8971 feat(email): image-free header — CSS stripes, slanted edge and border-drawn brand mark
Progressive enhancement: solid bgcolor band + text wordmark everywhere; the
brand mark is a CSS border trapezoid; diagonal stripes (linear-gradient) and the
slanted bottom edge (clip-path) render where supported and degrade to a flat
band elsewhere. No remote images left in either mail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:22:26 +02:00
Oliver Kieselbach a2d93e884c fix(email): native HTML header that survives blocked images
Solid bgcolor band with the wordmark as real text; only the 36px brand mark is
an image (empty alt). Replaces the full-width header PNG, which collapsed to a
bare green bar on clients that block remote images (iOS Mail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:15:45 +02:00
Oliver Kieselbach 425229deee feat(email): branded header image, sign-in link, drop preview banner in welcome/farewell mails
- header is a hosted PNG (www/email/header.png, rendered from the brand mark +
  wordmark) on a solid bgcolor band; mail clients drop CSS gradients, which left
  the old header white
- welcome mail links "sign in" to the portal, active-development banner removed
- em dashes replaced with en dashes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 23:55:53 +02:00
Oliver Kieselbach 8675e7ee06 feat(backend): provider-neutral EmailService on Mailchimp Transactional (Mandrill), replaces Resend
- EmailService + IEmailService: Mandrill messages/send via typed HttpClient with the
  Notification resilience policy; provider confined to one transport method and the
  Email:* settings (ApiKey, Endpoint, FromAddress, FromName). Resend NuGet dropped.
- open/click tracking explicitly off; sent/queued/scheduled = ok, rejected/invalid = warn
- callers (TenantApprovalService, PreviewWhitelistFunction) depend on IEmailService
- EmailServiceTests pin wire format, result semantics and fail-soft contract
- privacy page + OKF welcome-mail doc updated for the new sub-processor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 23:26:32 +02:00
Oliver Kieselbach e857e99436 fix(web): list Generic JSON provider last in channel provider dropdown
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:54:34 +02:00
Oliver Kieselbach 6435d5f290 feat(notifications): Discord webhook provider + HMAC signing for generic webhooks
Discord (provider 30) renders alerts as a single embed with Discord's
field/length caps; actions become markdown links since webhooks cannot
post buttons. Generic JSON channels gain an optional per-channel signing
secret: requests carry X-AutopilotMonitor-Timestamp and
X-AutopilotMonitor-Signature (sha256 hex HMAC over "{timestamp}.{body}"),
computed over the exact posted JSON with same-named custom headers
stripped first. Secret is redacted for readers and restored by channel
id on save; validation enforces 16-128 printable ASCII characters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:09:12 +02:00
Oliver Kieselbach 445ce4e75b feat(backend): 30-day retention grace period on Pro downgrade
Retention was the only enforcement point that destroyed state on a
Pro -> Community downgrade: the 12-hourly sweep clamps to the Community
cap at read time and would immediately start hard-deleting the
90-365-day band (trial expiry identical, silently).

New policy: for 30 days after losing Pro
(FeatureEntitlementCatalog.RetentionDowngradeGraceDays) the Pro cap
keeps applying. Anchor model: ProDowngradedUtc (backend-only field,
patch-denied + revert-protected) stamped/cleared by the plan endpoint's
new pure ApplyPlanChanges core on EFFECTIVE-edition transitions (covers
explicitly ended trials; a planTier downgrade under an active trial
deliberately does not stamp); trial expiry anchors read-time via
TrialExpiresUtc. days<=0 (infinite) stays untouched.

Ops visibility: TenantPlanDowngraded (PATCH handler, new OpsEventService
dep) plus TenantRetentionGraceExpiring/Ended from the daily
TrialExpirySweep with the same stateless window mechanics, emitted only
when stored retention exceeds the Community cap; all dual-registered in
the web OPS_EVENT_TYPES catalog. Fanout now uses its _utcNow seam
instead of inline DateTime.UtcNow. OKF doc
backend/plan-downgrade-retention-grace.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:00:53 +02:00
Oliver Kieselbach 4e54ffe4b4 feat(web): shared plan cards, public /plans and /buy pages
Community/Pro comparison cards extracted into a shared module
(components/plans: planData.ts as single copy source, PlanCards.tsx with
marketing/portal surfaces) so the portal Plan section and the new public
/plans page can never drift. Pro price is one constant (null renders
"Pricing - announced soon").

New public pages following the /help pattern: /plans (free-first
messaging, nav item "Plans") and /buy (direct purchase + Microsoft
Marketplace as coming-soon channels). Landing nav, footer, FinalCta and
get-started now link to /plans instead of the docs plans page; portal
Plan section links to /buy via absolute www URL. Purple utilities
neutralized inside .dark .landing-v2; sitemap/robots/lastmod cover
/plans, /buy and the previously missing /help.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:46:44 +02:00
Oliver Kieselbach 65d45a616e feat(web): Community Edition tag under navbar wordmark
Community tenants see a small "Community Edition" line under the brand
wordmark as a standing reminder (and nudge toward upgrade options); Pro
tenants (incl. trial) keep clean chrome. Label renders only after the
feature-flags fetch confirms the edition - no flash for Pro while loading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:38:19 +02:00
Oliver Kieselbach 4aa1f07fbb feat(web): green welcome accent + session-dismissable dev banner with telemetry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:17:53 +02:00
Oliver Kieselbach ede616444a fix(web): honest skip/uninstall/pending semantics in Download/Install Progress
IME reports skips and uninstall enforcements through the same completed
events, so both panels counted them as successful installs/downloads and
their pills contradicted the app_tracking_summary headers (session 502274b4:
"2 of 8 installed" vs "6 completed").

- Install fold: app_install_completed with state Skipped -> Skipped;
  successful uninstall intent -> new terminal state Uninstalled with its own
  pill and neutral row badge; completed now counts only real installs.
- Download fold: rows are classified by observed byte activity, never by
  intent (Win32 uninstalls re-download the full package and stay visible
  with an Uninstall badge); 0-byte rows sit behind a "no download" toggle.
- Both panels render tracker-known pending apps (summary pendingNames)
  behind a "pending" toggle, so every "X of Y" header fully adds up.
- Headers wrap on narrow screens (flex-wrap + gap instead of space-x).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:42:23 +02:00
Oliver Kieselbach 61a359ee54 feat(web): public /help page for support channels
Adds www.autopilotmonitor.com/help as a mail-linkable page describing how
to get help: Report Session in the portal for session-specific problems,
GitHub Issues for everything else, LinkedIn for private contact, plus
self-service links (docs, FAQ, announcements, changelog) and the private
security-advisory path. Registered as public path in hostRouting (guard
test pinned) and linked from the site footer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 18:12:09 +02:00
Oliver Kieselbach babc89c776 fix(backend): park silent WhiteGlove Part-2 sessions as AwaitingUser instead of Stalled
fairstone.ca analysis 2026-08-21: after the reseal-reboot technicians routinely
power devices off at the logon screen to box them. Both silence paths (2h
maintenance sweep, agent stall probe) then labeled the expected parking state
"Stalled - Agent silent", and stage 2 later reconciled it to Succeeded on the
Part-1 whiteglove_complete with a reason claiming Account Setup completed.

- EnrollmentTimeoutClassifier: shared pure gate IsWhiteGloveAwaitingUser
  (pre-provisioned + ResumedAt + Device Setup provisioned + zero user evidence
  since resume) with one canonical customer-facing reason; new rollup field
  HasEnrollmentComplete separates real completions from Part-1 whiteglove_complete
- ClassifyTimedOutSession: gated sessions are AwaitingUser within grace and
  reconcile past grace with an honest WhiteGlove reason; on resumed sessions
  whiteglove_complete no longer counts as terminal-complete evidence (rule 2),
  so partial user progress falls through to the honest AwaitingUser/Incomplete rules
- MaintenanceService stage 1 + session_stalled ingest mapping apply the same
  gate (fail-soft to Stalled); sweep audit now carries SessionsAwaitingUser
- Callsites (stage 2, max-lifetime verdict, late-telemetry reconcile, legacy
  reclassification) pass IsPreProvisioned/ResumedAt through

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:30:29 +02:00
Oliver Kieselbach a2f448cad0 fix(web): day-scale duration formatting in network timeline
fmtDuration rolls hours into days above 24h ("768d 8h" instead of
"18440h 58m") - minutes are noise at day scale. Surfaced by the first
fleet session with a CMOS clock years off (0bd4af1c, 2024 -> 2026 step);
also keeps the slanted clock-marker label inside its headroom. Test
pinned on the real fleet delta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 21:05:04 +02:00
Oliver Kieselbach f15e115dea fix(sessions): backfilled system-timeline events no longer anchor StartedAt/duration
Field case d0c5b672: a pre-agent system_clock_changed event (backfilled from
the Windows event log with its original timestamp, ~14min before the first
real activity) became the partition's earliest row and re-anchored the
session's StartedAt, inflating the duration toward pre-enrollment OOBE idle
time. With the watcher's 24h backfill lookback, the 2h backward-shift guard
only caps this effect, it does not prevent it.

SystemTimelineWatcher rows (clock steps, sleep episodes) are environment
observation, not enrollment activity, and are now excluded from all three
anchor derivations: the Events-table earliest-row probe (new
IsSessionAnchorEligible, probe scans past ineligible rows), the ingest
batch's EarliestEventTimestamp fold, and the web's client-side
enrollment-duration derivation. The events themselves stay on the timeline
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 20:18:57 +02:00
Oliver Kieselbach 977c5fb447 fix(web): headroom + symmetric insets for network-timeline marker labels
A left-edge "Clock -3h 0m" label collided with the HTML legend above the
band (first live fleet session d0c5b672). Marker-label headroom grows to
48 units so long labels stay inside the SVG, and the drawing content is
now inset 64 units on BOTH sides - the left inset mirrors the existing
right one, giving edge labels room and a calmer, symmetric frame.

Verified against the live session carrying the first system_clock_changed
event (backfilled step -> marker only, no rebase, as designed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 20:14:29 +02:00
Oliver Kieselbach 27fdd19568 feat(agent): optional Delivery Optimization GroupId from network fingerprint
EnableDoGroupIdAutoSet (ConfigVersion 40, default off): the agent seeds the
DOGroupId policy value with a deterministic GUID derived from the default
gateway's IPv4 + SendARP MAC, RealmJoin-compatible byte layout so mixed
fleets peer together. Foreign DOGroupId/DOGroupIdSource policies always win
(ownership tracked via StartupEventGate fingerprint); no random-GUID
fallback. New do_group_id_auto_set event, portal toggle, admin report row,
manifest/catalog sync, OKF doc. Gateway heuristic deduplicated into
NetworkInterfaceLocator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 20:09:45 +02:00
Oliver Kieselbach 72f7adcb3d feat(web): clock-era axis + sleep segments in network timeline
Consume the two new agent events:

- system_clock_changed: live-observed steps (timeDeltaMs authoritative,
  >=30s; sub-30s NTP nudges ignored) split the stream into clock eras and
  rebase every later timestamp by the cumulative delta, so the axis shows
  true elapsed time. Backfilled steps (pre-agent, replayed from the event
  log) are marked but never rebased. Each step gets a slanted "Clock +1h"
  lifecycle marker with old->new time and reason in the tooltip.
- system_sleep_episode: episodes (emitted retroactively at wake) carve an
  "Asleep" segment out of the active network instead of attributing the
  quiet period to it; tooltip carries kind (Modern Standby/sleep), wake
  source and AC/battery. Episode boundaries get the same era correction
  as the event that carried them.

Stats row gains "Asleep total" and "Clock changes"; the event list
includes both new types (now titled "Network & system events").

Layout: drawing content stops 64 units short of the right viewBox edge
(CONTENT_W), so right-edge lifecycle labels slant up-right like all
others instead of flipping left.

7 new vitest cases pin era rebasing (live/backfilled/nudge), sleep
overlay incl. era-corrected boundaries, and hotspot confidence.

Live verification against fleet data pending - the emitting agent was
released minutes ago; no session carries the events yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:57:19 +02:00
Oliver Kieselbach 06cf1c4c12 feat(agent): system timeline watcher — clock steps + sleep episodes as ground truth
Two enrollment blind spots become timeline ground truth from the System event
log via one EventLogWatcher (WindowsUpdateTracker pattern, 24h backfill with
RecordId watermark recovering pre-agent records):

- system_clock_changed (Kernel-General 1): oldTime/newTime/signed timeDeltaMs,
  reason and the setting process. 2s floor suppresses w32time 1ms micro-slews;
  steps >= 5min are Warning + immediate upload. Payload instants are the
  authoritative source (event timestamps can be clamped server-side).
- system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate,
  Kernel-Power 507 Modern Standby; 506 deliberately unconsumed): kind,
  enteredAt/exitedAt, durationSeconds, wake source / AC state. 60s real-sleep
  floor filters Modern Standby screen-off scenarios.

ConfigVersion 39 (Collectors.EnableSystemTimelineWatcher +
SystemEventBackfillLookbackMinutes). Both types are non-activity on agent and
backend (idle/stall clocks unaffected; a sleeping device stays Stalled).

Consumers: TimeJumpBadge gains the ground-truth cause "clock-set"
(classifyTimeJump matches backward display steps against recorded clock sets);
EventTimeline badge blocks for both events plus the previously bare
power_state_change; SessionInfoCard keeps the wall-clock duration and adds a
standby note; TimeAttributionCalculator builds SleepSpans (RebootSpan clone,
AttributionVersion 3) persisted as SleepSeconds/SleepSpansJson and rendered in
TimeAttributionLane; ANALYZE-DEV-012 (info, single) fires once per session for
episodes >= 5min - deliberately the only new rule, clock findings stay with
DEV-008 to avoid duplicate-finding flood.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:41:56 +02:00
Oliver Kieselbach e612c69178 feat(web): feature-form default for the grant command + docs link
The generated PowerShell command now uses the readable -Features form
(first catalog feature as the example) instead of raw permission strings;
a note below lists all available values incl. the All meta-feature and
how to fall back to -Permissions via Copy permissions. The section links
to the Optional Graph Permissions customer docs (via DOCS_URL registry),
and the "What this script does" bullet reflects the Cloud Shell
ambient-identity sign-in. Script help no longer claims the admin UI
generates the -Permissions form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:27:51 +02:00
Oliver Kieselbach 829a841d9b feat(web): slanted inline labels for network-timeline lifecycle markers
The gray lifecycle dots (Reboot / Desktop / Completed) were hard to hit
and mute without hovering. Their label is now written directly at the dot,
slanted up-right (flipping up-left near the right edge so it stays inside
the drawing), with the band shifted down to make headroom. The whole
marker group (line, dot, label) shares an enlarged invisible hit target,
and a label is suppressed when its neighbor sits too close - the tooltip
still carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:09:03 +02:00
Oliver Kieselbach 10eedded13 feat(web): confidence tier for network-timeline hotspot badge
An SSID matching the typical iPhone hotspot name (".. iPhone") is strong
evidence on its own — such networks now get a plain "Hotspot" badge, while
subnet-only evidence keeps the hedged "Hotspot?". Tooltip and footnote
explain the distinction; detection also fires on iPhone-named SSIDs
outside the Apple hotspot subnet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 19:00:56 +02:00
Oliver Kieselbach a9f6ccd86d feat(web): power + network status chips on the progress portal
Taskbar-style chips in the progress page status header showing the
device's last-reported power state (AC/battery with percent, low-battery
warning) and network state (WiFi with SSID tooltip + signal tiers,
Ethernet, offline). Derived purely client-side by folding the event
stream the portal already downloads (power_state_check/change,
network_interface_info, network_state_change, wifi_signal_info) - no
backend, API, or auth change. Chips render only while InProgress and
disappear gracefully for sessions from agents without these events;
devices without a battery show no power chip (Windows-tray parity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:56:04 +02:00
Oliver Kieselbach 7ba67df1a1 Agent instructions 2026-08-21 18:39:35 +02:00
Oliver Kieselbach e214e8dc1a feat(scripts): -Features All meta-value grants every optional permission at once
All is a script-side convenience that expands to every GraphFeatureCatalog
feature (works with -Revoke too). The catalog sync test now pins All in the
ValidateSet while keeping it out of the catalog comparison, and the admin UI
mentions the shorthand next to the generated grant command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:32:48 +02:00
Oliver Kieselbach f8d0ccd2f5 feat(web): per-session network timeline page + device-details deep link
New /sessions/network-timeline?id=&tenantId= page visualizing every network
the device was on during enrollment: one colored segment per network
(Ethernet / WiFi SSIDs, fixed-order validated palette), hatched offline
gaps, ESP phase context lane, lifecycle + connectivity-check markers with
hover/tap tooltips, stats row and the raw network event list.

Segment model derives from network_interface_info + network_state_change
(before/after), enriches SSIDs from wifi_signal_info, infers missing
post-reconnect SSIDs via matching gateway, and flags probable smartphone
hotspots from vendor-default subnets (Apple 172.20.10.x, Windows
192.168.137.x, legacy Android 192.168.43.x) - always as a heuristic badge.
Axis is honest: linear per segment, minimum width for short segments,
boundary ticks show real times. Mobile scrolls horizontally.

Entry point: "Network timeline" deep link in DeviceDetailsCard's Network
section via new networkTimelineUrl() builder. Route registered in the SWA
config (exact routes before the /sessions/* wildcard, incl. the index.txt
RSC route) and excluded from LegacyPathRedirect's /sessions/{id} rewrite
via the new static-siblings set (test pinned).

Planned follow-up once the agent emits them: clock-change events (rebase
axis into clock eras) and standby/Modern-Standby events (render sleep as
its own segment kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:26:49 +02:00
Oliver Kieselbach c9701301ea feat(scripts): Cloud Shell ambient-identity sign-in for Grant-AutopilotMonitorAddOn
Try Connect-MgGraph -Identity first (Azure Cloud Shell issues a delegated
token for the signed-in admin, avoiding the device-code flow that
Conditional Access commonly blocks), probe Graph readability, and fall
back to interactive sign-in with explicit scopes otherwise. The
AppRoleAssignment.ReadWrite.All scope guard now applies only to the
interactive path, since ambient-identity tokens carry a fixed scope set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 18:24:31 +02:00
192 changed files with 11550 additions and 1246 deletions
+147
View File
@@ -0,0 +1,147 @@
---
type: concept
title: Diagnostics Package
description: What the agent's diagnostics ZIP contains and why — the built-in section catalog shared with the portal, the RealmJoin and Device Preparation collection gates, the configured-path guards, caps, and the package manifest.
resource: agent
tags: [diagnostics, realmjoin, device-preparation, guardrails, portal]
timestamp: 2026-08-23
---
# Schema
The diagnostics package is the agent's last-resort support artifact: a ZIP built in memory
by `DiagnosticsPackageService.BuildArchiveBytes` and uploaded through a short-lived SAS
URL at the end of a session, at the WhiteGlove seal (`Always` mode) or on a server-requested
on-demand collection ([server actions](../backend/server-actions-on-demand-diagnostics.md)).
## Archive layout
```
sessioninfo.txt session/tenant/device/outcome, hardware via WMI
<built-in sections> catalog order, see below
AdditionalLogs/<last folder>/ admin-configured paths (global ∪ tenant)
package-manifest.txt every packaging decision (always written)
_TRUNCATED.txt only when a cap skipped something
```
## The built-in catalog is the single source of truth
Everything the package collects **before** any configured path is data, not code paths:
`DiagnosticsBuiltInSections.All` in Shared (`Models/Diagnostics/DiagnosticsBuiltInSections.cs`).
The agent iterates it in order; the backend serves the same list to the portal
(`GET /api/diagnostics/paths`, MemberRead) so what administrators see is exactly what the
agent does. There is no second copy to drift.
| Id | ZIP folder | Source (unexpanded) | Patterns | Recursive | Condition |
| --- | --- | --- | --- | --- | --- |
| AgentLogs | `AgentLogs` | `%ProgramData%\AutopilotMonitor\Logs` | log patterns | no | Always |
| ImeLogs | `ImeLogs` | `%ProgramData%\Microsoft\IntuneManagementExtension\Logs` | log patterns | no | Always |
| ImeBootstrapperEventLog | `ImeLogs` | `C:\Windows\System32\winevt\Logs` | `BootstrapperAgentServiceLogProvider.evtx` | no | DevicePreparation |
| AgentState | `AgentState` | `%ProgramData%\AutopilotMonitor\State` | log patterns + `*.complete`, `*.marker` | yes | Always |
| AgentSpool | `AgentSpool` | `%ProgramData%\AutopilotMonitor\Spool` | `*.jsonl`, `*.json` | no | Always |
| AgentMarkers | `AgentMarkers` | `%ProgramData%\AutopilotMonitor` | `*.complete`, `*.marker` | no | Always |
| RealmJoinWindows | `RealmJoinLogs/Windows` | `C:\Windows\Logs` | `realmjoin*.log` | no | RealmJoinWatcher |
| RealmJoinPackages | `RealmJoinLogs/Windows/RealmJoin` | `C:\Windows\Logs\RealmJoin` | `*.log` | yes | RealmJoinWatcher |
| RealmJoinChoco | `RealmJoinLogs/Choco` | `%ProgramData%\RealmJoin\choco\logs` | `*.log` | yes | RealmJoinWatcher |
| RealmJoinUserTray | `RealmJoinLogs/User` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin` | `tray*.log` | no | RealmJoinWatcher |
| RealmJoinUserLogs | `RealmJoinLogs/User/Logs` | `%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs` | `*.log` | yes | RealmJoinWatcher |
"Log patterns" = `*.log *.txt *.json *.jsonl *.etl *.evtx *.xml *.csv *.cab`. The five
`Always` sections are the historical layout (PR1-B forensics) and are lock-tested in order;
the RealmJoin ZIP layout mirrors the disk (`C:\Windows\Logs\{realmjoin*.log, RealmJoin\…}` →
`RealmJoinLogs/Windows/…`, `%LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\…}` →
`RealmJoinLogs/User/…`), so a flat section and a tree section never produce the same entry
name. Two sections may share a ZIP folder only when they read different source folders
(`ImeLogs` + the bootstrapper evtx) — `(ZipFolder, SourceFolder)` pairs are unique.
### Conditions
`DiagnosticsSectionCondition` is evaluated by the agent (`IsSectionActive`, pure) against its
effective configuration and the enrollment scenario; unknown values fail closed.
* **RealmJoinWatcher** — the tenant's RealmJoin Watcher toggle
(`AnalyzerConfiguration.EnableRealmJoinWatcher`). The package service only sees
`AgentConfiguration`, so `RemoteConfigMerger` mirrors that one Analyzers knob onto
`AgentConfiguration.EnableRealmJoinWatcher` (the RealmJoin host keeps reading the remote
block). Deliberately NOT additionally gated on the compile-time `RealmJoinTrackingEnabled`
kill switch: if the live watcher is ever disabled, the logs become more useful, not less.
No ConfigVersion bump — no new wire field.
* **DevicePreparation** — `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`,
the same deterministic `DevicePreparation\BootstrapperAgent\ExecutionContext` marker every
other WDP gate keys on ([WDP scenario gates](wdp-scenario-gates.md)); CloudAssigned*
fallback rules count as Classic. The probe is a registry read, evaluated once per build
(`SCENARIO: devicePreparation=… realmJoinWatcher=…` in the manifest); a probe failure is
treated as Classic and logged — the evtx export is skipped, never the package. The channel
is exclusively locked while active, so the existing `wevtutil epl` export path
(`ResolveChannelForEvtxFile`, field case 2026-08-17) handles it.
Inactive sections are never silent: `BUILT-IN SKIPPED (RealmJoin Watcher disabled): <Id>`,
`BUILT-IN SKIPPED (not a Device Preparation enrollment): <Id>`,
`BUILT-IN SKIPPED (no user session for token): <Id> '<source>'` (the
`%LOGGED_ON_USER_PROFILE%` token resolves via `UserProfileResolver` only once an
interactive user exists — identical to configured paths). Active ones log
`BUILT-IN: <Id> -> folder='…' zip='…' recursive=…` followed by the per-pattern
`ADDED / NO MATCH / FOLDER MISSING` lines.
## Built-ins bypass the configured-path guards — on purpose
`DiagnosticsPathGuards` ([gather-rule guardrails](../rules/gather-rule-guardrails.md))
validates **admin-typed** paths: allowlisted prefixes, wildcard only in the last segment,
`C:\Users` blocked except the token's `AppData\Local|Roaming`. The catalog is reviewed code,
exactly like the five sections that never went through the guard before — and
`%ProgramData%\RealmJoin` is not on the allowlist at all. What does apply to every section:
the reparse-point skip (files and directories), the `.evtx` channel export, and the caps
(100 MB per file, 500 MB total, 5000 files) tracked globally across sections by
`BudgetTracker`, with every skip recorded in the manifest and `_TRUNCATED.txt`.
## Configured paths: global ∪ tenant
`GetAgentConfigFunction.MergeDiagnosticsLogPaths` sends global paths (Global Admin,
`AdminConfiguration.DiagnosticsGlobalLogPathsJson`) first, then tenant paths
(`TenantConfiguration.DiagnosticsLogPathsJson`), dropping blank entries and collapsing
duplicates (trimmed, case-insensitive) onto the first occurrence — a path present in both
lists used to produce duplicate ZIP entry names. The agent keys each entry's ZIP folder on
the last directory name (`AdditionalLogs/<folder>`), so two configured paths ending in a
same-named folder share one ZIP folder.
## Portal surfaces
* Global Admin → Settings → Diagnostics Log Paths: the built-in catalog (collapsed, read-only,
neutral condition pills) above the editable global list; add-row first.
* Tenant → Settings → Agent → Diagnostics Package: built-in catalog (header carries the
tenant's persisted RealmJoin Watcher state, RealmJoin rows show on/off), the global paths
read-only, then the tenant's own entries; every role with a tenant membership can read the
catalog (the route is MemberRead, JWT-scoped). One line per entry (`DiagnosticsPathRow`),
full values in tooltips.
## Test seams
* `DiagnosticsPackageService` internal ctor: the five historical folder overrides map onto
catalog ids; `sectionFolderOverrides` covers any other section; `devicePreparationProbe`
replaces the registry read (seam callers default to "not WDP", so existing rigs never touch
the registry). `UserProfileResolver.SetForTesting(path|null)` / `Reset()` drive the token.
* Catalog invariants live in the backend test project (`DiagnosticsBuiltInSectionsTests`),
because Shared has no test project.
# Examples
A RealmJoin tenant (watcher on) with a signed-in user: `RealmJoinLogs/Windows/realmjoin.log`,
`RealmJoinLogs/Windows/RealmJoin/Packages/<pkg>/<timestamp>_install.log`,
`RealmJoinLogs/Choco/<pkg>/<timestamp>_install.log`, `RealmJoinLogs/User/tray.log`,
`RealmJoinLogs/User/Logs/RjImeHost.log`. The same tenant's package collected before the
desktop: the two `User` sections appear as `BUILT-IN SKIPPED (no user session for token)`.
A Classic enrollment: `BUILT-IN SKIPPED (not a Device Preparation enrollment):
ImeBootstrapperEventLog`. A tenant that had added
`%LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log` manually keeps working — the
files simply appear twice (`AdditionalLogs/Logs/…` and `RealmJoinLogs/User/Logs/…`) until the
entry is removed.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/DiagnosticsBuiltInSections.cs` — the catalog
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/DiagnosticsPackageService.cs` — `BuildArchiveBytes`, `AddBuiltInSection`, `IsSectionActive`, caps, evtx export
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs` — `EnableRealmJoinWatcher` mirror
* `src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsPathsFunction.cs` — `GET diagnostics/paths`
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `MergeDiagnosticsLogPaths`
* `src/Web/autopilot-monitor-web/components/diagnostics/` — `DiagnosticsPathRow`, `BuiltInSectionsList`, `builtInSectionDisplay`
* Tests: `DiagnosticsPackageServiceTests` (RealmJoin on/off, no user session, bootstrapper evtx, probe failure), `DiagnosticsBuiltInSectionsTests`, `GetDiagnosticsPathsPayloadTests`, `GetAgentConfigFunctionTests.MergeDiagnosticsLogPaths_*`
+113
View File
@@ -0,0 +1,113 @@
---
type: Concept
title: DO GroupId Auto-Set — Delivery Optimization peering group from a network fingerprint
description: EnableDoGroupIdAutoSet (ConfigVersion 40) makes the agent set the DOGroupId policy value to a deterministic GUID derived from the default gateway's IPv4 address and MAC (RealmJoin-compatible byte layout), so devices on the same local network form one DO peering group during enrollment; foreign DOGroupId/DOGroupIdSource policies always win.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs
tags:
- agent
- delivery-optimization
- network
- tenant-configuration
timestamp: 2026-08-21T00:00:00+02:00
---
# DO GroupId Auto-Set
## Schema
### The problem
With Delivery Optimization download mode Group (2), peering is scoped by GroupId. When no
GroupId is configured, Windows derives it from AD site → authenticated domain SID → Entra
tenant ID — for a cloud-native tenant that means **one tenant-wide group**, so "peers on
the same network" degrades to "peers anywhere in the tenant" and DO's cloud service has to
match them by public IP. A GroupId derived from the local network makes the group exactly
"devices behind the same gateway" — including branch offices behind NAT. RealmJoin ships
this as its network-fingerprint GroupId; our agent is on the device even earlier (during
ESP, when the big app downloads actually happen), so it can seed the same value for
Autopilot enrollments.
### The setting
`TenantConfiguration.EnableDoGroupIdAutoSet` (`bool?`, default null→false), served as
`AgentConfigResponse.EnableDoGroupIdAutoSet` — **ConfigVersion 40**. Portal: Settings →
Agent → Settings, toggle "Set Delivery Optimization Group ID", stored via the usual
tenant-config roundtrip (`TableConfigRepository` Store+Map). Also shown in the admin
tenant-config report. `RemoteConfigMerger` maps the bool 1:1; it survives the offline
config cache like every plain feature flag.
The agent does NOT configure `DODownloadMode` — the fingerprint GroupId only has an
effect when the tenant already deploys download mode Group (2) via Intune/GPO.
### The fingerprint
`DoGroupIdService.ComputeGroupId(ipv4Bytes, macBytes)` builds a 16-byte GUID:
```
{101,48,6} + {0,0,0} + {ip[1], 0, ip[2], ip[3]} + mac[0..5] → new Guid(byte[16])
```
Gateway discovery reuses the shared `NetworkInterfaceLocator` heuristic (Up, not
Loopback/Tunnel, non-0.0.0.0 gateway; IPv4 gateway required), the gateway MAC comes from
`ArpNativeMethods` (`iphlpapi!SendARP`). The byte layout — including the IP shuffle and
the zero padding — is **RealmJoin-compatible and must never change**: devices managed by
RealmJoin on the same network compute the identical GUID and peer with ours. The registry
value is written as `Guid.ToString()` (braceless lowercase), identical to RealmJoin's
`SetValue(name, Guid)` string form. Deliberate divergence from RealmJoin: **no
random-GUID fallback** — a random per-device ID would isolate the device in a one-member
group, which is worse than the OS default grouping, so no gateway/no MAC means skip.
### Ownership + conflict rules (never fight a foreign policy)
Target: `HKLM\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization\DOGroupId`
(64-bit registry view — the Policies store is WOW64-redirected; key paths reused from
`DoThrottlePolicyReader`). Only this GPO-store value is written; the legacy pre-1607
`GroupId` value name is intentionally not.
Skip conditions (Info event with `skipReason`, nothing written):
| skipReason | Meaning |
|---|---|
| `no_ipv4_gateway` | No active interface with an IPv4 default gateway |
| `arp_failed` | Gateway MAC could not be resolved via SendARP |
| `group_id_source_policy` | `DOGroupIdSource` configured in GPO or MDM store (dynamic group source wins) |
| `mdm_policy_present` | `DOGroupId` managed via the Policy CSP store (Intune owns it) |
| `foreign_value_present` | Existing GPO value neither matches the computed GUID nor the one we last wrote |
Ownership is tracked through the `StartupEventGate` fingerprint for the
`do_group_id_auto_set` key: `written:<guid>` records the GUID we last wrote, so after a
gateway change the agent recognizes its own stale value and updates it — while a value
written by anyone else is never overwritten (`DecideAction`: empty→write, equal→no-op,
ours→update, else→skip; unparseable existing values count as foreign). Every write is
verified by readback.
### The event
One `do_group_id_auto_set` event per outcome change (Source `StartupEnvironmentProbes`,
gated on the fingerprint — unchanged reruns across reboots stay silent; errors never
latch, so each failing start keeps its retry trail). Severity: Info for
success/skip, Warning for errors. Data: `groupId`, `previousValue`, `gatewayIp`,
`gatewayMac`, `written`, `skipped`, `skipReason`, `success`, `error`.
## Examples
First boot on a network with gateway `192.168.178.1` / MAC `00-11-22-33-44-55`:
```
do_group_id_auto_set (Info): Delivery Optimization GroupId set to
00063065-0000-00a8-b201-001122334455 (gateway 192.168.178.1)
```
Tenant already deploys a DO GroupId via Intune:
```
do_group_id_auto_set (Info): Delivery Optimization GroupId not set: mdm_policy_present
```
## Citations
* Service (fingerprint, ownership, write+readback): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DoGroupIdService.cs`
* ARP interop: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/ArpNativeMethods.cs`
* Probe wiring + event + fingerprint dedup: `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Runtime/StartupEnvironmentProbes.cs`
* Backend serve + ConfigVersion: `../../src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs`
* DO policy stores (key paths, Registry64 rationale): `../../src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DoThrottlePolicyReader.cs`
+3
View File
@@ -5,9 +5,12 @@ How the on-device agent works:
* [Runtime Overview](overview.md) - lifecycle (install → boot guards → runtime → termination), collector hosts, the single-rail event pipeline to the backend, config & kill switch, on-disk layout.
* [Decision Engine](decision-engine.md) - the pure-reducer state machine (DecisionCore): signals, state, effects, the A/B/C completion arms, invariants, threading.
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
* [Diagnostics Package](diagnostics-package.md) - what the diagnostics ZIP contains: the built-in section catalog shared with the portal (Shared `DiagnosticsBuiltInSections`, served by GET diagnostics/paths), the RealmJoin Watcher and Device Preparation collection gates, why built-ins bypass the configured-path guards, global ∪ tenant path dedup, caps and the package manifest.
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
* [Power-State Watcher](power-state-watcher.md) - live AC↔battery transitions and 50/30/15% downward threshold crossings via a WMI push subscription (no polling): power_state_change semantics (latch/debounce/cap, never armed without a battery) and the single-condition analyze rules ANALYZE-DEV-009/-010 on top.
* [DO GroupId Auto-Set](do-group-id-auto-set.md) - the optional Delivery Optimization peering-group seed: deterministic GUID from default gateway IPv4 + MAC (RealmJoin-compatible), written to the DOGroupId policy value with readback, ownership-tracked so foreign Intune/GPO values are never overwritten; skip reasons and the do_group_id_auto_set event.
* [System Timeline Watcher](system-timeline-watcher.md) - clock steps (system_clock_changed: oldTime/newTime/timeDeltaMs/process, micro-slew floor) and completed sleep episodes (system_sleep_episode: sleep/hibernate/Modern Standby with authoritative enteredAt/exitedAt) from one System-channel EventLogWatcher with pre-agent backfill; feeds the web clock-set jump cause, the standby duration note, time-attribution SleepSpans and ANALYZE-DEV-012.
Reading order for newcomers: overview → decision engine → persistence.
+110
View File
@@ -0,0 +1,110 @@
---
type: Concept
title: System Timeline Watcher — clock steps and sleep episodes from the System event log
description: How the agent turns two enrollment blind spots into timeline ground truth via one EventLogWatcher on the System channel — system_clock_changed (Kernel-General 1, oldTime/newTime/timeDeltaMs/process) and system_sleep_episode (Power-Troubleshooter 1 classic sleep/hibernate, Kernel-Power 507 Modern Standby) — including backfill of pre-agent records, the payload-times-are-authoritative rule, SleepSpans in time attribution, the web clock-set jump cause, and ANALYZE-DEV-012.
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs
tags:
- agent
- eventlog
- time
- clock-skew
- standby
- collector
- time-attribution
- analyze-rules
timestamp: 2026-08-21T00:00:00+02:00
---
# System Timeline Watcher
Two things silently distort every session timeline and its wall-clock duration:
1. **The OS clock gets stepped** — classically w32time correcting a wrong BIOS clock early
in OOBE, but also tzutil/MDM actions. Before this watcher the jump was only *inferred*
(TimeJumpBadge, `clock_skew`/DEV-008); Windows records the ground truth.
2. **The device sleeps mid-enrollment** — the timeline shows an unexplained gap, the
duration looks inflated, and nothing says "the device was simply asleep for 56 minutes".
`SystemTimelineTracker` (hosted by `SystemTimelineWatcherHost`, config flag
`Collectors.EnableSystemTimelineWatcher`, ConfigVersion 39) subscribes to the **System**
channel with one provider-qualified XPath and forwards both facts as events.
# Sources and events
| Source (System channel) | Emitted event | Key payload |
|---|---|---|
| `Microsoft-Windows-Kernel-General` EventID 1 | `system_clock_changed` | `oldTime`, `newTime` (ISO-8601), signed `timeDeltaMs`, `reason`/`reasonText` (1=`application_set`, 2=`hardware_clock_sync`), `processName`/`processId` |
| `Microsoft-Windows-Power-Troubleshooter` EventID 1 | `system_sleep_episode` | `kind`=`sleep`/`hibernate` (EffectiveState 5 ⇒ hibernate), `enteredAt`=SleepTime, `exitedAt`=WakeTime, `durationSeconds`, `wakeSourceType`/`wakeSourceText` |
| `Microsoft-Windows-Kernel-Power` EventID 507 | `system_sleep_episode` | `kind`=`modern_standby`, `exitedAt`=TimeCreated, `enteredAt`=exit − DurationInUs, `durationSeconds` (scenario), `sleepDurationSeconds` (real sleep), `reason`, `onAcPower`, `batteryRemainingCapacityOnExit` |
**506 is deliberately not consumed.** 507 carries the full episode; pairing 506/507 would
need an orphan-tolerant state machine across agent restarts for zero information gain — an
episode without a 507 never completed and would not be emitted under any design.
# Thresholds (constants, not knobs)
- `MinClockDeltaMs = 2000` — w32time logs Kernel-General 1 for routine **1 ms micro-slews**
(verified live, several per hour); only genuine steps are emitted. Suppressed records are
still claimed by the dedup watermark.
- `ClockDeltaWarningMs = 5 min` — larger steps are Warning + immediate upload (can break
token/cert validation windows; matches the web TimeJumpBadge threshold).
- `MinSleepDurationSeconds = 60` — **Modern Standby only**: every screen-off scenario logs a
506/507 pair, including seconds-long ones with `SleepDurationInUs 0` (`SleepEntered=false`);
the single duration gate suppresses both. Classic Power-Troubleshooter episodes have no
floor — they only exist for genuinely completed S3/S4 transitions.
# Payload times are authoritative
The decisive clock correction happens exactly when the device clock is untrustworthy, and
the backend clamps implausible event timestamps (±168 h/24 h) to server time. Every semantic
instant therefore travels as an explicit ISO-8601 payload field (`oldTime`/`newTime`,
`enteredAt`/`exitedAt`); consumers must never read the event's own `Timestamp` for these.
The event `Timestamp` is set to the record's TimeCreated (WindowsUpdateTracker backfill
convention) so rows land where the wake/step happened.
# Backfill and dedup
Template: `WindowsUpdateTracker`. `LoadWatermark → arm watcher → backfill` with lookback
`SystemEventBackfillLookbackMinutes` (default 1440 = 24 h; 0 disables). The System .evtx
persists across OOBE reboots, so the pre-agent w32time correction and pre-agent standby
episodes are recovered with `backfilled=true`. Cross-restart dedup via RecordId watermark
(`system-timeline-watermark.json` in the state directory) plus an intra-run `HashSet` —
deliberately **not** a high-water mark, which would drop exactly the older, never-emitted
pre-agent records (the live watcher arms before the backfill scan).
# Non-activity classification (both sides)
Both event types are environment observation, not enrollment progress:
`SignalActivityClassifier.NonActivityEventTypes` (agent — must not reset idle/stall clocks)
and `EventIngestProcessor.IsPeriodicOrStallEvent` (backend — a device that slept through its
stall must stay Stalled).
# Consumers
- **Web timeline** (`EventTimeline.tsx`): badge blocks for both events; the TimeJumpBadge
gains the ground-truth cause `clock-set` — `classifyTimeJump` receives the session's
`system_clock_changed` deltas and matches a backward display step against a backward clock
set within max(60 s, 20 %) (`lib/timeProvenance.ts`).
- **Duration story** (`SessionInfoCard` via `useSessionDerivedData.standbySeconds`): the
wall-clock duration deliberately keeps the pause (the enrollment really took that long);
a `· 🌙 56m standby` note explains it. Episodes are dedup'd on `enteredAt`.
- **Time attribution** (`TimeAttributionCalculator.BuildSleepSpans`, AttributionVersion 3):
`SleepSpan`s mirror `RebootSpan`s — cross-cutting annotations clipped to the observation
windows (a WhiteGlove-pause episode contributes only its in-window flanks), never a slice
of the wall-clock partition. Persisted as `SleepSeconds`/`SleepSpansJson`; rendered as the
standby chip in `TimeAttributionLane`.
- **ANALYZE-DEV-012** (info, `trigger: single`, `on_event:system_sleep_episode`): fires once
per session for episodes ≥ 5 min. Deliberately the ONLY new rule: clock issues already
have DEV-008 (`clock_skew`) — a second clock rule would double-report the same action item
(user decision 2026-08-21); clock steps stay timeline ground truth with badges instead.
# Citations
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/SystemTimelineWatcherHost.cs`
- `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` (`BuildSleepSpans`)
- `src/Web/autopilot-monitor-web/lib/timeProvenance.ts` (`clock-set`, `findExplainingClockChange`)
- `rules/analyze/ANALYZE-DEV-012.json`
- Live payload evidence (2026-08-21, Win11 26220): Kernel-General 1 with `TimeDeltaInMs=1`
micro-slews; Kernel-Power 507 with `SleepDurationInUs=2324095807`; Power-Troubleshooter 1
with `SleepTime`/`WakeTime` across a hibernate.
@@ -0,0 +1,93 @@
---
type: concept
title: Plan Downgrade Policy & the Retention Grace Period
description: What happens when a tenant loses Pro (explicit downgrade or trial expiry) — every entitlement gates read-time and reversibly, except retention, which gets a 30-day grace before the Community cap starts hard-deleting; the anchor model, the ops events, and the deliberate non-grace of everything else.
resource: backend
tags: [plan, edition, downgrade, retention, grace-period, trial, entitlements]
timestamp: 2026-08-22
---
# Schema
## The downgrade model: gate, don't destroy
Edition is resolved at READ time (`FeatureEntitlementCatalog.ResolveEdition`): Pro ⇔
permanent tier `pro`/legacy `enterprise` OR an active trial. A plan mutation is a single
field write (`PATCH config/{tenantId}/plan`, GA-only) — there is no downgrade event
handler, no cascade. On Pro → Community every entitlement simply gates on the next read
(≤ ~5 min config cache):
* **Unrestricted Mode** — effective value flips false; the GA on-request flag and the
tenant toggle survive untouched (inert). Re-upgrade restores it verbatim.
* **Delegated (MSP) admin** — scope suppressed at the home-tenant gate
(`DelegatedAdminService.ApplyHomeTenantGateAsync`); grant rows / tenant groups /
assignments are never touched. Resurrects on re-upgrade.
* **OOBE bootstrap** — endpoints 403/404 unless the additive GA per-tenant flag
(`BootstrapTokenEnabled`) is set; `BootstrapSessions` rows are kept.
* **Rate limits / MCP quota** — pure computed-at-read floors/plan names.
**Retention is the one exception**: the retention sweep (12-hourly
`SessionDeletionMaintenance` → `SessionRetentionFanoutService`) hard-deletes sessions
older than `GetEffectiveRetentionDays` = `min(stored DataRetentionDays, edition cap)`.
Without protection, a downgrade (or trial expiry) with stored retention 365 would start
irreversibly cascade-deleting the 90–365-day band on the next tick.
## The retention grace period (30 days)
`FeatureEntitlementCatalog.RetentionDowngradeGraceDays = 30`. For 30 days after losing
Pro, `GetEffectiveRetentionDays` keeps clamping to the **Pro** cap (365); only after the
grace does the Community cap (90) bite. The stored `DataRetentionDays` is never rewritten.
`days <= 0` (GA-only "infinite") short-circuits before any cap — those tenants are never
swept, grace or not.
**Anchor** — "when was Pro lost" (`TenantEntitlementService.GetRetentionGraceEndUtc`,
returns null while effectively Pro; grace end = latest anchor + 30d):
* **Explicit downgrade**: `TenantConfiguration.ProDowngradedUtc` (backend-only field, no
agent ConfigVersion impact), maintained exclusively by the plan endpoint's pure mutation
core `PlanManagementFunction.ApplyPlanChanges`. Stamped when the EFFECTIVE edition
(before vs. after, same `nowUtc`) transitions Pro → Community — this covers both a
planTier downgrade and a GA explicitly ending a trial (`trialExpiresUtc: null`, where
the trial timestamp disappears and could not anchor). Cleared on any transition back to
effectively Pro (PATCH pro, trial grant, self-service trial start), so a re-upgrade also
resets the grace clock.
* **Trial expiry**: no write happens — the stored `TrialExpiresUtc` itself is the anchor
(read-time, like edition resolution). A planTier downgrade under a still-active trial
deliberately does NOT stamp: the edition is still Pro, and the later trial expiry
anchors the grace then.
The field is on the patch-endpoint `BaseDeniedFields` and `RevertProtectedFields` lists
(a config revert must not time-travel the anchor) and roundtrips the table-serialization
contract (Store+Map + roundtrip tests). Legacy rows read null → no anchor → no grace,
which is correct: no tenant had been downgraded before the feature existed.
## Ops visibility (dual-registered event types)
* `TenantPlanDowngraded` (Warning) — emitted by the PATCH handler on an effective
Pro→Community transition; carries `retentionGraceEndsUtc` + stored retention days. The
response body also returns `retentionGraceEndsUtc`.
* `TenantRetentionGraceExpiring` / `TenantRetentionGraceEnded` (Warning) — emitted by the
daily `TrialExpirySweepFunction` (03:30 UTC) with the same stateless window mechanics as
the trial events (≤3-day heads-up re-fired daily; ended once via the 24h look-back), and
ONLY when data is actually at risk: stored `DataRetentionDays` above the Community cap
(0 = infinite ⇒ never). Re-upgraded tenants go silent automatically (grace resolves null).
All three are registered in the web `OPS_EVENT_TYPES` catalog (OpsAlertRulesSection.tsx)
so Telegram alert rules can route them.
## Deliberate non-goals
* No grace for Unrestricted/MSP/bootstrap/limits — reversible gates need none.
* The retention WRITE cap (`TenantConfigValidation`) does not honor the grace: during the
grace a tenant admin cannot SET a value above the Community cap. The grace protects
existing data; new writes follow the new plan.
* The feature-flags endpoint keeps publishing the plain edition cap
(`retentionCapDays`) — it feeds the settings input maximum, which is the write cap.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs` — grace + effective retention
* `src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs` — `ApplyPlanChanges` anchor lifecycle
* `src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs` — grace event windows
* `src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs` — enforcement point
* Tests: `TenantEntitlementServiceTests`, `PlanManagementTransitionTests`, `TrialExpirySweepFunctionTests`, `SessionRetentionFanoutServiceTests`, `TenantConfigTableSerializationTests`
+46 -1
View File
@@ -4,7 +4,7 @@ title: Tenant Activation & the Once-Only Welcome Mail
description: How a signup becomes an activated tenant (manual approve or auto-approve queue), and how the welcome mail is sent exactly once even though activation and the user-provided address race each other.
resource: backend
tags: [activation, auto-approve, welcome-email, preview-whitelist, race]
timestamp: 2026-08-02
timestamp: 2026-08-23
---
# Schema
@@ -58,6 +58,46 @@ The Global-Admin resend endpoint (`POST /api/preview/send-welcome-email/{tenantI
always sends (explicit intent) and consumes the marker best-effort so the automatic
paths never duplicate afterwards.
## Mail transport
Both transactional mails (welcome, post-offboarding farewell) go through the
provider-neutral `EmailService` (`IEmailService` for the welcome path,
`IOffboardFarewellEmailSender` for the farewell path). The provider is an implementation
detail confined to one private transport method plus the `Email:*` configuration
section — a provider swap must not rename classes, DI registrations or callers.
* Current provider: **Mailchimp Transactional (Mandrill)**, `POST {Email:Endpoint}`
(`messages/send`), API key in the JSON body, typed `HttpClient` with the shared
`ResiliencePolicies.Notification` retry policy (15 s timeout).
* Settings (App Settings use `Email__Key`): `Email:ApiKey` (required — empty means every
send is a logged no-op, never an error), `Email:Endpoint`, `Email:FromAddress`
(default `noreply@autopilotmonitor.com`), `Email:FromName` (default `Autopilot Monitor`).
* Result handling: per-recipient status `sent`/`queued`/`scheduled` = success;
`rejected`/`invalid` (with `reject_reason`), non-2xx or malformed bodies = warning.
Never throws — both callers rely on that fail-soft contract.
* `track_opens`/`track_clicks` are explicitly `false` and `auto_text` is `true`: the provider
receives only the recipient address and the tenant domain, which is exactly the claim on
the privacy page and the trust data-flows page. Changing tracking = changing those pages.
## Template overrides (operator-editable)
Global Admins can replace either HTML body without a deployment (portal: Settings →
Email Templates). `EmailTemplateService` resolves the body for every send: the stored
override wins, otherwise the built-in `EmailTemplates.*`. Overrides live in the
`PreviewConfig` table (partition `EmailTemplates`, row key `welcome` | `farewell`,
properties `Html`, `UpdatedBy`, `UpdatedUtc`), cached 5 minutes, invalidated on save/reset.
* The only placeholder is `{{domainName}}`; an empty domain renders as "your organization",
matching the built-ins. Subjects are not editable (pinned constants).
* Limit 30 000 characters (Table string property cap is 32K UTF-16 chars).
* The send path is fail-soft: a storage error falls back to the built-in body. The admin
read path is not — a broken store surfaces as an error instead of silently showing the
built-in.
* Endpoints (all GlobalAdminOnly): `GET|PUT|DELETE /api/global/email-templates/{kind}` and
`POST /api/global/email-templates/{kind}/test`. The test send goes to the caller's tenant
contact address (activation notification email → `ContactEmail` → caller UPN) with the
caller's tenant domain, optionally with an unsaved draft body; provider tag `test`.
# Examples
* Auto-approve wins the race: approve at T+64 s finds no address → logs "deferred to the
@@ -73,3 +113,8 @@ paths never duplicate afterwards.
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs` — save-path trigger, GA resend
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs` — conditional inserts (approved / welcome-email-sent)
* `src/Backend/AutopilotMonitor.Functions.Tests/TenantApprovalServiceTests.cs` — ordering + dedup pins
* `src/Backend/AutopilotMonitor.Functions/Services/EmailService.cs` — provider-neutral transport (Mandrill wire format, result handling, tracking off)
* `src/Backend/AutopilotMonitor.Functions.Tests/EmailServiceTests.cs` — wire-format, fail-soft and gate-ordering pins
* `src/Backend/AutopilotMonitor.Functions/Services/EmailTemplateService.cs` — override resolution, placeholder rendering, cache
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/EmailTemplatesFunction.cs` — GA endpoints (read / override / reset / test send)
* `src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionEmailTemplates.tsx` — portal editor, preview frame, test send
+4 -1
View File
@@ -19,7 +19,7 @@ https://docs.autopilotmonitor.com (separate repository).
# Agent
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [diagnostics package](agent/diagnostics-package.md) (built-in section catalog shared with the portal, RealmJoin/Device Preparation collection gates, caps, manifest), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
@@ -31,6 +31,8 @@ https://docs.autopilotmonitor.com (separate repository).
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
* [Power-State Watcher](agent/power-state-watcher.md) - Live AC↔battery transitions and 50/30/15% downward battery-threshold crossings during enrollment via a WMI Win32_PowerManagementEvent push subscription (zero polling, never armed on battery-less devices): the power_state_change event (transition field, per-level lifetime latch, 5s debounce, emission cap) and the analyze rules ANALYZE-DEV-009 (battery <15%, high) / ANALYZE-DEV-010 (AC→battery, warning) with on_event interim triggers.
* [System Timeline Watcher](agent/system-timeline-watcher.md) - Ground truth for two timeline blind spots from one System-channel EventLogWatcher with pre-agent backfill: system_clock_changed (Kernel-General 1 — oldTime/newTime/signed timeDeltaMs/reason/setting process; 2 s micro-slew floor, ≥5 min Warning+immediate) and system_sleep_episode (Power-Troubleshooter 1 sleep/hibernate + Kernel-Power 507 Modern Standby; authoritative enteredAt/exitedAt payload instants, 60 s Modern-Standby floor). Consumed by the TimeJumpBadge clock-set cause, the SessionInfoCard standby note, time-attribution SleepSpans (AttributionVersion 3) and ANALYZE-DEV-012.
* [DO GroupId Auto-Set](agent/do-group-id-auto-set.md) - EnableDoGroupIdAutoSet (ConfigVersion 40): the agent seeds the Delivery Optimization DOGroupId policy with a deterministic network-fingerprint GUID (default gateway IPv4 + SendARP MAC, RealmJoin-compatible byte layout) so same-network devices form one DO peering group during ESP; ownership-tracked via the StartupEventGate fingerprint, foreign DOGroupId/DOGroupIdSource policies always win, no random-GUID fallback.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
# Rules
@@ -51,6 +53,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [Attempt-Scoped App Install Durations](backend/app-install-duration-attempt.md) - AppInstallSummary.DurationSeconds = status-defining (last) install attempt via the LastAttemptStartedAt max-fold anchor instead of the first-observation→last-terminal span that billed multi-pass IME gaps to trivial apps; InstallPassCount, the weaker-terminal guard, the radar's cutover gate, and time attribution's union-of-attempt-segments occupancy.
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
* [App Version Duration Regression Radar](backend/app-version-regression-radar.md) - How app versions whose median install duration regressed vs. the previous version are detected (first-seen-ordered versions, median lift ≥2× plus a 5-minute absolute floor over measured installs only), how alert episodes live in the appversionregression tracker keyspace with drain/median re-arm semantics, and the bell/ops-event/analytics surfaces.
* [Plan Downgrade Policy & the Retention Grace Period](backend/plan-downgrade-retention-grace.md) - What happens when a tenant loses Pro: every entitlement gates read-time and reversibly ("gate, don't destroy"), except retention — a 30-day grace keeps the Pro cap before the Community cap starts hard-deleting; the ProDowngradedUtc/trial-expiry anchor model, the TenantPlanDowngraded/RetentionGrace* ops events, and the deliberate non-grace of everything else.
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
+16
View File
@@ -1,7 +1,23 @@
# Log
## 2026-08-23
* **New**: `agent/diagnostics-package.md` — the diagnostics ZIP's built-in collection is now ONE catalog in Shared (`DiagnosticsBuiltInSections.All`): the agent iterates it in order and the backend serves the same list to the portal (`GET diagnostics/paths`, MemberRead), so what administrators see is exactly what the agent collects. New conditional sections: RealmJoin client/package/Chocolatey/per-user logs behind the tenant's RealmJoin Watcher toggle (mirrored onto `AgentConfiguration.EnableRealmJoinWatcher` by the merger) and the Device Preparation bootstrapper event log behind the deterministic WDP marker (probe once per build, fail toward Classic); every skip is a manifest line. Built-ins bypass `DiagnosticsPathGuards` by design (reviewed code, `%ProgramData%\RealmJoin` is not allowlisted); caps/reparse/evtx export still apply. `MergeDiagnosticsLogPaths` dedups global ∪ tenant paths. Portal: collapsed read-only catalog + one-line rows in both cards, tenant context dropped the GA-only global fetch.
* **Update**: `rules/gather-rule-guardrails.md` — `DiagnosticsPathGuards` scope clarified: configured diagnostics paths only, never the built-in catalog.
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Template overrides" section: Global Admins can replace the welcome/farewell HTML in the portal (Settings → Email Templates); `EmailTemplateService` resolves override-or-built-in per send (PreviewConfig table, partition `EmailTemplates`, 5-min cache, `{{domainName}}` placeholder, 30k limit, fail-soft send path), GA endpoints `global/email-templates/{kind}` (+`/test` to the caller's tenant contact address). Also: the mail header is image-free (CSS-drawn brand mark, gradient stripes and clip-path edge as progressive enhancement).
## 2026-08-22
* **Update**: `backend/tenant-activation-welcome-mail.md` — new "Mail transport" section: transactional mail now goes through the provider-neutral `EmailService` (`IEmailService` + `IOffboardFarewellEmailSender`) with the provider confined to one private transport method and the `Email:*` settings (`ApiKey`, `Endpoint`, `FromAddress`, `FromName`); current provider Mailchimp Transactional (Mandrill) replacing Resend (NuGet dropped, typed `HttpClient` + `ResiliencePolicies.Notification`). Result-array semantics (`sent`/`queued`/`scheduled` ok, `rejected`/`invalid` warn), open/click tracking explicitly off because the privacy/trust claim depends on it.
* **New**: `backend/plan-downgrade-retention-grace.md` — plan downgrade policy: every Pro entitlement gates read-time and reversibly on downgrade ("gate, don't destroy"), except retention, which now gets a 30-day grace (`FeatureEntitlementCatalog.RetentionDowngradeGraceDays`) before the Community cap starts hard-deleting the 90–365-day band. Anchor model: `ProDowngradedUtc` stamped/cleared by the plan endpoint's pure `ApplyPlanChanges` core on EFFECTIVE-edition transitions (covers explicitly ended trials; planTier downgrade under an active trial deliberately doesn't stamp), trial expiry anchors read-time via `TrialExpiresUtc`. New Warning ops events `TenantPlanDowngraded` (PATCH handler) and `TenantRetentionGraceExpiring`/`TenantRetentionGraceEnded` (TrialExpirySweep window mechanics, only when stored retention exceeds the Community cap). Field is patch-denied + revert-protected; retention WRITE cap deliberately ignores the grace.
## 2026-08-21
* **New**: `agent/do-group-id-auto-set.md` — optional DO peering-group seed (`EnableDoGroupIdAutoSet`, default off, ConfigVersion 40): `DoGroupIdService` derives a deterministic GUID from the default gateway's IPv4 + SendARP MAC in RealmJoin's exact byte layout (cross-product peering) and writes `DOGroupId` in the 64-bit GPO Policies store with readback verification. Conservative by design: no random-GUID fallback (skip on missing gateway/MAC), `DOGroupIdSource`/MDM-managed values/foreign GPO values always win (skip reasons in the `do_group_id_auto_set` event), own stale values are recognized via the `written:<guid>` StartupEventGate fingerprint and updated after a gateway change. Gateway heuristic extracted into the shared `NetworkInterfaceLocator` (dedup of NetworkChangeDetector + DeviceInfoCollector copies).
* **New**: `agent/system-timeline-watcher.md` — clock-step and sleep-episode ground truth from the System event log: one `EventLogWatcher` (Kernel-General 1, Power-Troubleshooter 1, Kernel-Power 507; 506 deliberately unconsumed) emits `system_clock_changed` (oldTime/newTime/signed timeDeltaMs/reason/process; 2 s micro-slew floor, ≥5 min = Warning+immediate) and `system_sleep_episode` (kind sleep/hibernate/modern_standby, authoritative `enteredAt`/`exitedAt` payload instants, 60 s Modern-Standby floor). WindowsUpdateTracker-style backfill (24 h default, `system-timeline-watermark.json`) recovers pre-agent records; ConfigVersion 39 (`Collectors.EnableSystemTimelineWatcher`). Consumers: TimeJumpBadge cause `clock-set` (ground truth beats inference), SessionInfoCard standby note next to the unchanged wall-clock duration, `SleepSpan`s in time attribution (AttributionVersion 3, RebootSpan clone), and ANALYZE-DEV-012 (info, single, ≥5 min episode; deliberately the only new rule — clock issues stay with DEV-008).
* **New**: `agent/power-state-watcher.md` — live AC/battery observation during enrollment: `PowerStateWatcherHost` subscribes to the WMI push `Win32_PowerManagementEvent EventCode 10` (no polling; re-probes `GetSystemPowerStatus` per push, 5 s trailing-edge debounce, never armed on battery-less devices, `Collectors.EnablePowerStateWatcher` kill-switch, ConfigVersion 38). The pure `PowerStateTransitionTracker` emits `power_state_change` with `transition` = ac_to_battery (Warning, immediate) / battery_to_ac (Info) / threshold_crossed at 50/30/15% (each level latched once per lifetime, lowest-crossed-only on jumps, baseline-below-level covered, emission cap 20). Classified non-activity on both sides (SignalActivityClassifier + IsPeriodicOrStallEvent). New analyze rules ANALYZE-DEV-009 (thresholdPercent=15, high) and ANALYZE-DEV-010 (transition=ac_to_battery, warning), both with `on_event:power_state_change` interim triggers and deliberately single-required-condition (event_data conditions have no same-instance join).
* **Update**: `rules/rule-id-namespace.md` — the export→paste round-trip is no longer lossy: the JSON editors now map rule-shaped exports into the form (`gatherRuleToForm` unflattens the nested gather `parameters` object, analyze `jsonToForm` maps `evaluateOn` onto the eval checkboxes; `tags` and the effective `markSessionAsFailed` carry into the created rule). Both editors still accept the serialized-form shape; edit-mode pastes merge, with an explicit `parameters`/`evaluateOn` winning over stale flat fields.
+6
View File
@@ -31,6 +31,12 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
`DiagnosticsPathGuards.IsDiagnosticsPathAllowed` (allowlist `diagnosticsPathPrefixes`, a C#
literal mirrored in the JSON) applies the same hard blocks and the same user-profile
exception to **admin-configured diagnostics paths** only. The diagnostics package's built-in
sections are reviewed code and never pass through it — see
[Diagnostics Package](../agent/diagnostics-package.md).
**Hard blocks live in code, not in the JSON**, so a parse error cannot lift them — on a
parse failure the allowlists fall back to empty and everything is blocked (fail-closed),
but the hard blocks must hold regardless of configuration:
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-012",
"title": "Device Entered Standby During Enrollment",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:system_sleep_episode"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "slept_during_enrollment",
"source": "event_data",
"eventType": "system_sleep_episode",
"dataField": "durationSeconds",
"operator": "gte",
"value": "300",
"required": true
}
],
"explanation": "The device was asleep for {{durationSeconds}} seconds during the enrollment window. Nothing progresses while the device sleeps — downloads pause, the IME stops processing and the ESP simply waits — so the wall-clock enrollment duration includes this pause and can look much slower than the enrollment actually was. The matched system_sleep_episode event in the session timeline carries the exact enter/exit times, the episode kind (sleep, hibernate or Modern Standby) and, for Modern Standby, whether the device was on AC power. A long unexplained gap in the timeline right before that event is this pause, not missing telemetry.",
"remediation": [
{
"title": "Keep the device awake during enrollment",
"steps": [
"Check the matched system_sleep_episode event in the timeline for the episode bounds and kind — the enrollment duration minus the episode duration is the real active time.",
"If the device slept on battery, connect AC power during setup; Windows sleeps far more aggressively on battery.",
"Instruct users/technicians to keep the lid open and the device powered until the enrollment finishes.",
"For recurring cases, review the power plan applied during OOBE/ESP (e.g. via provisioning or Intune policy) — consider disabling sleep on AC for the enrollment window."
]
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"power",
"standby",
"sleep",
"environment"
]
}
+48
View File
@@ -1678,6 +1678,54 @@
"environment"
]
},
{
"ruleId": "ANALYZE-DEV-012",
"title": "Device Entered Standby During Enrollment",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"whiteglove_sealed",
"on_event:system_sleep_episode"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 60,
"conditions": [
{
"signal": "slept_during_enrollment",
"source": "event_data",
"eventType": "system_sleep_episode",
"dataField": "durationSeconds",
"operator": "gte",
"value": "300",
"required": true
}
],
"explanation": "The device was asleep for {{durationSeconds}} seconds during the enrollment window. Nothing progresses while the device sleeps — downloads pause, the IME stops processing and the ESP simply waits — so the wall-clock enrollment duration includes this pause and can look much slower than the enrollment actually was. The matched system_sleep_episode event in the session timeline carries the exact enter/exit times, the episode kind (sleep, hibernate or Modern Standby) and, for Modern Standby, whether the device was on AC power. A long unexplained gap in the timeline right before that event is this pause, not missing telemetry.",
"remediation": [
{
"title": "Keep the device awake during enrollment",
"steps": [
"Check the matched system_sleep_episode event in the timeline for the episode bounds and kind — the enrollment duration minus the episode duration is the real active time.",
"If the device slept on battery, connect AC power during setup; Windows sleeps far more aggressively on battery.",
"Instruct users/technicians to keep the lid open and the device powered until the enrollment finishes.",
"For recurring cases, review the power plan applied during OOBE/ESP (e.g. via provisioning or Intune policy) — consider disabling sleep on AC for the enrollment window."
]
}
],
"markSessionAsFailedDefault": false,
"tags": [
"device",
"power",
"standby",
"sleep",
"environment"
]
},
{
"ruleId": "ANALYZE-ENRL-001",
"title": "Enrollment Failed",
@@ -28,10 +28,13 @@
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
of Cloud PC first-connect enrollment)
- All -> every optional permission above, in one run
(also works with -Revoke to remove all optional grants at once)
.PARAMETER Permissions
Microsoft Graph application permission names to grant directly. Use when the admin UI
rendered a copy-paste command for you, or when you have a specific list in mind.
Microsoft Graph application permission names to grant directly. Use when you copied the
permission list from the admin UI ("Copy permissions"), or when you have a specific list
in mind.
.PARAMETER TenantId
Tenant ID (GUID) or verified domain. Optional -- defaults to the signed-in context.
@@ -47,12 +50,16 @@
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames
.EXAMPLE
# Same effect, using the explicit-permission form (what the admin UI generates):
# Same effect, using the explicit-permission form:
.\Grant-AutopilotMonitorAddOn.ps1 `
-ClientId "<your-client-id>" `
-Permissions "DeviceManagementScripts.Read.All" `
-TenantId "contoso.onmicrosoft.com"
.EXAMPLE
# Grant every optional capability in one run:
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features All
.EXAMPLE
# Verify current state only, no changes:
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<your-client-id>" -Features ScriptDisplayNames -VerifyOnly
@@ -65,6 +72,12 @@
Requires:
- Microsoft.Graph.Authentication PowerShell module (auto-installs if missing).
- Sign-in as Global Administrator OR Privileged Role Administrator OR Cloud Application Administrator.
Sign-in behaviour:
In Azure Cloud Shell the script signs in via the ambient identity endpoint (a delegated
token for the already-signed-in user) - no device-code prompt, which Conditional Access
policies commonly block. Outside Cloud Shell it falls back to a normal interactive
Microsoft Graph sign-in. -TenantId only applies to the interactive fallback.
- The Autopilot Monitor app must be admin-consented in your tenant first
(its service principal must exist before grants can be added).
@@ -80,7 +93,7 @@ param(
[string]$ClientId,
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
[ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')]
[ValidateSet('All', 'ScriptDisplayNames', 'W365CloudPcValidation')]
[string[]]$Features,
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
@@ -106,7 +119,11 @@ $FeatureCatalog = @{
}
# Translate Features -> Permissions when caller used the high-level form.
# 'All' is a script-side meta-value: it expands to every catalog feature.
if ($PSCmdlet.ParameterSetName -eq 'ByFeatures') {
if ($Features -contains 'All') {
$Features = @($FeatureCatalog.Keys)
}
$Permissions = @()
foreach ($f in $Features) {
$Permissions += $FeatureCatalog[$f]
@@ -133,7 +150,25 @@ if ($TenantId) { $connectParams['TenantId'] = $TenantId }
Write-Host ""
Write-Host "[Auth] Connecting to Microsoft Graph..." -ForegroundColor Cyan
Connect-MgGraph @connectParams | Out-Null
# Preferred path: ambient identity. In Azure Cloud Shell the local identity endpoint issues a
# delegated Graph token for the signed-in user, which avoids the device-code flow that
# Conditional Access policies commonly block. Outside Azure this throws and we fall back to
# an interactive sign-in with explicit scopes.
$identityConnected = $false
try {
Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop | Out-Null
# An ambient identity can exist yet hold no Graph permissions (e.g. a plain VM managed
# identity) - probe with a read before committing to this session.
Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$select=id&`$top=1" -ErrorAction Stop | Out-Null
$identityConnected = $true
Write-Host "[Auth] Connected via ambient identity (Azure Cloud Shell / managed identity)." -ForegroundColor Green
}
catch {
if (Get-MgContext) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null }
Write-Host "[Auth] No usable ambient identity - falling back to interactive sign-in..." -ForegroundColor Yellow
Connect-MgGraph @connectParams | Out-Null
}
$ctx = Get-MgContext
if (-not $ctx) {
@@ -144,7 +179,10 @@ Write-Host "[Auth] Signed in as : $($ctx.Account)"
Write-Host "[Auth] Tenant : $($ctx.TenantId)"
Write-Host "[Auth] Granted scopes: $($ctx.Scopes -join ', ')"
if (-not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
# Scope check only applies to the interactive path: an ambient-identity (Cloud Shell) token
# carries a fixed scope set (incl. Directory.AccessAsUser.All) instead of the requested scopes,
# and its effective rights follow the signed-in user's directory role.
if (-not $identityConnected -and -not ($ctx.Scopes -contains 'AppRoleAssignment.ReadWrite.All')) {
Write-Warning "Scope 'AppRoleAssignment.ReadWrite.All' was NOT granted. The signed-in user likely lacks the required role (Global Admin / Privileged Role Admin / Cloud App Admin)."
if (-not $VerifyOnly) { return }
}
@@ -37,6 +37,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
nameof(AgentConfigResponse.EnrollmentSummaryLaunchRetrySeconds),
nameof(AgentConfigResponse.NtpServer),
nameof(AgentConfigResponse.EnableTimezoneAutoSet),
nameof(AgentConfigResponse.EnableDoGroupIdAutoSet),
nameof(AgentConfigResponse.DiagnosticsUploadEnabled),
nameof(AgentConfigResponse.DiagnosticsUploadMode),
nameof(AgentConfigResponse.DiagnosticsLogPaths),
@@ -52,6 +53,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds);
// the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory.
nameof(AgentConfigResponse.Collectors),
// Merge reads one Analyzers knob (EnableRealmJoinWatcher → diagnostics RealmJoin sections);
// the remaining flags flow untouched to DefaultComponentFactory + AgentAnalyzerManager.
nameof(AgentConfigResponse.Analyzers),
};
/// <summary>
@@ -69,8 +73,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
nameof(AgentConfigResponse.ImeLogPatterns),
// Passed by AgentRuntimeHost into IComponentFactory.CreateCollectorHosts → ImeLogHost sealing re-emit.
nameof(AgentConfigResponse.WhiteGloveSealingPatternIds),
// Consumed by DefaultComponentFactory (RealmJoin/KeepAwake/ConsoleBypass host gates) + AgentAnalyzerManager.
nameof(AgentConfigResponse.Analyzers),
// Self-update ZIP integrity second trust channel — BinaryIntegrityVerifier / Program update path.
nameof(AgentConfigResponse.LatestAgentSha256),
// Running-EXE integrity verification — BinaryIntegrityVerifier post-config check.
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
EnrollmentSummaryLaunchRetrySeconds = 300,
NtpServer = "pool.ntp.org",
EnableTimezoneAutoSet = true,
EnableDoGroupIdAutoSet = true,
DiagnosticsUploadEnabled = true,
DiagnosticsUploadMode = "OnFailure",
DiagnosticsLogPaths = new List<DiagnosticsLogPath>
@@ -83,6 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(300, agent.EnrollmentSummaryLaunchRetrySeconds);
Assert.Equal("pool.ntp.org", agent.NtpServer);
Assert.True(agent.EnableTimezoneAutoSet);
Assert.True(agent.EnableDoGroupIdAutoSet);
Assert.True(agent.DiagnosticsUploadEnabled);
Assert.Equal("OnFailure", agent.DiagnosticsUploadMode);
Assert.Single(agent.DiagnosticsLogPaths);
@@ -366,6 +368,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(30, agent.HelloWaitTimeoutSeconds);
}
[Fact]
public void Merge_mirrors_analyzers_realmjoin_watcher_onto_agent_config()
{
// The diagnostics package gates its RealmJoin sections on this flag but never
// sees the remote response — the merger carries the one Analyzers knob over.
var agent = NewAgentConfig();
var remote = FullRemote();
remote.Analyzers = new AnalyzerConfiguration { EnableRealmJoinWatcher = true };
RemoteConfigMerger.Merge(agent, remote);
Assert.True(agent.EnableRealmJoinWatcher);
}
[Fact]
public void Merge_clears_realmjoin_watcher_when_remote_analyzers_missing()
{
var agent = NewAgentConfig();
agent.EnableRealmJoinWatcher = true; // stale cached true must not survive
var remote = FullRemote();
remote.Analyzers = null!;
RemoteConfigMerger.Merge(agent, remote);
Assert.False(agent.EnableRealmJoinWatcher);
}
[Fact]
public void Merge_replaces_diagnostics_log_paths_with_empty_list_when_remote_null()
{
@@ -1,9 +1,11 @@
#nullable enable
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
@@ -46,7 +48,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Directory.CreateDirectory(SpoolFolder);
}
public DiagnosticsPackageService Build(System.Action<AgentConfiguration>? mutateConfig = null)
public DiagnosticsPackageService Build(
System.Action<AgentConfiguration>? mutateConfig = null,
IReadOnlyDictionary<string, string>? sectionFolderOverrides = null,
System.Func<bool>? devicePreparationProbe = null)
{
// BackendApiClient is required by the public ctor but BuildArchiveBytes
// never touches it — construct with a throwaway HttpClient. Tests only
@@ -71,7 +76,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
imeLogFolderOverride: ImeTmp.Path,
agentStateFolderOverride: StateFolder,
agentSpoolFolderOverride: SpoolFolder,
agentDataFolderOverride: DataFolder);
agentDataFolderOverride: DataFolder,
sectionFolderOverrides: sectionFolderOverrides,
devicePreparationProbe: devicePreparationProbe);
}
public void Dispose()
@@ -287,6 +294,185 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Assert.DoesNotContain(entries, e => e.StartsWith("AgentSpool/"));
}
// ===================================================== built-in catalog gates ====
// The RealmJoin sections ride behind the tenant's RealmJoin Watcher toggle, the
// Device Preparation bootstrapper event log behind the deterministic WDP marker.
// Every conditional section is skipped WITH a manifest line, never silently.
/// <summary>RealmJoin system-side folders redirected to temp (user-side goes via UserProfileResolver).</summary>
private sealed class RealmJoinRig : System.IDisposable
{
public TempDirectory WindowsLogs { get; } = new TempDirectory();
public TempDirectory Choco { get; } = new TempDirectory();
public TempDirectory UserProfile { get; } = new TempDirectory();
public string PackagesFolder => Path.Combine(WindowsLogs.Path, "RealmJoin");
public RealmJoinRig()
{
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin.log"), "rj");
File.WriteAllText(Path.Combine(WindowsLogs.Path, "realmjoin2.log"), "rj2");
var pkg = Path.Combine(PackagesFolder, "Packages", "generic-7zip");
Directory.CreateDirectory(pkg);
File.WriteAllText(Path.Combine(pkg, "install.log"), "pkg");
var choco = Path.Combine(Choco.Path, "generic-7zip");
Directory.CreateDirectory(choco);
File.WriteAllText(Path.Combine(choco, "2026-06-30_install.log"), "choco");
var userRj = Path.Combine(UserProfile.Path, "AppData", "Local", "RealmJoin");
Directory.CreateDirectory(Path.Combine(userRj, "Logs", "generic-office"));
File.WriteAllText(Path.Combine(userRj, "tray.log"), "tray");
File.WriteAllText(Path.Combine(userRj, "config.pjson"), "not a log");
File.WriteAllText(Path.Combine(userRj, "Logs", "RjImeHost.log"), "host");
File.WriteAllText(Path.Combine(userRj, "Logs", "generic-office", "usersettings.log"), "pkg-user");
}
public IReadOnlyDictionary<string, string> Overrides => new Dictionary<string, string>
{
["RealmJoinWindows"] = WindowsLogs.Path,
["RealmJoinPackages"] = PackagesFolder,
["RealmJoinChoco"] = Choco.Path,
};
public void Dispose()
{
WindowsLogs.Dispose();
Choco.Dispose();
UserProfile.Dispose();
}
}
[Fact]
public void BuildArchiveBytes_skips_realmjoin_sections_when_watcher_disabled()
{
using var rig = new Rig();
using var rj = new RealmJoinRig();
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
try
{
var bytes = rig.Build(sectionFolderOverrides: rj.Overrides).BuildArchiveBytes(enrollmentSucceeded: true);
var entries = ZipEntryNames(bytes);
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("SCENARIO: devicePreparation=False realmJoinWatcher=False", manifest);
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinWindows", manifest);
Assert.Contains("BUILT-IN SKIPPED (RealmJoin Watcher disabled): RealmJoinUserLogs", manifest);
}
finally
{
UserProfileResolver.Reset();
}
}
[Fact]
public void BuildArchiveBytes_includes_realmjoin_sections_when_watcher_enabled()
{
using var rig = new Rig();
using var rj = new RealmJoinRig();
UserProfileResolver.SetForTesting(rj.UserProfile.Path);
try
{
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
.BuildArchiveBytes(enrollmentSucceeded: false);
var entries = ZipEntryNames(bytes);
// ZIP layout mirrors the disk layout: flat client logs + package tree under
// Windows/, tray logs + per-user Logs tree under User/.
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
Assert.Contains("RealmJoinLogs/Windows/realmjoin2.log", entries);
Assert.Contains("RealmJoinLogs/Windows/RealmJoin/Packages/generic-7zip/install.log", entries);
Assert.Contains("RealmJoinLogs/Choco/generic-7zip/2026-06-30_install.log", entries);
Assert.Contains("RealmJoinLogs/User/tray.log", entries);
Assert.Contains("RealmJoinLogs/User/Logs/RjImeHost.log", entries);
Assert.Contains("RealmJoinLogs/User/Logs/generic-office/usersettings.log", entries);
// Only tray*.log is collected from the user RealmJoin root — config stays out.
Assert.DoesNotContain(entries, e => e.EndsWith("config.pjson"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("BUILT-IN: RealmJoinUserLogs -> folder=", manifest);
Assert.DoesNotContain("BUILT-IN SKIPPED (RealmJoin Watcher disabled)", manifest);
}
finally
{
UserProfileResolver.Reset();
}
}
[Fact]
public void BuildArchiveBytes_skips_realmjoin_user_sections_without_user_session()
{
using var rig = new Rig();
using var rj = new RealmJoinRig();
// Token present, no interactive user detected → user-profile sections skip with a
// manifest line; the system-side sections are unaffected.
UserProfileResolver.SetForTesting(null!);
try
{
var bytes = rig.Build(cfg => cfg.EnableRealmJoinWatcher = true, rj.Overrides)
.BuildArchiveBytes(enrollmentSucceeded: true);
var entries = ZipEntryNames(bytes);
Assert.Contains("RealmJoinLogs/Windows/realmjoin.log", entries);
Assert.DoesNotContain(entries, e => e.StartsWith("RealmJoinLogs/User"));
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserTray", manifest);
Assert.Contains("BUILT-IN SKIPPED (no user session for token): RealmJoinUserLogs", manifest);
}
finally
{
UserProfileResolver.Reset();
}
}
[Fact]
public void BuildArchiveBytes_collects_bootstrapper_event_log_only_on_device_preparation()
{
using var rig = new Rig();
using var winevt = new TempDirectory();
// Synthetic evtx: no registered channel claims the file, so the export falls
// back to a raw copy — the point here is the scenario gate, not wevtutil.
File.WriteAllBytes(Path.Combine(winevt.Path, "BootstrapperAgentServiceLogProvider.evtx"), new byte[] { 1, 2, 3 });
var overrides = new Dictionary<string, string> { ["ImeBootstrapperEventLog"] = winevt.Path };
var classic = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => false)
.BuildArchiveBytes(enrollmentSucceeded: true);
Assert.DoesNotContain("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(classic));
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog",
ReadZipEntryText(classic, "package-manifest.txt"));
var wdp = rig.Build(sectionFolderOverrides: overrides, devicePreparationProbe: () => true)
.BuildArchiveBytes(enrollmentSucceeded: true);
Assert.Contains("ImeLogs/BootstrapperAgentServiceLogProvider.evtx", ZipEntryNames(wdp));
Assert.Contains("SCENARIO: devicePreparation=True", ReadZipEntryText(wdp, "package-manifest.txt"));
}
[Fact]
public void BuildArchiveBytes_treats_failing_device_preparation_probe_as_classic()
{
using var rig = new Rig();
var bytes = rig.Build(devicePreparationProbe: () => throw new System.InvalidOperationException("registry boom"))
.BuildArchiveBytes(enrollmentSucceeded: true);
var manifest = ReadZipEntryText(bytes, "package-manifest.txt")!;
Assert.Contains("SCENARIO PROBE FAILED (treated as Classic): registry boom", manifest);
Assert.Contains("BUILT-IN SKIPPED (not a Device Preparation enrollment): ImeBootstrapperEventLog", manifest);
Assert.Contains("sessioninfo.txt", ZipEntryNames(bytes)); // archive still built
}
[Theory]
[InlineData(DiagnosticsSectionCondition.Always, false, false, true)]
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, false, true, false)]
[InlineData(DiagnosticsSectionCondition.RealmJoinWatcher, true, false, true)]
[InlineData(DiagnosticsSectionCondition.DevicePreparation, true, false, false)]
[InlineData(DiagnosticsSectionCondition.DevicePreparation, false, true, true)]
[InlineData((DiagnosticsSectionCondition)999, true, true, false)]
public void IsSectionActive_gates_on_condition(DiagnosticsSectionCondition condition, bool watcher, bool wdp, bool expected)
{
var section = new DiagnosticsBuiltInSection("X", "X", @"C:\X", new[] { "*.log" }, false, "x", condition);
var cfg = Cfg();
cfg.EnableRealmJoinWatcher = watcher;
Assert.Equal(expected, DiagnosticsPackageService.IsSectionActive(section, cfg, wdp));
}
private static string[] ZipEntryNames(byte[] zipBytes)
{
using var ms = new MemoryStream(zipBytes);
@@ -0,0 +1,429 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// SystemTimelineTracker — clock steps (Kernel-General 1) and completed sleep episodes
/// (Power-Troubleshooter 1 / Kernel-Power 507) from the System channel. Drives the primitive
/// <see cref="SystemTimelineTracker.ProcessEvent"/> test seam (no real EventRecord, which is
/// abstract + Windows-only), mirroring the WindowsUpdateTracker test pattern.
/// </summary>
public sealed class SystemTimelineTrackerTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 8, 21, 10, 0, 0, DateTimeKind.Utc);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly FakeSignalIngressSink _sink;
private readonly SystemTimelineTracker _tracker;
private long _nextRecordId = 1;
public SystemTimelineTrackerTests()
{
_sink = new FakeSignalIngressSink();
var post = new InformationalEventPost(_sink, new VirtualClock(At));
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
_tracker = new SystemTimelineTracker(
sessionId: "sess-stl",
tenantId: "tenant-stl",
post: post,
logger: logger,
backfillEnabled: false,
stateDirectory: null); // in-memory dedup for most tests
}
public void Dispose() => _tmp.Dispose();
// -------------------------------------------------------------------
// Emission helpers
// -------------------------------------------------------------------
private void EmitClockChange(
DateTime? oldTime, DateTime? newTime, string? rawDeltaMs = null,
int reason = 1, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (oldTime.HasValue) data["OldTime"] = oldTime.Value.ToString("o");
if (newTime.HasValue) data["NewTime"] = newTime.Value.ToString("o");
if (rawDeltaMs != null) data["TimeDeltaInMs"] = rawDeltaMs;
data["Reason"] = reason.ToString();
data["ProcessName"] = @"\Device\HarddiskVolume3\Windows\System32\svchost.exe";
data["ProcessID"] = "6892";
_tracker.ProcessEvent(
providerName: SystemTimelineTracker.ProviderKernelGeneral,
eventId: SystemTimelineTracker.EventId_ClockChange,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? At,
eventData: data,
isBackfill: isBackfill);
}
private void EmitClassicResume(
DateTime sleepTime, DateTime wakeTime, string? effectiveState = "1",
string? wakeSourceText = null, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["SleepTime"] = sleepTime.ToString("o"),
["WakeTime"] = wakeTime.ToString("o"),
["WakeSourceType"] = "0",
};
if (effectiveState != null) data["EffectiveState"] = effectiveState;
if (wakeSourceText != null) data["WakeSourceText"] = wakeSourceText;
_tracker.ProcessEvent(
providerName: SystemTimelineTracker.ProviderPowerTroubleshooter,
eventId: SystemTimelineTracker.EventId_ClassicResume,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? wakeTime,
eventData: data,
isBackfill: isBackfill);
}
private void EmitModernStandbyExit(
long sleepDurationUs, long durationUs, string powerStateAc = "true",
long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
{
var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["SleepDurationInUs"] = sleepDurationUs.ToString(),
["DurationInUs"] = durationUs.ToString(),
["Reason"] = "32",
["PowerStateAc"] = powerStateAc,
["BatteryRemainingCapacityOnExit"] = "34180",
};
_tracker.ProcessEvent(
providerName: SystemTimelineTracker.ProviderKernelPower,
eventId: SystemTimelineTracker.EventId_ModernStandbyExit,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? At,
eventData: data,
isBackfill: isBackfill);
}
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(string eventType) =>
_sink.Posted.Where(p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == eventType).ToList();
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
// -------------------------------------------------------------------
// system_clock_changed
// -------------------------------------------------------------------
[Fact]
public void ClockStep_AboveThreshold_EmitsInfo_WithSignedDeltaAndNormalizedTimes()
{
var oldTime = At;
var newTime = At.AddSeconds(10);
EmitClockChange(oldTime, newTime, reason: 1);
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("false", s.Payload![SignalPayloadKeys.ImmediateUpload]);
var data = Data(s);
Assert.Equal(10_000L, data["timeDeltaMs"]);
Assert.Equal("application_set", data["reasonText"]);
Assert.Equal(1, data["reason"]);
Assert.Equal(oldTime.ToString("o"), data["oldTime"]);
Assert.Equal(newTime.ToString("o"), data["newTime"]);
Assert.Equal(6892, data["processId"]);
Assert.Contains("forward", (string)s.Payload![SignalPayloadKeys.Message]);
Assert.Contains("svchost.exe", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Theory]
[InlineData(1)]
[InlineData(1999)]
public void ClockMicroStep_BelowThreshold_Suppressed(int deltaMs)
{
EmitClockChange(At, At.AddMilliseconds(deltaMs));
Assert.Empty(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void ClockStep_Backward_EmitsNegativeDelta()
{
EmitClockChange(oldTime: At, newTime: At.AddSeconds(-3));
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal(-3000L, Data(s)["timeDeltaMs"]);
Assert.Contains("backward", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Fact]
public void ClockStep_AtLeastFiveMinutes_WarningAndImmediateUpload()
{
EmitClockChange(At, At.AddMinutes(5), reason: 2);
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
Assert.Equal("hardware_clock_sync", Data(s)["reasonText"]);
}
[Fact]
public void ClockStep_NoUsableTimesOrDelta_Skipped()
{
EmitClockChange(oldTime: null, newTime: null, rawDeltaMs: "not-a-number");
Assert.Empty(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void ClockStep_FallsBackToTimeDeltaInMs_WhenTimesUnparseable()
{
// Only the raw delta parses — the direction information of NewTime/OldTime is gone,
// but the magnitude still gates and emits.
EmitClockChange(oldTime: null, newTime: null, rawDeltaMs: "7200000");
var s = Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
Assert.Equal(7_200_000L, Data(s)["timeDeltaMs"]);
}
// -------------------------------------------------------------------
// system_sleep_episode — classic (Power-Troubleshooter 1)
// -------------------------------------------------------------------
[Fact]
public void ClassicResume_EffectiveState5_EmitsHibernateEpisode()
{
var sleepAt = At.AddHours(-2);
EmitClassicResume(sleepAt, At, effectiveState: "5", wakeSourceText: "Power Button");
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
var data = Data(s);
Assert.Equal("hibernate", data["kind"]);
Assert.Equal(7200L, data["durationSeconds"]);
Assert.Equal(sleepAt.ToString("o"), data["enteredAt"]);
Assert.Equal(At.ToString("o"), data["exitedAt"]);
Assert.Equal("Power Button", data["wakeSourceText"]);
Assert.Contains("hibernate", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Fact]
public void ClassicResume_NonHibernateState_EmitsSleepKind()
{
EmitClassicResume(At.AddMinutes(-30), At, effectiveState: "4");
Assert.Equal("sleep", Data(Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode)))["kind"]);
}
[Fact]
public void ClassicResume_ShortEpisode_StillEmits()
{
// No duration floor for classic sleep: the event only exists for genuinely completed
// S3/S4 transitions, so a 30 s real sleep is still signal.
EmitClassicResume(At.AddSeconds(-30), At);
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
Assert.Equal(30L, Data(s)["durationSeconds"]);
}
// -------------------------------------------------------------------
// system_sleep_episode — Modern Standby (Kernel-Power 507)
// -------------------------------------------------------------------
[Fact]
public void ModernStandbyExit_EmitsEpisode_WithDerivedEnterTime()
{
// Values from a live 507 on this dev machine: 38 min scenario, ~38 min actual sleep.
EmitModernStandbyExit(sleepDurationUs: 2_324_095_807, durationUs: 2_330_201_652);
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
var data = Data(s);
Assert.Equal("modern_standby", data["kind"]);
Assert.Equal(2330L, data["durationSeconds"]);
Assert.Equal(2324L, data["sleepDurationSeconds"]);
Assert.Equal(true, data["onAcPower"]);
Assert.Equal(34180L, data["batteryRemainingCapacityOnExit"]);
Assert.Equal(At.ToString("o"), data["exitedAt"]);
Assert.Equal(At.AddMilliseconds(-2_330_201.652).ToString("o"), data["enteredAt"]);
Assert.Contains("Modern Standby", (string)s.Payload![SignalPayloadKeys.Message]);
}
[Fact]
public void ModernStandbyExit_SleepBelowFloor_Suppressed()
{
EmitModernStandbyExit(sleepDurationUs: 30_000_000, durationUs: 90_000_000); // 30 s sleep
Assert.Empty(ByType(Constants.EventTypes.SystemSleepEpisode));
}
[Fact]
public void ModernStandbyExit_ScreenOffWithoutSleep_Suppressed()
{
// SleepEntered=false scenarios carry SleepDurationInUs 0 — the single duration gate
// suppresses them without a separate branch.
EmitModernStandbyExit(sleepDurationUs: 0, durationUs: 6_106_696);
Assert.Empty(ByType(Constants.EventTypes.SystemSleepEpisode));
}
[Fact]
public void ModernStandbyExit_ScenarioShorterThanSleep_ClampsToSleepDuration()
{
EmitModernStandbyExit(sleepDurationUs: 120_000_000, durationUs: 0);
var data = Data(Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode)));
Assert.Equal(120L, data["durationSeconds"]);
Assert.Equal(120L, data["sleepDurationSeconds"]);
}
// -------------------------------------------------------------------
// Backfill semantics
// -------------------------------------------------------------------
[Fact]
public void BackfilledEvent_UsesEventTimeForTimelineTimestamp_AndFlagsData()
{
// A pre-agent standby episode (record created before the agent started) must land on
// the timeline where the device actually woke, not at agent start.
var wakeTime = At.AddHours(-3);
EmitClassicResume(wakeTime.AddHours(-1), wakeTime, isBackfill: true, timeCreatedUtc: wakeTime);
var s = Assert.Single(ByType(Constants.EventTypes.SystemSleepEpisode));
Assert.Equal(wakeTime, s.OccurredAtUtc);
Assert.Equal(true, Data(s)["backfilled"]);
}
// -------------------------------------------------------------------
// Dedup / watermark
// -------------------------------------------------------------------
[Fact]
public void SameRecordId_ProcessedTwice_EmitsOnce()
{
EmitClockChange(At, At.AddMinutes(1), recordId: 42);
EmitClockChange(At, At.AddMinutes(1), recordId: 42);
Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void BackfillEventWithLowerRecordId_AfterLiveEvent_IsStillEmitted()
{
// The live watcher is armed BEFORE backfill runs, so a live record with a higher
// RecordId can be processed first. An older, never-emitted backfill record must NOT be
// suppressed — pre-agent episodes are the whole point (high-water-mark trap).
EmitClockChange(At, At.AddMinutes(1), recordId: 100); // live, high
EmitClockChange(At, At.AddMinutes(2), recordId: 50, isBackfill: true); // backfill, older
Assert.Equal(2, ByType(Constants.EventTypes.SystemClockChanged).Count);
}
[Fact]
public void Watermark_PersistsAcrossTrackerInstances()
{
var post = new InformationalEventPost(_sink, new VirtualClock(At));
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var clockData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["OldTime"] = At.ToString("o"),
["NewTime"] = At.AddMinutes(10).ToString("o"),
["Reason"] = "1",
};
var first = new SystemTimelineTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
first.ProcessEvent(SystemTimelineTracker.ProviderKernelGeneral, SystemTimelineTracker.EventId_ClockChange,
recordId: 500, timeCreatedUtc: At, eventData: clockData, isBackfill: false);
// A fresh tracker (agent restart) must load the watermark and skip the already-emitted
// record when the backfill re-reads it.
var second = new SystemTimelineTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
second.LoadWatermark();
second.ProcessEvent(SystemTimelineTracker.ProviderKernelGeneral, SystemTimelineTracker.EventId_ClockChange,
recordId: 500, timeCreatedUtc: At, eventData: clockData, isBackfill: true);
Assert.Single(ByType(Constants.EventTypes.SystemClockChanged));
}
[Fact]
public void UnknownProviderOrEventId_Ignored()
{
_tracker.ProcessEvent("Microsoft-Windows-Kernel-Power", eventId: 566, recordId: _nextRecordId++,
timeCreatedUtc: At, eventData: new Dictionary<string, string>(), isBackfill: false);
_tracker.ProcessEvent("Some-Other-Provider", eventId: 1, recordId: _nextRecordId++,
timeCreatedUtc: At, eventData: new Dictionary<string, string>(), isBackfill: false);
Assert.Empty(_sink.Posted);
}
// -------------------------------------------------------------------
// XPath + parsing
// -------------------------------------------------------------------
[Fact]
public void BuildXPath_ContainsAllThreeProviderIdPairs_AndNo506()
{
var xpath = SystemTimelineTracker.BuildXPath();
Assert.Contains("Provider[@Name='Microsoft-Windows-Kernel-General'] and EventID=1", xpath);
Assert.Contains("Provider[@Name='Microsoft-Windows-Power-Troubleshooter'] and EventID=1", xpath);
Assert.Contains("Provider[@Name='Microsoft-Windows-Kernel-Power'] and EventID=507", xpath);
Assert.DoesNotContain("506", xpath);
}
[Fact]
public void BuildBackfillXPath_AppendsTimediffClause()
{
var xpath = SystemTimelineTracker.BuildBackfillXPath(86_400_000);
Assert.Contains("TimeCreated[timediff(@SystemTime) <= 86400000]", xpath);
Assert.Contains("EventID=507", xpath);
}
[Fact]
public void ParseEventData_RealKernelGeneralPayload_YieldsAuthoritativeFields()
{
// Verbatim (values shortened) from a live Kernel-General 1 record on a 26220 dev machine.
const string xml =
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>" +
"<System><Provider Name='Microsoft-Windows-Kernel-General'/><EventID>1</EventID></System>" +
"<EventData>" +
"<Data Name='NewTime'>2026-08-21T09:40:02.1987028Z</Data>" +
"<Data Name='OldTime'>2026-08-21T09:40:02.1973028Z</Data>" +
"<Data Name='TimeDeltaInMs'>1</Data>" +
"<Data Name='Reason'>1</Data>" +
"<Data Name='ProcessName'>\\Device\\HarddiskVolume3\\Windows\\System32\\svchost.exe</Data>" +
"<Data Name='ProcessID'>6892</Data>" +
"</EventData></Event>";
var data = SystemTimelineTracker.ParseEventData(xml);
Assert.Equal("1", data["TimeDeltaInMs"]);
Assert.Equal("2026-08-21T09:40:02.1987028Z", data["newtime"]); // case-insensitive keys
Assert.NotNull(SystemTimelineTracker.TryParseUtc(data["OldTime"]));
}
[Fact]
public void FormatDuration_CompactForms()
{
Assert.Equal("2h 05m", SystemTimelineTracker.FormatDuration(TimeSpan.FromMinutes(125)));
Assert.Equal("56m 12s", SystemTimelineTracker.FormatDuration(TimeSpan.FromSeconds(56 * 60 + 12)));
Assert.Equal("45s", SystemTimelineTracker.FormatDuration(TimeSpan.FromSeconds(45)));
}
[Fact]
public void ProcessLeafName_StripsDevicePath()
{
Assert.Equal("svchost.exe",
SystemTimelineTracker.ProcessLeafName(@"\Device\HarddiskVolume3\Windows\System32\svchost.exe"));
Assert.Null(SystemTimelineTracker.ProcessLeafName(null));
Assert.Null(SystemTimelineTracker.ProcessLeafName(@"\Device\HarddiskVolume3\"));
}
}
}
@@ -0,0 +1,126 @@
using System;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Transport
{
public class DoGroupIdServiceTests
{
// -------------------------------------------------------------------
// ComputeGroupId — RealmJoin byte-layout compatibility
// -------------------------------------------------------------------
[Fact]
public void ComputeGroupId_MatchesHandComputedVector()
{
// Layout: {101,48,6} + {0,0,0} + {ip[1],0,ip[2],ip[3]} + mac — through new Guid(byte[])
// the first three groups print little-endian, the last eight bytes verbatim.
var guid = DoGroupIdService.ComputeGroupId(
new byte[] { 192, 168, 178, 1 },
new byte[] { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55 });
Assert.Equal("00063065-0000-00a8-b201-001122334455", guid.ToString());
}
[Theory]
[InlineData(new byte[] { 10, 0, 0, 1 }, new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF })]
[InlineData(new byte[] { 192, 168, 1, 254 }, new byte[] { 0x00, 0x50, 0x56, 0x01, 0x02, 0x03 })]
[InlineData(new byte[] { 172, 16, 254, 3 }, new byte[] { 0xF0, 0x9F, 0xC2, 0x00, 0x00, 0x01 })]
public void ComputeGroupId_MatchesRealmJoinReferenceImplementation(byte[] ip, byte[] mac)
{
Assert.Equal(RealmJoinReferenceGuid(ip, mac), DoGroupIdService.ComputeGroupId(ip, mac));
}
/// <summary>Verbatim port of RealmJoin's ConvertToGuid — the compatibility contract.</summary>
private static Guid RealmJoinReferenceGuid(byte[] ipBytes, byte[] address)
{
byte[] prefix = { 101, 48, 6 };
var stuff = new byte[4];
stuff[0] = ipBytes[1];
stuff[2] = ipBytes[2];
stuff[3] = ipBytes[3];
return new Guid(prefix
.Concat(new byte[16 - prefix.Length - stuff.Length - address.Length])
.Concat(stuff)
.Concat(address)
.ToArray());
}
[Fact]
public void ComputeGroupId_RejectsNonIpv4AndBadMacLengths()
{
var ip4 = new byte[] { 10, 0, 0, 1 };
var mac = new byte[] { 1, 2, 3, 4, 5, 6 };
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(new byte[16], mac));
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(null!, mac));
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(ip4, new byte[8]));
Assert.Throws<ArgumentException>(() => DoGroupIdService.ComputeGroupId(ip4, null!));
}
[Fact]
public void ComputeGroupId_RegistryFormat_IsBracelessLowercase()
{
// RealmJoin writes SetValue(name, Guid) which stringifies via Guid.ToString() ("d"
// format) — our written value must be the identical string form.
var text = DoGroupIdService.ComputeGroupId(
new byte[] { 10, 1, 2, 3 }, new byte[] { 0xAB, 0xCD, 0xEF, 0x01, 0x02, 0x03 }).ToString();
Assert.DoesNotContain("{", text);
Assert.Equal(text.ToLowerInvariant(), text);
Assert.Equal(36, text.Length);
}
// -------------------------------------------------------------------
// DecideAction — ownership matrix
// -------------------------------------------------------------------
private const string Computed = "00063065-0000-00a8-b201-001122334455";
private const string Other = "11111111-2222-3333-4444-555555555555";
[Theory]
[InlineData(null)]
[InlineData("")]
public void DecideAction_NoExistingValue_Writes(string? existing)
{
Assert.Equal(DoGroupIdAction.Write, DoGroupIdService.DecideAction(existing, Computed, null));
}
[Fact]
public void DecideAction_ExistingEqualsComputed_IsNoOp()
{
Assert.Equal(DoGroupIdAction.NoOp, DoGroupIdService.DecideAction(Computed, Computed, null));
}
[Fact]
public void DecideAction_ExistingEqualsComputed_ToleratesBracesAndCase()
{
// A value written by another tool in "{...}"/uppercase form is still semantically ours.
var braced = "{" + Computed.ToUpperInvariant() + "}";
Assert.Equal(DoGroupIdAction.NoOp, DoGroupIdService.DecideAction(braced, Computed, null));
}
[Fact]
public void DecideAction_ExistingIsOurStaleValue_WritesUpdate()
{
// Gateway changed: the registry still holds the GUID we wrote for the old network.
Assert.Equal(DoGroupIdAction.Write, DoGroupIdService.DecideAction(Other, Computed, Other));
}
[Fact]
public void DecideAction_ForeignValue_Skips()
{
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction(Other, Computed, null));
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction(Other, Computed, Computed));
}
[Fact]
public void DecideAction_NonGuidGarbageExisting_SkipsAsForeign()
{
// Never overwrite a value we can't even parse — conservative by design.
Assert.Equal(DoGroupIdAction.SkipForeign, DoGroupIdService.DecideAction("not-a-guid", Computed, null));
}
}
}
@@ -0,0 +1,57 @@
#nullable enable
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 Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// SystemTimelineWatcherHost — thin ICollectorHost wrapper over SystemTimelineTracker.
/// Real event-log arming stays untested by convention (ConsoleBypassWatcher precedent);
/// these tests pin the lifecycle contract only.
/// </summary>
public sealed class SystemTimelineWatcherHostTests : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
public void Dispose() => _tmp.Dispose();
private SystemTimelineWatcherHost CreateHost() => new SystemTimelineWatcherHost(
sessionId: "sess-stl",
tenantId: "tenant-stl",
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
ingress: new FakeSignalIngressSink(),
clock: new VirtualClock(new DateTime(2026, 8, 21, 10, 0, 0, DateTimeKind.Utc)),
backfillLookbackMinutes: 0,
stateDirectory: _tmp.Path);
[Fact]
public void Stop_BeforeStart_DoesNotThrow()
{
using (var host = CreateHost())
{
host.Stop();
}
}
[Fact]
public void Dispose_Twice_DoesNotThrow()
{
var host = CreateHost();
host.Dispose();
host.Dispose();
}
[Fact]
public void Name_IsStable()
{
using (var host = CreateHost())
{
Assert.Equal("SystemTimelineTracker", host.Name);
}
}
}
}
@@ -339,6 +339,114 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
Assert.False(StartupEnvironmentProbes.IsNtpWithinTolerance(null!));
}
// ================================================================= DO GroupId auto-set
// Builder/fingerprint tests only — TrySetGroupId itself does live registry + ARP I/O and
// must never run in a unit test (it would write HKLM on the build machine).
[Fact]
public void BuildDoGroupIdEvent_written_success_is_info_with_full_data()
{
var result = new DoGroupIdSetResult
{
Success = true,
Written = true,
GroupId = "00063065-0000-00a8-b201-001122334455",
PreviousValue = null,
GatewayIp = "192.168.178.1",
GatewayMac = "00-11-22-33-44-55",
};
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal("do_group_id_auto_set", evt.EventType);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Equal("StartupEnvironmentProbes", evt.Source);
Assert.Equal("S1", evt.SessionId);
Assert.Equal("T1", evt.TenantId);
Assert.Contains("00063065-0000-00a8-b201-001122334455", evt.Message);
Assert.Equal("192.168.178.1", evt.Data["gatewayIp"]);
Assert.Equal("00-11-22-33-44-55", evt.Data["gatewayMac"]);
Assert.Equal(true, evt.Data["written"]);
Assert.Equal(true, evt.Data["success"]);
Assert.Equal(false, evt.Data["skipped"]);
}
[Fact]
public void BuildDoGroupIdEvent_noop_success_says_already_set()
{
var result = new DoGroupIdSetResult
{
Success = true,
Written = false,
GroupId = "00063065-0000-00a8-b201-001122334455",
};
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Contains("already set", evt.Message);
Assert.Equal(false, evt.Data["written"]);
}
[Fact]
public void BuildDoGroupIdEvent_skip_is_info_with_reason()
{
var result = new DoGroupIdSetResult
{
Skipped = true,
SkipReason = "foreign_value_present",
PreviousValue = "11111111-2222-3333-4444-555555555555",
};
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Contains("foreign_value_present", evt.Message);
Assert.Equal("foreign_value_present", evt.Data["skipReason"]);
Assert.Equal("11111111-2222-3333-4444-555555555555", evt.Data["previousValue"]);
}
[Fact]
public void BuildDoGroupIdEvent_error_is_warning()
{
var result = new DoGroupIdSetResult { Error = "readback mismatch" };
var evt = StartupEnvironmentProbes.BuildDoGroupIdEvent(Config(), result);
Assert.Equal(EventSeverity.Warning, evt.Severity);
Assert.Contains("readback mismatch", evt.Message);
Assert.Equal("readback mismatch", evt.Data["error"]);
}
[Fact]
public void DoGroupIdFingerprint_roundtrips_ownership_marker_for_success()
{
var fp = StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult
{
Success = true,
GroupId = "00063065-0000-00a8-b201-001122334455",
});
Assert.Equal("written:00063065-0000-00a8-b201-001122334455", fp);
Assert.Equal("00063065-0000-00a8-b201-001122334455", StartupEnvironmentProbes.ExtractLastWrittenGroupId(fp));
}
[Fact]
public void DoGroupIdFingerprint_skip_carries_no_ownership_and_errors_never_dedupe()
{
var skipFp = StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult
{
Skipped = true,
SkipReason = "mdm_policy_present",
});
Assert.Equal("skipped:mdm_policy_present", skipFp);
Assert.Null(StartupEnvironmentProbes.ExtractLastWrittenGroupId(skipFp));
// Errors: no fingerprint → the probe re-runs AND re-emits each failing start.
Assert.Null(StartupEnvironmentProbes.ComputeDoGroupIdFingerprint(new DoGroupIdSetResult { Error = "boom" }));
Assert.Null(StartupEnvironmentProbes.ExtractLastWrittenGroupId(null));
}
[Fact]
public async Task RunAsync_skips_geo_timezone_and_ntp_once_all_succeeded_only_power_state_emits()
{
@@ -109,6 +109,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public bool EnableTimezoneAutoSet { get; set; } = false;
/// <summary>
/// Whether to set the Delivery Optimization group ID (DOGroupId policy value) from a
/// deterministic network fingerprint (default gateway IP + MAC, RealmJoin-compatible).
/// Existing DOGroupId/DOGroupIdSource policies are never overwritten.
/// Default: false
/// </summary>
public bool EnableDoGroupIdAutoSet { get; set; } = false;
/// <summary>
/// Continue-Anyway observation mode (operator-set tenant opt-in, delivered via
/// remote config): stamped into the esp_config_detected decision-signal payload so
@@ -254,6 +262,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public bool UnrestrictedMode { get; set; } = false;
/// <summary>
/// Mirror of <c>AnalyzerConfiguration.EnableRealmJoinWatcher</c> (remote, per tenant).
/// Gates the RealmJoin built-in sections of the diagnostics package — the package
/// service only sees this configuration, not the remote response. The RealmJoin host
/// itself keeps reading the remote Analyzers block. Default: false.
/// </summary>
public bool EnableRealmJoinWatcher { get; set; } = false;
/// <summary>
/// Sanitized command-line arguments for the agent_started event.
/// Secrets (e.g. bootstrap token) are redacted before assignment.
@@ -94,12 +94,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
// a tenant admin can clear a previously-applied custom NTP server.
agentConfig.NtpServer = remoteConfig.NtpServer;
agentConfig.EnableTimezoneAutoSet = remoteConfig.EnableTimezoneAutoSet;
agentConfig.EnableDoGroupIdAutoSet = remoteConfig.EnableDoGroupIdAutoSet;
// ---------------- Diagnostics — remote wins (including DiagnosticsUploadMode="Off"
// which must clear any CLI-initial default).
agentConfig.DiagnosticsUploadEnabled = remoteConfig.DiagnosticsUploadEnabled;
agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode;
agentConfig.DiagnosticsLogPaths = remoteConfig.DiagnosticsLogPaths ?? new List<DiagnosticsLogPath>();
// The diagnostics package gates its RealmJoin sections on the tenant's watcher
// toggle but never sees the remote response — mirror the one Analyzers knob here.
// The remaining Analyzers flags flow untouched to DefaultComponentFactory.
agentConfig.EnableRealmJoinWatcher = remoteConfig.Analyzers?.EnableRealmJoinWatcher ?? false;
agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents;
@@ -0,0 +1,681 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Watches the <c>System</c> event log for two enrollment blind spots and forwards them as
/// timeline ground truth:
/// <list type="bullet">
/// <item><b>System clock steps</b> (Kernel-General EventID 1) → <c>system_clock_changed</c>.
/// The payload carries <c>oldTime</c>/<c>newTime</c>/<c>timeDeltaMs</c> plus the
/// setting process — the "clock set from X to Y" fact that today has to be inferred
/// from timestamp discontinuities. w32time also logs frequent 1 ms micro-slews on the
/// same EventID, so emissions are gated on <see cref="MinClockDeltaMs"/>.</item>
/// <item><b>Completed sleep episodes</b> → <c>system_sleep_episode</c>. Classic S3/S4 comes
/// from Power-Troubleshooter EventID 1 (one event per episode at wake, carrying
/// SleepTime + WakeTime). Modern Standby comes from Kernel-Power EventID 507 (exit),
/// whose payload carries the scenario duration and the actual sleep residency; the
/// enter time is derived as exit − duration. Kernel-Power 506 (enter) is deliberately
/// NOT consumed: 507 already carries everything, and pairing 506/507 would need an
/// orphan-tolerant state machine across agent restarts for zero information gain —
/// an episode without a 507 never completed and would not be emitted under any design.</item>
/// </list>
/// <para>
/// <b>Payload times are authoritative.</b> The decisive clock correction (w32time fixing a
/// wrong BIOS clock) happens exactly when the device clock is untrustworthy, and the backend
/// clamps implausible event timestamps to server time. All semantic instants therefore travel
/// as explicit ISO-8601 payload fields, never only as the event's own <c>Timestamp</c>.
/// </para>
/// <para>
/// <b>Backfill + cross-restart dedup</b> mirror <see cref="WindowsUpdateTracker"/>: the System
/// .evtx persists across the OOBE reboots, so a startup scan recovers pre-agent clock steps
/// and standby episodes; a persisted RecordId watermark plus an intra-run seen-set (a HashSet,
/// NOT a high-water mark — see the field comment) keep restarts from re-emitting.
/// </para>
/// </summary>
internal sealed class SystemTimelineTracker : IDisposable
{
internal const string Channel = "System";
internal const string ProviderKernelGeneral = "Microsoft-Windows-Kernel-General"; // EventID 1: system time changed
internal const string ProviderPowerTroubleshooter = "Microsoft-Windows-Power-Troubleshooter"; // EventID 1: resumed from classic sleep/hibernate
internal const string ProviderKernelPower = "Microsoft-Windows-Kernel-Power"; // EventID 507: Modern Standby exit
internal const int EventId_ClockChange = 1;
internal const int EventId_ClassicResume = 1;
internal const int EventId_ModernStandbyExit = 507;
internal const string WatermarkStateFileName = "system-timeline-watermark.json";
// w32time logs Kernel-General 1 for routine 1 ms slew corrections (verified live: several
// per hour). Only genuine steps are timeline-relevant; anything below this is claimed in
// the dedup set but never emitted.
internal const long MinClockDeltaMs = 2000;
// A step of at least 5 minutes can invalidate token/cert validation windows and visibly
// reorders the timeline (matches the web's TimeJumpBadge threshold) — surface it fast.
internal const long ClockDeltaWarningMs = 5 * 60 * 1000;
// Modern Standby logs a 506/507 pair for every screen-off scenario, including seconds-long
// ones where the device never actually slept (SleepDurationInUs 0). Only episodes with
// real sleep residency are emitted. Classic Power-Troubleshooter episodes have no floor:
// they only exist for genuinely completed S3/S4 transitions.
internal const long MinSleepDurationSeconds = 60;
private readonly AgentLogger _logger;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly InformationalEventPost _post;
private readonly bool _backfillEnabled;
private readonly int _backfillLookbackMinutes;
private readonly string _stateDirectory;
private EventLogWatcher _watcher;
// Dedup. Guarded because EventRecordWritten callbacks run concurrently with the backfill scan.
//
// Cross-restart boundary loaded from disk: the highest RecordId emitted in a PRIOR run.
// Immutable during this run — records at/below it were already emitted before this process
// started and must not be re-emitted by the backfill scan.
private long _restartWatermark = -1;
// RecordIds claimed in THIS run — the intra-run dedup set. Deliberately a HashSet, NOT a
// high-water mark: the live watcher is armed before the backfill runs, so a live record with
// a HIGHER RecordId can be processed before/during backfill. A high-water mark would then
// make the dedup skip every older, never-emitted backfill record — silently dropping exactly
// the pre-agent clock corrections and standby episodes this tracker exists for. The targeted
// records are low-volume, so the set stays tiny.
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
// Highest RecordId claimed so far (max of restart watermark + this run). Persisted so the
// next run's restart watermark is correct.
private long _maxEmittedRecordId = -1;
private readonly object _watermarkLock = new object();
public SystemTimelineTracker(
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger,
bool backfillEnabled = true,
int backfillLookbackMinutes = 1440,
string stateDirectory = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_backfillEnabled = backfillEnabled;
_backfillLookbackMinutes = backfillLookbackMinutes;
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
}
public void Start()
{
LoadWatermark();
StartWatcher();
if (_backfillEnabled && _backfillLookbackMinutes > 0)
BackfillRecentEvents();
else
_logger.Info("SystemTimeline backfill disabled by config");
}
public void Stop()
{
if (_watcher == null) return;
try
{
_watcher.Enabled = false;
_watcher.Dispose();
_logger.Info("SystemTimeline watcher stopped");
}
catch (Exception ex)
{
_logger.Error("Error stopping SystemTimeline watcher", ex);
}
finally
{
_watcher = null;
}
}
public void Dispose() => Stop();
// -----------------------------------------------------------------------
// Watcher lifecycle
// -----------------------------------------------------------------------
private void StartWatcher()
{
try
{
var query = new EventLogQuery(Channel, PathType.LogName, BuildXPath());
_watcher = new EventLogWatcher(query);
_watcher.EventRecordWritten += OnEventRecordWritten;
_watcher.Enabled = true;
_logger.Info($"SystemTimeline watcher started: {Channel} (clock changes + sleep episodes)");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"SystemTimeline event log not found: {Channel} (normal on non-Windows test environments)");
}
catch (UnauthorizedAccessException ex)
{
_logger.Warning($"SystemTimeline watcher access denied for {Channel}: {ex.Message}");
}
catch (Exception ex)
{
_logger.Error($"Failed to start SystemTimeline watcher for {Channel}", ex);
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
collectorName: "SystemTimelineTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
}
}
/// <summary>
/// Provider-qualified XPath for the three targeted signals. Provider filtering is mandatory
/// on the System channel — EventID 1 alone matches many unrelated providers.
/// Exposed as internal for tests.
/// </summary>
internal static string BuildXPath() =>
"*[System[" + ProviderIdClauses() + "]]";
/// <summary>Backfill variant of <see cref="BuildXPath"/> with the lookback window applied.</summary>
internal static string BuildBackfillXPath(long lookbackMs) =>
"*[System[(" + ProviderIdClauses() + $") and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
private static string ProviderIdClauses() =>
$"(Provider[@Name='{ProviderKernelGeneral}'] and EventID={EventId_ClockChange})" +
$" or (Provider[@Name='{ProviderPowerTroubleshooter}'] and EventID={EventId_ClassicResume})" +
$" or (Provider[@Name='{ProviderKernelPower}'] and EventID={EventId_ModernStandbyExit})";
// -----------------------------------------------------------------------
// Event processing
// -----------------------------------------------------------------------
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
{
if (e.EventRecord == null) return;
try
{
ProcessRecord(e.EventRecord, isBackfill: false);
}
catch (Exception ex)
{
_logger.Error("Error processing SystemTimeline event", ex);
}
}
private void ProcessRecord(EventRecord record, bool isBackfill)
{
var recordId = record.RecordId ?? -1;
// Cheap dedup gate BEFORE the XML rendering work.
if (IsAlreadyProcessed(recordId))
return;
string xml = null;
try { xml = record.ToXml(); }
catch { /* fall through with empty EventData */ }
ProcessEvent(
providerName: record.ProviderName,
eventId: record.Id,
recordId: recordId,
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
eventData: ParseEventData(xml),
isBackfill: isBackfill);
}
/// <summary>
/// Core processing extracted to primitive inputs so tests can drive it without synthesizing
/// an abstract, Windows-only <see cref="EventRecord"/> (WindowsUpdateTracker test-seam
/// pattern; a plain dictionary instead of named args because three providers with ~10
/// payload fields each make a fixed signature impractical).
/// </summary>
internal void ProcessEvent(
string providerName,
int eventId,
long recordId,
DateTime? timeCreatedUtc,
Dictionary<string, string> eventData,
bool isBackfill)
{
if (!MarkProcessed(recordId))
return; // already emitted (cross-restart or duplicate delivery)
eventData = eventData ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.Equals(providerName, ProviderKernelGeneral, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ClockChange)
HandleClockChange(recordId, timeCreatedUtc, eventData, isBackfill);
else if (string.Equals(providerName, ProviderPowerTroubleshooter, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ClassicResume)
HandleClassicResume(recordId, timeCreatedUtc, eventData, isBackfill);
else if (string.Equals(providerName, ProviderKernelPower, StringComparison.OrdinalIgnoreCase) && eventId == EventId_ModernStandbyExit)
HandleModernStandbyExit(recordId, timeCreatedUtc, eventData, isBackfill);
// Anything else slipped past the XPath filter — ignore.
}
// -----------------------------------------------------------------------
// system_clock_changed (Kernel-General EventID 1)
// -----------------------------------------------------------------------
private void HandleClockChange(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
{
eventData.TryGetValue("NewTime", out var newTimeRaw);
eventData.TryGetValue("OldTime", out var oldTimeRaw);
var newTime = TryParseUtc(newTimeRaw);
var oldTime = TryParseUtc(oldTimeRaw);
// Signed delta preferably from the authoritative instants (NewTime − OldTime carries
// the direction unambiguously); TimeDeltaInMs is the fallback when either fails to
// parse. Records with neither are unusable and skipped (already claimed by dedup).
long deltaMs;
if (newTime.HasValue && oldTime.HasValue)
{
deltaMs = (long)Math.Round((newTime.Value - oldTime.Value).TotalMilliseconds);
}
else if (eventData.TryGetValue("TimeDeltaInMs", out var deltaRaw) &&
long.TryParse(deltaRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedDelta))
{
deltaMs = parsedDelta;
}
else
{
_logger.Debug($"SystemTimeline: clock-change record {recordId} has no usable times/delta — skipped");
return;
}
if (Math.Abs(deltaMs) < MinClockDeltaMs)
return; // w32time micro-slew — claimed (MarkProcessed) but never emitted
eventData.TryGetValue("Reason", out var reasonRaw);
int.TryParse(reasonRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var reason);
var reasonText = reason == 1 ? "application_set"
: reason == 2 ? "hardware_clock_sync"
: "unknown";
eventData.TryGetValue("ProcessName", out var processName);
var data = new Dictionary<string, object>
{
{ "timeDeltaMs", deltaMs },
{ "reason", reason },
{ "reasonText", reasonText },
{ "backfilled", isBackfill },
};
// Authoritative instants: re-normalized ISO-8601 when parseable, raw pass-through
// otherwise — the payload must survive even a malformed provider rendering.
data["newTime"] = newTime.HasValue ? newTime.Value.ToString("o") : (object)(newTimeRaw ?? string.Empty);
data["oldTime"] = oldTime.HasValue ? oldTime.Value.ToString("o") : (object)(oldTimeRaw ?? string.Empty);
if (recordId >= 0) data["recordId"] = recordId;
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
if (!string.IsNullOrEmpty(processName)) data["processName"] = processName;
if (eventData.TryGetValue("ProcessID", out var pidRaw) &&
int.TryParse(pidRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid))
{
data["processId"] = pid;
}
var direction = deltaMs >= 0 ? "forward" : "backward";
var processLeaf = ProcessLeafName(processName);
var message = $"System clock stepped {direction} {FormatDuration(TimeSpan.FromMilliseconds(Math.Abs(deltaMs)))}" +
$" (reason: {reasonText}" + (processLeaf != null ? $", process: {processLeaf})" : ")");
var isLargeStep = Math.Abs(deltaMs) >= ClockDeltaWarningMs;
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
// The record's own creation time, not UtcNow: backfilled pre-agent clock steps must
// land on the timeline where they happened (WindowsUpdateTracker convention).
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = Constants.EventTypes.SystemClockChanged,
Severity = isLargeStep ? EventSeverity.Warning : EventSeverity.Info,
Source = "SystemTimelineWatcher",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
ImmediateUpload = isLargeStep,
});
}
// -----------------------------------------------------------------------
// system_sleep_episode — classic sleep/hibernate (Power-Troubleshooter EventID 1)
// -----------------------------------------------------------------------
private void HandleClassicResume(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
{
eventData.TryGetValue("SleepTime", out var sleepRaw);
eventData.TryGetValue("WakeTime", out var wakeRaw);
var enteredAt = TryParseUtc(sleepRaw);
var exitedAt = TryParseUtc(wakeRaw);
// EffectiveState 5 = hibernate (S4); TargetState is the fallback when the effective
// state is missing (older event versions).
eventData.TryGetValue("EffectiveState", out var effectiveState);
eventData.TryGetValue("TargetState", out var targetState);
var stateValue = !string.IsNullOrEmpty(effectiveState) ? effectiveState : targetState;
var kind = stateValue == "5" ? "hibernate" : "sleep";
long? durationSeconds = null;
if (enteredAt.HasValue && exitedAt.HasValue && exitedAt.Value > enteredAt.Value)
durationSeconds = (long)Math.Round((exitedAt.Value - enteredAt.Value).TotalSeconds);
var data = new Dictionary<string, object>
{
{ "kind", kind },
{ "backfilled", isBackfill },
};
data["enteredAt"] = enteredAt.HasValue ? enteredAt.Value.ToString("o") : (object)(sleepRaw ?? string.Empty);
data["exitedAt"] = exitedAt.HasValue ? exitedAt.Value.ToString("o") : (object)(wakeRaw ?? string.Empty);
if (durationSeconds.HasValue) data["durationSeconds"] = durationSeconds.Value;
if (recordId >= 0) data["recordId"] = recordId;
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
if (eventData.TryGetValue("WakeSourceType", out var wakeTypeRaw) &&
int.TryParse(wakeTypeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var wakeType))
{
data["wakeSourceType"] = wakeType;
}
eventData.TryGetValue("WakeSourceText", out var wakeText);
if (!string.IsNullOrEmpty(wakeText)) data["wakeSourceText"] = wakeText;
var durationLabel = durationSeconds.HasValue
? " after " + FormatDuration(TimeSpan.FromSeconds(durationSeconds.Value))
: string.Empty;
var wakeLabel = !string.IsNullOrEmpty(wakeText) ? $" (wake source: {wakeText})" : string.Empty;
EmitSleepEpisode(timeCreatedUtc, data,
$"Device resumed from {kind}{durationLabel}{wakeLabel}");
}
// -----------------------------------------------------------------------
// system_sleep_episode — Modern Standby (Kernel-Power EventID 507)
// -----------------------------------------------------------------------
private void HandleModernStandbyExit(long recordId, DateTime? timeCreatedUtc, Dictionary<string, string> eventData, bool isBackfill)
{
eventData.TryGetValue("SleepDurationInUs", out var sleepUsRaw);
long.TryParse(sleepUsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var sleepUs);
var sleepDurationSeconds = sleepUs / 1_000_000;
// Every screen-off scenario logs a 507, including seconds-long ones where the device
// never slept (SleepDurationInUs 0, incl. SleepEntered=false) — one gate covers both.
if (sleepDurationSeconds < MinSleepDurationSeconds)
return; // claimed but not an episode worth the timeline
eventData.TryGetValue("DurationInUs", out var durationUsRaw);
long.TryParse(durationUsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var durationUs);
if (durationUs < sleepUs) durationUs = sleepUs; // scenario duration can never undercut its sleep residency
var durationSeconds = durationUs / 1_000_000;
var exitedAt = timeCreatedUtc; // 507 is written at scenario exit
DateTime? enteredAt = exitedAt.HasValue
? exitedAt.Value.AddMilliseconds(-(durationUs / 1000.0))
: (DateTime?)null;
var data = new Dictionary<string, object>
{
{ "kind", "modern_standby" },
{ "durationSeconds", durationSeconds },
{ "sleepDurationSeconds", sleepDurationSeconds },
{ "backfilled", isBackfill },
};
if (enteredAt.HasValue) data["enteredAt"] = enteredAt.Value.ToString("o");
if (exitedAt.HasValue) data["exitedAt"] = exitedAt.Value.ToString("o");
if (recordId >= 0) data["recordId"] = recordId;
if (timeCreatedUtc.HasValue) data["timeCreated"] = timeCreatedUtc.Value.ToString("o");
if (eventData.TryGetValue("Reason", out var reasonRaw) &&
int.TryParse(reasonRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var reason))
{
data["reason"] = reason;
}
bool? onAcPower = null;
if (eventData.TryGetValue("PowerStateAc", out var acRaw) && bool.TryParse(acRaw, out var ac))
{
onAcPower = ac;
data["onAcPower"] = ac;
}
if (eventData.TryGetValue("BatteryRemainingCapacityOnExit", out var battRaw) &&
long.TryParse(battRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var batt))
{
data["batteryRemainingCapacityOnExit"] = batt;
}
var powerLabel = onAcPower.HasValue ? (onAcPower.Value ? ", on AC" : ", on battery") : string.Empty;
EmitSleepEpisode(timeCreatedUtc, data,
$"Device exited Modern Standby after {FormatDuration(TimeSpan.FromSeconds(durationSeconds))}" +
$" (slept {FormatDuration(TimeSpan.FromSeconds(sleepDurationSeconds))}{powerLabel})");
}
private void EmitSleepEpisode(DateTime? timeCreatedUtc, Dictionary<string, object> data, string message)
{
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
// Wake/exit moment (the record's creation time) — backfilled pre-agent episodes
// must land on the timeline where the device actually woke.
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
EventType = Constants.EventTypes.SystemSleepEpisode,
Severity = EventSeverity.Info,
Source = "SystemTimelineWatcher",
Phase = EnrollmentPhase.Unknown,
Message = message,
Data = data,
ImmediateUpload = false,
});
}
// -----------------------------------------------------------------------
// Parsing helpers
// -----------------------------------------------------------------------
/// <summary>
/// Extracts <c>&lt;Data Name="..."&gt;value&lt;/Data&gt;</c> pairs from an event's rendered
/// XML, keyed case-insensitively. Namespace-agnostic (matches on local name). Returns an
/// empty map on null/malformed XML — never throws.
/// </summary>
internal static Dictionary<string, string> ParseEventData(string xml)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(xml)) return result;
try
{
var doc = XDocument.Parse(xml);
foreach (var el in doc.Descendants().Where(e => e.Name.LocalName == "Data"))
{
var nameAttr = el.Attribute("Name");
if (nameAttr == null || string.IsNullOrEmpty(nameAttr.Value)) continue;
if (!result.ContainsKey(nameAttr.Value))
result[nameAttr.Value] = el.Value;
}
}
catch
{
// Malformed / unexpected XML — best effort, return what we have.
}
return result;
}
internal static DateTime? TryParseUtc(string value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
? parsed
: (DateTime?)null;
}
/// <summary>Compact human duration for messages: "2h 05m", "56m 12s", "45s".</summary>
internal static string FormatDuration(TimeSpan span)
{
if (span.TotalHours >= 1)
return $"{(long)span.TotalHours}h {span.Minutes:D2}m";
if (span.TotalMinutes >= 1)
return $"{span.Minutes}m {span.Seconds:D2}s";
return $"{(long)Math.Ceiling(span.TotalSeconds)}s";
}
/// <summary>Leaf file name of a kernel device path like <c>\Device\HarddiskVolume3\Windows\System32\svchost.exe</c>.</summary>
internal static string ProcessLeafName(string devicePath)
{
if (string.IsNullOrEmpty(devicePath)) return null;
var idx = devicePath.LastIndexOf('\\');
var leaf = idx >= 0 ? devicePath.Substring(idx + 1) : devicePath;
return string.IsNullOrEmpty(leaf) ? null : leaf;
}
// -----------------------------------------------------------------------
// Backfill
// -----------------------------------------------------------------------
private void BackfillRecentEvents()
{
try
{
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
_logger.Info($"SystemTimeline backfill: scanning {Channel} " +
$"(lookback={_backfillLookbackMinutes}min, restartWatermark={_restartWatermark})");
var query = new EventLogQuery(Channel, PathType.LogName, BuildBackfillXPath(lookbackMs));
int processed = 0;
using (var reader = new EventLogReader(query))
{
EventRecord record;
while ((record = reader.ReadEvent()) != null)
{
using (record)
{
ProcessRecord(record, isBackfill: true);
processed++;
}
}
}
if (processed == 0)
_logger.Debug($"SystemTimeline backfill: no targeted events in last {_backfillLookbackMinutes} minutes");
else
_logger.Info($"SystemTimeline backfill: scanned {processed} record(s)");
}
catch (EventLogNotFoundException)
{
_logger.Warning($"SystemTimeline event log not found during backfill: {Channel} (normal on non-Windows test environments)");
}
catch (UnauthorizedAccessException ex)
{
_logger.Warning($"SystemTimeline backfill access denied: {ex.Message}");
}
catch (Exception ex)
{
_logger.Warning($"SystemTimeline backfill failed: {ex.Message}");
}
}
// -----------------------------------------------------------------------
// Watermark dedup (cross-restart)
// -----------------------------------------------------------------------
private bool IsAlreadyProcessed(long recordId)
{
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
lock (_watermarkLock)
{
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
}
}
/// <summary>
/// Atomically claims <paramref name="recordId"/>. Returns true if this is the first time we
/// see it (caller should evaluate/emit), false if it was already processed — either in a
/// PRIOR run (at/below the restart watermark) or already this run (in the seen-set). Only
/// advances + persists the max on a genuinely-new, higher RecordId. Records without a
/// RecordId (-1) are always processed, never tracked or persisted. Note: threshold-suppressed
/// records are claimed too, so a restart's backfill does not re-evaluate them.
/// </summary>
private bool MarkProcessed(long recordId)
{
if (recordId < 0) return true;
long toPersist = -1;
lock (_watermarkLock)
{
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
return false;
if (recordId > _maxEmittedRecordId)
{
_maxEmittedRecordId = recordId;
toPersist = _maxEmittedRecordId;
}
}
if (toPersist >= 0) PersistWatermark(toPersist);
return true;
}
internal void LoadWatermark()
{
if (string.IsNullOrEmpty(_stateDirectory)) return;
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
if (!File.Exists(filePath)) return;
try
{
var json = File.ReadAllText(filePath);
var state = JsonConvert.DeserializeObject<WatermarkState>(json);
if (state != null)
{
lock (_watermarkLock)
{
_restartWatermark = state.LastRecordId;
_maxEmittedRecordId = state.LastRecordId;
}
_logger.Info($"SystemTimeline watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to load SystemTimeline watermark: {ex.Message}");
}
}
private void PersistWatermark(long recordId)
{
if (string.IsNullOrEmpty(_stateDirectory)) return;
try
{
Directory.CreateDirectory(_stateDirectory);
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
var state = new WatermarkState { LastRecordId = recordId, 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 { }
}
catch (Exception ex)
{
_logger.Warning($"Failed to persist SystemTimeline watermark: {ex.Message}");
}
}
internal sealed class WatermarkState
{
public long LastRecordId { get; set; }
public DateTime PersistedUtc { get; set; }
}
}
}
@@ -0,0 +1,64 @@
#nullable enable
using System;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// ARP resolution via <c>iphlpapi!SendARP</c> — resolves the MAC address of an on-link IPv4
/// host (in practice: the default gateway). IPv4 only by API design; the OS answers from its
/// ARP cache or sends a real ARP request, so the target must be on the local subnet.
/// </summary>
internal static class ArpNativeMethods
{
[DllImport("iphlpapi.dll", ExactSpelling = true)]
private static extern int SendARP(uint destIP, uint srcIP, byte[] macAddress, ref uint macAddressLength);
/// <summary>
/// Fail-soft MAC resolution. Returns false (with a diagnostic in <paramref name="error"/>)
/// for non-IPv4 addresses, SendARP failures, or unexpected reply lengths — never throws.
/// </summary>
internal static bool TryResolveMac(IPAddress? ipv4Address, out byte[] mac, out string? error)
{
mac = new byte[6];
error = null;
try
{
if (ipv4Address == null || ipv4Address.AddressFamily != AddressFamily.InterNetwork)
{
error = "not an IPv4 address";
return false;
}
// SendARP takes the IPv4 address as a uint in network byte order (first octet in
// the lowest byte).
var addressBytes = ipv4Address.GetAddressBytes();
uint dest = ((uint)addressBytes[3] << 24)
| ((uint)addressBytes[2] << 16)
| ((uint)addressBytes[1] << 8)
| addressBytes[0];
uint len = (uint)mac.Length;
var err = SendARP(dest, 0, mac, ref len);
if (err != 0)
{
error = $"SendARP returned {err}";
return false;
}
if (len != 6)
{
error = $"SendARP returned unexpected MAC length {len}";
return false;
}
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}
}
@@ -0,0 +1,68 @@
#nullable enable
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring
{
/// <summary>
/// Shared "which NIC is the device actually using" heuristic: Up, not Loopback/Tunnel,
/// carries a non-0.0.0.0 default gateway. Fail-soft — any enumeration error yields null.
/// </summary>
internal static class NetworkInterfaceLocator
{
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
internal static NetworkInterface? FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
/// <summary>First IPv4 default gateway of the interface (0.0.0.0 excluded), or null.</summary>
internal static IPAddress? GetIpv4Gateway(NetworkInterface nic)
{
try
{
foreach (var gw in nic.GetIPProperties().GatewayAddresses)
{
var address = gw?.Address;
if (address != null
&& address.AddressFamily == AddressFamily.InterNetwork
&& address.ToString() != "0.0.0.0")
{
return address;
}
}
}
catch
{
// Caller handles null
}
return null;
}
}
}
@@ -10,7 +10,9 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
@@ -124,62 +126,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
private readonly string _imeLogFolder;
private readonly string _agentLogFolder;
private readonly string _agentStateFolder;
private readonly string _agentSpoolFolder;
private readonly string _agentDataFolder;
// Built-in sections come from the Shared catalog (DiagnosticsBuiltInSections.All) — the
// same list the backend serves to the portal, so what administrators see is exactly
// what this service collects. Test seam: section id → folder; production leaves the
// map empty and every section resolves from the catalog's (unexpanded) source folder.
private readonly IReadOnlyDictionary<string, string> _sectionFolderOverrides;
private static readonly string DefaultImeLogFolder =
Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs");
private static readonly string DefaultAgentLogFolder =
Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
private static readonly string DefaultAgentStateFolder =
Environment.ExpandEnvironmentVariables(Constants.StateDirectory);
private static readonly string DefaultAgentSpoolFolder =
Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory);
private static readonly string DefaultAgentDataFolder =
Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor");
/// <summary>
/// File extensions collected from built-in log folders (Agent + IME).
/// Covers standard logs, structured data, traces, event logs, and diagnostics archives.
/// </summary>
private static readonly string[] LogFilePatterns = new[]
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab"
};
/// <summary>
/// Patterns collected from the agent state folder. Adds completion markers on top of
/// the standard log patterns so files like <c>enrollment-complete.marker</c> and
/// <c>whiteglove-backfill-state.json</c> reach the diagnostics archive.
/// </summary>
private static readonly string[] StateFilePatterns = new[]
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
"*.complete", "*.marker"
};
/// <summary>
/// Marker patterns from the top-level data directory. Kept tight on purpose: only
/// completion/exit markers, no session.id / bootstrap.json (those are config, not
/// forensic state).
/// </summary>
private static readonly string[] RootMarkerPatterns = new[] { "*.complete", "*.marker" };
/// <summary>
/// Telemetry spool: only the JSON-shaped files. The spool folder may also contain
/// quarantine subfolders, so subfolders are excluded to keep the archive focused
/// on what is actually pending upload.
/// </summary>
private static readonly string[] SpoolFilePatterns = new[] { "*.jsonl", "*.json" };
// Scenario oracle for DiagnosticsSectionCondition.DevicePreparation. Production reads
// the deterministic WDP registry marker (the same gate every other WDP switch uses);
// tests inject a constant so they never touch the registry.
private readonly Func<bool> _devicePreparationProbe;
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient)
: this(configuration, logger, apiClient, null, null, null, null, null)
@@ -188,7 +144,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// Test seam: allows xUnit fixtures to redirect log/state/spool/data folders to a
// temp dir without touching real %ProgramData% paths. Production callers always go
// through the public ctor.
// through the public ctor. The five named overrides map onto the catalog's section
// ids; sectionFolderOverrides covers every other section (RealmJoin*, ImeBootstrapper…).
internal DiagnosticsPackageService(
AgentConfiguration configuration,
AgentLogger logger,
@@ -197,18 +154,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
string imeLogFolderOverride,
string agentStateFolderOverride,
string agentSpoolFolderOverride,
string agentDataFolderOverride)
string agentDataFolderOverride,
IReadOnlyDictionary<string, string> sectionFolderOverrides = null,
Func<bool> devicePreparationProbe = null)
{
_configuration = configuration;
_logger = logger;
_apiClient = apiClient;
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
_agentLogFolder = agentLogFolderOverride ?? DefaultAgentLogFolder;
_imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
_agentStateFolder = agentStateFolderOverride ?? DefaultAgentStateFolder;
_agentSpoolFolder = agentSpoolFolderOverride ?? DefaultAgentSpoolFolder;
_agentDataFolder = agentDataFolderOverride ?? DefaultAgentDataFolder;
var overrides = new Dictionary<string, string>(StringComparer.Ordinal);
void Map(string sectionId, string folder)
{
if (folder != null) overrides[sectionId] = folder;
}
Map("AgentLogs", agentLogFolderOverride);
Map("ImeLogs", imeLogFolderOverride);
Map("AgentState", agentStateFolderOverride);
Map("AgentSpool", agentSpoolFolderOverride);
Map("AgentMarkers", agentDataFolderOverride);
if (sectionFolderOverrides != null)
{
foreach (var kv in sectionFolderOverrides)
overrides[kv.Key] = kv.Value;
}
_sectionFolderOverrides = overrides;
// Tests that pass folder overrides but no probe must stay registry-free: a seam
// caller gets "not WDP" by default, the production ctor gets the real marker.
_devicePreparationProbe = devicePreparationProbe
?? (overrides.Count == 0
? (Func<bool>)EnrollmentRegistryDetector.IsDeterministicDevicePreparation
: () => false);
}
/// <summary>
@@ -370,33 +347,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
// 1. sessioninfo.txt — informational metadata, not subject to caps.
AddSessionInfo(archive, enrollmentSucceeded);
// 2. Agent logs
foreach (var pattern in LogFilePatterns)
AddLogFiles(archive, _agentLogFolder, "AgentLogs", pattern, tracker);
// 2. Built-in sections — ONE catalog shared with the backend (the portal
// shows exactly this list): agent logs, IME logs (+ the Device Preparation
// bootstrapper event log on WDP), agent state (recursive: `.quarantine`
// and `.part1-<utc>/` buckets ride along), telemetry spool, top-level
// markers, and the RealmJoin logs behind the tenant's watcher toggle.
// The scenario probe is a registry read — evaluate it once per build.
var isDevicePreparation = ProbeDevicePreparation();
ManifestLine($"SCENARIO: devicePreparation={isDevicePreparation} realmJoinWatcher={_configuration.EnableRealmJoinWatcher}");
foreach (var section in DiagnosticsBuiltInSections.All)
AddBuiltInSection(archive, section, tracker, isDevicePreparation);
// 3. IME logs (all relevant files in the IME log folder)
foreach (var pattern in LogFilePatterns)
AddLogFiles(archive, _imeLogFolder, "ImeLogs", pattern, tracker);
// 4. Agent state: snapshot, journal, signal-log, ime-tracker-state,
// enrollment-complete.marker, final-status.json, whiteglove-backfill-state.json.
// Recursive to include `.quarantine` subfolders AND `.part1-<utc>/` buckets
// that StateArchiver creates on WG Part-2 boot — both ride along under
// AgentState/ via the same recursive walk.
foreach (var pattern in StateFilePatterns)
AddLogFiles(archive, _agentStateFolder, "AgentState", pattern, tracker, includeSubfolders: true);
// 5. Telemetry spool — pending uploads + upload cursor (forensic: what didn't ship?)
foreach (var pattern in SpoolFilePatterns)
AddLogFiles(archive, _agentSpoolFolder, "AgentSpool", pattern, tracker, includeSubfolders: false);
// 6. Top-level markers (whiteglove.complete, clean-exit marker) — Part-1/Part-2
// bridge + ghost-restart guard. Top-directory only to avoid re-zipping
// State/Spool/Logs/Agent which already have their own sections.
foreach (var pattern in RootMarkerPatterns)
AddLogFiles(archive, _agentDataFolder, "AgentMarkers", pattern, tracker, includeSubfolders: false);
// 7. Configured additional log paths (global + tenant, validated by guards)
// 3. Configured additional log paths (global + tenant, validated by guards)
foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List<Shared.Models.DiagnosticsLogPath>())
{
// Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception
@@ -447,6 +409,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
private bool ProbeDevicePreparation()
{
try
{
return _devicePreparationProbe();
}
catch (Exception ex)
{
// Fail toward Classic like every other WDP gate: the evtx export is skipped,
// never the whole package.
_logger.Warning($"Device Preparation probe failed — treating as Classic: {ex.Message}");
ManifestLine($"SCENARIO PROBE FAILED (treated as Classic): {ex.Message}");
return false;
}
}
/// <summary>
/// Pure gate: is this built-in section collected under the given configuration and
/// enrollment scenario? Unknown conditions (a newer Shared catalog than this agent)
/// fail closed.
/// </summary>
internal static bool IsSectionActive(DiagnosticsBuiltInSection section, AgentConfiguration configuration, bool isDevicePreparation)
{
switch (section.Condition)
{
case DiagnosticsSectionCondition.Always:
return true;
case DiagnosticsSectionCondition.RealmJoinWatcher:
return configuration.EnableRealmJoinWatcher;
case DiagnosticsSectionCondition.DevicePreparation:
return isDevicePreparation;
default:
return false;
}
}
private static string DescribeInactiveCondition(DiagnosticsSectionCondition condition)
{
switch (condition)
{
case DiagnosticsSectionCondition.RealmJoinWatcher:
return "RealmJoin Watcher disabled";
case DiagnosticsSectionCondition.DevicePreparation:
return "not a Device Preparation enrollment";
default:
return $"unknown condition {condition}";
}
}
private void AddBuiltInSection(ZipArchive archive, DiagnosticsBuiltInSection section, BudgetTracker tracker, bool isDevicePreparation)
{
if (!IsSectionActive(section, _configuration, isDevicePreparation))
{
ManifestLine($"BUILT-IN SKIPPED ({DescribeInactiveCondition(section.Condition)}): {section.Id}");
return;
}
// Override (test seam) or catalog source folder with %ProgramData% /
// %LOGGED_ON_USER_PROFILE% expanded; null means the token is present but no
// interactive user has been detected yet — same skip as configured paths.
var folder = _sectionFolderOverrides.TryGetValue(section.Id, out var overrideFolder)
? overrideFolder
: UserProfileResolver.ExpandCustomTokens(section.SourceFolder);
if (folder == null)
{
_logger.Warning($"Built-in diagnostics section skipped (no user session for token): {section.Id}");
ManifestLine($"BUILT-IN SKIPPED (no user session for token): {section.Id} '{section.SourceFolder}'");
return;
}
ManifestLine($"BUILT-IN: {section.Id} -> folder='{folder}' zip='{section.ZipFolder}' recursive={section.IncludeSubfolders}");
foreach (var pattern in section.Patterns)
AddLogFiles(archive, folder, section.ZipFolder, pattern, tracker, section.IncludeSubfolders);
}
private void AddSessionInfo(ZipArchive archive, bool? enrollmentSucceeded)
{
var sb = new StringBuilder();
@@ -20,7 +20,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
public static class UserProfileResolver
{
public const string Token = "%LOGGED_ON_USER_PROFILE%";
// Spelled once, in Shared: the built-in diagnostics catalog uses the same token.
public const string Token = Shared.Models.DiagnosticsBuiltInSections.UserProfileToken;
private static readonly object Lock = new object();
private static bool _resolved;
@@ -922,36 +922,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
}
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
private static NetworkInterface FindActiveNetworkInterface() =>
Monitoring.NetworkInterfaceLocator.FindActiveNetworkInterface();
/// <summary>
/// Collects WiFi signal info via the native WLAN API (<see cref="WifiInfoReader"/>,
@@ -420,36 +420,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Helpers
// -------------------------------------------------------------------
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
private static NetworkInterface FindActiveNetworkInterface()
{
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
foreach (var nic in interfaces)
{
if (nic.OperationalStatus != OperationalStatus.Up)
continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var ipProps = nic.GetIPProperties();
foreach (var gw in ipProps.GatewayAddresses)
{
if (gw.Address.ToString() != "0.0.0.0")
return nic;
}
}
}
catch
{
// Caller handles null
}
return null;
}
private static NetworkInterface FindActiveNetworkInterface() =>
NetworkInterfaceLocator.FindActiveNetworkInterface();
private static string BuildChangeMessage(NetworkStateSnapshot before, NetworkStateSnapshot after)
{
@@ -0,0 +1,217 @@
#nullable enable
using System;
using System.Net;
using System.Net.Sockets;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
public class DoGroupIdSetResult
{
/// <summary>The DOGroupId value is the computed one after this run (written or already there).</summary>
public bool Success { get; set; }
/// <summary>True when this run actually wrote the registry value (false for the idempotent no-op).</summary>
public bool Written { get; set; }
/// <summary>Deliberate non-action (existing foreign policy/value, no usable network) — not an error.</summary>
public bool Skipped { get; set; }
public string? SkipReason { get; set; }
public string? GroupId { get; set; }
public string? PreviousValue { get; set; }
public string? GatewayIp { get; set; }
public string? GatewayMac { get; set; }
public string? Error { get; set; }
}
internal enum DoGroupIdAction
{
Write,
NoOp,
SkipForeign,
}
/// <summary>
/// Sets the Delivery Optimization group ID policy value (<c>DOGroupId</c> under the GPO
/// DeliveryOptimization key) from a network fingerprint: a deterministic GUID derived from the
/// default gateway's IPv4 address and MAC address, so all devices behind the same gateway land
/// in the same DO peering group. The byte layout is RealmJoin-compatible — devices managed by
/// RealmJoin on the same network compute the identical GUID and peer with ours.
/// <para>
/// Deliberately conservative where RealmJoin is not: no random-GUID fallback (a per-device
/// random ID would isolate the device in a one-member group — worse than the OS default
/// grouping), and existing foreign configuration always wins: a DOGroupIdSource policy, an
/// MDM-managed DOGroupId, or a GPO value we did not write ourselves all result in a skip.
/// Never throws.
/// </para>
/// </summary>
public static class DoGroupIdService
{
internal const string GroupIdValueName = "DOGroupId";
internal const string GroupIdSourceValueName = "DOGroupIdSource";
// RealmJoin's fingerprint-GUID prefix — part of the compatible byte layout, do not change.
private static readonly byte[] GuidPrefix = { 101, 48, 6 };
/// <summary>
/// Computes and applies the fingerprint GroupId. <paramref name="lastWrittenGroupId"/> is
/// the GUID a previous agent run wrote (null when none is recorded) — it lets us update our
/// own value after a gateway change while never touching a foreign one.
/// </summary>
public static DoGroupIdSetResult TrySetGroupId(string? lastWrittenGroupId, AgentLogger logger)
{
var result = new DoGroupIdSetResult();
try
{
// ---- Network fingerprint -------------------------------------------------
var nic = NetworkInterfaceLocator.FindActiveNetworkInterface();
var gateway = nic == null ? null : NetworkInterfaceLocator.GetIpv4Gateway(nic);
if (gateway == null)
{
result.Skipped = true;
result.SkipReason = "no_ipv4_gateway";
logger.Info("DO GroupId auto-set: no active interface with an IPv4 default gateway — skipping.");
return result;
}
result.GatewayIp = gateway.ToString();
if (!ArpNativeMethods.TryResolveMac(gateway, out var mac, out var arpError))
{
result.Skipped = true;
result.SkipReason = "arp_failed";
result.Error = arpError;
logger.Warning($"DO GroupId auto-set: could not resolve gateway MAC ({arpError}) — skipping.");
return result;
}
result.GatewayMac = BitConverter.ToString(mac);
var computed = ComputeGroupId(gateway.GetAddressBytes(), mac).ToString();
result.GroupId = computed;
// ---- Foreign-configuration checks ----------------------------------------
// DOGroupIdSource (either store) selects a dynamic group source — a fingerprint
// GroupId would fight it. An MDM-managed DOGroupId means Intune owns the setting.
if (ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdSourceValueName) != null
|| ReadPolicyValue(DoThrottlePolicyReader.MdmKeyPath, GroupIdSourceValueName) != null)
{
result.Skipped = true;
result.SkipReason = "group_id_source_policy";
logger.Info("DO GroupId auto-set: DOGroupIdSource policy is configured — skipping.");
return result;
}
if (ReadPolicyValue(DoThrottlePolicyReader.MdmKeyPath, GroupIdValueName) != null)
{
result.Skipped = true;
result.SkipReason = "mdm_policy_present";
logger.Info("DO GroupId auto-set: DOGroupId is managed via MDM policy — skipping.");
return result;
}
// ---- Ownership decision ---------------------------------------------------
var existing = ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdValueName) as string;
result.PreviousValue = existing;
switch (DecideAction(existing, computed, lastWrittenGroupId))
{
case DoGroupIdAction.NoOp:
result.Success = true;
logger.Info($"DO GroupId auto-set: already set to {computed} — nothing to do.");
return result;
case DoGroupIdAction.SkipForeign:
result.Skipped = true;
result.SkipReason = "foreign_value_present";
logger.Info($"DO GroupId auto-set: existing DOGroupId '{existing}' was not written by us — skipping.");
return result;
}
// ---- Write + readback -----------------------------------------------------
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
using (var key = hklm.CreateSubKey(DoThrottlePolicyReader.GpoKeyPath))
{
key.SetValue(GroupIdValueName, computed, RegistryValueKind.String);
}
var readback = ReadPolicyValue(DoThrottlePolicyReader.GpoKeyPath, GroupIdValueName) as string;
if (!string.Equals(readback, computed, StringComparison.OrdinalIgnoreCase))
{
result.Error = $"readback mismatch: wrote '{computed}', read '{readback ?? "<null>"}'";
logger.Warning($"DO GroupId auto-set failed: {result.Error}");
return result;
}
result.Success = true;
result.Written = true;
logger.Info($"DO GroupId auto-set: DOGroupId set to {computed} (gateway {result.GatewayIp}, previous: {existing ?? "<none>"})");
return result;
}
catch (Exception ex)
{
result.Error = ex.Message;
logger.Warning($"DO GroupId auto-set failed: {ex.Message}");
return result;
}
}
/// <summary>
/// RealmJoin-compatible fingerprint GUID: 16 bytes laid out as
/// prefix {101,48,6} + {0,0,0} + {ip[1],0,ip[2],ip[3]} + mac[6], passed to
/// <c>new Guid(byte[])</c>. The IP byte shuffle mirrors RealmJoin's "swap for
/// new Guid()" step — the layout must stay bit-identical for cross-product peering.
/// </summary>
internal static Guid ComputeGroupId(byte[] ipv4Bytes, byte[] macBytes)
{
if (ipv4Bytes == null || ipv4Bytes.Length != 4)
throw new ArgumentException("IPv4 address bytes required", nameof(ipv4Bytes));
if (macBytes == null || macBytes.Length != 6)
throw new ArgumentException("6-byte MAC required", nameof(macBytes));
var bytes = new byte[16];
Array.Copy(GuidPrefix, 0, bytes, 0, GuidPrefix.Length);
// bytes[3..5] stay zero (RealmJoin's zero padding)
bytes[6] = ipv4Bytes[1];
// bytes[7] stays zero
bytes[8] = ipv4Bytes[2];
bytes[9] = ipv4Bytes[3];
Array.Copy(macBytes, 0, bytes, 10, macBytes.Length);
return new Guid(bytes);
}
/// <summary>
/// Pure ownership decision. GUID comparison tolerates case and braces so a value written
/// by another tool in "{...}" form still matches semantically.
/// </summary>
internal static DoGroupIdAction DecideAction(string? existing, string computed, string? lastWrittenByUs)
{
if (string.IsNullOrEmpty(existing))
return DoGroupIdAction.Write;
if (GuidEquals(existing, computed))
return DoGroupIdAction.NoOp;
if (GuidEquals(existing, lastWrittenByUs))
return DoGroupIdAction.Write; // our own stale value — gateway changed
return DoGroupIdAction.SkipForeign;
}
private static bool GuidEquals(string? a, string? b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
return Guid.TryParse(a, out var ga) && Guid.TryParse(b, out var gb) && ga == gb;
}
private static object? ReadPolicyValue(string keyPath, string valueName)
{
// Registry64 explicitly — SOFTWARE\Policies is WOW64-redirected, and the policy
// stores live in the 64-bit view regardless of the agent's bitness.
using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
using (var key = hklm.OpenSubKey(keyPath))
{
return key?.GetValue(valueName);
}
}
}
}
@@ -557,6 +557,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
stateDirectory: _stateDirectory));
}
// System timeline watcher — System channel: Kernel-General 1 (system clock stepped,
// ground truth for time jumps incl. old/new time + setting process) and completed
// sleep/standby episodes (Power-Troubleshooter 1, Kernel-Power 507). Backfill recovers
// the pre-agent OOBE clock correction and standby episodes from the persistent .evtx.
if (collectors.EnableSystemTimelineWatcher)
{
hosts.Add(new SystemTimelineWatcherHost(
sessionId: sessionId,
tenantId: tenantId,
logger: logger,
ingress: ingress,
clock: clock,
backfillLookbackMinutes: collectors.SystemEventBackfillLookbackMinutes,
stateDirectory: _stateDirectory));
}
// ----- Peripheral hosts (event-only; driven by remote-config toggles) --------------
// V1 parity (PeriodicCollectorManager) — combine Performance + AgentSelfMetrics under
@@ -0,0 +1,68 @@
#nullable enable
using System;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
{
/// <summary>
/// Standalone host for the <see cref="SystemTimelineTracker"/> — watches the <c>System</c>
/// event log for system clock steps (Kernel-General 1) and completed sleep/standby episodes
/// (Power-Troubleshooter 1 / Kernel-Power 507) and forwards them via the shared single-rail
/// ingress. Mirrors <see cref="WindowsUpdateWatcherHost"/>: builds its own
/// <see cref="InformationalEventPost"/> and delegates Start/Stop to the tracker.
/// </summary>
internal sealed class SystemTimelineWatcherHost : ICollectorHost
{
public string Name => "SystemTimelineTracker";
private readonly SystemTimelineTracker _tracker;
private readonly AgentLogger _logger;
private int _disposed;
public SystemTimelineWatcherHost(
string sessionId,
string tenantId,
AgentLogger logger,
ISignalIngressSink ingress,
IClock clock,
int backfillLookbackMinutes,
string stateDirectory)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var post = new InformationalEventPost(ingress, clock, logger);
_tracker = new SystemTimelineTracker(
sessionId: sessionId,
tenantId: tenantId,
post: post,
logger: logger,
backfillEnabled: backfillLookbackMinutes > 0,
backfillLookbackMinutes: backfillLookbackMinutes,
stateDirectory: stateDirectory);
}
public void Start()
{
_tracker.Start();
_logger.Info("SystemTimelineWatcherHost: started.");
}
public void Stop()
{
try { _tracker.Stop(); }
catch (Exception ex) { _logger.Warning($"SystemTimelineWatcherHost: stop failed: {ex.Message}"); }
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
Stop();
}
}
}
@@ -61,6 +61,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// detection — the exact scenario the power watcher exists to observe.
SharedConstants.EventTypes.PowerStateChange,
// Same reasoning for the system timeline watcher: a clock step, a wake from standby,
// or a backfilled episode at agent start is environment observation and must not
// reset the idle/stall clocks.
SharedConstants.EventTypes.SystemClockChanged,
SharedConstants.EventTypes.SystemSleepEpisode,
// Agent health / control / transport — not device/enrollment progress.
SharedConstants.EventTypes.CollectorDegraded,
SharedConstants.EventTypes.TelemetryUploadPoisoned,
@@ -124,6 +124,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Persistence
}
}
/// <summary>
/// Read-only peek of the key's current fingerprint (possibly restored from a previous
/// agent run), or null when none is recorded. Like <see cref="HasFingerprint"/> this makes
/// NO claim — use it when the fingerprint carries state the caller needs to read back
/// (e.g. the DO GroupId ownership marker).
/// </summary>
public string? GetFingerprint(string key)
{
if (string.IsNullOrEmpty(key)) return null;
lock (_lock)
{
return _entries.TryGetValue(key, out var entry) ? entry.Fingerprint : null;
}
}
/// <summary>Retry-until-success: true when <see cref="MarkSucceeded"/> latched this key in any run.</summary>
public bool AlreadySucceeded(string key)
{
@@ -25,6 +25,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
/// Always emitted — offset &gt;60s is Warning, smaller is Info, failure is Warning.</item>
/// <item><b>power_state_check</b> — AC vs. battery snapshot via Win32 <c>GetSystemPowerStatus</c>.
/// On battery with &lt;80% charge is Warning, otherwise Info, probe failure is Warning.</item>
/// <item><b>do_group_id_auto_set</b> — only when <c>EnableDoGroupIdAutoSet=true</c>. Sets the
/// Delivery Optimization <c>DOGroupId</c> policy value from a network fingerprint via
/// <see cref="DoGroupIdService"/>; skips (Info) rather than fight foreign policy values.</item>
/// </list>
/// <para>
/// Each probe runs best-effort: failures are logged + optionally emitted as a Warning event,
@@ -78,6 +81,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
await RunNtpCheck(configuration, logger, post, gate).ConfigureAwait(false);
}
if (configuration.EnableDoGroupIdAutoSet)
RunDoGroupIdAutoSet(configuration, logger, post, gate);
RunPowerStateCheck(configuration, logger, post);
}
@@ -331,6 +337,99 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
};
}
/// <summary>
/// One-shot DO GroupId apply with change-based restart dedup: the gate fingerprint doubles
/// as the ownership marker ("written:&lt;guid&gt;" records the GUID we last wrote, so a later
/// run can distinguish our own stale value from a foreign one after a gateway change).
/// Unchanged outcomes (same GUID, same skip reason) re-run silently without a duplicate
/// event; errors never claim a fingerprint so each failing start keeps its retry trail.
/// </summary>
private static void RunDoGroupIdAutoSet(
AgentConfiguration configuration,
AgentLogger logger,
Orchestration.InformationalEventPost post,
Persistence.StartupEventGate? gate)
{
try
{
var lastWritten = ExtractLastWrittenGroupId(gate?.GetFingerprint(Constants.EventTypes.DoGroupIdAutoSet));
var result = DoGroupIdService.TrySetGroupId(lastWritten, logger);
var fingerprint = ComputeDoGroupIdFingerprint(result);
if (fingerprint != null && gate != null
&& !gate.ShouldEmit(Constants.EventTypes.DoGroupIdAutoSet, fingerprint))
{
logger.Debug("StartupEnvironmentProbes: DO GroupId outcome unchanged — skipping duplicate event (restart dedup).");
return;
}
SafeEmit(post, logger, BuildDoGroupIdEvent(configuration, result));
if (fingerprint != null)
gate?.MarkEmitted(Constants.EventTypes.DoGroupIdAutoSet);
}
catch (Exception ex)
{
logger.Warning($"StartupEnvironmentProbes: DO GroupId auto-set threw: {ex.Message}");
}
}
internal const string DoGroupIdWrittenFingerprintPrefix = "written:";
/// <summary>
/// Success (written or already correct) fingerprints as "written:&lt;guid&gt;", a skip as
/// "skipped:&lt;reason&gt;"; errors return null so they are never deduped.
/// </summary>
internal static string? ComputeDoGroupIdFingerprint(DoGroupIdSetResult result)
{
if (result.Success) return DoGroupIdWrittenFingerprintPrefix + result.GroupId;
if (result.Skipped) return "skipped:" + result.SkipReason;
return null;
}
/// <summary>Ownership marker readback: the GUID from a "written:" fingerprint, else null.</summary>
internal static string? ExtractLastWrittenGroupId(string? fingerprint) =>
fingerprint != null && fingerprint.StartsWith(DoGroupIdWrittenFingerprintPrefix, StringComparison.Ordinal)
? fingerprint.Substring(DoGroupIdWrittenFingerprintPrefix.Length)
: null;
internal static EnrollmentEvent BuildDoGroupIdEvent(AgentConfiguration configuration, DoGroupIdSetResult result)
{
string message;
if (result.Success && result.Written)
message = $"Delivery Optimization GroupId set to {result.GroupId} (gateway {result.GatewayIp})";
else if (result.Success)
message = $"Delivery Optimization GroupId already set to {result.GroupId}";
else if (result.Skipped)
message = $"Delivery Optimization GroupId not set: {result.SkipReason}";
else
message = $"Delivery Optimization GroupId auto-set failed: {result.Error}";
return new EnrollmentEvent
{
SessionId = configuration.SessionId,
TenantId = configuration.TenantId,
EventType = Constants.EventTypes.DoGroupIdAutoSet,
Severity = result.Success || result.Skipped ? EventSeverity.Info : EventSeverity.Warning,
Source = "StartupEnvironmentProbes",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
Message = message,
Data = new Dictionary<string, object>
{
{ "groupId", result.GroupId ?? "" },
{ "previousValue", result.PreviousValue ?? "" },
{ "gatewayIp", result.GatewayIp ?? "" },
{ "gatewayMac", result.GatewayMac ?? "" },
{ "written", result.Written },
{ "skipped", result.Skipped },
{ "skipReason", result.SkipReason ?? "" },
{ "success", result.Success },
{ "error", result.Error ?? "" },
},
};
}
private static void RunPowerStateCheck(
AgentConfiguration configuration,
AgentLogger logger,
@@ -0,0 +1,96 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Invariants of the built-in diagnostics catalog (<see cref="DiagnosticsBuiltInSections"/>).
/// Shared has no test project, so the catalog is pinned here: the agent iterates it to build the
/// archive and the backend serves it to the portal, so a malformed entry would either break
/// collection on every device or mislead every administrator reading the list.
/// </summary>
public class DiagnosticsBuiltInSectionsTests
{
[Fact]
public void SectionIds_AreUnique()
{
var ids = DiagnosticsBuiltInSections.All.Select(s => s.Id).ToArray();
Assert.Equal(ids.Length, ids.Distinct(StringComparer.Ordinal).Count());
}
[Fact]
public void ZipFolderAndSourceFolderPairs_AreUnique()
{
// Two sections may share a ZIP folder (ImeLogs + the bootstrapper evtx) only when they
// read from different source folders — the same (zip, source) pair would double-add
// every file under duplicate entry names.
var pairs = DiagnosticsBuiltInSections.All
.Select(s => $"{s.ZipFolder}|{s.SourceFolder}".ToLowerInvariant())
.ToArray();
Assert.Equal(pairs.Length, pairs.Distinct().Count());
}
[Fact]
public void AlwaysSections_KeepHistoricalArchiveLayoutAndOrder()
{
// Archive-layout lock: these five sections (and their order) are what every diagnostics
// ZIP has carried since the PR1-B forensics work; tooling and support habits rely on it.
var always = DiagnosticsBuiltInSections.All
.Where(s => s.Condition == DiagnosticsSectionCondition.Always)
.Select(s => s.Id)
.ToArray();
Assert.Equal(new[] { "AgentLogs", "ImeLogs", "AgentState", "AgentSpool", "AgentMarkers" }, always);
}
[Fact]
public void ConditionalSections_CarryTheExpectedGate()
{
foreach (var section in DiagnosticsBuiltInSections.All)
{
if (section.Id.StartsWith("RealmJoin", StringComparison.Ordinal))
Assert.Equal(DiagnosticsSectionCondition.RealmJoinWatcher, section.Condition);
if (section.Id == "ImeBootstrapperEventLog")
Assert.Equal(DiagnosticsSectionCondition.DevicePreparation, section.Condition);
}
Assert.Contains(DiagnosticsBuiltInSections.All, s => s.Id == "ImeBootstrapperEventLog");
Assert.Equal(5, DiagnosticsBuiltInSections.All.Count(s => s.Condition == DiagnosticsSectionCondition.RealmJoinWatcher));
}
[Fact]
public void UserProfileTokenSections_AreNeverAlwaysOn()
{
// The token resolves only once an interactive user exists; an Always section reading
// from the user profile would be skipped on every pre-desktop package and mislead the
// portal's "collected by every agent" wording.
var tokenSections = DiagnosticsBuiltInSections.All
.Where(s => s.SourceFolder.Contains(DiagnosticsBuiltInSections.UserProfileToken, StringComparison.OrdinalIgnoreCase))
.ToArray();
Assert.NotEmpty(tokenSections);
Assert.All(tokenSections, s => Assert.NotEqual(DiagnosticsSectionCondition.Always, s.Condition));
}
[Fact]
public void Patterns_AreFileNamesWithoutPathSeparators()
{
Assert.All(DiagnosticsBuiltInSections.All, s =>
{
Assert.NotEmpty(s.Patterns);
Assert.All(s.Patterns, p =>
{
Assert.False(string.IsNullOrWhiteSpace(p));
Assert.DoesNotContain('\\', p);
Assert.DoesNotContain('/', p);
});
Assert.False(string.IsNullOrWhiteSpace(s.Description));
Assert.False(s.ZipFolder.StartsWith("/") || s.ZipFolder.EndsWith("/"));
Assert.DoesNotContain('\\', s.ZipFolder);
});
}
[Fact]
public void NoBuiltInSection_UsesTheConfiguredPathsZipFolder()
{
// AdditionalLogs/<folder> is reserved for admin-configured entries.
Assert.All(DiagnosticsBuiltInSections.All,
s => Assert.False(s.ZipFolder.StartsWith("AdditionalLogs", StringComparison.OrdinalIgnoreCase)));
}
}
@@ -0,0 +1,124 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.Models.Notifications;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Discord embed rendering: payload shape, color mapping, and the Discord embed limits
/// (25 fields, 256/1024 name/value, 4096 description, 6000 total).
/// </summary>
public class DiscordRendererTests
{
private static JsonElement RenderEmbed(NotificationAlert alert)
{
var json = new DiscordRenderer().RenderToJson(alert);
var doc = JsonDocument.Parse(json);
var embeds = doc.RootElement.GetProperty("embeds");
Assert.Equal(1, embeds.GetArrayLength());
return embeds[0];
}
[Fact]
public void RendersSingleEmbed_WithTitleColorFieldsAndLinks()
{
var alert = new NotificationAlert
{
Title = "Enrollment Succeeded",
Summary = "Enrollment Succeeded: DESK-001",
Severity = NotificationSeverity.Success,
ThemeColor = "00B050",
Facts = { new NotificationFact { Name = "Device", Value = "DESK-001" } },
Sections = { new NotificationSection { Title = "Details", Text = "All apps installed." } },
Actions = { new NotificationAction { Type = "openUrl", Title = "Open session", Url = "https://portal.example.com/sessions/abc" } },
};
var embed = RenderEmbed(alert);
Assert.Equal("✅ Enrollment Succeeded", embed.GetProperty("title").GetString());
Assert.Equal(0x00B050, embed.GetProperty("color").GetInt32());
var description = embed.GetProperty("description").GetString()!;
Assert.StartsWith("Enrollment Succeeded: DESK-001", description);
Assert.Contains("**Details**\nAll apps installed.", description);
// Webhooks cannot post buttons — actions become markdown links.
Assert.Contains("[Open session](https://portal.example.com/sessions/abc)", description);
var fields = embed.GetProperty("fields");
Assert.Equal(1, fields.GetArrayLength());
Assert.Equal("Device", fields[0].GetProperty("name").GetString());
Assert.Equal("DESK-001", fields[0].GetProperty("value").GetString());
Assert.True(fields[0].GetProperty("inline").GetBoolean());
}
[Theory]
[InlineData(NotificationSeverity.Success, 0x2ECC71)]
[InlineData(NotificationSeverity.Error, 0xE74C3C)]
[InlineData(NotificationSeverity.Warning, 0xF1C40F)]
[InlineData(NotificationSeverity.Info, 0x3498DB)]
public void Color_FallsBackToSeverity_WhenThemeColorMissingOrInvalid(NotificationSeverity severity, int expected)
{
foreach (var themeColor in new[] { "", "not-hex" })
{
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = severity, ThemeColor = themeColor });
Assert.Equal(expected, embed.GetProperty("color").GetInt32());
}
}
[Fact]
public void Fields_CappedAt25_AndEmptyFactsSkipped()
{
var alert = new NotificationAlert { Title = "T", Summary = "S" };
alert.Facts.Add(new NotificationFact { Name = "", Value = "dropped" });
alert.Facts.Add(new NotificationFact { Name = "dropped", Value = " " });
for (var i = 0; i < 30; i++)
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = "v" });
var fields = RenderEmbed(alert).GetProperty("fields");
Assert.Equal(25, fields.GetArrayLength());
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // empty facts skipped, not counted
}
[Fact]
public void FieldValue_TruncatedTo1024_WithEllipsis()
{
var alert = new NotificationAlert { Title = "T" };
alert.Facts.Add(new NotificationFact { Name = "long", Value = new string('x', 2000) });
var value = RenderEmbed(alert).GetProperty("fields")[0].GetProperty("value").GetString()!;
Assert.Equal(1024, value.Length);
Assert.EndsWith("…", value);
}
[Fact]
public void TotalEmbedLength_CappedAt6000_ByDroppingTrailingFields()
{
var alert = new NotificationAlert { Title = "T", Summary = "S" };
for (var i = 0; i < 25; i++)
alert.Facts.Add(new NotificationFact { Name = $"f{i}", Value = new string('v', 1000) });
var embed = RenderEmbed(alert);
var fields = embed.GetProperty("fields");
var total = embed.GetProperty("title").GetString()!.Length
+ embed.GetProperty("description").GetString()!.Length;
for (var i = 0; i < fields.GetArrayLength(); i++)
total += fields[i].GetProperty("name").GetString()!.Length + fields[i].GetProperty("value").GetString()!.Length;
Assert.True(fields.GetArrayLength() < 25);
Assert.True(total <= 6000);
Assert.Equal("f0", fields[0].GetProperty("name").GetString()); // leading fields kept
}
[Fact]
public void EmptyDescription_Omitted_AndProviderTypeIsDiscord()
{
var embed = RenderEmbed(new NotificationAlert { Title = "T", Severity = NotificationSeverity.Info });
Assert.False(embed.TryGetProperty("description", out _));
Assert.False(embed.TryGetProperty("fields", out _));
Assert.Equal(WebhookProviderType.Discord, new DiscordRenderer().ProviderType);
}
}
@@ -0,0 +1,315 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Coverage for the provider-neutral <see cref="EmailService"/> (welcome + farewell mails).
/// The provider HTTP call is driven through a stub handler, so the Mandrill wire format,
/// the result-array interpretation and the fail-soft contract are pinned without network.
/// <list type="bullet">
/// <item>Neither send method ever throws — not on a missing key, missing recipient,
/// HTTP 500, a rejected recipient, nor a malformed body.</item>
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
/// <item>Tracking is off on the wire (trust-page claim: provider receives only the
/// recipient address and tenant domain).</item>
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
/// interpolated, empty domain falls back to "your organization".</item>
/// </list>
/// </summary>
public sealed class EmailServiceTests
{
private const string TenantId = "88888888-8888-8888-8888-888888888888";
// ----- configuration gates -----
[Fact]
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
{
var (sut, handler, logger) = Build(apiKey: "");
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
Assert.Equal(0, handler.CallCount);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
}
[Fact]
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
{
var (sut, handler, logger) = Build(apiKey: "any-non-empty-key");
await sut.SendAsync("", "contoso.invalid", TenantId);
Assert.Equal(0, handler.CallCount);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
}
[Fact]
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
{
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
// the key line must show up.
var (sut, _, logger) = Build(apiKey: "");
await sut.SendAsync("", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
Assert.DoesNotContain(logger.Entries,
e => e.Message.Contains("No notification email captured"));
}
[Fact]
public async Task SendPreviewApprovedEmailAsync_EmptyApiKey_NoOps()
{
var (sut, handler, logger) = Build(apiKey: "");
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
Assert.Equal(0, handler.CallCount);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
}
// ----- wire format -----
[Fact]
public async Task SendPreviewApprovedEmailAsync_PostsMandrillPayload_WithTrackingOff()
{
var (sut, handler, logger) = Build(apiKey: "md-test-key");
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
Assert.Equal(1, handler.CallCount);
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
Assert.Equal(EmailService.DefaultEndpoint, handler.LastRequest.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
var root = doc.RootElement;
Assert.Equal("md-test-key", root.GetProperty("key").GetString());
var message = root.GetProperty("message");
Assert.Equal(EmailService.DefaultFromAddress, message.GetProperty("from_email").GetString());
Assert.Equal(EmailService.DefaultFromName, message.GetProperty("from_name").GetString());
Assert.Equal("it@contoso.invalid", message.GetProperty("to")[0].GetProperty("email").GetString());
Assert.Equal("to", message.GetProperty("to")[0].GetProperty("type").GetString());
Assert.Equal(EmailTemplates.PreviewApprovedSubject, message.GetProperty("subject").GetString());
Assert.Contains("contoso.invalid", message.GetProperty("html").GetString());
Assert.True(message.GetProperty("auto_text").GetBoolean());
Assert.False(message.GetProperty("track_opens").GetBoolean());
Assert.False(message.GetProperty("track_clicks").GetBoolean());
Assert.Equal("welcome", message.GetProperty("tags")[0].GetString());
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Information && e.Message.Contains("Preview approval email sent"));
}
[Fact]
public async Task SendAsync_UsesFarewellTemplate_AndFarewellTag()
{
var (sut, handler, logger) = Build(apiKey: "md-test-key");
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
using var doc = JsonDocument.Parse(handler.LastBody!);
var message = doc.RootElement.GetProperty("message");
Assert.Equal(EmailTemplates.OffboardingFarewellSubject, message.GetProperty("subject").GetString());
Assert.Equal("offboarding-farewell", message.GetProperty("tags")[0].GetString());
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Information && e.Message.Contains("Offboard farewell email sent"));
}
[Fact]
public async Task ConfiguredEndpointAndSender_OverrideDefaults()
{
var (sut, handler, _) = Build(apiKey: "md-test-key", extra: new Dictionary<string, string?>
{
["Email:Endpoint"] = "https://mail.example.invalid/send",
["Email:FromAddress"] = "hello@example.invalid",
["Email:FromName"] = "Example Sender",
});
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
Assert.Equal("https://mail.example.invalid/send", handler.LastRequest!.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
var message = doc.RootElement.GetProperty("message");
Assert.Equal("hello@example.invalid", message.GetProperty("from_email").GetString());
Assert.Equal("Example Sender", message.GetProperty("from_name").GetString());
}
// ----- provider responses / fail-soft -----
[Fact]
public async Task ProviderHttpError_DoesNotThrow_AndLogsWarning_WithoutSuccessLine()
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => Json(HttpStatusCode.InternalServerError,
"{\"status\":\"error\",\"code\":-1,\"name\":\"Invalid_Key\",\"message\":\"Invalid API key\"}"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("500") && e.Message.Contains("Invalid_Key"));
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
}
[Fact]
public async Task ProviderRejectsRecipient_LogsWarningWithReason_WithoutSuccessLine()
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => Json(HttpStatusCode.OK,
"[{\"email\":\"ops@contoso.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\",\"_id\":\"abc\"}]"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("rejected") && e.Message.Contains("hard-bounce"));
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
}
[Theory]
[InlineData("sent")]
[InlineData("queued")]
[InlineData("scheduled")]
public async Task ProviderAcceptedStatuses_CountAsSuccess(string status)
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => Json(HttpStatusCode.OK,
$"[{{\"email\":\"ops@contoso.invalid\",\"status\":\"{status}\",\"_id\":\"abc\"}}]"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Information);
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning);
}
[Fact]
public async Task MalformedProviderBody_DoesNotThrow_AndLogsWarning()
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => Json(HttpStatusCode.OK, "not json"));
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
}
[Fact]
public async Task TransportException_DoesNotThrow_AndLogsWarning()
{
var (sut, _, logger) = Build(apiKey: "md-test-key",
responder: _ => throw new HttpRequestException("simulated provider outage"));
await sut.SendPreviewApprovedEmailAsync("it@contoso.invalid", "contoso.invalid");
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Warning && e.Message.Contains("Failed to send welcome mail"));
}
// ----- templates -----
[Fact]
public void FarewellTemplate_IsFinal_NoDraftResidue()
{
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.DoesNotContain("DRAFT", html);
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
Assert.DoesNotContain("TODO", html);
}
[Fact]
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.Contains("contoso.invalid", html);
// The mail is sent from a noreply address after portal access is gone, so the
// feedback pointers must be external channels the recipient can actually reach.
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
Assert.Contains("linkedin.com", html);
}
[Fact]
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("");
Assert.Contains("your organization", html);
}
// ----- harness -----
private static HttpResponseMessage Json(HttpStatusCode status, string body)
=> new(status) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
private static (EmailService sut, StubHandler handler, CapturingLogger<EmailService> logger) Build(
string apiKey,
Dictionary<string, string?>? extra = null,
System.Func<HttpRequestMessage, HttpResponseMessage>? responder = null)
{
var settings = new Dictionary<string, string?> { ["Email:ApiKey"] = apiKey };
if (extra is not null)
foreach (var kv in extra) settings[kv.Key] = kv.Value;
var config = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var handler = new StubHandler();
if (responder is not null) handler.Responder = responder;
var logger = new CapturingLogger<EmailService>();
var templates = new EmailTemplateService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
Microsoft.Extensions.Logging.Abstractions.NullLogger<EmailTemplateService>.Instance);
var sut = new EmailService(new HttpClient(handler, disposeHandler: false), config, templates, logger);
return (sut, handler, logger);
}
private sealed class StubHandler : HttpMessageHandler
{
public int CallCount { get; private set; }
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public System.Func<HttpRequestMessage, HttpResponseMessage> Responder { get; set; } = _ =>
Json(HttpStatusCode.OK, "[{\"email\":\"x@y.invalid\",\"status\":\"sent\",\"_id\":\"abc\"}]");
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
LastRequest = request;
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return Responder(request);
}
}
private sealed class CapturingLogger<T> : ILogger<T>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
System.Func<TState, System.Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -0,0 +1,131 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the operator-override layer over the built-in transactional templates:
/// override wins when stored, built-in otherwise; placeholder rendering matches the
/// built-ins' empty-domain fallback; the send path is fail-soft on storage errors while the
/// admin path is not; save validates and invalidates the cache; reset is idempotent.
/// </summary>
public sealed class EmailTemplateServiceTests
{
[Fact]
public async Task NoOverride_ReturnsBuiltIn_Rendered()
{
var (sut, _) = Build();
var html = await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid");
Assert.Equal(EmailTemplates.GetPreviewApprovedHtml("contoso.invalid"), html);
Assert.DoesNotContain(EmailTemplateService.DomainPlaceholder, html);
}
[Fact]
public async Task Override_WinsAndRendersPlaceholder()
{
var (sut, repo) = Build();
repo.Setup(r => r.GetEmailTemplateOverrideAsync("farewell"))
.ReturnsAsync(new EmailTemplateOverride { Kind = "farewell", Html = "<p>Bye {{domainName}}</p>" });
var html = await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid");
Assert.Equal("<p>Bye contoso.invalid</p>", html);
}
[Fact]
public async Task Override_EmptyDomain_FallsBackToGenericLabel_LikeBuiltIn()
{
var (sut, repo) = Build();
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome"))
.ReturnsAsync(new EmailTemplateOverride { Kind = "welcome", Html = "Hi {{domainName}}" });
Assert.Equal("Hi your organization", await sut.GetHtmlAsync(EmailTemplateKind.Welcome, ""));
}
[Fact]
public async Task StorageError_SendPath_FallsBackToBuiltIn_AdminPath_Throws()
{
var (sut, repo) = Build();
repo.Setup(r => r.GetEmailTemplateOverrideAsync(It.IsAny<string>()))
.ThrowsAsync(new System.InvalidOperationException("storage down"));
var html = await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid");
Assert.Contains("contoso.invalid", html);
await Assert.ThrowsAsync<System.InvalidOperationException>(() => sut.GetOverrideAsync(EmailTemplateKind.Welcome));
}
[Fact]
public async Task Save_Validates_ThenInvalidatesCache()
{
var (sut, repo) = Build();
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome")).ReturnsAsync((EmailTemplateOverride?)null);
// warm the cache with "no override"
Assert.Null(await sut.GetOverrideAsync(EmailTemplateKind.Welcome));
await Assert.ThrowsAsync<System.ArgumentException>(() => sut.SaveOverrideAsync(EmailTemplateKind.Welcome, " ", "ga@contoso.invalid"));
await Assert.ThrowsAsync<System.ArgumentException>(() =>
sut.SaveOverrideAsync(EmailTemplateKind.Welcome, new string('x', EmailTemplateService.MaxHtmlLength + 1), "ga@contoso.invalid"));
repo.Verify(r => r.SaveEmailTemplateOverrideAsync(It.IsAny<EmailTemplateOverride>()), Times.Never);
EmailTemplateOverride? stored = null;
repo.Setup(r => r.SaveEmailTemplateOverrideAsync(It.IsAny<EmailTemplateOverride>()))
.Callback<EmailTemplateOverride>(e => stored = e).Returns(Task.CompletedTask);
repo.Setup(r => r.GetEmailTemplateOverrideAsync("welcome")).ReturnsAsync(() => stored);
var saved = await sut.SaveOverrideAsync(EmailTemplateKind.Welcome, "<p>{{domainName}}</p>", "ga@contoso.invalid");
Assert.Equal("welcome", saved.Kind);
Assert.Equal("ga@contoso.invalid", saved.UpdatedBy);
// cache was invalidated: the next read sees the stored override, not the cached null
Assert.NotNull(await sut.GetOverrideAsync(EmailTemplateKind.Welcome));
Assert.Equal("<p>contoso.invalid</p>", await sut.GetHtmlAsync(EmailTemplateKind.Welcome, "contoso.invalid"));
}
[Fact]
public async Task Delete_ClearsCache_ReturnsToBuiltIn()
{
var (sut, repo) = Build();
var stored = new EmailTemplateOverride { Kind = "farewell", Html = "custom {{domainName}}" };
repo.Setup(r => r.GetEmailTemplateOverrideAsync("farewell")).ReturnsAsync(() => stored);
Assert.Equal("custom contoso.invalid", await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid"));
repo.Setup(r => r.DeleteEmailTemplateOverrideAsync("farewell")).Callback(() => stored = null!).Returns(Task.CompletedTask);
await sut.DeleteOverrideAsync(EmailTemplateKind.Farewell, "ga@contoso.invalid");
Assert.Equal(EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid"),
await sut.GetHtmlAsync(EmailTemplateKind.Farewell, "contoso.invalid"));
}
[Theory]
[InlineData("welcome", true)]
[InlineData("Farewell", true)]
[InlineData("invoice", false)]
[InlineData(null, false)]
public void TryParseKind_AcceptsOnlyKnownKinds(string? value, bool expected)
=> Assert.Equal(expected, EmailTemplateService.TryParseKind(value, out _));
[Fact]
public void BuiltInRaw_ContainsPlaceholder_ForBothKinds()
{
Assert.Contains(EmailTemplateService.DomainPlaceholder, EmailTemplateService.BuiltInRaw(EmailTemplateKind.Welcome));
Assert.Contains(EmailTemplateService.DomainPlaceholder, EmailTemplateService.BuiltInRaw(EmailTemplateKind.Farewell));
}
private static (EmailTemplateService sut, Mock<IConfigRepository> repo) Build()
{
var repo = new Mock<IConfigRepository>(MockBehavior.Loose);
var sut = new EmailTemplateService(repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<EmailTemplateService>.Instance);
return (sut, repo);
}
}
@@ -191,6 +191,21 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.Equal(EndpointPolicy.GlobalAdminOnly, reseed.Policy);
}
/// <summary>
/// The built-in diagnostics catalog + global paths are platform-wide, secret-free data that
/// every settings viewer needs (what is collected from the tenant's devices). Member tier,
/// JWT-scoped — an upgrade to a global tier would hide it from tenant admins again.
/// </summary>
[Fact]
public void DiagnosticsPaths_IsMemberReadJwtScoped()
{
var entry = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/diagnostics/paths");
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.MemberRead, entry.Policy);
Assert.Equal(TenantScoping.Jwt, entry.TenantScoping);
}
/// <summary>
/// Unregistered routes must return null (fail-closed).
/// </summary>
@@ -471,4 +471,148 @@ public class EnrollmentTimeoutClassifierTests
Assert.NotEqual(SessionStatus.Failed, status);
}
}
// -------- WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21) --------
private static readonly DateTime Resumed = Start.AddMinutes(20);
private static (SessionStatus, string) ClassifyWhiteGlove(
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 51)
{
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
var now = Start.AddHours(hoursSinceStart);
return EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, now, grace, isPreProvisioned: true, resumedAt: Resumed);
}
/// <summary>The fairstone event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
private static EnrollmentEvent[] WgParkedEvents() => new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
};
[Fact]
public void ExtractRollup_separates_enrollment_complete_from_whiteglove_complete()
{
var wgOnly = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("whiteglove_complete") });
Assert.True(wgOnly.HasTerminalComplete);
Assert.False(wgOnly.HasEnrollmentComplete);
var real = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_complete") });
Assert.True(real.HasTerminalComplete);
Assert.True(real.HasEnrollmentComplete);
}
[Fact]
public void WhiteGloveGate_requires_preprovisioned_and_resume_and_device_setup()
{
var parked = EnrollmentTimeoutClassifier.ExtractRollup(WgParkedEvents());
Assert.True(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, Resumed));
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, false, Resumed));
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, null));
// Powered off before Device Setup ever provisioned → not the parking state.
var early = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp("ESP provisioning status: DeviceSetup — 3 of 4 subcategories completed"),
});
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(early, true, Resumed));
}
[Fact]
public void WhiteGloveGate_any_user_evidence_or_terminal_disarms_it()
{
foreach (var evidence in new[]
{
Esp(AccountSetup15), // Account Setup progressed → user was there
Evt("desktop_arrived"), // real-user desktop
Evt("hello_provisioning_completed"), // positive Hello terminal
Evt("enrollment_complete"), // real completion
Evt("enrollment_failed"), // explicit failure
})
{
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), evidence,
});
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(rollup, true, Resumed));
}
}
[Fact]
public void Classify_whiteglove_part2_parked_within_grace_is_AwaitingUser()
{
// The fairstone shape: technician powers the device off at the logon screen minutes
// after the reseal-reboot. Part-1 whiteglove_complete must NOT reconcile to Succeeded.
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, status);
Assert.Contains("sealed or powered off awaiting the end user", reason);
Assert.Contains("(Account Setup 0/5)", reason);
}
[Fact]
public void Classify_whiteglove_part2_parked_past_grace_reconciles_with_honest_reason()
{
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 60, grace: 51);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("pre-provisioning (WhiteGlove Part 1) completed", reason);
Assert.DoesNotContain("Account Setup completed", reason);
}
[Fact]
public void Classify_whiteglove_part2_with_partial_user_progress_is_not_reconciled_on_part1_evidence()
{
// User signed in and Account Setup progressed to 1/5, then silence. The Part-1
// whiteglove_complete previously reconciled this to Succeeded claiming "Account Setup
// completed" — on a resumed session only a real enrollment_complete proves the outcome,
// so this honest shape falls through to the AwaitingUser/Incomplete rules.
var events = new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
};
var (withinGrace, _) = ClassifyWhiteGlove(events, hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, withinGrace);
var (pastGrace, reason) = ClassifyWhiteGlove(events, hoursSinceStart: 60, grace: 51);
Assert.Equal(SessionStatus.Incomplete, pastGrace);
Assert.Contains("1/5", reason);
}
[Fact]
public void Classify_whiteglove_part2_with_desktop_and_hello_still_reconciles_via_user_evidence()
{
// Both Classic completion prerequisites in → the user provably finished; the
// user-completed reconcile (rule 4) owns this, not the parking gate.
var (status, reason) = ClassifyWhiteGlove(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
Evt("desktop_arrived"), Evt("hello_provisioning_completed"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("user completed setup", reason);
}
[Fact]
public void Classify_whiteglove_part2_real_enrollment_complete_still_reconciles_to_Succeeded()
{
var (status, _) = ClassifyWhiteGlove(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), Evt("enrollment_complete"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
}
[Fact]
public void Classify_unresumed_session_keeps_terminal_complete_reconcile()
{
// No resumedAt (plain sessions, or a sealed Part 1 whose Pending write was lost):
// whiteglove_complete keeps reconciling to Succeeded exactly as before.
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
});
var (status, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, Start.AddHours(6), 51, isPreProvisioned: true, resumedAt: null);
Assert.Equal(SessionStatus.Succeeded, status);
}
}
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
@@ -32,6 +33,44 @@ public class GetAgentConfigFunctionTests
Assert.Equal(expectedMajor, actual);
}
// ── MergeDiagnosticsLogPaths — global ∪ tenant without duplicate ZIP entries ──────────
private static DiagnosticsLogPath P(string path, string description = "") =>
new() { Path = path, Description = description };
[Fact]
public void MergeDiagnosticsLogPaths_GlobalFirst_OrderPreserved_DuplicatesDroppedCaseInsensitively()
{
var global = new[] { P(@"C:\Windows\Panther\setupact.log", "global"), P(@"C:\Windows\Logs\DISM\dism.log") };
var tenant = new[]
{
P(@"c:\windows\panther\SETUPACT.LOG", "tenant copy"), // same path, different case → dropped
P(@" C:\Windows\Logs\DISM\dism.log "), // trimmed duplicate → dropped
P(@"C:\Windows\SetupDiag\*.log", "tenant"),
};
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(global, tenant);
Assert.Equal(new[]
{
@"C:\Windows\Panther\setupact.log",
@"C:\Windows\Logs\DISM\dism.log",
@"C:\Windows\SetupDiag\*.log",
}, merged.Select(p => p.Path));
Assert.Equal("global", merged[0].Description); // first occurrence (global) wins
}
[Fact]
public void MergeDiagnosticsLogPaths_DropsBlankEntries()
{
var merged = GetAgentConfigFunction.MergeDiagnosticsLogPaths(
new[] { P(""), P(" "), P(@"C:\Install\Log\*.log") },
new DiagnosticsLogPath[] { null!, P(@"C:\Install\Log\*.log") });
Assert.Single(merged);
Assert.Equal(@"C:\Install\Log\*.log", merged[0].Path);
}
[Theory]
// CustomerSas: gated on the per-tenant SAS URL being present.
[InlineData("https://acct.blob.core.windows.net/c?sv=...", "CustomerSas", true)]
@@ -0,0 +1,70 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Diagnostics;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Wire shape of <c>GET /api/diagnostics/paths</c> (<see cref="GetDiagnosticsPathsFunction.BuildPayload"/>).
/// The route is MemberRead, so the field set is locked: the payload must stay "what is
/// collected" — never grow admin-only config.
/// </summary>
public class GetDiagnosticsPathsPayloadTests
{
// Mirrors the worker's serializer (Program.cs: PropertyNamingPolicy = CamelCase) so the
// nested DiagnosticsLogPath class serializes the way the portal actually receives it.
private static readonly JsonSerializerOptions WorkerJson = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private static JsonElement Serialize(AdminConfiguration config)
{
var json = JsonSerializer.Serialize(GetDiagnosticsPathsFunction.BuildPayload(config), WorkerJson);
return JsonDocument.Parse(json).RootElement;
}
[Fact]
public void Payload_HasExactlyBuiltInAndGlobalPaths()
{
var element = Serialize(new AdminConfiguration());
var fieldNames = element.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
Assert.Equal(new[] { "builtIn", "globalPaths" }, fieldNames);
Assert.Equal(JsonValueKind.Array, element.GetProperty("globalPaths").ValueKind);
Assert.Equal(0, element.GetProperty("globalPaths").GetArrayLength());
}
[Fact]
public void BuiltIn_MirrorsTheCatalog_WithConditionAsName()
{
var builtIn = Serialize(new AdminConfiguration()).GetProperty("builtIn");
Assert.Equal(DiagnosticsBuiltInSections.All.Count, builtIn.GetArrayLength());
var first = builtIn[0];
Assert.Equal("AgentLogs", first.GetProperty("id").GetString());
Assert.Equal("AgentLogs", first.GetProperty("zipFolder").GetString());
Assert.Equal(AutopilotMonitor.Shared.Constants.LogDirectory, first.GetProperty("sourceFolder").GetString());
Assert.Equal(DiagnosticsBuiltInSections.LogFilePatterns.Length, first.GetProperty("patterns").GetArrayLength());
Assert.False(first.GetProperty("includeSubfolders").GetBoolean());
Assert.Equal("Always", first.GetProperty("condition").GetString());
var conditions = builtIn.EnumerateArray().Select(s => s.GetProperty("condition").GetString()).ToArray();
Assert.Contains("RealmJoinWatcher", conditions);
Assert.Contains("DevicePreparation", conditions);
Assert.All(conditions, c => Assert.False(int.TryParse(c, out _), "condition must serialize as the enum name"));
}
[Fact]
public void GlobalPaths_RoundTripTheStoredJson()
{
var config = new AdminConfiguration
{
DiagnosticsGlobalLogPathsJson =
"[{\"path\":\"C:\\\\Windows\\\\Panther\\\\setupact.log\",\"description\":\"Setup\",\"isBuiltIn\":true,\"includeSubfolders\":false}]",
};
var globalPaths = Serialize(config).GetProperty("globalPaths");
Assert.Equal(1, globalPaths.GetArrayLength());
Assert.Equal(@"C:\Windows\Panther\setupact.log", globalPaths[0].GetProperty("path").GetString());
Assert.Equal("Setup", globalPaths[0].GetProperty("description").GetString());
}
}
@@ -54,9 +54,13 @@ public class GraphFeatureCatalogSyncTests
.Select(m => m.Groups["f"].Value)
.ToArray();
// 'All' is a script-side meta-value that expands to every catalog feature — it is not
// (and must never become) a GraphFeatureCatalog entry, but the script must keep offering it.
Assert.Contains("All", scriptFeatures);
Assert.Equal(
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
scriptFeatures.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
scriptFeatures.Where(f => f != "All").OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
}
private static string ReadGrantScript()
@@ -1,4 +1,5 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
@@ -64,6 +65,17 @@ public class NotificationChannelsTests
Assert.Empty(slack.GetCustomHeaders());
}
[Fact]
public void GetSigningSecret_OnlyForGenericJson()
{
var secret = "0123456789abcdef";
Assert.Equal(secret, new NotificationChannel { Id = "g", ProviderType = 20, SigningSecret = secret }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "s", ProviderType = 10, SigningSecret = secret }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "d", ProviderType = 30, SigningSecret = secret }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "w", ProviderType = 20, SigningSecret = " " }.GetSigningSecret());
Assert.Null(new NotificationChannel { Id = "n", ProviderType = 20 }.GetSigningSecret());
}
// ── Legacy synthesis (GetNotificationChannels) ────────────────────────
[Fact]
@@ -141,7 +153,7 @@ public class NotificationChannelsTests
var cfg = new TenantConfiguration
{
NotificationChannelsJson = "[" +
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\"") + "," +
ChannelJson("ch-1", extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"k\\\"}\",\"signingSecret\":\"0123456789abcdef\"") + "," +
ChannelJson("ch-2", providerType: 10, url: "https://slack.example/h") + "]",
};
@@ -151,8 +163,10 @@ public class NotificationChannelsTests
Assert.Equal("ch-1", redacted[0].Id); // structure visible
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].Url); // secrets masked
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].CustomHeadersJson);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[0].SigningSecret);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted[1].Url);
Assert.Null(redacted[1].CustomHeadersJson); // unset stays unset
Assert.Null(redacted[1].SigningSecret);
}
[Fact]
@@ -169,7 +183,7 @@ public class NotificationChannelsTests
{
NotificationChannelsJson = "[" +
ChannelJson("ch-1", url: "https://real.example/hook",
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\"") + "]",
extra: ",\"customHeadersJson\":\"{\\\"X-Api-Key\\\":\\\"real\\\"}\",\"signingSecret\":\"real-secret-0123\"") + "]",
};
// Round-trip the redacted view back onto a save (defense-in-depth path).
@@ -179,6 +193,7 @@ public class NotificationChannelsTests
var ch = Assert.Single(NotificationChannel.ParseList(incoming.NotificationChannelsJson));
Assert.Equal("https://real.example/hook", ch.Url);
Assert.Equal("{\"X-Api-Key\":\"real\"}", ch.CustomHeadersJson);
Assert.Equal("real-secret-0123", ch.SigningSecret);
}
[Fact]
@@ -244,4 +259,40 @@ public class NotificationChannelsTests
Assert.NotNull(error);
Assert.Contains("headers", error);
}
// ── Signing-secret validation ─────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("0123456789abcdef")] // 16 chars (minimum)
public void ValidateWebhookSigningSecret_AcceptsAbsentOrValid(string? secret)
{
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
// 128 chars (maximum) and a typical generated 64-char hex secret
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 128)));
Assert.Null(TenantConfigValidation.ValidateWebhookSigningSecret(new string('f', 64)));
}
[Theory]
[InlineData("short-15-chars.")] // below minimum
[InlineData("has a space in the middle!")] // whitespace
[InlineData("line\nbreak-0123456789")] // control char
[InlineData("umlauts-äöü-0123456789")] // non-ASCII
[InlineData("***REDACTED***")] // unrestored sentinel (too short anyway)
public void ValidateWebhookSigningSecret_RejectsInvalid(string secret)
{
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(secret));
Assert.NotNull(TenantConfigValidation.ValidateWebhookSigningSecret(new string('a', 129)));
}
[Fact]
public void ValidateNotificationChannels_RejectsBadPerChannelSigningSecret()
{
var json = "[" + ChannelJson("ch-1", extra: ",\"signingSecret\":\"too-short\"") + "]";
var error = UpdateTenantConfigurationFunction.ValidateNotificationChannels(json);
Assert.NotNull(error);
Assert.Contains("signing secret", error);
}
}
@@ -7,7 +7,7 @@ namespace AutopilotMonitor.Functions.Tests.Offboarding;
/// <summary>
/// Test double for <see cref="IOffboardFarewellEmailSender"/>. Records every invocation so
/// the handler-side tests can assert "did the farewell send fire after History → Completed?"
/// without booting the real Resend client. Default = silent noop; set <see cref="ThrowOnSend"/>
/// without booting the real mail provider client. Default = silent noop; set <see cref="ThrowOnSend"/>
/// to simulate downstream failures and prove the handler's fail-soft contract.
/// </summary>
internal sealed class FakeOffboardFarewellEmailSender : IOffboardFarewellEmailSender
@@ -221,7 +221,7 @@ public class TenantOffboardingHandlerOrderingTests
public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailNull()
{
// Tenants that never set a preview-notification email get null capture; the handler
// skip-gates the send so no Resend call is even attempted.
// skip-gates the send so no provider call is even attempted.
var harness = Harness.New();
Assert.Null(harness.Repo.History[HistoryRowKey].NotificationEmail);
@@ -235,7 +235,7 @@ public class TenantOffboardingHandlerOrderingTests
public async Task PostDrain_FarewellEmail_Skipped_WhenNotificationEmailWhitespace()
{
// Defensive: legacy History rows may have a non-null empty/whitespace value. Treat
// these as "no email captured" so we never call Resend with a useless string.
// these as "no email captured" so we never call the mail provider with a useless string.
var harness = Harness.New();
harness.Repo.History[HistoryRowKey].NotificationEmail = " ";
@@ -248,11 +248,11 @@ public class TenantOffboardingHandlerOrderingTests
[Fact]
public async Task PostDrain_FarewellEmail_SenderThrows_HandlerSurvives_HistoryStillCompleted()
{
// The send is best-effort. A Resend outage / template bug / serializer crash must
// The send is best-effort. A mail-provider outage / template bug / serializer crash must
// NOT propagate to the queue worker — that would re-poison a Completed offboard.
var harness = Harness.New();
harness.Repo.History[HistoryRowKey].NotificationEmail = "ops@contoso.invalid";
harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated Resend outage");
harness.FarewellEmail.ThrowOnSend = new InvalidOperationException("simulated mail-provider outage");
await harness.Sut.HandleAsync(harness.Envelope());
@@ -0,0 +1,145 @@
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="PlanManagementFunction.ApplyPlanChanges"/> — the pure mutation core
/// of the PATCH plan endpoint. Pins the retention-grace anchor lifecycle: an EFFECTIVE
/// Pro→Community transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/>, any
/// effectively-Pro outcome clears it, and a planTier downgrade under an active trial does
/// NOT stamp (the edition is still Pro — the later trial expiry is the anchor then).
/// </summary>
public class PlanManagementTransitionTests
{
private static readonly DateTime Now = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
private const string Caller = "ga@operator.example";
private static (TenantEdition Before, TenantEdition After, Dictionary<string, string> Changes) Apply(
TenantConfiguration config, string? planTier = null, bool trialProvided = false, DateTime? trialExpiresUtc = null)
{
var changes = new Dictionary<string, string>();
var (before, after) = PlanManagementFunction.ApplyPlanChanges(
config, planTier, trialProvided, trialExpiresUtc, Caller, Now, changes);
return (before, after, changes);
}
[Fact]
public void Downgrade_ProToCommunity_StampsAnchorAndRecordsChange()
{
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" };
var (before, after, changes) = Apply(config, planTier: "community");
Assert.Equal(TenantEdition.Pro, before);
Assert.Equal(TenantEdition.Community, after);
Assert.Equal(Now, config.ProDowngradedUtc);
Assert.True(changes.ContainsKey("ProDowngradedUtc"));
Assert.True(changes.ContainsKey("PlanTier"));
}
[Fact]
public void Upgrade_CommunityToPro_ClearsAnchor()
{
var config = new TenantConfiguration
{
TenantId = "t1",
PlanTier = "community",
ProDowngradedUtc = Now.AddDays(-10)
};
var (_, after, changes) = Apply(config, planTier: "pro");
Assert.Equal(TenantEdition.Pro, after);
Assert.Null(config.ProDowngradedUtc);
Assert.True(changes.ContainsKey("ProDowngradedUtc"));
}
[Fact]
public void Downgrade_WithActiveTrial_DoesNotStamp_EditionStaysPro()
{
// planTier drops but the trial keeps the EFFECTIVE edition Pro: no anchor now —
// the trial's own expiry timestamp anchors the grace later, read-time.
var config = new TenantConfiguration
{
TenantId = "t1",
PlanTier = "pro",
TrialExpiresUtc = Now.AddDays(5)
};
var (before, after, changes) = Apply(config, planTier: "community");
Assert.Equal(TenantEdition.Pro, before);
Assert.Equal(TenantEdition.Pro, after);
Assert.Null(config.ProDowngradedUtc);
Assert.False(changes.ContainsKey("ProDowngradedUtc"));
}
[Fact]
public void EndingTrialExplicitly_StampsAnchor()
{
// GA sets trialExpiresUtc: null on a trial-Pro tenant — TrialExpiresUtc is gone, so
// WITHOUT the explicit stamp the grace would have no anchor at all.
var config = new TenantConfiguration
{
TenantId = "t1",
PlanTier = "free",
TrialExpiresUtc = Now.AddDays(5)
};
var (before, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: null);
Assert.Equal(TenantEdition.Pro, before);
Assert.Equal(TenantEdition.Community, after);
Assert.Equal(Now, config.ProDowngradedUtc);
Assert.Null(config.TrialExpiresUtc);
}
[Fact]
public void GrantingTrial_ToDowngradedTenant_ClearsAnchor()
{
var config = new TenantConfiguration
{
TenantId = "t1",
PlanTier = "community",
ProDowngradedUtc = Now.AddDays(-10)
};
var (_, after, _) = Apply(config, trialProvided: true, trialExpiresUtc: Now.AddDays(30));
Assert.Equal(TenantEdition.Pro, after);
Assert.Null(config.ProDowngradedUtc);
Assert.Equal(Caller, config.TrialGrantedBy);
}
[Fact]
public void NoOpPatch_SameTier_RecordsNothing()
{
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "community" };
var (before, after, changes) = Apply(config, planTier: "community");
Assert.Equal(TenantEdition.Community, before);
Assert.Equal(TenantEdition.Community, after);
Assert.Empty(changes);
Assert.Null(config.ProDowngradedUtc);
}
[Fact]
public void RepeatedDowngradeAfterReUpgrade_RestampsWithNewTimestamp()
{
var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" };
Apply(config, planTier: "community");
Assert.Equal(Now, config.ProDowngradedUtc);
Apply(config, planTier: "pro");
Assert.Null(config.ProDowngradedUtc);
var changes = new Dictionary<string, string>();
var later = Now.AddDays(40);
PlanManagementFunction.ApplyPlanChanges(config, "community", false, null, Caller, later, changes);
Assert.Equal(later, config.ProDowngradedUtc);
}
}
@@ -1,134 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Coverage for the post-offboarding farewell-email send path. The actual Resend HTTP
/// call is not exercised here — every test drives one of the no-op gates. Pins:
/// <list type="bullet">
/// <item><see cref="ResendEmailService.SendAsync"/> never throws — not when the API key
/// is empty and not when the recipient is missing. The handler's fail-soft
/// try/catch is a belt; this is the suspenders.</item>
/// <item>Gate ordering: missing-key short-circuits before missing-recipient.</item>
/// <item>The farewell template is final: no [DRAFT]/placeholder residue, domain is
/// interpolated, empty domain falls back to "your organization".</item>
/// </list>
/// </summary>
public sealed class ResendEmailServiceTests
{
[Fact]
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
{
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "", logger);
await sut.SendAsync(
toEmail: "ops@contoso.invalid",
domainName: "contoso.invalid",
tenantId: "88888888-8888-8888-8888-888888888888",
ct: CancellationToken.None);
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
}
[Fact]
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
{
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "any-non-empty-key", logger);
await sut.SendAsync(
toEmail: "",
domainName: "contoso.invalid",
tenantId: "88888888-8888-8888-8888-888888888888");
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
}
[Fact]
public async Task SendAsync_EmptyApiKey_ShortCircuitsBeforeRecipientCheck()
{
// Pins the gate ordering: missing-key > missing-email. Both are empty here; only
// the key line must show up.
var logger = new CapturingLogger<ResendEmailService>();
var sut = Build(apiKey: "", logger);
await sut.SendAsync(
toEmail: "",
domainName: "contoso.invalid",
tenantId: "88888888-8888-8888-8888-888888888888");
Assert.Contains(logger.Entries,
e => e.Level == LogLevel.Debug && e.Message.Contains("RESEND_API_KEY not configured"));
Assert.DoesNotContain(logger.Entries,
e => e.Message.Contains("No notification email captured"));
}
[Fact]
public void FarewellTemplate_IsFinal_NoDraftResidue()
{
Assert.DoesNotContain("DRAFT", EmailTemplates.OffboardingFarewellSubject);
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.DoesNotContain("DRAFT", html);
Assert.DoesNotContain("TEMPLATE NOT FINALISED", html);
Assert.DoesNotContain("TODO", html);
}
[Fact]
public void FarewellTemplate_InterpolatesDomain_AndLinksFeedbackChannels()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("contoso.invalid");
Assert.Contains("contoso.invalid", html);
// The mail is sent from a noreply address after portal access is gone, so the
// feedback pointers must be external channels the recipient can actually reach.
Assert.Contains("https://github.com/okieselbach/AutopilotMonitor/issues", html);
Assert.Contains("linkedin.com", html);
}
[Fact]
public void FarewellTemplate_EmptyDomain_FallsBackToGenericLabel()
{
var html = EmailTemplates.GetOffboardingFarewellHtml("");
Assert.Contains("your organization", html);
}
private static ResendEmailService Build(string apiKey, ILogger<ResendEmailService> logger)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["RESEND_API_KEY"] = apiKey,
})
.Build();
return new ResendEmailService(config, logger);
}
private sealed class CapturingLogger<T> : ILogger<T>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, System.Exception? exception,
System.Func<TState, System.Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -331,8 +331,9 @@ public class SessionRetentionFanoutServiceTests
var baseTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var deadline = baseTime.AddMinutes(50);
var clockCalls = 0;
// Call sequence: tenant-A boundary (1), session s0 (2), session s1 (3), session s2 (4 → past deadline).
var harness = new Harness(utcNow: () => ++clockCalls >= 4 ? deadline.AddMinutes(1) : baseTime);
// Call sequence: tenant-A boundary (1), retention resolution (2), session s0 (3),
// session s1 (4), session s2 (5 → past deadline).
var harness = new Harness(utcNow: () => ++clockCalls >= 5 ? deadline.AddMinutes(1) : baseTime);
var sessions = new List<SessionSummary>();
for (int i = 0; i < 4; i++) sessions.Add(Summary(TenantA, $"s{i}"));
@@ -388,6 +389,38 @@ public class SessionRetentionFanoutServiceTests
};
}
// =========================================== Retention downgrade grace ====
[Fact]
public async Task Tenant_InRetentionGrace_KeepsProCutoff()
{
// Downgraded 10 days ago, stored retention 365: the grace keeps the Pro cap, so the
// cutoff must be now-365d — NOT now-90d (which would enqueue the 90–365d band).
var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
var harness = new Harness(utcNow: () => now);
harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty<SessionSummary>(),
planTier: "community", proDowngradedUtc: now.AddDays(-10));
await harness.RunAsync();
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
"t1", now.AddDays(-365), It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
}
[Fact]
public async Task Tenant_AfterRetentionGrace_CommunityCutoffApplies()
{
var now = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
var harness = new Harness(utcNow: () => now);
harness.WithTenant("t1", retentionDays: 365, sessions: Array.Empty<SessionSummary>(),
planTier: "community", proDowngradedUtc: now.AddDays(-31));
await harness.RunAsync();
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
"t1", now.AddDays(-90), It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
}
// ============================================================ Harness ====
private sealed class Harness
@@ -450,11 +483,18 @@ public class SessionRetentionFanoutServiceTests
return result;
}
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free")
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free",
DateTime? proDowngradedUtc = null)
{
_tenantIds.Add(tenantId);
TenantConfig.Setup(t => t.GetConfigurationAsync(tenantId))
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, DataRetentionDays = retentionDays, PlanTier = planTier });
.ReturnsAsync(new TenantConfiguration
{
TenantId = tenantId,
DataRetentionDays = retentionDays,
PlanTier = planTier,
ProDowngradedUtc = proDowngradedUtc,
});
MaintenanceRepo.Setup(m => m.GetSessionsOlderThanAsync(tenantId, It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<bool>()))
.ReturnsAsync(new List<SessionSummary>(WithTenantId(tenantId, sessions)));
}
@@ -101,6 +101,52 @@ public class SessionStartedAtBackwardShiftGuardTests
Assert.Equal((int)(lastEvent - earliest).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
}
// =========================== system-timeline rows are not session anchors ====
[Fact]
public void IsSessionAnchorEligible_excludes_system_timeline_watcher_rows()
{
Assert.False(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r") { ["Source"] = "SystemTimelineWatcher" }));
Assert.True(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r") { ["Source"] = "ImeLogTracker" }));
Assert.True(TableStorageService.IsSessionAnchorEligible(
new TableEntity("p", "r"))); // legacy row without Source projection
}
[Fact]
public async Task Pending_duration_skips_backfilled_system_timeline_row_and_anchors_on_activity()
{
// Field case d0c5b672: a pre-agent system_clock_changed event (backfilled from the
// Windows event log, original timestamp ~14min before the first real activity) was
// the partition's earliest row and re-anchored StartedAt. The probe must skip
// SystemTimelineWatcher rows and anchor on the first activity event instead —
// even when the watcher row sits well inside the 2h plausibility window.
var clockEventAt = StartedAt.AddMinutes(-90);
var firstActivity = StartedAt.AddMinutes(-5);
var lastEvent = StartedAt.AddMinutes(70);
var harness = new Harness(SessionRow(), eventRows: new[]
{
EventRow(clockEventAt, sequence: 18, source: "SystemTimelineWatcher"),
EventRow(firstActivity, sequence: 2, source: "ImeLogTracker"),
});
var ok = await harness.Sut.UpdateSessionStatusAsync(
TenantId, SessionId, SessionStatus.Pending,
latestEventTimestamp: lastEvent);
Assert.True(ok);
Assert.NotNull(harness.Written);
Assert.Equal((int)(lastEvent - firstActivity).TotalSeconds, harness.Written!.GetInt32("DurationSeconds"));
}
private static TableEntity EventRow(DateTime occurredUtc, int sequence, string source) =>
new($"{TenantId}_{SessionId}", $"{occurredUtc:yyyyMMddHHmmssfff}_{sequence:D10}")
{
["OccurredUtc"] = new DateTimeOffset(occurredUtc),
["Source"] = source,
};
// ============================================================ Harness ====
private static TableEntity SessionRow()
@@ -126,7 +172,7 @@ public class SessionStartedAtBackwardShiftGuardTests
public TableStorageService Sut { get; }
public TableEntity? Written { get; private set; }
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null)
public Harness(TableEntity existing, DateTime? earliestStoredEvent = null, TableEntity[]? eventRows = null)
{
var sessions = new Mock<TableClient>();
@@ -150,7 +196,7 @@ public class SessionStartedAtBackwardShiftGuardTests
});
var events = new Mock<TableClient>();
var eventRows = earliestStoredEvent.HasValue
var servedRows = eventRows ?? (earliestStoredEvent.HasValue
? new[]
{
new TableEntity($"{TenantId}_{SessionId}", $"{earliestStoredEvent.Value:yyyyMMddHHmmssfff}_0000000001")
@@ -158,12 +204,12 @@ public class SessionStartedAtBackwardShiftGuardTests
["OccurredUtc"] = new DateTimeOffset(earliestStoredEvent.Value),
},
}
: Array.Empty<TableEntity>();
: Array.Empty<TableEntity>());
events.Setup(t => t.QueryAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(AsyncPageable<TableEntity>.FromPages(new[]
{
Page<TableEntity>.FromValues(eventRows, null, new Mock<Response>().Object),
Page<TableEntity>.FromValues(servedRows, null, new Mock<Response>().Object),
}));
var serviceClient = new Mock<TableServiceClient>();
@@ -0,0 +1,137 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// ANALYZE-DEV-012 — pins the firing behaviour of the built-in that consumes the agent's
/// <c>system_sleep_episode</c> events (SystemTimelineWatcherHost): informational, fires once
/// (trigger single) when a completed sleep/hibernate/Modern-Standby episode of at least
/// 5 minutes is observed. Single-required-condition on durationSeconds so the match is pinned
/// to one event instance and the interpolated duration is the matched value. Also pins that the
/// agent's numeric payload (long) satisfies the rule's string "300" via numeric gte.
/// </summary>
public class SleepEpisodeRuleTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "66666666-7777-8888-9999-aaaaaaaaaaaa";
[Fact]
public async Task ANALYZE_DEV_012_fires_info_on_a_five_minute_episode()
{
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
Assert.True(rule.Enabled);
Assert.Equal("info", rule.Severity);
Assert.False(rule.MarkSessionAsFailedDefault); // environment observation, never a KO verdict
var events = new List<EnrollmentEvent>
{
SleepEpisode(durationSeconds: 300, kind: "modern_standby"),
};
var outcome = await RunAsync(rule, events);
var result = Assert.Single(outcome.Results);
Assert.Equal("ANALYZE-DEV-012", result.RuleId);
Assert.Equal("info", result.Severity);
// durationSeconds is the matched condition's dataField, so {{durationSeconds}} in the
// explanation interpolates the matched value (the DEV-009 whitelist lesson).
var matched = AsDict(result.MatchedConditions["slept_during_enrollment"]);
Assert.Equal("durationSeconds", AsString(matched["field"]));
}
[Theory]
[InlineData(60)] // real but short episode — below the rule's 5-minute floor
[InlineData(299)] // boundary: gte 300 must not fire at 299
public async Task ANALYZE_DEV_012_does_not_fire_below_the_five_minute_floor(long durationSeconds)
{
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
var events = new List<EnrollmentEvent>
{
SleepEpisode(durationSeconds, kind: "sleep"),
};
var outcome = await RunAsync(rule, events);
Assert.Empty(outcome.Results);
}
[Fact]
public async Task ANALYZE_DEV_012_does_not_fire_on_a_clock_change_event()
{
// The sibling watcher event carries a large timeDeltaMs but is a different event type —
// the rule must key on system_sleep_episode only.
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
var events = new List<EnrollmentEvent>
{
new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "system_clock_changed",
Timestamp = DateTime.UtcNow,
Sequence = 7,
Data = new Dictionary<string, object>
{
["timeDeltaMs"] = 7_200_000L,
["durationSeconds"] = 900L, // adversarial: same field name on the wrong type
["reason"] = 1,
},
},
};
var outcome = await RunAsync(rule, events);
Assert.Empty(outcome.Results);
}
// ===== Event builder — mirrors the SystemTimelineTracker emit shape =====
private static EnrollmentEvent SleepEpisode(long durationSeconds, string kind) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "system_sleep_episode",
Timestamp = DateTime.UtcNow,
Sequence = 42,
Data = new Dictionary<string, object>
{
["kind"] = kind,
["enteredAt"] = DateTime.UtcNow.AddSeconds(-durationSeconds).ToString("o"),
["exitedAt"] = DateTime.UtcNow.ToString("o"),
["durationSeconds"] = durationSeconds,
["backfilled"] = false,
}
};
private static Dictionary<string, object> AsDict(object o)
{
if (o is Dictionary<string, object> d) return d;
throw new InvalidOperationException($"Expected Dictionary<string,object>, got {o?.GetType().Name ?? "null"}");
}
private static string AsString(object o) => o?.ToString() ?? string.Empty;
private static async Task<AnalysisOutcome> RunAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
{
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<SleepEpisodeRuleTests>.Instance);
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
}
}
@@ -21,7 +21,7 @@ public class TenantApprovalServiceTests
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<ResendEmailService> _resendMock;
private readonly Mock<IEmailService> _emailMock;
private readonly TenantApprovalService _sut;
public TenantApprovalServiceTests()
@@ -37,9 +37,10 @@ public class TenantApprovalServiceTests
configRepo, cache, NullLogger<PreviewWhitelistService>.Instance, _tenantConfigMock.Object)
{ CallBase = false };
_resendMock = new Mock<ResendEmailService>(
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
NullLogger<ResendEmailService>.Instance);
_emailMock = new Mock<IEmailService>();
_emailMock.Setup(x => x.SendPreviewApprovedEmailAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_sut = new TenantApprovalService(
NullLogger<TenantApprovalService>.Instance,
@@ -47,7 +48,7 @@ public class TenantApprovalServiceTests
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
_resendMock.Object);
_emailMock.Object);
}
[Fact]
@@ -94,7 +95,7 @@ public class TenantApprovalServiceTests
Assert.False(sent);
_previewMock.Verify(x => x.TryMarkWelcomeEmailSentAsync(It.IsAny<string>()), Times.Never);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
@@ -109,7 +110,7 @@ public class TenantApprovalServiceTests
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.True(sent);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com"), Times.Once);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", "contoso.com", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@@ -121,7 +122,7 @@ public class TenantApprovalServiceTests
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.False(sent);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
@@ -134,7 +135,7 @@ public class TenantApprovalServiceTests
var sent = await _sut.TrySendWelcomeEmailAsync(TenantId);
Assert.False(sent);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
@@ -151,6 +152,6 @@ public class TenantApprovalServiceTests
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
Assert.True(newlyApproved);
_resendMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>()), Times.Once);
_emailMock.Verify(x => x.SendPreviewApprovedEmailAsync("it@contoso.com", It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
}
@@ -50,9 +50,7 @@ public class TenantAutoApproveHandlerTests
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
new Mock<ResendEmailService>(
Mock.Of<Microsoft.Extensions.Configuration.IConfiguration>(),
NullLogger<ResendEmailService>.Instance).Object)
Mock.Of<IEmailService>())
{ CallBase = false };
var opsRepo = new Mock<IOpsEventRepository>();
@@ -20,6 +20,7 @@ public class TenantConfigTableSerializationTests
{
var expires = new DateTime(2026, 8, 6, 10, 30, 0, DateTimeKind.Utc);
var started = new DateTime(2026, 7, 7, 10, 30, 0, DateTimeKind.Utc);
var downgraded = new DateTime(2026, 8, 10, 8, 0, 0, DateTimeKind.Utc);
var config = new TenantConfiguration
{
TenantId = TenantId,
@@ -29,7 +30,8 @@ public class TenantConfigTableSerializationTests
TrialExpiresUtc = expires,
TrialStartedUtc = started,
TrialConsumed = true,
TrialGrantedBy = "alice@contoso.com"
TrialGrantedBy = "alice@contoso.com",
ProDowngradedUtc = downgraded
};
var entity = TableConfigRepository.ConvertToTenantTableEntity(config);
@@ -40,6 +42,7 @@ public class TenantConfigTableSerializationTests
Assert.Equal(started, mapped.TrialStartedUtc);
Assert.True(mapped.TrialConsumed);
Assert.Equal("alice@contoso.com", mapped.TrialGrantedBy);
Assert.Equal(downgraded, mapped.ProDowngradedUtc);
}
[Fact]
@@ -59,6 +62,7 @@ public class TenantConfigTableSerializationTests
Assert.Null(mapped.TrialStartedUtc);
Assert.False(mapped.TrialConsumed);
Assert.Null(mapped.TrialGrantedBy);
Assert.Null(mapped.ProDowngradedUtc);
}
[Fact]
@@ -77,6 +81,7 @@ public class TenantConfigTableSerializationTests
Assert.Null(mapped.TrialExpiresUtc);
Assert.False(mapped.TrialConsumed);
Assert.Null(mapped.TrialGrantedBy);
Assert.Null(mapped.ProDowngradedUtc); // pre-grace row: no anchor, no grace
}
[Fact]
@@ -120,6 +125,31 @@ public class TenantConfigTableSerializationTests
Assert.Null(mapped.AbsoluteMaxSessionHours);
}
[Fact]
public void Roundtrip_EnableDoGroupIdAutoSet_SurvivesStoreAndMap()
{
var config = new TenantConfiguration
{
TenantId = TenantId,
DomainName = "contoso.com",
UpdatedBy = "admin@contoso.com",
EnableDoGroupIdAutoSet = true
};
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.True(mapped.EnableDoGroupIdAutoSet);
}
[Fact]
public void Map_LegacyRow_WithoutDoGroupIdColumn_ReadsNull()
{
// null = "not set" → GetAgentConfigFunction serves the agent default (false).
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
Assert.Null(TableConfigRepository.ConvertFromTenantTableEntity(entity).EnableDoGroupIdAutoSet);
}
[Fact]
public void Roundtrip_NotificationChannelsJson_SurvivesStoreAndMap()
{
@@ -194,8 +194,10 @@ public class TenantEntitlementServiceTests
}
[Fact]
public void GetEffectiveRetentionDays_TrialExpiry_DegradesCapFrom365To90()
public void GetEffectiveRetentionDays_TrialExpiry_ProCapHoldsThroughGrace_ThenDegradesTo90()
{
// Trial expiry is a downgrade: the Pro cap keeps applying for the 30-day retention
// grace (anchor = TrialExpiresUtc, no write involved), THEN the Community cap bites.
var config = new TenantConfiguration
{
TenantId = TenantId,
@@ -204,6 +206,93 @@ public class TenantEntitlementServiceTests
TrialExpiresUtc = Now.AddDays(1)
};
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
// 1 day after expiry: inside the grace — still the Pro cap.
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
// 31 days after expiry: grace over — Community cap enforced.
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(32)));
}
// ── Retention downgrade grace (GetRetentionGraceEndUtc + cap interplay) ──────
[Fact]
public void GetRetentionGraceEndUtc_ProTenant_ReturnsNull()
{
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "pro",
ProDowngradedUtc = Now.AddDays(-5) // stale leftover must not matter while Pro
};
Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
}
[Fact]
public void GetRetentionGraceEndUtc_NeverLostPro_ReturnsNull()
{
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = "free" };
Assert.Null(TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
}
[Fact]
public void GetRetentionGraceEndUtc_ExplicitDowngrade_AnchorPlusGraceDays()
{
var downgraded = Now.AddDays(-10);
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "community",
ProDowngradedUtc = downgraded
};
Assert.Equal(
downgraded.AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays),
TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
}
[Fact]
public void GetRetentionGraceEndUtc_LatestAnchorWins()
{
// Downgrade, re-granted trial, trial expired again: the LATER trial expiry anchors.
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "community",
ProDowngradedUtc = Now.AddDays(-20),
TrialExpiresUtc = Now.AddDays(-3)
};
Assert.Equal(
Now.AddDays(-3).AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays),
TenantEntitlementService.GetRetentionGraceEndUtc(config, Now));
}
[Fact]
public void GetEffectiveRetentionDays_ExplicitDowngrade_ProCapDuringGrace_CommunityCapAfter()
{
var config = new TenantConfiguration
{
TenantId = TenantId,
PlanTier = "community",
DataRetentionDays = 365,
ProDowngradedUtc = Now
};
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(29)));
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(31)));
}
[Fact]
public void GetEffectiveRetentionDays_GraceDoesNotRaiseAnythingElse()
{
// 0 (infinite) passes through untouched, and a stored value below the Community cap
// is unaffected — the grace only swaps which CAP clamps.
var zero = new TenantConfiguration
{
TenantId = TenantId, PlanTier = "community", DataRetentionDays = 0, ProDowngradedUtc = Now
};
Assert.Equal(0, TenantEntitlementService.GetEffectiveRetentionDays(zero, Now.AddDays(5)));
var below = new TenantConfiguration
{
TenantId = TenantId, PlanTier = "community", DataRetentionDays = 60, ProDowngradedUtc = Now
};
Assert.Equal(60, TenantEntitlementService.GetEffectiveRetentionDays(below, Now.AddDays(5)));
}
}
@@ -499,6 +499,136 @@ public class TimeAttributionCalculatorTests
Assert.Equal(1200 - 300, b.GetSegmentTotals()[TimeAttributionSegments.EspApps]);
}
// ── sleep spans: payload instants are authoritative, annotation not partition ─────────
[Fact]
public void SleepSpan_ComesFromPayloadInstants_NotFromEventTimestamp()
{
var enteredAt = T0.AddMinutes(8);
var exitedAt = T0.AddMinutes(14);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(5), "esp_phase_changed", EnrollmentPhase.AppsDevice),
// Emitted at wake (its Timestamp is the wake moment, possibly clamped) — only the
// payload carries the true episode bounds.
Evt(T0.AddMinutes(14.2), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "modern_standby",
["enteredAt"] = enteredAt.ToString("o"),
["exitedAt"] = exitedAt.ToString("o"),
["durationSeconds"] = 360L,
}),
Evt(T0.AddMinutes(20), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
AssertExactPartition(b); // sleep is an annotation — the wall-clock partition is untouched
var span = Assert.Single(b.SleepSpans);
Assert.Equal(enteredAt, span.StartUtc);
Assert.Equal(exitedAt, span.EndUtc);
Assert.Equal(360, span.Seconds);
Assert.Equal(360, b.SleepSeconds);
Assert.Equal("modern_standby", span.Kind);
Assert.Equal(TimeAttributionSegments.EspApps, span.SegmentKey);
}
[Fact]
public void SleepSpan_DuplicateEpisode_NotDoubleCounted()
{
var enteredAt = T0.AddMinutes(8);
var exitedAt = T0.AddMinutes(10);
Dictionary<string, object> Episode() => new Dictionary<string, object>
{
["kind"] = "sleep",
["enteredAt"] = enteredAt.ToString("o"),
["exitedAt"] = exitedAt.ToString("o"),
};
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
// Same episode observed twice (e.g. backfilled again after an agent restart from a
// different event-log record).
Evt(T0.AddMinutes(10.1), "system_sleep_episode", data: Episode()),
Evt(T0.AddMinutes(12), "system_sleep_episode", data: Episode()),
Evt(T0.AddMinutes(20), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
Assert.Single(b.SleepSpans);
Assert.Equal(120, b.SleepSeconds);
}
[Fact]
public void SleepSpan_MissingOrUnparseablePayload_Ignored()
{
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(5), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "sleep",
["enteredAt"] = "not-a-timestamp",
["exitedAt"] = T0.AddMinutes(5).ToString("o"),
}),
Evt(T0.AddMinutes(6), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "sleep", // bounds missing entirely
}),
// exitedAt <= enteredAt — pathological, no claim.
Evt(T0.AddMinutes(7), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "sleep",
["enteredAt"] = T0.AddMinutes(7).ToString("o"),
["exitedAt"] = T0.AddMinutes(6).ToString("o"),
}),
Evt(T0.AddMinutes(20), "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt: T0.AddMinutes(20), durationSeconds: 1200))!;
Assert.Empty(b.SleepSpans);
Assert.Equal(0, b.SleepSeconds);
}
[Fact]
public void WhiteGlove_SleepAcrossPause_CountsOnlyInWindowFlanks()
{
var part1End = T0.AddMinutes(20);
var resumedAt = T0.AddDays(2);
var completedAt = resumedAt.AddMinutes(10);
var events = new List<EnrollmentEvent>
{
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
Evt(T0.AddMinutes(2), "esp_phase_changed", EnrollmentPhase.AppsDevice),
Evt(part1End, "whiteglove_part1_complete"),
// Sealed device slept through the pause: episode starts 1 min before the seal and
// ends 30 s into part 2 — only the in-window flanks may count (90 s), never the
// ~2-day pause (RebootSpan precedent).
Evt(resumedAt.AddSeconds(35), "system_sleep_episode", data: new Dictionary<string, object>
{
["kind"] = "hibernate",
["enteredAt"] = part1End.AddMinutes(-1).ToString("o"),
["exitedAt"] = resumedAt.AddSeconds(30).ToString("o"),
}),
Evt(completedAt, "enrollment_complete"),
};
var b = TimeAttributionCalculator.Compute(
Input(events, completedAt, durationSeconds: 1800, wg: true, resumedAt: resumedAt))!;
AssertExactPartition(b);
var span = Assert.Single(b.SleepSpans);
Assert.Equal(90, span.Seconds);
Assert.Equal(90, b.SleepSeconds);
}
// ── WhiteGlove pause vs. intervals/reboots (Codex review P1): the pause lies INSIDE
// the windows' hull, so hull-clamping alone would count it — a reboot bracketing the
// pause must contribute only its in-window flanks, never the pause itself ─────────────
@@ -46,6 +46,11 @@ public class TimeAttributionPersistenceAndAggregationTests
{
new() { StartUtc = T0.AddMinutes(10), EndUtc = T0.AddMinutes(14), Seconds = 240, SegmentKey = TimeAttributionSegments.EspApps },
},
SleepSeconds = 360,
SleepSpans = new List<SleepSpan>
{
new() { StartUtc = T0.AddMinutes(20), EndUtc = T0.AddMinutes(26), Seconds = 360, SegmentKey = TimeAttributionSegments.EspApps, Kind = "modern_standby" },
},
BlockingApps = new List<BlockingAppInterval>
{
new() { AppId = AppX, AppName = "App X", StartUtc = T0.AddMinutes(6), EndUtc = T0.AddMinutes(16), Seconds = 600 },
@@ -77,6 +82,13 @@ public class TimeAttributionPersistenceAndAggregationTests
Assert.Equal(TimeAttributionSegments.EspApps, reboot.SegmentKey);
Assert.Equal(240, reboot.Seconds);
Assert.Equal(360, mapped.SleepSeconds);
var sleep = Assert.Single(mapped.SleepSpans);
Assert.Equal("modern_standby", sleep.Kind);
Assert.Equal(TimeAttributionSegments.EspApps, sleep.SegmentKey);
Assert.Equal(T0.AddMinutes(20), sleep.StartUtc);
Assert.Equal(360, sleep.Seconds);
var app = Assert.Single(mapped.BlockingApps);
Assert.Equal(AppX, app.AppId);
Assert.Equal("App X", app.AppName);
@@ -20,7 +20,8 @@ public class TrialExpirySweepFunctionTests
private static readonly DateTime Now = new(2026, 7, 7, 3, 30, 0, DateTimeKind.Utc);
private static TenantConfiguration Tenant(
string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com") => new()
string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com",
int retentionDays = 90, DateTime? proDowngradedUtc = null) => new()
{
TenantId = id,
DomainName = domain,
@@ -28,6 +29,8 @@ public class TrialExpirySweepFunctionTests
PlanTier = planTier,
TrialExpiresUtc = trialExpiresUtc,
TrialConsumed = trialExpiresUtc.HasValue,
DataRetentionDays = retentionDays,
ProDowngradedUtc = proDowngradedUtc,
};
private static (TrialExpirySweepFunction Sut, List<OpsEventEntry> Events) Build(params TenantConfiguration[] configs)
@@ -150,6 +153,88 @@ public class TrialExpirySweepFunctionTests
Assert.Equal(2, events.Count);
}
// ── Retention downgrade grace events ─────────────────────────────────────────
[Fact]
public async Task GraceEndingSoon_WithDataAtRisk_EmitsGraceExpiring()
{
// Downgraded 28 days ago with stored retention 365: grace ends in 2 days.
var (sut, events) = Build(Tenant("t1", null, planTier: "community",
retentionDays: 365, proDowngradedUtc: Now.AddDays(-28)));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(1, result.GraceExpiringEmitted);
var e = Assert.Single(events);
Assert.Equal("TenantRetentionGraceExpiring", e.EventType);
Assert.Equal(OpsEventSeverity.Warning, e.Severity);
Assert.Contains("2 day", e.Message);
}
[Fact]
public async Task GraceEndedWithinLookBack_EmitsGraceEnded_OlderStaysSilent()
{
var (sut, events) = Build(
// Grace ended 12h ago → emit once.
Tenant("just-ended", null, planTier: "community",
retentionDays: 365, proDowngradedUtc: Now.AddDays(-30).AddHours(-12)),
// Grace ended 3 days ago → reported by a previous run, silent.
Tenant("long-ended", null, planTier: "community",
retentionDays: 365, proDowngradedUtc: Now.AddDays(-33)));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(1, result.GraceEndedEmitted);
var e = Assert.Single(events);
Assert.Equal("TenantRetentionGraceEnded", e.EventType);
Assert.Equal("just-ended", e.TenantId);
}
[Fact]
public async Task Grace_NoDataAtRisk_StaysSilent()
{
var (sut, events) = Build(
// Stored retention at the Community cap — nothing to lose.
Tenant("at-cap", null, planTier: "community",
retentionDays: 90, proDowngradedUtc: Now.AddDays(-29)),
// 0 = infinite escape hatch — the fanout never deletes, no warning needed.
Tenant("infinite", null, planTier: "community",
retentionDays: 0, proDowngradedUtc: Now.AddDays(-29)));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(0, result.GraceExpiringEmitted);
Assert.Equal(0, result.GraceEndedEmitted);
Assert.Empty(events);
}
[Fact]
public async Task Grace_ReUpgradedTenant_StaysSilent()
{
// Back on Pro: GetRetentionGraceEndUtc returns null even with a stale anchor.
var (sut, events) = Build(Tenant("t1", null, planTier: "pro",
retentionDays: 365, proDowngradedUtc: Now.AddDays(-29)));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(0, result.GraceExpiringEmitted);
Assert.Empty(events);
}
[Fact]
public async Task ExpiredTrial_WithDataAtRisk_GetsBothTrialExpiredAndLaterGraceEvents()
{
// The trial-expiry anchor drives the grace window too: expired 2h ago fires
// TenantTrialExpired now; 28 days later the same tenant enters the grace heads-up.
var (sut, events) = Build(Tenant("t1", Now.AddHours(-2), retentionDays: 365));
var result = await sut.RunCoreAsync(CancellationToken.None);
Assert.Equal(1, result.ExpiredEmitted);
Assert.Equal(0, result.GraceExpiringEmitted); // grace end is ~30d out — beyond heads-up
Assert.Single(events);
}
[Fact]
public async Task ConfigLoadFailure_ReturnsEmptyResult_NeverThrows()
{
@@ -0,0 +1,39 @@
using AutopilotMonitor.Functions.Services.Notifications;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// HMAC request-signature scheme for generic JSON webhooks: known-vector stability (the
/// scheme is a published contract receivers implement against), determinism, and
/// secret/message sensitivity.
/// </summary>
public class WebhookSignatureTests
{
[Fact]
public void ComputeSignature_MatchesKnownVector()
{
// Independently computed: HMACSHA256("whsec_test_0123456789abcdef", "1700000000.{"a":1}")
var signature = WebhookSignatureCalculator.ComputeSignature(
"whsec_test_0123456789abcdef", "1700000000", "{\"a\":1}");
Assert.Equal("sha256=07728d7e7b1736bac3de36a46c9bc0b660d59aa4a6f251544124fede1dc42a6c", signature);
}
[Fact]
public void ComputeSignature_IsDeterministic_AndSensitiveToEveryInput()
{
var baseline = WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}");
Assert.Equal(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{}"));
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789X", "1700000000", "{}"));
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000001", "{}"));
Assert.NotEqual(baseline, WebhookSignatureCalculator.ComputeSignature("secret-0123456789", "1700000000", "{ }"));
}
[Fact]
public void HeaderNames_AreTheDocumentedContract()
{
Assert.Equal("X-AutopilotMonitor-Timestamp", WebhookSignatureCalculator.TimestampHeader);
Assert.Equal("X-AutopilotMonitor-Signature", WebhookSignatureCalculator.SignatureHeader);
}
}
@@ -56,7 +56,6 @@
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
<PackageReference Include="Resend" Version="0.2.1" />
</ItemGroup>
<ItemGroup>
@@ -494,6 +494,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
// --- Email Template Overrides ---
private const string EmailTemplatesPartition = "EmailTemplates";
public async Task<EmailTemplateOverride?> GetEmailTemplateOverrideAsync(string kind)
{
try
{
var entity = await _previewConfigTableClient.GetEntityAsync<TableEntity>(EmailTemplatesPartition, kind);
var html = entity.Value.GetString("Html");
if (string.IsNullOrEmpty(html)) return null;
return new EmailTemplateOverride
{
Kind = kind,
Html = html,
UpdatedBy = entity.Value.GetString("UpdatedBy") ?? string.Empty,
UpdatedUtc = entity.Value.GetDateTime("UpdatedUtc") ?? DateTime.MinValue,
};
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
}
public async Task SaveEmailTemplateOverrideAsync(EmailTemplateOverride overrideEntry)
{
var entity = new TableEntity(EmailTemplatesPartition, overrideEntry.Kind)
{
["Html"] = overrideEntry.Html,
["UpdatedBy"] = overrideEntry.UpdatedBy,
["UpdatedUtc"] = overrideEntry.UpdatedUtc == default ? DateTime.UtcNow : overrideEntry.UpdatedUtc,
};
await _previewConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);
}
public async Task DeleteEmailTemplateOverrideAsync(string kind)
{
try
{
await _previewConfigTableClient.DeleteEntityAsync(EmailTemplatesPartition, kind);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// already gone — reset is idempotent
}
}
// --- Preview Notification Email ---
public async Task<string?> GetNotificationEmailAsync(string tenantId)
@@ -620,6 +668,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "RebootDelaySeconds", config.RebootDelaySeconds },
{ "EnableGeoLocation", config.EnableGeoLocation },
{ "EnableTimezoneAutoSet", config.EnableTimezoneAutoSet },
{ "EnableDoGroupIdAutoSet", config.EnableDoGroupIdAutoSet },
{ "NtpServer", config.NtpServer },
{ "EnableImeMatchLog", config.EnableImeMatchLog },
{ "EnableGatherRuleDebugLog", config.EnableGatherRuleDebugLog },
@@ -670,6 +719,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "TrialStartedUtc", config.TrialStartedUtc },
{ "TrialConsumed", config.TrialConsumed },
{ "TrialGrantedBy", config.TrialGrantedBy },
{ "ProDowngradedUtc", config.ProDowngradedUtc },
// SLA targets
{ "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null },
{ "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes },
@@ -727,6 +777,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
RebootDelaySeconds = entity.GetInt32("RebootDelaySeconds"),
EnableGeoLocation = entity.GetBoolean("EnableGeoLocation"),
EnableTimezoneAutoSet = entity.GetBoolean("EnableTimezoneAutoSet"),
EnableDoGroupIdAutoSet = entity.GetBoolean("EnableDoGroupIdAutoSet"),
NtpServer = string.IsNullOrWhiteSpace(entity.GetString("NtpServer")) ? "time.windows.com" : entity.GetString("NtpServer"),
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
EnableGatherRuleDebugLog = entity.GetBoolean("EnableGatherRuleDebugLog"),
@@ -781,6 +832,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
TrialConsumed = entity.GetBoolean("TrialConsumed") ?? false,
TrialGrantedBy = entity.GetString("TrialGrantedBy"),
ProDowngradedUtc = entity.GetDateTime("ProDowngradedUtc"),
// SLA targets
SlaTargetSuccessRate = entity.GetDouble("SlaTargetSuccessRate") != null ? (decimal)entity.GetDouble("SlaTargetSuccessRate")! : null,
SlaTargetMaxDurationMinutes = entity.GetInt32("SlaTargetMaxDurationMinutes"),
@@ -0,0 +1,163 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin;
/// <summary>
/// Global-Admin management of the two transactional email templates (welcome, farewell):
/// read the effective template, store/reset an HTML override, and send a test mail.
/// All routes are GlobalAdminOnly via <c>EndpointAccessPolicyCatalog</c>.
/// </summary>
public class EmailTemplatesFunction
{
private readonly ILogger<EmailTemplatesFunction> _logger;
private readonly EmailTemplateService _templates;
private readonly IEmailService _email;
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TenantConfigurationService _tenantConfigurationService;
public EmailTemplatesFunction(
ILogger<EmailTemplatesFunction> logger,
EmailTemplateService templates,
IEmailService email,
PreviewWhitelistService previewWhitelistService,
TenantConfigurationService tenantConfigurationService)
{
_logger = logger;
_templates = templates;
_email = email;
_previewWhitelistService = previewWhitelistService;
_tenantConfigurationService = tenantConfigurationService;
}
/// <summary>
/// GET /api/global/email-templates/{kind}
/// Effective template: the override when stored, otherwise the built-in (raw, with the
/// {{domainName}} placeholder so it can be edited as-is).
/// </summary>
[Function("GetEmailTemplate")]
public async Task<HttpResponseData> Get(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/email-templates/{kind}")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
var overrideEntry = await _templates.GetOverrideAsync(templateKind);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
kind = EmailTemplateService.KindKey(templateKind),
subject = EmailTemplateService.Subject(templateKind),
isOverridden = overrideEntry is not null,
html = overrideEntry?.Html ?? EmailTemplateService.BuiltInRaw(templateKind),
builtInHtml = EmailTemplateService.BuiltInRaw(templateKind),
updatedBy = overrideEntry?.UpdatedBy,
updatedUtc = overrideEntry?.UpdatedUtc,
placeholder = EmailTemplateService.DomainPlaceholder,
maxLength = EmailTemplateService.MaxHtmlLength,
});
return response;
}
/// <summary>PUT /api/global/email-templates/{kind} — body { html } stores an override.</summary>
[Function("SaveEmailTemplate")]
public async Task<HttpResponseData> Save(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/email-templates/{kind}")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
var error = EmailTemplateService.Validate(body?.Html);
if (error is not null)
return await BadRequest(req, error);
var upn = TenantHelper.GetUserIdentifier(req);
var saved = await _templates.SaveOverrideAsync(templateKind, body!.Html!, upn);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { kind = saved.Kind, isOverridden = true, updatedBy = saved.UpdatedBy, updatedUtc = saved.UpdatedUtc });
return response;
}
/// <summary>DELETE /api/global/email-templates/{kind} — resets to the built-in template.</summary>
[Function("ResetEmailTemplate")]
public async Task<HttpResponseData> Reset(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "global/email-templates/{kind}")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
await _templates.DeleteOverrideAsync(templateKind, TenantHelper.GetUserIdentifier(req));
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { kind = EmailTemplateService.KindKey(templateKind), isOverridden = false });
return response;
}
/// <summary>
/// POST /api/global/email-templates/{kind}/test — body { html? } sends the effective template
/// (or the unsaved draft) to the caller's tenant contact address: the activation
/// notification email, then TenantConfiguration.ContactEmail, then the caller's UPN.
/// Domain = the caller's tenant domain.
/// </summary>
[Function("SendEmailTemplateTest")]
public async Task<HttpResponseData> SendTest(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/email-templates/{kind}/test")] HttpRequestData req,
string kind)
{
if (!EmailTemplateService.TryParseKind(kind, out var templateKind))
return await BadRequest(req, "Unknown template kind. Use 'welcome' or 'farewell'.");
var body = await req.ReadFromJsonAsync<EmailTemplateRequest>();
var draft = string.IsNullOrWhiteSpace(body?.Html) ? null : body!.Html;
if (draft is not null)
{
var error = EmailTemplateService.Validate(draft);
if (error is not null) return await BadRequest(req, error);
}
var tenantId = TenantHelper.GetTenantId(req);
var upn = TenantHelper.GetUserIdentifier(req);
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
var toEmail = await _previewWhitelistService.GetNotificationEmailAsync(tenantId);
if (string.IsNullOrWhiteSpace(toEmail)) toEmail = tenantConfig.ContactEmail;
if (string.IsNullOrWhiteSpace(toEmail)) toEmail = upn.Contains('@') ? upn : null;
if (string.IsNullOrWhiteSpace(toEmail))
return await BadRequest(req, "No contact email found for your tenant — set a notification email or ContactEmail first.");
var sent = await _email.SendTestAsync(templateKind, toEmail, tenantConfig.DomainName, draft);
_logger.LogInformation("Email template test {Kind} requested by {Upn} → {ToEmail}: {Result}", templateKind, upn, toEmail, sent ? "accepted" : "failed");
if (!sent)
{
var failed = req.CreateResponse(HttpStatusCode.BadGateway);
await failed.WriteAsJsonAsync(new { error = "The email provider did not accept the message. Check the backend log for the provider response.", sentTo = toEmail });
return failed;
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { sentTo = toEmail, domainName = tenantConfig.DomainName, draft = draft is not null });
return response;
}
private static async Task<HttpResponseData> BadRequest(HttpRequestData req, string error)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error });
return bad;
}
}
public class EmailTemplateRequest
{
public string? Html { get; set; }
}
@@ -142,6 +142,23 @@ namespace AutopilotMonitor.Functions.Functions.Config
|| string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Global ∪ tenant diagnostics paths: global first, order preserved, blank paths dropped,
/// duplicates (trimmed, case-insensitive) collapsed onto the first occurrence. The agent
/// keys ZIP entries on the path, so a path present in both lists would otherwise produce
/// duplicate entry names inside the archive.
/// </summary>
internal static List<DiagnosticsLogPath> MergeDiagnosticsLogPaths(
IEnumerable<DiagnosticsLogPath> global,
IEnumerable<DiagnosticsLogPath> tenant)
{
return global.Concat(tenant)
.Where(p => !string.IsNullOrWhiteSpace(p?.Path))
.GroupBy(p => p.Path.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
}
/// <summary>
/// Returns the (ZipSha256, ExeSha256) pair appropriate for the calling agent.
/// Reads the X-Agent-Version header, parses the major, and dispatches to the
@@ -242,10 +259,10 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Get active IME log patterns for this tenant (from Table Storage)
var imeLogPatterns = await _imeLogPatternService.GetActivePatternsForTenantAsync(tenantId);
// Merge global + tenant-specific diagnostics log paths
var globalDiagPaths = adminConfig.GetDiagnosticsGlobalLogPaths();
var tenantDiagPaths = tenantConfig.GetDiagnosticsLogPaths();
var diagLogPaths = globalDiagPaths.Concat(tenantDiagPaths).ToList();
// Merge global + tenant-specific diagnostics log paths (the built-in sections are
// compiled into the agent — DiagnosticsBuiltInSections — and never travel here)
var diagLogPaths = MergeDiagnosticsLogPaths(
adminConfig.GetDiagnosticsGlobalLogPaths(), tenantConfig.GetDiagnosticsLogPaths());
// Select per-line hash oracle from the X-Agent-Version header (parametric per major).
// The wire response keeps generic field names (LatestAgentSha256 / LatestAgentExeSha256)
@@ -274,7 +291,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
ConfigVersion = 38, // Collectors.EnablePowerStateWatcher (live AC/battery watcher)
ConfigVersion = 40, // EnableDoGroupIdAutoSet (Delivery Optimization group ID from network fingerprint)
UploadIntervalSeconds = Shared.Constants.DefaultUploadIntervalSeconds,
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
KeepLogFile = tenantConfig.KeepLogFile ?? false,
@@ -312,6 +329,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
AllowAgentDowngrade = adminConfig.AllowAgentDowngrade,
NtpServer = string.IsNullOrEmpty(tenantConfig.NtpServer) ? "time.windows.com" : tenantConfig.NtpServer,
EnableTimezoneAutoSet = tenantConfig.EnableTimezoneAutoSet ?? false,
EnableDoGroupIdAutoSet = tenantConfig.EnableDoGroupIdAutoSet ?? false,
SendTraceEvents = tenantConfig.SendTraceEvents,
UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(tenantConfig, DateTime.UtcNow),
GatherRules = gatherRules,
@@ -27,14 +27,16 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly TenantConfigurationService _configService;
private readonly AdminConfigurationService _adminConfigService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEvents;
private readonly TimeProvider _time;
public PlanManagementFunction(
ILogger<PlanManagementFunction> logger,
TenantConfigurationService configService,
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo)
: this(logger, configService, adminConfigService, maintenanceRepo, TimeProvider.System)
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEvents)
: this(logger, configService, adminConfigService, maintenanceRepo, opsEvents, TimeProvider.System)
{
}
@@ -44,12 +46,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
TenantConfigurationService configService,
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEvents,
TimeProvider time)
{
_logger = logger;
_configService = configService;
_adminConfigService = adminConfigService;
_maintenanceRepo = maintenanceRepo;
_opsEvents = opsEvents;
_time = time;
}
@@ -139,22 +143,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
var nowUtc = _time.GetUtcNow().UtcDateTime;
var changes = new Dictionary<string, string>();
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
{
changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
config.PlanTier = newPlanTier;
}
if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
{
changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
config.TrialExpiresUtc = newTrialExpiresUtc;
if (newTrialExpiresUtc.HasValue)
{
config.TrialStartedUtc ??= nowUtc;
config.TrialGrantedBy = caller;
}
}
var (editionBefore, editionAfter) =
ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
if (changes.Count > 0)
{
@@ -171,9 +161,17 @@ namespace AutopilotMonitor.Functions.Functions.Config
requestCtx.TargetTenantId,
caller,
changes);
}
var effectiveEdition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
if (editionBefore == TenantEdition.Pro && editionAfter == TenantEdition.Community)
{
await _opsEvents.RecordTenantPlanDowngradedAsync(
requestCtx.TargetTenantId,
config.DomainName,
caller,
TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc),
config.DataRetentionDays);
}
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
@@ -182,7 +180,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
planTier = config.PlanTier,
trialExpiresUtc = config.TrialExpiresUtc,
trialConsumed = config.TrialConsumed,
effectiveEdition = effectiveEdition.ToString().ToLowerInvariant()
effectiveEdition = editionAfter.ToString().ToLowerInvariant(),
retentionGraceEndsUtc = TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc)
});
return response;
}
@@ -195,6 +194,61 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
/// <summary>
/// Pure mutation core of the PATCH plan endpoint — no I/O, unit-testable. Applies the
/// plan/trial changes to <paramref name="config"/>, records human-readable entries in
/// <paramref name="changes"/>, and returns the EFFECTIVE edition before/after (same
/// <paramref name="nowUtc"/> for both, so only the mutation itself can flip it).
///
/// Maintains the retention downgrade grace anchor: an effective Pro→Community
/// transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/> (covers both a
/// planTier downgrade and an explicitly ended trial); any state that is effectively Pro
/// afterwards clears the anchor, so a re-upgrade also resets the grace clock.
/// </summary>
internal static (TenantEdition Before, TenantEdition After) ApplyPlanChanges(
TenantConfiguration config,
string? newPlanTier,
bool trialProvided,
DateTime? newTrialExpiresUtc,
string caller,
DateTime nowUtc,
Dictionary<string, string> changes)
{
var before = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
{
changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
config.PlanTier = newPlanTier;
}
if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
{
changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
config.TrialExpiresUtc = newTrialExpiresUtc;
if (newTrialExpiresUtc.HasValue)
{
config.TrialStartedUtc ??= nowUtc;
config.TrialGrantedBy = caller;
}
}
var after = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
if (before == TenantEdition.Pro && after == TenantEdition.Community)
{
changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> {FormatUtc(nowUtc)}";
config.ProDowngradedUtc = nowUtc;
}
else if (after == TenantEdition.Pro && config.ProDowngradedUtc.HasValue)
{
changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> (none)";
config.ProDowngradedUtc = null;
}
return (before, after);
}
/// <summary>
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
/// 30-day Pro trial, exactly once per tenant. 409 when the trial was already
@@ -262,6 +316,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
config.TrialConsumed = true;
config.TrialGrantedBy = caller;
// Effectively Pro again — a leftover retention grace anchor is obsolete.
config.ProDowngradedUtc = null;
config.UpdatedBy = caller;
// Fail-loud: throws when the write did not persist (cache invalidated in finally).
@@ -82,7 +82,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
var testAlert = NotificationAlertBuilder.BuildTestAlert();
var result = await _webhookNotificationService.SendNotificationWithResultAsync(
channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders());
channel.Url, (WebhookProviderType)channel.ProviderType, testAlert, channel.GetCustomHeaders(), channel.GetSigningSecret());
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
@@ -0,0 +1,77 @@
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Diagnostics
{
public class GetDiagnosticsPathsFunction
{
private readonly ILogger<GetDiagnosticsPathsFunction> _logger;
private readonly AdminConfigurationService _adminConfigService;
public GetDiagnosticsPathsFunction(
ILogger<GetDiagnosticsPathsFunction> logger,
AdminConfigurationService adminConfigService)
{
_logger = logger;
_adminConfigService = adminConfigService;
}
/// <summary>
/// GET /api/diagnostics/paths
/// What every diagnostics package collects before a tenant's own entries: the built-in
/// section catalog (compiled into the agent, shared through
/// <see cref="DiagnosticsBuiltInSections"/>) and the platform-wide global paths set by
/// Global Admins. Member-readable by design — the catalog is code, the global list is
/// non-secret admin data, and every settings viewer needs to see what is collected from
/// the tenant's devices. Tenant-less: the payload is platform-wide, not tenant data.
/// </summary>
[Function("GetDiagnosticsPaths")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "diagnostics/paths")] HttpRequestData req)
{
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var adminConfig = await _adminConfigService.GetConfigurationAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(BuildPayload(adminConfig));
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting diagnostics paths");
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
}
}
/// <summary>
/// Static projection so unit tests pin the wire shape without an HttpRequestData mock.
/// <c>condition</c> travels as the enum NAME ("Always" | "RealmJoinWatcher" |
/// "DevicePreparation"), never the integer — the web switches on the string.
/// </summary>
internal static object BuildPayload(AdminConfiguration adminConfig) => new
{
builtIn = DiagnosticsBuiltInSections.All.Select(s => new
{
id = s.Id,
zipFolder = s.ZipFolder,
sourceFolder = s.SourceFolder,
patterns = s.Patterns,
includeSubfolders = s.IncludeSubfolders,
description = s.Description,
condition = s.Condition.ToString(),
}).ToArray(),
globalPaths = adminConfig.GetDiagnosticsGlobalLogPaths(),
};
}
}
@@ -10,15 +10,21 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Maintenance;
/// <summary>
/// Daily informational sweep over tenant Pro trials. NOT load-bearing — trial expiry
/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition);
/// this timer only surfaces the transitions as ops events so the operator has visibility:
/// Daily informational sweep over the tenant plan lifecycle. NOT load-bearing — trial expiry
/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition),
/// and the retention grace is likewise resolved read-time by the retention fanout; this timer
/// only surfaces the transitions as ops events so the operator has visibility:
/// <list type="bullet">
/// <item><c>TenantTrialExpired</c> (Warning) — the trial ended within the last sweep window
/// (24h look-back matching the daily cadence).</item>
/// <item><c>TenantTrialExpiring</c> (Info) — the trial ends within the next
/// <see cref="ExpiringHeadsUpDays"/> days (re-emitted each daily run until expiry —
/// acceptable for an Info-tier heads-up, no dedupe state to maintain).</item>
/// <item><c>TenantRetentionGraceExpiring</c> / <c>TenantRetentionGraceEnded</c> (Warning) —
/// same window mechanics for the retention downgrade grace
/// (TenantEntitlementService.GetRetentionGraceEndUtc). Emitted only when data is
/// actually at risk: stored retention above the Community cap (0 = infinite ⇒ never
/// at risk, the fanout skips those tenants).</item>
/// </list>
/// Tenants whose stored <c>PlanTier</c> is already a permanent Pro tier ("pro"/legacy "enterprise") are skipped —
/// their trial timestamps are inert leftovers and expiry changes nothing.
@@ -90,45 +96,93 @@ public class TrialExpirySweepFunction
{
ct.ThrowIfCancellationRequested();
if (config.TrialExpiresUtc is not DateTime expiry)
continue;
// Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing.
if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
continue;
result.TrialsSeen++;
if (expiry <= now)
{
if (expiry > now - ExpiredLookBack)
{
await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry)
.ConfigureAwait(false);
result.ExpiredEmitted++;
}
// Older expiries were reported by a previous run — stay silent.
}
else if (expiry <= now + ExpiringHeadsUp)
{
var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays));
await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft)
.ConfigureAwait(false);
result.ExpiringEmitted++;
}
await SweepTrialAsync(config, now, result).ConfigureAwait(false);
await SweepRetentionGraceAsync(config, now, result).ConfigureAwait(false);
}
_logger.LogInformation(
"TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted}",
result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted);
"TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted} " +
"graceExpiringEmitted={GraceExpiringEmitted} graceEndedEmitted={GraceEndedEmitted}",
result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted,
result.GraceExpiringEmitted, result.GraceEndedEmitted);
return result;
}
private async Task SweepTrialAsync(
AutopilotMonitor.Shared.Models.TenantConfiguration config, DateTime now, SweepResult result)
{
if (config.TrialExpiresUtc is not DateTime expiry)
return;
// Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing.
if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
return;
result.TrialsSeen++;
if (expiry <= now)
{
if (expiry > now - ExpiredLookBack)
{
await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry)
.ConfigureAwait(false);
result.ExpiredEmitted++;
}
// Older expiries were reported by a previous run — stay silent.
}
else if (expiry <= now + ExpiringHeadsUp)
{
var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays));
await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft)
.ConfigureAwait(false);
result.ExpiringEmitted++;
}
}
/// <summary>
/// Retention downgrade grace visibility: warn shortly before and once after the grace ends,
/// but only when data is actually at risk — stored retention above the Community cap.
/// (0 = the GA-only infinite escape hatch: the fanout skips those tenants, nothing is ever
/// deleted, so the comparison excludes it naturally.) GetRetentionGraceEndUtc returns null
/// for effectively-Pro tenants, so re-upgraded tenants go silent automatically.
/// </summary>
private async Task SweepRetentionGraceAsync(
AutopilotMonitor.Shared.Models.TenantConfiguration config, DateTime now, SweepResult result)
{
var communityCap = FeatureEntitlementCatalog.Get(TenantEdition.Community).RetentionCapDays;
if (config.DataRetentionDays <= communityCap)
return;
if (TenantEntitlementService.GetRetentionGraceEndUtc(config, now) is not DateTime graceEnd)
return;
if (graceEnd <= now)
{
if (graceEnd > now - ExpiredLookBack)
{
await _opsEvents.RecordTenantRetentionGraceEndedAsync(
config.TenantId, config.DomainName, graceEnd, config.DataRetentionDays)
.ConfigureAwait(false);
result.GraceEndedEmitted++;
}
}
else if (graceEnd <= now + ExpiringHeadsUp)
{
var daysLeft = Math.Max(1, (int)Math.Ceiling((graceEnd - now).TotalDays));
await _opsEvents.RecordTenantRetentionGraceExpiringAsync(
config.TenantId, config.DomainName, graceEnd, daysLeft, config.DataRetentionDays)
.ConfigureAwait(false);
result.GraceExpiringEmitted++;
}
}
/// <summary>Summary counters returned from <see cref="RunCoreAsync"/> so tests can assert outcomes.</summary>
internal sealed class SweepResult
{
public int TrialsSeen { get; set; }
public int ExpiredEmitted { get; set; }
public int ExpiringEmitted { get; set; }
public int GraceExpiringEmitted { get; set; }
public int GraceEndedEmitted { get; set; }
}
}
@@ -19,20 +19,20 @@ public class PreviewWhitelistFunction
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TenantApprovalService _tenantApprovalService;
private readonly TenantConfigurationService _tenantConfigurationService;
private readonly ResendEmailService _resendEmailService;
private readonly IEmailService _emailService;
public PreviewWhitelistFunction(
ILogger<PreviewWhitelistFunction> logger,
PreviewWhitelistService previewWhitelistService,
TenantApprovalService tenantApprovalService,
TenantConfigurationService tenantConfigurationService,
ResendEmailService resendEmailService)
IEmailService emailService)
{
_logger = logger;
_previewWhitelistService = previewWhitelistService;
_tenantApprovalService = tenantApprovalService;
_tenantConfigurationService = tenantConfigurationService;
_resendEmailService = resendEmailService;
_emailService = emailService;
}
/// <summary>
@@ -221,7 +221,7 @@ public class PreviewWhitelistFunction
}
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
await _resendEmailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
await _emailService.SendPreviewApprovedEmailAsync(email, tenantConfig.DomainName);
// Explicit GA send always sends; consume the once-only marker (best-effort) so
// the automatic paths won't produce a duplicate afterwards.
@@ -182,6 +182,35 @@ namespace AutopilotMonitor.Functions.Helpers
var headerError = ValidateWebhookCustomHeaders(channel.CustomHeadersJson);
if (headerError != null)
return $"channel \"{label}\" headers: {headerError}";
var secretError = ValidateWebhookSigningSecret(channel.SigningSecret);
if (secretError != null)
return $"channel \"{label}\" signing secret: {secretError}";
}
return null;
}
/// <summary>
/// Validates a generic-webhook HMAC signing secret. Returns an error message, or null when
/// valid/absent. A short key is brute-forceable as an HMAC key; the printable-ASCII rule
/// keeps the secret copy-paste safe and (as a side effect) rejects a stray unrestored
/// redaction sentinel, which is shorter than the minimum. No provider gate here — a secret
/// on a non-generic channel is stored but inert at dispatch (GetSigningSecret), matching
/// how CustomHeadersJson behaves.
/// </summary>
internal static string? ValidateWebhookSigningSecret(string? secret)
{
if (string.IsNullOrWhiteSpace(secret))
return null;
if (secret.Length < 16 || secret.Length > 128)
return "must be 16-128 characters.";
foreach (var ch in secret)
{
if (ch < 0x21 || ch > 0x7E)
return "must be printable ASCII without spaces or line breaks.";
}
return null;
@@ -72,7 +72,9 @@ public static class TimeAttributionCalculator
/// <summary>Bump on any semantic change so aggregates never mix definitions (truthfulness rule 8).</summary>
// v2: PriorEnrollmentResidue flag — sessions whose phase anchors are distorted by
// pre-enrollment state on disk are now flagged and excluded from fleet segment stats.
public const int CurrentVersion = 2;
// v3: SleepSpans — completed sleep/standby episodes (system_sleep_episode) as cross-cutting
// annotations mirroring RebootSpans; the maintenance sweep recomputes older rows.
public const int CurrentVersion = 3;
/// <summary>
/// Enrollment class for fleet aggregation — classes are NEVER mixed in one aggregate (a
@@ -168,6 +170,7 @@ public static class TimeAttributionCalculator
}
var rebootSpans = BuildRebootSpans(events, windows, spans);
var sleepSpans = BuildSleepSpans(events, windows, spans);
if (events.Any(e => e.EventType == Constants.EventTypes.AgentLateStart))
flags |= TimeAttributionFlags.PartialObservation;
@@ -191,6 +194,8 @@ public static class TimeAttributionCalculator
UnattributedSeconds = unattributed,
RebootSeconds = rebootSpans.Sum(r => r.Seconds),
RebootSpans = rebootSpans,
SleepSeconds = sleepSpans.Sum(s => s.Seconds),
SleepSpans = sleepSpans,
BlockingApps = blockingApps
.OrderByDescending(a => a.Seconds)
.ThenBy(a => a.AppName, StringComparer.OrdinalIgnoreCase)
@@ -709,6 +714,63 @@ public static class TimeAttributionCalculator
return result;
}
// ── sleep spans ─────────────────────────────────────────────────────────
/// <summary>
/// One span per completed sleep episode (<c>system_sleep_episode</c>). The payload's
/// <c>enteredAt</c>/<c>exitedAt</c> are the authoritative instants — the event's own
/// timestamp is stamped at wake and may have been clamped server-side. Structural clone of
/// <see cref="BuildRebootSpans"/>: spans are clipped to the observation hull (a WhiteGlove
/// pause episode contributes nothing), dedup'd on the enter instant (an episode straddling
/// an agent restart can be backfilled twice from different records), and annotate the
/// segment they started in — they are never a slice of the wall-clock partition.
/// </summary>
private static List<SleepSpan> BuildSleepSpans(
List<EnrollmentEvent> events, List<Window> windows, List<TimeAttributionSpan> spans)
{
var result = new List<SleepSpan>();
var seenEpisodes = new HashSet<DateTime>();
foreach (var evt in events)
{
if (evt.EventType != Constants.EventTypes.SystemSleepEpisode || evt.Data == null) continue;
var enteredAt = ParseUtcInstant(evt.Data, "enteredAt");
var exitedAt = ParseUtcInstant(evt.Data, "exitedAt");
if (!enteredAt.HasValue || !exitedAt.HasValue || exitedAt.Value <= enteredAt.Value) continue;
if (!seenEpisodes.Add(enteredAt.Value)) continue; // duplicate observation of the same episode
var clamped = ClampToWindows(enteredAt.Value, exitedAt.Value, windows);
if (!clamped.HasValue) continue;
var seconds = clamped.Value.Seconds;
if (seconds <= 0) continue;
var kind = evt.Data.TryGetValue("kind", out var kindObj) ? kindObj?.ToString() ?? string.Empty : string.Empty;
result.Add(new SleepSpan
{
StartUtc = clamped.Value.Start,
EndUtc = clamped.Value.End,
Seconds = seconds,
SegmentKey = SegmentAt(spans, clamped.Value.Start),
Kind = kind,
});
}
return result.OrderBy(s => s.StartUtc).ToList();
}
private static DateTime? ParseUtcInstant(Dictionary<string, object> data, string key)
{
if (!data.TryGetValue(key, out var raw)) return null;
var text = raw?.ToString();
if (string.IsNullOrEmpty(text)) return null;
return DateTime.TryParse(text, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
? parsed
: (DateTime?)null;
}
private static string SegmentAt(List<TimeAttributionSpan> spans, DateTime ts)
{
foreach (var span in spans)
@@ -437,8 +437,15 @@ builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddHttpClient<TelegramNotificationService>()
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddSingleton<ResendEmailService>();
builder.Services.AddSingleton<IOffboardFarewellEmailSender>(sp => sp.GetRequiredService<ResendEmailService>());
// Transactional email (welcome / farewell). Typed client like the other outbound notifiers;
// the provider is an implementation detail of EmailService + the Email:* settings.
builder.Services.AddSingleton<EmailTemplateService>();
builder.Services.AddSingleton<IEmailTemplateProvider>(sp => sp.GetRequiredService<EmailTemplateService>());
builder.Services.AddHttpClient<EmailService>()
.ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(15))
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().Notification);
builder.Services.AddTransient<IEmailService>(sp => sp.GetRequiredService<EmailService>());
builder.Services.AddTransient<IOffboardFarewellEmailSender>(sp => sp.GetRequiredService<EmailService>());
builder.Services.AddSingleton<GlobalNotificationService>();
builder.Services.AddSingleton<TenantNotificationService>();
@@ -282,6 +282,9 @@ public static class EndpointAccessPolicyCatalog
new("GET", "audit/tpm-pss-unsupported", EndpointPolicy.MemberRead),
new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
// Platform-defined diagnostics collection (built-in catalog + global paths): read-only,
// no secrets, needed by every settings viewer — member tier, JWT-scoped (no tenant parameter).
new("GET", "diagnostics/paths", EndpointPolicy.MemberRead, TenantScoping.Jwt),
new("GET", "rules/gather", EndpointPolicy.MemberRead),
new("GET", "rules/analyze", EndpointPolicy.MemberRead),
new("GET", "rules/ime-log-patterns", EndpointPolicy.MemberRead),
@@ -497,6 +500,12 @@ public static class EndpointAccessPolicyCatalog
new("POST", "versions/block", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "versions/block/{encodedPattern}", EndpointPolicy.GlobalAdminOnly),
new("POST", "maintenance/trigger", EndpointPolicy.GlobalAdminOnly),
// Transactional email templates (welcome / farewell): read, override, reset, test send.
// Read stays GA-only too — the override HTML is operator content, not tenant data.
new("GET", "global/email-templates/{kind}", EndpointPolicy.GlobalAdminOnly),
new("PUT", "global/email-templates/{kind}", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/email-templates/{kind}", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/email-templates/{kind}/test", EndpointPolicy.GlobalAdminOnly),
// One-time retro-reconcile of misclassified historical sessions (audit 2026-07-16) —
// mutating, dry-run by default, GA only.
new("POST", "maintenance/reclassify-legacy", EndpointPolicy.GlobalAdminOnly),
@@ -95,6 +95,16 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public const string LegacyEnterpriseTierName = "enterprise";
/// <summary>
/// Retention downgrade grace period: for this many days after a tenant loses Pro
/// (explicit downgrade or trial expiry) the retention sweep keeps enforcing the PRO
/// cap, so the downgrade does not immediately hard-delete data older than the
/// Community cap. Retention is the only entitlement with a grace — everything else
/// gates read-time immediately because it is reversible without data loss.
/// Resolved via <see cref="Services.TenantEntitlementService.GetRetentionGraceEndUtc"/>.
/// </summary>
public const int RetentionDowngradeGraceDays = 30;
private static readonly EditionEntitlements Community = new()
{
Edition = TenantEdition.Community,
@@ -162,16 +162,17 @@ namespace AutopilotMonitor.Functions.Services.Deletion
// trial expired, or whose stored value predates the cap, is enforced here even though
// the stored DataRetentionDays is left untouched. days <= 0 stays the GA-only
// "infinite" escape hatch (skipped below, never clamped).
var nowUtc = _utcNow();
var storedDays = config?.DataRetentionDays ?? 90;
var retentionDays = config == null
? 90
: TenantEntitlementService.GetEffectiveRetentionDays(config, DateTime.UtcNow);
: TenantEntitlementService.GetEffectiveRetentionDays(config, nowUtc);
if (retentionDays > 0 && retentionDays < storedDays)
{
_logger.LogWarning(
"Tenant {TenantId}: DataRetentionDays={Stored} exceeds the {Edition}-edition cap — enforcing {Effective} days",
tenantId, storedDays,
TenantEntitlementService.ResolveEdition(config!, DateTime.UtcNow), retentionDays);
TenantEntitlementService.ResolveEdition(config!, nowUtc), retentionDays);
}
if (retentionDays <= 0)
@@ -180,7 +181,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion
return;
}
var cutoffUtc = DateTime.UtcNow.AddDays(-retentionDays);
var cutoffUtc = nowUtc.AddDays(-retentionDays);
// Server-bounded read: fetch one more than the per-run dispatch cap. The loop below
// only ever advances MaxEnqueuesPerTenantPerRun sessions, so loading the whole backlog
@@ -0,0 +1,259 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Sends the product's transactional emails (tenant-activation welcome, post-offboarding
/// farewell). Best-effort: failures are logged as warnings and never propagated.
/// <para>
/// The class and its callers are provider-neutral. Everything provider-specific is confined
/// to the <c>Email:*</c> configuration section and <see cref="SendViaMandrillAsync"/>, so a
/// future provider swap touches the transport method and the app settings only.
/// Current provider: Mailchimp Transactional (Mandrill) — <c>POST {Email:Endpoint}</c> with the
/// API key in the JSON body. Open/click tracking is explicitly disabled so the provider
/// receives nothing beyond the recipient address and the tenant domain (trust-page claim).
/// </para>
/// <para>
/// Configuration (App Settings use <c>Email__Key</c>):
/// <c>Email:ApiKey</c> (required; empty ⇒ every send is a logged no-op),
/// <c>Email:Endpoint</c> (default <see cref="DefaultEndpoint"/>),
/// <c>Email:FromAddress</c> (default <see cref="DefaultFromAddress"/>),
/// <c>Email:FromName</c> (default <see cref="DefaultFromName"/>).
/// </para>
/// </summary>
public class EmailService : IEmailService, IOffboardFarewellEmailSender
{
public const string ApiKeyConfigKey = "Email:ApiKey";
public const string DefaultEndpoint = "https://mandrillapp.com/api/1.0/messages/send";
public const string DefaultFromAddress = "noreply@autopilotmonitor.com";
public const string DefaultFromName = "Autopilot Monitor";
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient _http;
private readonly IEmailTemplateProvider _templates;
private readonly ILogger<EmailService> _logger;
private readonly string _apiKey;
private readonly string _endpoint;
private readonly string _fromAddress;
private readonly string _fromName;
public EmailService(
HttpClient http,
IConfiguration configuration,
IEmailTemplateProvider templates,
ILogger<EmailService> logger)
{
_http = http;
_templates = templates;
_logger = logger;
_apiKey = configuration[ApiKeyConfigKey] ?? string.Empty;
_endpoint = FirstNonBlank(configuration["Email:Endpoint"], DefaultEndpoint);
_fromAddress = FirstNonBlank(configuration["Email:FromAddress"], DefaultFromAddress);
_fromName = FirstNonBlank(configuration["Email:FromName"], DefaultFromName);
}
/// <inheritdoc />
public async Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug("{ConfigKey} not configured — skipping preview approval email", ApiKeyConfigKey);
return;
}
if (string.IsNullOrWhiteSpace(toEmail))
{
_logger.LogDebug("No notification email set — skipping preview approval email for {Domain}", domainName);
return;
}
var sent = await SendViaMandrillAsync(
toEmail,
EmailTemplates.PreviewApprovedSubject,
await _templates.GetHtmlAsync(EmailTemplateKind.Welcome, domainName, ct),
tag: "welcome",
ct);
if (sent)
{
_logger.LogInformation(
"Preview approval email sent to {ToEmail} for domain {Domain}",
toEmail, domainName);
}
}
/// <summary>
/// Sends the post-offboarding "sorry to see you go" farewell email.
/// No-op if the API key or recipient email is not configured. Best-effort: failures
/// are logged as warnings and never propagated (the offboarding correctness contract
/// does not depend on email delivery).
/// </summary>
public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug(
"{ConfigKey} not configured — skipping offboard farewell email for tenant {TenantId}",
ApiKeyConfigKey, tenantId);
return;
}
if (string.IsNullOrWhiteSpace(toEmail))
{
_logger.LogDebug(
"No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})",
tenantId, domainName);
return;
}
var sent = await SendViaMandrillAsync(
toEmail,
EmailTemplates.OffboardingFarewellSubject,
await _templates.GetHtmlAsync(EmailTemplateKind.Farewell, domainName, ct),
tag: "offboarding-farewell",
ct);
if (sent)
{
_logger.LogInformation(
"Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})",
toEmail, tenantId, domainName);
}
}
/// <inheritdoc />
public async Task<bool> SendTestAsync(EmailTemplateKind kind, string toEmail, string domainName, string? draftHtml, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogWarning("{ConfigKey} not configured — cannot send {Kind} test email", ApiKeyConfigKey, kind);
return false;
}
var html = draftHtml is null
? await _templates.GetHtmlAsync(kind, domainName, ct)
: EmailTemplateService.Render(draftHtml, domainName);
var sent = await SendViaMandrillAsync(toEmail, EmailTemplateService.Subject(kind), html, tag: "test", ct);
if (sent)
_logger.LogInformation("{Kind} test email sent to {ToEmail} (draft={IsDraft})", kind, toEmail, draftHtml is not null);
return sent;
}
/// <summary>
/// The only provider-specific code path. Posts a Mandrill <c>messages/send</c> request and
/// interprets the per-recipient result array: <c>sent</c>/<c>queued</c>/<c>scheduled</c>
/// count as success, <c>rejected</c>/<c>invalid</c> as failure (with the provider's
/// <c>reject_reason</c> in the warning). Never throws.
/// </summary>
private async Task<bool> SendViaMandrillAsync(string toEmail, string subject, string html, string tag, CancellationToken ct)
{
try
{
var request = new MandrillSendRequest
{
Key = _apiKey,
Message = new MandrillMessage
{
FromEmail = _fromAddress,
FromName = _fromName,
To = new[] { new MandrillRecipient { Email = toEmail, Type = "to" } },
Subject = subject,
Html = html,
AutoText = true,
TrackOpens = false,
TrackClicks = false,
Tags = new[] { tag },
},
};
using var response = await _http.PostAsJsonAsync(_endpoint, request, JsonOptions, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Email provider returned {StatusCode} for {Tag} mail to {ToEmail}: {Body}",
(int)response.StatusCode, tag, toEmail, Truncate(body));
return false;
}
var results = JsonSerializer.Deserialize<MandrillSendResult[]>(body, JsonOptions);
var result = results?.FirstOrDefault();
if (result is null)
{
_logger.LogWarning(
"Email provider returned an empty result for {Tag} mail to {ToEmail}: {Body}",
tag, toEmail, Truncate(body));
return false;
}
if (!IsAcceptedStatus(result.Status))
{
_logger.LogWarning(
"Email provider did not accept {Tag} mail to {ToEmail}: status={Status} reason={Reason}",
tag, toEmail, result.Status, result.RejectReason ?? "n/a");
return false;
}
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send {Tag} mail to {ToEmail}", tag, toEmail);
return false;
}
}
private static bool IsAcceptedStatus(string? status)
=> status is "sent" or "queued" or "scheduled";
private static string FirstNonBlank(string? value, string fallback)
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
private static string Truncate(string value)
=> value.Length <= 500 ? value : value[..500] + "…";
// --- Mandrill wire format (https://mailchimp.com/developer/transactional/api/messages/send-new-message/) ---
private sealed class MandrillSendRequest
{
[JsonPropertyName("key")] public string Key { get; set; } = string.Empty;
[JsonPropertyName("message")] public MandrillMessage Message { get; set; } = new();
}
private sealed class MandrillMessage
{
[JsonPropertyName("from_email")] public string FromEmail { get; set; } = string.Empty;
[JsonPropertyName("from_name")] public string FromName { get; set; } = string.Empty;
[JsonPropertyName("to")] public MandrillRecipient[] To { get; set; } = Array.Empty<MandrillRecipient>();
[JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty;
[JsonPropertyName("html")] public string Html { get; set; } = string.Empty;
[JsonPropertyName("auto_text")] public bool AutoText { get; set; }
[JsonPropertyName("track_opens")] public bool TrackOpens { get; set; }
[JsonPropertyName("track_clicks")] public bool TrackClicks { get; set; }
[JsonPropertyName("tags")] public string[] Tags { get; set; } = Array.Empty<string>();
}
private sealed class MandrillRecipient
{
[JsonPropertyName("email")] public string Email { get; set; } = string.Empty;
[JsonPropertyName("type")] public string Type { get; set; } = "to";
}
private sealed class MandrillSendResult
{
[JsonPropertyName("email")] public string? Email { get; set; }
[JsonPropertyName("status")] public string? Status { get; set; }
[JsonPropertyName("reject_reason")] public string? RejectReason { get; set; }
[JsonPropertyName("_id")] public string? Id { get; set; }
}
}
@@ -0,0 +1,151 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>The two transactional mails the product sends.</summary>
public enum EmailTemplateKind
{
Welcome,
Farewell,
}
/// <summary>
/// Resolves the HTML body for a transactional mail: the operator override when one is
/// stored, otherwise the built-in template. Consumed by <see cref="EmailService"/>.
/// </summary>
public interface IEmailTemplateProvider
{
Task<string> GetHtmlAsync(EmailTemplateKind kind, string domainName, CancellationToken ct = default);
}
/// <summary>
/// Operator-editable email templates. Overrides live in the PreviewConfig table (partition
/// "EmailTemplates", row key = kind) and are cached for 5 minutes; the built-in templates in
/// <see cref="EmailTemplates"/> remain the fallback. Subjects are not editable — they are pinned
/// constants.
/// <para>
/// Rendering contract: the only placeholder is <c>{{domainName}}</c>; an empty domain renders as
/// "your organization", exactly like the built-ins. Resolution is fail-soft — a storage error
/// falls back to the built-in template so activation/offboarding mails keep going out.
/// </para>
/// </summary>
public class EmailTemplateService : IEmailTemplateProvider
{
public const string DomainPlaceholder = "{{domainName}}";
public const string EmptyDomainLabel = "your organization";
/// <summary>Azure Table string properties cap at 32K UTF-16 chars; keep headroom.</summary>
public const int MaxHtmlLength = 30_000;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private readonly IConfigRepository _configRepo;
private readonly IMemoryCache _cache;
private readonly ILogger<EmailTemplateService> _logger;
public EmailTemplateService(IConfigRepository configRepo, IMemoryCache cache, ILogger<EmailTemplateService> logger)
{
_configRepo = configRepo;
_cache = cache;
_logger = logger;
}
public static bool TryParseKind(string? value, out EmailTemplateKind kind)
{
switch (value?.Trim().ToLowerInvariant())
{
case "welcome": kind = EmailTemplateKind.Welcome; return true;
case "farewell": kind = EmailTemplateKind.Farewell; return true;
default: kind = default; return false;
}
}
public static string KindKey(EmailTemplateKind kind) => kind == EmailTemplateKind.Welcome ? "welcome" : "farewell";
public static string Subject(EmailTemplateKind kind) => kind == EmailTemplateKind.Welcome
? EmailTemplates.PreviewApprovedSubject
: EmailTemplates.OffboardingFarewellSubject;
/// <summary>Built-in template with the placeholder left in place (for the editor).</summary>
public static string BuiltInRaw(EmailTemplateKind kind) => kind == EmailTemplateKind.Welcome
? EmailTemplates.GetPreviewApprovedHtml(DomainPlaceholder)
: EmailTemplates.GetOffboardingFarewellHtml(DomainPlaceholder);
/// <summary>Substitutes the placeholder; an empty domain becomes "your organization".</summary>
public static string Render(string rawHtml, string? domainName)
{
var display = string.IsNullOrWhiteSpace(domainName) ? EmptyDomainLabel : domainName.Trim();
return rawHtml.Replace(DomainPlaceholder, display, StringComparison.Ordinal);
}
/// <summary>Returns null when the HTML is acceptable, otherwise the validation message.</summary>
public static string? Validate(string? html)
{
if (string.IsNullOrWhiteSpace(html)) return "Template HTML must not be empty.";
if (html.Length > MaxHtmlLength) return $"Template HTML exceeds {MaxHtmlLength:N0} characters ({html.Length:N0}).";
return null;
}
public async Task<string> GetHtmlAsync(EmailTemplateKind kind, string domainName, CancellationToken ct = default)
{
var overrideEntry = await GetOverrideAsync(kind, failSoft: true);
return overrideEntry is null
? Render(BuiltInRaw(kind), domainName)
: Render(overrideEntry.Html, domainName);
}
/// <summary>
/// The stored override, or null for built-in. <paramref name="failSoft"/> swallows storage
/// errors (send path must never depend on the override store); the admin endpoints pass
/// false so a broken store surfaces as an error instead of silently showing the built-in.
/// </summary>
public virtual async Task<EmailTemplateOverride?> GetOverrideAsync(EmailTemplateKind kind, bool failSoft = false)
{
var key = CacheKey(kind);
if (_cache.TryGetValue(key, out EmailTemplateOverride? cached))
return cached;
try
{
var entry = await _configRepo.GetEmailTemplateOverrideAsync(KindKey(kind));
// null is a valid (and the common) value — cache it too so the send path
// doesn't hit storage on every mail.
_cache.Set(key, entry, CacheDuration);
return entry;
}
catch (Exception ex) when (failSoft)
{
_logger.LogWarning(ex, "Failed to read {Kind} email template override — using built-in template", kind);
return null;
}
}
public virtual async Task<EmailTemplateOverride> SaveOverrideAsync(EmailTemplateKind kind, string html, string updatedBy)
{
var error = Validate(html);
if (error is not null) throw new ArgumentException(error, nameof(html));
var entry = new EmailTemplateOverride
{
Kind = KindKey(kind),
Html = html,
UpdatedBy = updatedBy,
UpdatedUtc = DateTime.UtcNow,
};
await _configRepo.SaveEmailTemplateOverrideAsync(entry);
_cache.Remove(CacheKey(kind));
_logger.LogInformation("Email template override saved: {Kind} by {UpdatedBy} ({Length} chars)", kind, updatedBy, html.Length);
return entry;
}
public virtual async Task DeleteOverrideAsync(EmailTemplateKind kind, string deletedBy)
{
await _configRepo.DeleteEmailTemplateOverrideAsync(KindKey(kind));
_cache.Remove(CacheKey(kind));
_logger.LogInformation("Email template override reset to built-in: {Kind} by {DeletedBy}", kind, deletedBy);
}
private static string CacheKey(EmailTemplateKind kind) => $"email-template-override:{KindKey(kind)}";
}
@@ -3,7 +3,7 @@ using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Email templates for Resend notifications.
/// Templates for the product's transactional emails (sent via <see cref="EmailService"/>).
/// Keep all email content here for easy maintenance.
/// </summary>
public static class EmailTemplates
@@ -37,12 +37,33 @@ public static class EmailTemplates
<tr><td align=""center"">
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 6px rgba(0,0,0,0.07);"">
<!-- Header -->
<!-- Header: image-free. Base for every client: solid bgcolor band, text wordmark, brand mark
drawn with CSS borders (trapezoid + a green wedge for the slanted right edge; must stay
<div>s — Outlook paints transparent <td> borders white). Progressive enhancement where
supported: ONE linear-gradient (hex colours, % stops — the only form Outlook web/iOS
renders; multiple layers or rgba stops are dropped) forms the facets, and clip-path cuts
the slanted bottom edge (Apple Mail only). No bgcolor inside the mark: Outlook dark mode
inverts cell backgrounds but leaves border colours alone.
Geometry is the approved reference (scratch header-render.html): facets
light right of (40%,0)->(70%,100%), dark right of (55%,0)->(85%,100%) and left of
(78%,0)->(100%,60%) — the last one is dropped for the single-gradient constraint.
Change only against a side-by-side render. -->
<tr>
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
<td bgcolor=""#33b161"" style=""background-color:#33b161; background-image:linear-gradient(39.8deg, #33b161 0%, #33b161 53.8%, #3cbb6a 53.8%, #3cbb6a 65.4%, #2ea85a 65.4%, #2ea85a 100%); clip-path:polygon(0 0, 100% 0, 100% 100%, 0 82%); padding:28px 32px 52px; line-height:0; font-size:0;"">
<table cellpadding=""0"" cellspacing=""0"" border=""0""><tr>
<td valign=""middle"" style=""padding:0 12px 0 0; line-height:0; font-size:0;"">
<!-- brand mark, 34x33, three non-overlapping rows (Outlook ignores negative margins and
paints transparent td borders white, so: div triangles + one white td only) -->
<div style=""width:0; height:0; border-left:31px solid #ffffff; border-top:13px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div>
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""border-collapse:collapse;""><tr>
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:18px; border-left:31px solid #ffffff; border-top:0; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:0; border-left:3px solid #ffffff; border-top:18px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
</tr></table>
<div style=""width:0; height:0; border-right:34px solid #ffffff; border-bottom:2px solid transparent; border-left:0; border-top:0; display:block; font-size:0; line-height:0;""></div>
</td>
<td valign=""middle"" style=""color:#ffffff; font-size:22px; font-weight:600; letter-spacing:-0.2px; line-height:1; white-space:nowrap; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"">Autopilot Monitor</td>
</tr></table>
</td>
</tr>
<!-- Body -->
@@ -56,14 +77,14 @@ public static class EmailTemplates
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
Thank you for taking the time to try Autopilot Monitor. Every organization that
puts it to work in a real environment helps shape what it becomes &mdash; we're
puts it to work in a real environment helps shape what it becomes &ndash; we're
genuinely grateful you gave it a chance.
</p>
<!-- Gentle feedback ask -->
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 12px;"">
If something didn't fit &mdash; a missing feature, a bug that got in the way, or
simply a better alternative &mdash; we'd love to hear about it. A couple of honest
If something didn't fit &ndash; a missing feature, a bug that got in the way, or
simply a better alternative &ndash; we'd love to hear about it. A couple of honest
sentences help more than you'd think:
</p>
@@ -78,7 +99,7 @@ public static class EmailTemplates
<p style=""color:#6b7280; font-size:13px; line-height:1.6; margin:0;"">
This is the last email you'll receive from us. This mailbox doesn't accept
replies &mdash; please use the links above if you'd like to get in touch.
replies &ndash; please use the links above if you'd like to get in touch.
</p>
</td>
</tr>
@@ -115,12 +136,33 @@ public static class EmailTemplates
<tr><td align=""center"">
<table width=""600"" cellpadding=""0"" cellspacing=""0"" style=""background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 6px rgba(0,0,0,0.07);"">
<!-- Header -->
<!-- Header: image-free. Base for every client: solid bgcolor band, text wordmark, brand mark
drawn with CSS borders (trapezoid + a green wedge for the slanted right edge; must stay
<div>s — Outlook paints transparent <td> borders white). Progressive enhancement where
supported: ONE linear-gradient (hex colours, % stops — the only form Outlook web/iOS
renders; multiple layers or rgba stops are dropped) forms the facets, and clip-path cuts
the slanted bottom edge (Apple Mail only). No bgcolor inside the mark: Outlook dark mode
inverts cell backgrounds but leaves border colours alone.
Geometry is the approved reference (scratch header-render.html): facets
light right of (40%,0)->(70%,100%), dark right of (55%,0)->(85%,100%) and left of
(78%,0)->(100%,60%) — the last one is dropped for the single-gradient constraint.
Change only against a side-by-side render. -->
<tr>
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
<td bgcolor=""#33b161"" style=""background-color:#33b161; background-image:linear-gradient(39.8deg, #33b161 0%, #33b161 53.8%, #3cbb6a 53.8%, #3cbb6a 65.4%, #2ea85a 65.4%, #2ea85a 100%); clip-path:polygon(0 0, 100% 0, 100% 100%, 0 82%); padding:28px 32px 52px; line-height:0; font-size:0;"">
<table cellpadding=""0"" cellspacing=""0"" border=""0""><tr>
<td valign=""middle"" style=""padding:0 12px 0 0; line-height:0; font-size:0;"">
<!-- brand mark, 34x33, three non-overlapping rows (Outlook ignores negative margins and
paints transparent td borders white, so: div triangles + one white td only) -->
<div style=""width:0; height:0; border-left:31px solid #ffffff; border-top:13px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div>
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""border-collapse:collapse;""><tr>
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:18px; border-left:31px solid #ffffff; border-top:0; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
<td style=""padding:0; font-size:0; line-height:0;""><div style=""width:0; height:0; border-left:3px solid #ffffff; border-top:18px solid transparent; border-right:0; border-bottom:0; display:block; font-size:0; line-height:0;""></div></td>
</tr></table>
<div style=""width:0; height:0; border-right:34px solid #ffffff; border-bottom:2px solid transparent; border-left:0; border-top:0; display:block; font-size:0; line-height:0;""></div>
</td>
<td valign=""middle"" style=""color:#ffffff; font-size:22px; font-weight:600; letter-spacing:-0.2px; line-height:1; white-space:nowrap; font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;"">Autopilot Monitor</td>
</tr></table>
</td>
</tr>
<!-- Body -->
@@ -129,8 +171,8 @@ public static class EmailTemplates
<h2 style=""color:#111827; margin:0 0 16px; font-size:20px;"">Welcome to Autopilot Monitor!</h2>
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 16px;"">
Great news &mdash; access for <strong>{displayDomain}</strong> has been activated and is ready to use.
You can now sign in and start monitoring your Windows Autopilot enrollments in real time.
Great news &ndash; access for <strong>{displayDomain}</strong> has been activated and is ready to use.
You can now <a href=""{Constants.PortalBaseUrl}"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">sign in</a> and start monitoring your Windows Autopilot enrollments in real time.
</p>
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
@@ -149,13 +191,6 @@ public static class EmailTemplates
</tr>
</table>
<!-- Active development note -->
<div style=""background-color:#fef3c7; border:1px solid #fde68a; border-radius:8px; padding:16px 20px; margin:0 0 24px;"">
<p style=""color:#92400e; font-size:14px; line-height:1.5; margin:0;"">
<strong>Please note:</strong> Autopilot Monitor is in active development. Some features are still being built
and things may occasionally not work as expected. Your patience and understanding are greatly appreciated!
</p>
</div>
<!-- Feedback -->
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 12px;"">
@@ -169,7 +204,7 @@ public static class EmailTemplates
</ul>
<p style=""color:#6b7280; font-size:14px; line-height:1.6; margin:0;"">
Thanks for being part of the journey &mdash; enjoy Autopilot Monitor!
Thanks for being part of the journey &ndash; enjoy Autopilot Monitor!
</p>
</td>
</tr>
@@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Services
bool AccountSetupAllSucceeded,
bool HasExplicitFailure,
bool HasTerminalComplete,
bool HasEnrollmentComplete,
bool HasAgentEmergencyBreak,
bool DesktopArrived,
bool HelloResolved,
@@ -113,7 +114,7 @@ namespace AutopilotMonitor.Functions.Services
bool deviceAll = false;
int acctBestN = 0, acctBestM = 0;
bool acctFallbackAll = false;
bool hasFailure = false, hasComplete = false, hasEmergencyBreak = false;
bool hasFailure = false, hasComplete = false, hasEnrollmentComplete = false, hasEmergencyBreak = false;
bool desktopArrived = false, helloResolved = false;
bool realmJoinDetected = false, realmJoinResolved = false;
bool sawHelloPolicyEnabled = false, sawHelloPolicyDisabled = false;
@@ -135,7 +136,12 @@ namespace AutopilotMonitor.Functions.Services
hasFailure = true;
}
else if (Eq(type, "enrollment_complete") || Eq(type, "whiteglove_complete"))
{
hasComplete = true;
// whiteglove_complete only proves Part 1 (technician flow) — the WhiteGlove
// awaiting-user gate needs to know whether a REAL enrollment completion exists.
if (Eq(type, "enrollment_complete")) hasEnrollmentComplete = true;
}
else if (Eq(type, "agent_emergency_break"))
hasEmergencyBreak = true;
else if (Eq(type, "desktop_arrived"))
@@ -217,6 +223,7 @@ namespace AutopilotMonitor.Functions.Services
AccountSetupAllSucceeded: acctAll,
HasExplicitFailure: hasFailure,
HasTerminalComplete: hasComplete,
HasEnrollmentComplete: hasEnrollmentComplete,
HasAgentEmergencyBreak: hasEmergencyBreak,
DesktopArrived: desktopArrived,
HelloResolved: helloResolved,
@@ -226,6 +233,46 @@ namespace AutopilotMonitor.Functions.Services
RealmJoinResolved: realmJoinResolved);
}
/// <summary>
/// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21). After the
/// reseal-reboot the session is BY DEFINITION in the user-driven final phase: the agent
/// resumed (<c>whiteglove_resumed</c> → ResumedAt, the only writer of that column), the
/// Part-2 Device ESP re-confirms in minutes, and the device sits at the logon screen.
/// Technicians routinely power the machine off there to box it — well before the 10-min
/// hybrid-login evidence window — so agent silence in this state means "sealed / powered
/// off awaiting the end user", not a stall. The gate demands the full evidence set:
/// pre-provisioned + resumed + Device Setup provisioned, and NO trace of a real user
/// since the resume (no Account Setup progress, no real-user desktop, no Hello terminal,
/// no enrollment completion) and no explicit failure. Any user evidence keeps the honest
/// Stalled/timeout handling — a stall after the user showed up is a real stall.
/// Shared by the maintenance sweep stage 1, the <c>session_stalled</c> ingest mapping and
/// <see cref="ClassifyTimedOutSession"/> so all paths apply one evidence rule.
/// </summary>
public static bool IsWhiteGloveAwaitingUser(
in EspProvisioningRollup rollup, bool isPreProvisioned, DateTime? resumedAt)
=> isPreProvisioned
&& resumedAt.HasValue
&& rollup.DeviceSetupAllSucceeded
&& !rollup.HasExplicitFailure
&& !rollup.HasEnrollmentComplete
&& rollup.AccountSetupSucceededCount == 0
&& !rollup.DesktopArrived
&& !rollup.HelloResolved;
/// <summary>
/// Customer-facing reason for a WhiteGlove Part-2 session parked as AwaitingUser by the
/// silence/stall paths. One authoring spot so the sweep, the ingest mapping and the
/// timeout classifier show the identical text.
/// </summary>
public static string WhiteGloveAwaitingUserReason(in EspProvisioningRollup rollup)
{
var acct = rollup.AccountSetupTotal > 0
? $" (Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal})"
: " (Account Setup not yet started)";
return "Pre-provisioning (WhiteGlove) completed; no user sign-in since Part 2 resumed — " +
$"device presumably sealed or powered off awaiting the end user{acct}";
}
/// <summary>
/// Decide the honest terminal (or non-terminal) state for a timed-out session. See the
/// decision table in the design note. Returns the target status and a human-readable
@@ -242,17 +289,44 @@ namespace AutopilotMonitor.Functions.Services
/// mechanism declared success too early" — session efbc17ff. Falls back to
/// <paramref name="startedAtUtc"/> when unknown (same fallback the stalled-marker uses).
/// </param>
/// <param name="isPreProvisioned">Session's IsPreProvisioned flag (WhiteGlove Part-2 gate input).</param>
/// <param name="resumedAt">Session's ResumedAt — set exclusively by the whiteglove_resumed ingest.</param>
public static (SessionStatus Status, string Reason) ClassifyTimedOutSession(
EspProvisioningRollup rollup, DateTime startedAtUtc, DateTime nowUtc, int graceHours,
DateTime? lastEventAtUtc = null)
DateTime? lastEventAtUtc = null, bool isPreProvisioned = false, DateTime? resumedAt = null)
{
// 1. An explicit terminal failure event is a real failure (defensive — such a session
// would normally already be terminal via ingest and not reach the sweep).
if (rollup.HasExplicitFailure)
return (SessionStatus.Failed, "Enrollment reported an explicit failure before timeout");
// 1b. WhiteGlove Part 2 with zero user evidence since the resume: the device is parked
// between technician and end user (sealed box / powered off at the logon screen).
// Must run BEFORE rule 2 — the Part-1 whiteglove_complete in the same event stream
// sets HasTerminalComplete and would otherwise reconcile the session to Succeeded
// with a reason claiming Account Setup completed, which never happened. Within
// grace the honest label is AwaitingUser; past grace the Part-1 success stands as
// the session outcome (the user phase is tracked by the session the device
// registers when it is finally unboxed), stated with an honest WhiteGlove reason.
if (IsWhiteGloveAwaitingUser(rollup, isPreProvisioned, resumedAt))
{
if ((nowUtc - startedAtUtc).TotalHours < graceHours)
return (SessionStatus.AwaitingUser, WhiteGloveAwaitingUserReason(rollup));
return (SessionStatus.Succeeded, AppendReconcileTiming(
$"Reconciled at timeout: pre-provisioning (WhiteGlove Part 1) completed — no user sign-in observed within {graceHours}h grace",
startedAtUtc, nowUtc, lastEventAtUtc));
}
// 2. Account Setup fully succeeded (or a terminal completion event) → reconcile to success.
if (rollup.AccountSetupAllSucceeded || rollup.HasTerminalComplete)
// Once Part 2 has resumed, the Part-1 whiteglove_complete in the same stream no longer
// proves the session's outcome — only a real enrollment_complete does. Without this,
// a resumed session with partial user progress (e.g. Account Setup 1/5, then silence)
// reconciled to Succeeded on Part-1 evidence, with a reason claiming Account Setup
// completed; rules 4-6 below hold the honest verdicts for that shape.
var terminalCompleteProvesOutcome = rollup.HasEnrollmentComplete
|| (rollup.HasTerminalComplete && !resumedAt.HasValue);
if (rollup.AccountSetupAllSucceeded || terminalCompleteProvesOutcome)
return (SessionStatus.Succeeded, AppendReconcileTiming(
"Reconciled at timeout: Account Setup completed (all subcategories succeeded / enrollment_complete observed)",
startedAtUtc, nowUtc, lastEventAtUtc));
@@ -14,7 +14,15 @@ namespace AutopilotMonitor.Functions.Services
foreach (var evt in storedEvents)
{
if (!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value)
// System-timeline-watcher rows (clock steps, sleep episodes) are backfilled
// from the Windows event log with original timestamps up to 24h before the
// agent started — environment observation, not enrollment activity. They must
// not pull the session anchor toward pre-enrollment OOBE idle time (mirrors
// TableStorageService.IsSessionAnchorEligible on the Events-table probe;
// field case d0c5b672).
var anchorEligible = evt.Source != "SystemTimelineWatcher";
if (anchorEligible &&
(!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value))
classification.EarliestEventTimestamp = evt.Timestamp;
if (!classification.LatestEventTimestamp.HasValue || evt.Timestamp > classification.LatestEventTimestamp.Value)
classification.LatestEventTimestamp = evt.Timestamp;
@@ -279,7 +287,8 @@ namespace AutopilotMonitor.Functions.Services
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp);
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp,
isPreProvisioned: session?.IsPreProvisioned == true, resumedAt: session?.ResumedAt);
// Keep the max-lifetime trigger visible: the Succeeded reasons already carry their own
// silence-transparency clause, everything else gets the watchdog context appended.
@@ -348,7 +357,8 @@ namespace AutopilotMonitor.Functions.Services
var now = DateTime.UtcNow;
var effectiveStart = session.ResumedAt ?? session.StartedAt;
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session.LastEventAt ?? c.LatestEventTimestamp);
rollup, effectiveStart, now, graceHours, session.LastEventAt ?? c.LatestEventTimestamp,
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt);
if (targetStatus != SessionStatus.Succeeded)
return;
@@ -368,6 +378,49 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// WhiteGlove Part-2 awaiting-user gate for the agent-reported stall
/// (<c>session_stalled</c>). Pre-provisioned sessions with a ResumedAt whose event
/// stream shows no user evidence since the resume are parked as AwaitingUser instead of
/// Stalled — same evidence rule as the maintenance sweep
/// (<see cref="EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser"/>). Returns true
/// when the AwaitingUser write was applied; false (also on any error, fail-soft) lets
/// the caller run the normal Stalled mapping.
/// </summary>
private async Task<bool> TryMapStallToWhiteGloveAwaitingUserAsync(
IngestEventsRequest request, string sessionPrefix, EventClassification c)
{
try
{
var session = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId);
if (session?.IsPreProvisioned != true || !session.ResumedAt.HasValue)
return false;
var sessionEvents = await _sessionRepo.GetSessionEventsAsync(
request.TenantId, request.SessionId, maxResults: 1000);
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
if (!EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(
rollup, isPreProvisioned: true, session.ResumedAt))
return false;
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.AwaitingUser,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
failureReason: EnrollmentTimeoutClassifier.WhiteGloveAwaitingUserReason(rollup));
if (transitioned)
_logger.LogInformation(
"{SessionPrefix} Status: AwaitingUser (agent stall probe on WhiteGlove Part 2 with no user evidence since resume)",
sessionPrefix);
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"{SessionPrefix} WhiteGlove awaiting-user gate failed for session_stalled; falling back to Stalled", sessionPrefix);
return false;
}
}
private static bool IsPeriodicOrStallEvent(string? eventType) => eventType switch
{
"performance_snapshot" => true,
@@ -381,6 +434,10 @@ namespace AutopilotMonitor.Functions.Services
// Environment observation (battery drain / AC flap), not enrollment progress —
// must not count as "real activity" that heals a Stalled session.
"power_state_change" => true,
// System timeline watcher: a clock step or a completed standby episode is environment
// observation too — a device that slept through its stall must stay Stalled.
"system_clock_changed" => true,
"system_sleep_episode" => true,
_ => false
};
@@ -555,6 +612,16 @@ namespace AutopilotMonitor.Functions.Services
if (c.SessionStalledEvent != null)
{
// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21): the
// agent-side stall probe also fires on a resumed Part-2 device that simply sits
// at the logon screen with nobody signing in. With zero user evidence since the
// resume that is the expected parking state between technician and end user, so
// the honest label is AwaitingUser, not Stalled. Rare path (one probe batch per
// stalling session), so the session + events reads are acceptable; fail-soft —
// any error falls back to the plain Stalled mapping.
if (await TryMapStallToWhiteGloveAwaitingUserAsync(request, sessionPrefix, c))
return (statusTransitioned, whiteGloveStatusTransitioned, failureReason);
var stalledReason = "Agent reported stall after 60min without progress (stall_probe)";
var stalledTransitioned = await _sessionRepo.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.Stalled,
@@ -0,0 +1,26 @@
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Provider-neutral transactional email contract for the tenant-activation welcome mail.
/// <para>
/// Always fail-soft — implementations must NOT throw; activation correctness never depends
/// on email delivery (the Global Admin "Send welcome email" button is the manual fallback).
/// The farewell mail after offboarding lives on the sibling
/// <see cref="IOffboardFarewellEmailSender"/> contract; both are served by <see cref="EmailService"/>.
/// </para>
/// </summary>
public interface IEmailService
{
/// <summary>
/// Sends the tenant-activation welcome email. No-op when the provider is not configured
/// or the recipient is empty.
/// </summary>
Task SendPreviewApprovedEmailAsync(string toEmail, string domainName, CancellationToken ct = default);
/// <summary>
/// Operator test send: the effective template of <paramref name="kind"/> (or an unsaved
/// <paramref name="draftHtml"/>, placeholder-rendered) to <paramref name="toEmail"/>.
/// Returns true when the provider accepted the message. Never throws.
/// </summary>
Task<bool> SendTestAsync(EmailTemplateKind kind, string toEmail, string domainName, string? draftHtml, CancellationToken ct = default);
}
@@ -109,7 +109,8 @@ namespace AutopilotMonitor.Functions.Services
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
var effectiveStart = session.ResumedAt ?? session.StartedAt;
var (target, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session.LastEventAt);
rollup, effectiveStart, now, graceHours, session.LastEventAt,
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt);
if (target == SessionStatus.Failed)
{
@@ -349,6 +349,7 @@ namespace AutopilotMonitor.Functions.Services
tenantId, silenceCutoff, hardCutoff: cutoffTime);
int silentMarked = 0;
int silentAwaitingUser = 0;
foreach (var silent in silentSessions)
{
// The query is a permissive two-frame pre-filter; the decision is made
@@ -360,6 +361,19 @@ namespace AutopilotMonitor.Functions.Services
if (lastContactAt > silenceCutoff)
continue; // provably alive on the server clock
// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis
// 2026-08-21): after the reseal-reboot the technician routinely powers
// the device off at the logon screen to box it — that silence is the
// expected parking state between technician and end user, not a stall.
// Only pre-provisioned sessions with a ResumedAt pay the event read;
// any user evidence since the resume falls through to Stalled.
if (silent.IsPreProvisioned && silent.ResumedAt.HasValue
&& await TryMarkWhiteGloveAwaitingUserAsync(silent))
{
silentAwaitingUser++;
continue;
}
var silentMinutes = (int)(now - lastContactAt).TotalMinutes;
await _sessionRepo.UpdateSessionStatusAsync(
silent.TenantId,
@@ -370,19 +384,20 @@ namespace AutopilotMonitor.Functions.Services
silentMarked++;
}
if (silentMarked > 0)
if (silentMarked > 0 || silentAwaitingUser > 0)
{
totalSessionsMarkedStalled += silentMarked;
_logger.LogInformation($"Tenant {tenantId}: Marked {silentMarked} agent-silent sessions as Stalled (silence threshold: {agentSilenceHours}h)");
_logger.LogInformation($"Tenant {tenantId}: Marked {silentMarked} agent-silent sessions as Stalled, {silentAwaitingUser} as AwaitingUser (WhiteGlove Part 2; silence threshold: {agentSilenceHours}h)");
await _maintenanceRepo.LogAuditEntryAsync(
tenantId,
"SessionStalled",
"Session",
$"{silentMarked} sessions",
$"{silentMarked + silentAwaitingUser} sessions",
"System.Maintenance",
new Dictionary<string, string>
{
{ "SessionsMarkedStalled", silentMarked.ToString() },
{ "SessionsAwaitingUser", silentAwaitingUser.ToString() },
{ "AgentSilenceHours", agentSilenceHours.ToString() },
{ "SilenceCutoff", silenceCutoff.ToString("yyyy-MM-ddTHH:mm:ss") }
});
@@ -454,7 +469,8 @@ namespace AutopilotMonitor.Functions.Services
// Device Setup → Incomplete. Never Failed without an explicit failure signal.
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, effectiveStart, now, graceHours, session.LastEventAt);
rollup, effectiveStart, now, graceHours, session.LastEventAt,
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt);
// No-op if the verdict equals the current (non-terminal) state.
if (targetStatus == session.Status)
@@ -569,6 +585,44 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Applies the WhiteGlove Part-2 awaiting-user gate to one agent-silent candidate
/// (pre-provisioned + ResumedAt set — the caller pre-filters so only WhiteGlove Part-2
/// runs pay the event read). Reads the session's events, distills the classifier rollup
/// and, when <see cref="EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser"/> confirms
/// there is no user evidence since the resume, parks the session as AwaitingUser instead
/// of Stalled. Returns true when the AwaitingUser write happened; false (fail-soft, also
/// on read errors) lets the caller fall through to the normal Stalled marker.
/// </summary>
private async Task<bool> TryMarkWhiteGloveAwaitingUserAsync(SessionSummary silent)
{
try
{
var events = await _sessionRepo.GetSessionEventsAsync(
silent.TenantId, silent.SessionId, maxResults: 1000);
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
if (!EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(
rollup, silent.IsPreProvisioned, silent.ResumedAt))
return false;
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
silent.TenantId,
silent.SessionId,
SessionStatus.AwaitingUser,
failureReason: EnrollmentTimeoutClassifier.WhiteGloveAwaitingUserReason(rollup));
if (transitioned)
_logger.LogInformation(
$"Session {silent.SessionId}: WhiteGlove Part 2 silent with no user evidence — parked as AwaitingUser instead of Stalled");
return transitioned;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
$"WhiteGlove awaiting-user gate failed for session {silent.SessionId}; falling back to Stalled marker");
return false;
}
}
/// <summary>
/// Builds the server-authored <c>session_timeout</c> event injected into the stream when the
/// maintenance sweep graduates a stalled session to terminal Failed. Static + pure so the field
@@ -0,0 +1,140 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Models.Notifications;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services.Notifications
{
/// <summary>
/// Renders a NotificationAlert as a Discord webhook payload (single embed).
/// Discord webhooks cannot post buttons, so openUrl actions are appended to the
/// embed description as markdown links.
/// </summary>
public class DiscordRenderer : INotificationRenderer
{
// Discord embed limits: https://discord.com/developers/docs/resources/message#embed-object-embed-limits
private const int MaxTitleLength = 256;
private const int MaxDescriptionLength = 4096;
private const int MaxFields = 25;
private const int MaxFieldNameLength = 256;
private const int MaxFieldValueLength = 1024;
private const int MaxEmbedTotalLength = 6000;
public WebhookProviderType ProviderType => WebhookProviderType.Discord;
public string RenderToJson(NotificationAlert alert)
{
var title = Truncate($"{GetSeverityEmoji(alert.Severity)} {alert.Title}", MaxTitleLength);
var description = Truncate(BuildDescription(alert), MaxDescriptionLength);
// Facts as inline fields. Discord rejects fields with an empty name or value.
var facts = alert.Facts
.Where(f => !string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value))
.Take(MaxFields)
.Select(f => (Name: Truncate(f.Name, MaxFieldNameLength), Value: Truncate(f.Value, MaxFieldValueLength)))
.ToList();
// Enforce the 6000-char total cap across title + description + fields:
// drop trailing fields first, then shrink the description to the remaining budget.
var fixedLength = title.Length + description.Length;
while (facts.Count > 0 && fixedLength + facts.Sum(f => f.Name.Length + f.Value.Length) > MaxEmbedTotalLength)
facts.RemoveAt(facts.Count - 1);
if (fixedLength > MaxEmbedTotalLength)
description = Truncate(description, MaxEmbedTotalLength - title.Length);
var fields = facts
.Select(f => new { name = f.Name, value = f.Value, inline = true })
.ToArray();
var embed = new
{
title,
description = description.Length > 0 ? description : null,
color = GetColor(alert),
fields = fields.Length > 0 ? fields : null
};
var payload = new { embeds = new[] { embed } };
return JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver
{
NamingStrategy = new Newtonsoft.Json.Serialization.CamelCaseNamingStrategy()
}
});
}
private static string BuildDescription(NotificationAlert alert)
{
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(alert.Summary))
sb.Append(alert.Summary);
foreach (var section in alert.Sections)
{
var text = "";
if (!string.IsNullOrEmpty(section.Title))
text += $"**{section.Title}**\n";
if (!string.IsNullOrEmpty(section.Text))
text += section.Text;
if (text.Length > 0)
{
if (sb.Length > 0)
sb.Append("\n\n");
sb.Append(text);
}
}
foreach (var action in alert.Actions.Where(a => a.Type == "openUrl" && !string.IsNullOrEmpty(a.Url)))
{
if (sb.Length > 0)
sb.Append("\n\n");
sb.Append($"[{action.Title}]({action.Url})");
}
return sb.ToString();
}
private static int GetColor(NotificationAlert alert)
{
if (!string.IsNullOrEmpty(alert.ThemeColor)
&& int.TryParse(alert.ThemeColor.TrimStart('#'), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var color))
return color;
return alert.Severity switch
{
NotificationSeverity.Success => 0x2ECC71,
NotificationSeverity.Error => 0xE74C3C,
NotificationSeverity.Warning => 0xF1C40F,
_ => 0x3498DB
};
}
private static string GetSeverityEmoji(NotificationSeverity severity)
{
return severity switch
{
NotificationSeverity.Success => "\u2705",
NotificationSeverity.Error => "\u274c",
NotificationSeverity.Warning => "\u26a0\ufe0f",
NotificationSeverity.Info => "\u2139\ufe0f",
_ => "\u2139\ufe0f"
};
}
private static string Truncate(string value, int maxLength)
{
if (maxLength <= 0)
return "";
if (value.Length <= maxLength)
return value;
return value.Substring(0, maxLength - 1) + "\u2026";
}
}
}
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
[WebhookProviderType.TeamsWorkflowWebhook] = new TeamsWorkflowAdaptiveCardRenderer(),
[WebhookProviderType.Slack] = new SlackRenderer(),
[WebhookProviderType.GenericJson] = new GenericJsonRenderer(),
[WebhookProviderType.Discord] = new DiscordRenderer(),
};
}
@@ -36,9 +37,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
/// Sends a notification (fire-and-forget, non-fatal). Exceptions are logged as warnings.
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request,
/// e.g. an API-key/Authorization header for a ticketing system or SMTP gateway.
/// <paramref name="signingSecret"/> (generic webhooks only) adds HMAC signature headers
/// (see <see cref="WebhookSignatureCalculator"/>).
/// </summary>
public async Task SendNotificationAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null)
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
{
if (string.IsNullOrEmpty(webhookUrl) || providerType == WebhookProviderType.None)
return;
@@ -54,7 +57,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
await SsrfGuard.ValidateDestinationAsync(webhookUrl);
var json = renderer.RenderToJson(alert);
var response = await PostAsync(webhookUrl, json, customHeaders);
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
if (response.IsSuccessStatusCode)
_logger.LogInformation("Webhook notification sent: {Summary} (provider={ProviderType})", alert.Summary, providerType);
@@ -88,16 +91,18 @@ namespace AutopilotMonitor.Functions.Services.Notifications
channel.Url,
(WebhookProviderType)channel.ProviderType,
alert,
channel.GetCustomHeaders());
channel.GetCustomHeaders(),
channel.GetSigningSecret());
}
}
/// <summary>
/// Sends a notification and returns the result (for test endpoint). Not fire-and-forget.
/// <paramref name="customHeaders"/> (generic webhooks only) are attached to the request.
/// <paramref name="customHeaders"/> and <paramref name="signingSecret"/> (generic
/// webhooks only) are applied exactly like in <see cref="SendNotificationAsync"/>.
/// </summary>
public async Task<WebhookTestResult> SendNotificationWithResultAsync(string webhookUrl, WebhookProviderType providerType, NotificationAlert alert,
IReadOnlyDictionary<string, string>? customHeaders = null)
IReadOnlyDictionary<string, string>? customHeaders = null, string? signingSecret = null)
{
if (string.IsNullOrEmpty(webhookUrl))
return new WebhookTestResult { Success = false, Message = "Webhook URL is not configured." };
@@ -120,7 +125,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
try
{
var json = renderer.RenderToJson(alert);
var response = await PostAsync(webhookUrl, json, customHeaders);
var response = await PostAsync(webhookUrl, json, customHeaders, signingSecret);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
@@ -145,8 +150,11 @@ namespace AutopilotMonitor.Functions.Services.Notifications
/// <summary>
/// POSTs the rendered JSON, attaching any custom headers as request headers. Restricted
/// (framing/host/content) headers are already filtered upstream by GetGenericWebhookHeaders().
/// When a signing secret is present, HMAC signature headers are computed over the exact
/// JSON string being posted — after custom headers, so they can never be spoofed by one.
/// </summary>
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders)
private Task<HttpResponseMessage> PostAsync(string webhookUrl, string json, IReadOnlyDictionary<string, string>? customHeaders,
string? signingSecret = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, webhookUrl)
{
@@ -159,6 +167,16 @@ namespace AutopilotMonitor.Functions.Services.Notifications
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (!string.IsNullOrEmpty(signingSecret))
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture);
request.Headers.Remove(WebhookSignatureCalculator.TimestampHeader);
request.Headers.Remove(WebhookSignatureCalculator.SignatureHeader);
request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.TimestampHeader, timestamp);
request.Headers.TryAddWithoutValidation(WebhookSignatureCalculator.SignatureHeader,
WebhookSignatureCalculator.ComputeSignature(signingSecret, timestamp, json));
}
return _http.SendAsync(request);
}
}
@@ -0,0 +1,31 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace AutopilotMonitor.Functions.Services.Notifications
{
/// <summary>
/// Computes the HMAC request signature for generic JSON webhooks (Stripe-style scheme).
/// The receiver recomputes HMACSHA256(secret, "{timestamp}.{rawBody}") and compares it
/// (constant-time) against the signature header; the timestamp binds replay protection.
/// </summary>
public static class WebhookSignatureCalculator
{
/// <summary>Header carrying the unix-seconds timestamp the signature was computed with.</summary>
public const string TimestampHeader = "X-AutopilotMonitor-Timestamp";
/// <summary>Header carrying the signature: "sha256=&lt;lowercase hex HMAC&gt;".</summary>
public const string SignatureHeader = "X-AutopilotMonitor-Signature";
/// <summary>
/// Computes "sha256=&lt;hex&gt;" over "{timestamp}.{body}" with the given secret.
/// Secret and message are UTF-8; hex is lowercase.
/// </summary>
public static string ComputeSignature(string secret, string timestamp, string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}"));
return "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
}
}
}
@@ -527,6 +527,60 @@ namespace AutopilotMonitor.Functions.Services
tenantId, "System.TrialSweep", new { domainName, trialExpiredUtc });
}
// ── Plan downgrade / retention grace (informational — enforcement is read-time) ──
// All three types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES
// (memory feedback_ops_event_types_dual_register). Downgraded is dispatched by
// PlanManagementFunction, the grace events by TrialExpirySweepFunction.
/// <summary>
/// A GA plan mutation dropped the tenant's EFFECTIVE edition Pro → Community. The
/// retention downgrade grace period starts now; data older than the Community cap is
/// deleted only after it ends. Warning-tier: the business moment (e.g. non-payment)
/// an operator wants a ping for.
/// </summary>
public Task RecordTenantPlanDowngradedAsync(
string tenantId, string? domainName, string caller, DateTime? retentionGraceEndsUtc, int storedRetentionDays)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var graceNote = retentionGraceEndsUtc is DateTime graceEnd
? $"retention grace until {graceEnd:yyyy-MM-dd HH:mm}Z"
: "no retention grace applicable";
return WriteAsync(OpsEventCategory.Tenant, "TenantPlanDowngraded", OpsEventSeverity.Warning,
$"Tenant {tenantLabel} was downgraded Pro → Community by {caller} — {graceNote} (stored retention {storedRetentionDays}d)",
tenantId, caller, new { domainName, retentionGraceEndsUtc, storedRetentionDays });
}
/// <summary>
/// Heads-up: a downgraded tenant's retention grace ends within the next few days —
/// after that, the sweep enforces the Community cap and deletes the older data.
/// Emitted only for tenants that actually have data at risk (stored retention above
/// the Community cap). Re-fired daily during the heads-up window (no dedup state).
/// </summary>
public Task RecordTenantRetentionGraceExpiringAsync(
string tenantId, string? domainName, DateTime graceEndsUtc, int daysLeft, int storedRetentionDays)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceExpiring", OpsEventSeverity.Warning,
$"Retention grace for downgraded tenant {tenantLabel} ends in {daysLeft} day(s) ({graceEndsUtc:yyyy-MM-dd HH:mm}Z) — " +
$"data older than the Community cap will then be deleted (stored retention {storedRetentionDays}d)",
tenantId, "System.TrialSweep", new { domainName, graceEndsUtc, daysLeft, storedRetentionDays });
}
/// <summary>
/// A downgraded tenant's retention grace ended within the last sweep window — from now
/// on the retention sweep enforces the Community cap and starts deleting the older data.
/// Last call for a GA intervention (re-upgrade / retention decision).
/// </summary>
public Task RecordTenantRetentionGraceEndedAsync(
string tenantId, string? domainName, DateTime graceEndedUtc, int storedRetentionDays)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
return WriteAsync(OpsEventCategory.Tenant, "TenantRetentionGraceEnded", OpsEventSeverity.Warning,
$"Retention grace for downgraded tenant {tenantLabel} ended ({graceEndedUtc:yyyy-MM-dd HH:mm}Z) — " +
$"the Community retention cap is now enforced and older data will be deleted (stored retention {storedRetentionDays}d)",
tenantId, "System.TrialSweep", new { domainName, graceEndedUtc, storedRetentionDays });
}
/// <summary>
/// F3 regression radar (insights spec §F3): an analyze rule's 7-day hit rate rose ≥2×
/// over its 28-day baseline with disjoint Wilson intervals. Fired ONCE per episode (the
@@ -1,118 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Resend;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Sends transactional emails via Resend.com for tenant-activation notifications.
/// Best-effort: failures are logged as warnings and never propagated.
/// Temporary — remove after GA.
/// </summary>
public class ResendEmailService : IOffboardFarewellEmailSender
{
private readonly string _apiKey;
private readonly ILogger<ResendEmailService> _logger;
public ResendEmailService(
IConfiguration configuration,
ILogger<ResendEmailService> logger)
{
_logger = logger;
_apiKey = configuration["RESEND_API_KEY"] ?? string.Empty;
}
/// <summary>
/// Sends the tenant-activation welcome email.
/// No-op if the API key or recipient email is not configured.
/// </summary>
public virtual async Task SendPreviewApprovedEmailAsync(string toEmail, string domainName)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug("RESEND_API_KEY not configured — skipping preview approval email");
return;
}
if (string.IsNullOrWhiteSpace(toEmail))
{
_logger.LogDebug("No notification email set — skipping preview approval email for {Domain}", domainName);
return;
}
try
{
var resend = ResendClient.Create(_apiKey);
var message = new EmailMessage
{
From = "Autopilot Monitor <noreply@autopilotmonitor.com>",
To = toEmail,
Subject = EmailTemplates.PreviewApprovedSubject,
HtmlBody = EmailTemplates.GetPreviewApprovedHtml(domainName)
};
await resend.EmailSendAsync(message);
_logger.LogInformation(
"Preview approval email sent to {ToEmail} for domain {Domain}",
toEmail, domainName);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to send preview approval email to {ToEmail} for domain {Domain}",
toEmail, domainName);
}
}
/// <summary>
/// Sends the post-offboarding "sorry to see you go" farewell email.
/// No-op if the API key or recipient email is not configured. Best-effort: failures
/// are logged as warnings and never propagated (the offboarding correctness contract
/// does not depend on email delivery).
/// </summary>
public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_apiKey))
{
_logger.LogDebug(
"RESEND_API_KEY not configured — skipping offboard farewell email for tenant {TenantId}",
tenantId);
return;
}
if (string.IsNullOrWhiteSpace(toEmail))
{
_logger.LogDebug(
"No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})",
tenantId, domainName);
return;
}
try
{
var resend = ResendClient.Create(_apiKey);
var message = new EmailMessage
{
From = "Autopilot Monitor <noreply@autopilotmonitor.com>",
To = toEmail,
Subject = EmailTemplates.OffboardingFarewellSubject,
HtmlBody = EmailTemplates.GetOffboardingFarewellHtml(domainName)
};
await resend.EmailSendAsync(message, ct);
_logger.LogInformation(
"Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})",
toEmail, tenantId, domainName);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to send offboard farewell email to {ToEmail} for tenant {TenantId} ({Domain})",
toEmail, tenantId, domainName);
}
}
}
@@ -3250,12 +3250,27 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Returns the earliest event timestamp persisted for a session, if any.
/// Events are written with RowKey "{Timestamp}_{Sequence}", so querying the partition
/// and taking the first row yields the earliest event. Resolution must NOT rely on
/// the system Timestamp: it is reset by storage migrations, and this value feeds the
/// StartedAt/Duration reconciliation — a migration-reset read would rewrite those
/// session fields with the migration date.
/// Event sources whose rows never anchor the session. The system timeline watcher
/// backfills environment observations (clock steps, sleep episodes) from the Windows
/// event log with their ORIGINAL timestamps — up to 24h before the agent started.
/// They belong on the timeline, but they are not enrollment activity: anchoring on
/// them silently stretches StartedAt/DurationSeconds toward pre-enrollment OOBE idle
/// time (field case d0c5b672: a pre-agent clock correction pulled StartedAt ~14min
/// before the first real activity; the 2h backward-shift guard only caps, not
/// prevents, this). User decision 2026-08-21: the session anchor stays on real
/// enrollment activity.
/// </summary>
internal static bool IsSessionAnchorEligible(TableEntity eventRow)
=> eventRow.GetString("Source") != "SystemTimelineWatcher";
/// <summary>
/// Returns the earliest ANCHOR-ELIGIBLE event timestamp persisted for a session, if
/// any. Events are written with RowKey "{Timestamp}_{Sequence}", so scanning the
/// partition in order and taking the first eligible row (see
/// <see cref="IsSessionAnchorEligible"/>) yields the earliest activity event.
/// Resolution must NOT rely on the system Timestamp: it is reset by storage
/// migrations, and this value feeds the StartedAt/Duration reconciliation — a
/// migration-reset read would rewrite those session fields with the migration date.
/// </summary>
private async Task<DateTime?> GetEarliestSessionEventTimestampAsync(string tenantId, string sessionId)
{
@@ -3265,12 +3280,15 @@ namespace AutopilotMonitor.Functions.Services
var partitionKey = $"{tenantId}_{sessionId}";
var query = tableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{partitionKey}'",
maxPerPage: 1,
select: new[] { "RowKey", BusinessTimestamp.OccurredUtcColumn, "Timestamp" }
maxPerPage: 25,
select: new[] { "RowKey", BusinessTimestamp.OccurredUtcColumn, "Timestamp", "Source" }
);
await foreach (var entity in query)
{
if (!IsSessionAnchorEligible(entity))
continue; // environment observation, not enrollment activity — keep scanning
return ResolveEventTimestampOrNull(entity);
}
}
@@ -117,10 +117,12 @@ namespace AutopilotMonitor.Functions.Services
["WallClockSeconds"] = b.WallClockSeconds,
["UnattributedSeconds"] = b.UnattributedSeconds,
["RebootSeconds"] = b.RebootSeconds,
["SleepSeconds"] = b.SleepSeconds,
["BlockingAppCount"] = b.BlockingAppCount,
["QualityFlags"] = (int)b.QualityFlags,
["SegmentsJson"] = JsonSerializer.Serialize(b.Segments),
["RebootSpansJson"] = JsonSerializer.Serialize(b.RebootSpans),
["SleepSpansJson"] = JsonSerializer.Serialize(b.SleepSpans),
["BlockingAppsJson"] = JsonSerializer.Serialize(b.BlockingApps),
["ComputedAt"] = DateTime.UtcNow,
};
@@ -142,11 +144,13 @@ namespace AutopilotMonitor.Functions.Services
WallClockSeconds = entity.GetInt32("WallClockSeconds") ?? 0,
UnattributedSeconds = entity.GetInt32("UnattributedSeconds") ?? 0,
RebootSeconds = entity.GetInt32("RebootSeconds") ?? 0,
SleepSeconds = entity.GetInt32("SleepSeconds") ?? 0,
BlockingAppCount = entity.GetInt32("BlockingAppCount") ?? 0,
EspAppsOccupancySeconds = entity.GetInt32("EspAppsOccupancySeconds"),
QualityFlags = (TimeAttributionFlags)(entity.GetInt32("QualityFlags") ?? 0),
Segments = DeserializeJsonColumn<TimeAttributionSpan>(entity.GetString("SegmentsJson")),
RebootSpans = DeserializeJsonColumn<RebootSpan>(entity.GetString("RebootSpansJson")),
SleepSpans = DeserializeJsonColumn<SleepSpan>(entity.GetString("SleepSpansJson")),
BlockingApps = DeserializeJsonColumn<BlockingAppInterval>(entity.GetString("BlockingAppsJson")),
};
}
@@ -16,20 +16,20 @@ public class TenantApprovalService
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly TenantConfigurationService _tenantConfigurationService;
private readonly TenantAdminsService _tenantAdminsService;
private readonly ResendEmailService _resendEmailService;
private readonly IEmailService _emailService;
public TenantApprovalService(
ILogger<TenantApprovalService> logger,
PreviewWhitelistService previewWhitelistService,
TenantConfigurationService tenantConfigurationService,
TenantAdminsService tenantAdminsService,
ResendEmailService resendEmailService)
IEmailService emailService)
{
_logger = logger;
_previewWhitelistService = previewWhitelistService;
_tenantConfigurationService = tenantConfigurationService;
_tenantAdminsService = tenantAdminsService;
_resendEmailService = resendEmailService;
_emailService = emailService;
}
/// <summary>
@@ -140,7 +140,7 @@ public class TenantApprovalService
}
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
_ = _resendEmailService.SendPreviewApprovedEmailAsync(
_ = _emailService.SendPreviewApprovedEmailAsync(
notificationEmail, tenantConfig.DomainName)
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"Fire-and-forget welcome email failed for tenant {TenantId}", tenantId),
@@ -121,6 +121,7 @@ namespace AutopilotMonitor.Functions.Services
"LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy",
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
"ProDowngradedUtc",
};
/// <summary>
@@ -148,6 +149,7 @@ namespace AutopilotMonitor.Functions.Services
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"OnboardedBy", "OnboardedAt",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
"ProDowngradedUtc",
};
private static readonly Dictionary<string, PropertyInfo> ModelProperties =
@@ -97,10 +97,36 @@ namespace AutopilotMonitor.Functions.Services
&& config.UnrestrictedModeEnabled
&& config.UnrestrictedMode;
/// <summary>
/// End of the retention downgrade grace period, or null when none applies (tenant is
/// effectively Pro, or never lost Pro). The anchor is the LATEST of the explicit
/// downgrade timestamp (<see cref="TenantConfiguration.ProDowngradedUtc"/>, written by
/// the plan endpoint) and an expired trial's <see cref="TenantConfiguration.TrialExpiresUtc"/>
/// (read-time, no write on expiry). A returned value in the past means the grace is over.
/// </summary>
public static DateTime? GetRetentionGraceEndUtc(TenantConfiguration config, DateTime nowUtc)
{
if (ResolveEdition(config, nowUtc) == TenantEdition.Pro)
return null;
DateTime? anchor = config.ProDowngradedUtc;
if (config.TrialExpiresUtc is DateTime trialEnd && trialEnd <= nowUtc &&
(anchor is null || trialEnd > anchor))
{
anchor = trialEnd;
}
return anchor?.AddDays(FeatureEntitlementCatalog.RetentionDowngradeGraceDays);
}
/// <summary>
/// The retention days the platform actually enforces for this config: the stored value
/// clamped to the edition's cap. <c>days &lt;= 0</c> is the GA-only "infinite" escape hatch
/// and is passed through unclamped (the fanout skips those tenants entirely).
/// Downgrade grace: for <see cref="FeatureEntitlementCatalog.RetentionDowngradeGraceDays"/>
/// days after losing Pro the PRO cap keeps applying, so a downgrade or trial expiry never
/// immediately hard-deletes the 90–365-day band. Retention is the only entitlement with
/// a grace — the others are reversible gates.
/// </summary>
public static int GetEffectiveRetentionDays(TenantConfiguration config, DateTime nowUtc)
{
@@ -108,7 +134,14 @@ namespace AutopilotMonitor.Functions.Services
if (days <= 0)
return 0;
var cap = FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).RetentionCapDays;
var edition = ResolveEdition(config, nowUtc);
var cap = FeatureEntitlementCatalog.Get(edition).RetentionCapDays;
if (edition == TenantEdition.Community &&
GetRetentionGraceEndUtc(config, nowUtc) is DateTime graceEnd && graceEnd > nowUtc)
{
cap = FeatureEntitlementCatalog.Get(TenantEdition.Pro).RetentionCapDays;
}
return Math.Min(days, cap);
}
}
@@ -108,9 +108,11 @@ export const EVENT_TYPES_CATALOG = {
'device_location',
'outbound_ip',
'timezone_auto_set',
'do_group_id_auto_set',
'ntp_time_check',
'power_state_check',
'power_state_change',
'system_clock_changed',
],
identity_events: [
'aad_placeholder_user_detected',
@@ -156,6 +158,7 @@ export const EVENT_TYPES_CATALOG = {
'ime_session_change',
'ime_process_exited',
'system_reboot_detected',
'system_sleep_episode',
'prior_run_died_with_state',
'previous_crash_detected',
'agent_emergency_break',
@@ -29,7 +29,7 @@ export const KEY_EVENT_TYPES = new Set([
'desktop_arrived', 'hello_policy_detected', 'waiting_for_hello', 'hello_completion_timeout',
'agent_started', 'agent_shutdown', 'agent_shutting_down', 'agent_trace',
'script_started', 'script_completed', 'script_failed', 'historic_ime_replay_detected',
'vulnerability_report',
'vulnerability_report', 'system_sleep_episode',
]);
// Phase-defining events promoted to the top of the triage timeline. Module-level
// (not handler-local) so the same drift test can validate it.
@@ -560,9 +560,12 @@ namespace AutopilotMonitor.Shared
public const string DeviceLocation = "device_location";
public const string OutboundIp = "outbound_ip";
public const string TimezoneAutoSet = "timezone_auto_set";
public const string DoGroupIdAutoSet = "do_group_id_auto_set";
public const string NtpTimeCheck = "ntp_time_check";
public const string PowerStateCheck = "power_state_check";
public const string PowerStateChange = "power_state_change";
public const string SystemClockChanged = "system_clock_changed"; // Kernel-General EventID 1: OS clock stepped (oldTime/newTime/timeDeltaMs in Data are authoritative — the event's own Timestamp may be clamped server-side).
public const string SystemSleepEpisode = "system_sleep_episode"; // Completed sleep/hibernate/modern-standby episode (Power-Troubleshooter 1 / Kernel-Power 507), emitted retroactively at wake.
public const string AgentTrace = "agent_trace";
// Crash / process lifecycle
@@ -83,6 +83,12 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<Dictionary<string, string>> GetPreviewConfigAsync();
Task<bool> SavePreviewConfigAsync(string key, string value);
// --- Email Template Overrides (operator-level, PreviewConfig table, partition "EmailTemplates") ---
/// <summary>Returns the stored override for the given kind, or null when the built-in template applies.</summary>
Task<EmailTemplateOverride?> GetEmailTemplateOverrideAsync(string kind);
Task SaveEmailTemplateOverrideAsync(EmailTemplateOverride overrideEntry);
Task DeleteEmailTemplateOverrideAsync(string kind);
// --- Preview Notification Email ---
Task<string?> GetNotificationEmailAsync(string tenantId);
Task SaveNotificationEmailAsync(string tenantId, string? email);
@@ -225,6 +225,14 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool EnableTimezoneAutoSet { get; set; } = false;
/// <summary>
/// Whether to set the Delivery Optimization group ID (DOGroupId policy value) from a
/// deterministic network fingerprint (default gateway IP + MAC, RealmJoin-compatible
/// byte layout). Existing DOGroupId/DOGroupIdSource policies are never overwritten.
/// Default: false
/// </summary>
public bool EnableDoGroupIdAutoSet { get; set; } = false;
/// <summary>
/// Control-channel mirror of the telemetry-response block signal: the calling device
/// (serial) or agent version matched an active admin Block/Kill rule. Delivered on the
@@ -530,6 +538,30 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int MdmRebootPolicyBackfillLookbackMinutes { get; set; } = 60;
// -----------------------------------------------------------------------
// System timeline watcher (System channel: clock changes + sleep episodes)
// -----------------------------------------------------------------------
/// <summary>
/// Master switch for the system timeline watcher. When enabled the agent subscribes to the
/// System event log for Kernel-General EventID 1 (system clock stepped — ground truth for
/// time jumps incl. old/new time, delta and the setting process) and completed sleep
/// episodes (Power-Troubleshooter EventID 1 for classic sleep/hibernate, Kernel-Power
/// EventID 507 for Modern Standby) and emits system_clock_changed / system_sleep_episode
/// events. Explains timeline gaps and time jumps that are otherwise blind spots.
/// Default: true.
/// </summary>
public bool EnableSystemTimelineWatcher { get; set; } = true;
/// <summary>
/// Lookback window in minutes for the system timeline backfill scan on startup. The
/// decisive clock correction (w32time fixing a wrong BIOS clock) and pre-agent standby
/// episodes happen early in OOBE, often before the agent starts; the System .evtx persists
/// across reboots, so a generous backfill recovers them. Bounded well inside the backend's
/// 168 h past-tolerance clamp. 0 = backfill disabled. Default: 1440 minutes (24 h).
/// </summary>
public int SystemEventBackfillLookbackMinutes { get; set; } = 1440;
/// <summary>
/// Creates default collector configuration
/// </summary>
@@ -0,0 +1,17 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Operator-supplied replacement for one built-in transactional email template
/// (kind = "welcome" | "farewell"). Stored in the PreviewConfig table, partition
/// "EmailTemplates". The HTML may contain the <c>{{domainName}}</c> placeholder.
/// </summary>
public sealed class EmailTemplateOverride
{
public string Kind { get; set; } = string.Empty;
public string Html { get; set; } = string.Empty;
public string UpdatedBy { get; set; } = string.Empty;
public DateTime UpdatedUtc { get; set; }
}
}
@@ -166,6 +166,17 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string? TrialGrantedBy { get; set; }
/// <summary>
/// When the tenant's EFFECTIVE edition last dropped Pro → Community via the plan
/// endpoint (UTC). Anchors the retention downgrade grace period: for
/// RetentionDowngradeGraceDays after losing Pro the retention cap stays at the Pro
/// value so a downgrade (e.g. non-payment) does not immediately hard-delete data
/// older than the Community cap. Trial expiry needs no write — TrialExpiresUtc
/// itself is the anchor there. Cleared whenever the tenant becomes effectively Pro
/// again. Backend-only: not delivered to the agent (no ConfigVersion impact).
/// </summary>
public DateTime? ProDowngradedUtc { get; set; }
/// <summary>
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
/// Comma-separated list
@@ -366,6 +377,16 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool? EnableTimezoneAutoSet { get; set; }
/// <summary>
/// Whether to set the Delivery Optimization group ID (DOGroupId policy value) from a
/// network fingerprint: a deterministic GUID derived from the default gateway's IP and
/// MAC address, so devices on the same local network peer with each other (byte layout
/// is RealmJoin-compatible). Only takes effect with DO Download Mode = Group (2);
/// existing DOGroupId/DOGroupIdSource policies (Intune/GPO) are never overwritten.
/// null = use agent default (false).
/// </summary>
public bool? EnableDoGroupIdAutoSet { get; set; }
/// <summary>
/// Whether to write a match log for every IME log line matched by a pattern.
/// When true, the agent writes to the default path Constants.ImeMatchLogPath.
@@ -799,6 +820,7 @@ namespace AutopilotMonitor.Shared.Models
{
channel.Url = RedactNullable(channel.Url);
channel.CustomHeadersJson = RedactNullable(channel.CustomHeadersJson);
channel.SigningSecret = RedactNullable(channel.SigningSecret);
}
return Notifications.NotificationChannel.SerializeList(channels);
@@ -857,6 +879,7 @@ namespace AutopilotMonitor.Shared.Models
continue;
if (channel.Url == Constants.RedactedSecretPlaceholder) channel.Url = match.Url;
if (channel.CustomHeadersJson == Constants.RedactedSecretPlaceholder) channel.CustomHeadersJson = match.CustomHeadersJson;
if (channel.SigningSecret == Constants.RedactedSecretPlaceholder) channel.SigningSecret = match.SigningSecret;
}
NotificationChannelsJson = Notifications.NotificationChannel.SerializeList(incoming);
@@ -0,0 +1,183 @@
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Gate for a built-in diagnostics section. The agent evaluates it against its effective
/// configuration and the enrollment scenario; the portal renders it as a context pill.
/// </summary>
public enum DiagnosticsSectionCondition
{
/// <summary>Collected by every agent on every enrollment.</summary>
Always = 0,
/// <summary>
/// Collected only when the tenant's RealmJoin Watcher is enabled
/// (<c>AnalyzerConfiguration.EnableRealmJoinWatcher</c>).
/// </summary>
RealmJoinWatcher = 1,
/// <summary>
/// Collected only on Windows Autopilot Device Preparation enrollments (deterministic
/// WDP marker — see <c>EnrollmentRegistryDetector.IsDeterministicDevicePreparation</c>).
/// </summary>
DevicePreparation = 2,
}
/// <summary>
/// One built-in collection section of the diagnostics ZIP. Pure data — no I/O. The agent
/// expands <see cref="SourceFolder"/> (environment variables and the
/// <see cref="DiagnosticsBuiltInSections.UserProfileToken"/>) at collection time.
/// </summary>
public sealed class DiagnosticsBuiltInSection
{
/// <summary>Stable key — used for test overrides and package-manifest lines.</summary>
public string Id { get; }
/// <summary>Folder inside the ZIP (e.g. <c>AgentLogs</c>, <c>RealmJoinLogs/Windows</c>).</summary>
public string ZipFolder { get; }
/// <summary>
/// UNEXPANDED source folder: may contain <c>%ProgramData%</c> or the
/// <see cref="DiagnosticsBuiltInSections.UserProfileToken"/>.
/// </summary>
public string SourceFolder { get; }
/// <summary>File-name patterns collected from <see cref="SourceFolder"/>.</summary>
public IReadOnlyList<string> Patterns { get; }
/// <summary>When true, subdirectories are walked recursively (tree preserved in the ZIP).</summary>
public bool IncludeSubfolders { get; }
/// <summary>Human-readable description shown in the portal.</summary>
public string Description { get; }
public DiagnosticsSectionCondition Condition { get; }
public DiagnosticsBuiltInSection(
string id,
string zipFolder,
string sourceFolder,
string[] patterns,
bool includeSubfolders,
string description,
DiagnosticsSectionCondition condition = DiagnosticsSectionCondition.Always)
{
Id = id;
ZipFolder = zipFolder;
SourceFolder = sourceFolder;
Patterns = patterns;
IncludeSubfolders = includeSubfolders;
Description = description;
Condition = condition;
}
}
/// <summary>
/// The single source of truth for what a diagnostics package collects BEFORE any
/// admin-configured path: the agent iterates <see cref="All"/> in order when building the
/// archive, and the backend serves the same list to the portal (GET diagnostics/paths) so
/// what administrators see is exactly what the agent does. Built-in sections are reviewed
/// code and therefore bypass the configured-path guards (DiagnosticsPathGuards), which exist
/// to validate admin-typed input.
/// </summary>
public static class DiagnosticsBuiltInSections
{
/// <summary>
/// Custom token resolved by the agent to the signed-in user's profile folder
/// (the agent runs as SYSTEM, so <c>%USERPROFILE%</c> would point at the SYSTEM profile).
/// </summary>
public const string UserProfileToken = "%LOGGED_ON_USER_PROFILE%";
/// <summary>
/// File extensions collected from log folders (Agent + IME): standard logs, structured
/// data, traces, event logs, and diagnostics archives.
/// </summary>
public static readonly string[] LogFilePatterns =
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
};
/// <summary>
/// Patterns collected from the agent state folder: the log patterns plus completion
/// markers (<c>enrollment-complete.marker</c>, <c>whiteglove-backfill-state.json</c>).
/// </summary>
public static readonly string[] StateFilePatterns =
{
"*.log", "*.txt", "*.json", "*.jsonl",
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab",
"*.complete", "*.marker",
};
/// <summary>
/// Marker patterns from the top-level data directory. Kept tight on purpose: only
/// completion/exit markers, no session.id / bootstrap.json (config, not forensic state).
/// </summary>
public static readonly string[] RootMarkerPatterns = { "*.complete", "*.marker" };
/// <summary>Telemetry spool: only the JSON-shaped files (pending uploads + upload cursor).</summary>
public static readonly string[] SpoolFilePatterns = { "*.jsonl", "*.json" };
/// <summary>
/// Ordered catalog — archive order equals catalog order. The Always sections form the
/// historical package layout and are lock-tested; the conditional sections were added
/// 2026-08-23 (RealmJoin logs behind the tenant's RealmJoin Watcher toggle, the Device
/// Preparation bootstrapper event log behind the deterministic WDP marker).
/// </summary>
public static readonly IReadOnlyList<DiagnosticsBuiltInSection> All = new[]
{
new DiagnosticsBuiltInSection(
"AgentLogs", "AgentLogs", Constants.LogDirectory, LogFilePatterns, false,
"Autopilot Monitor agent logs"),
new DiagnosticsBuiltInSection(
"ImeLogs", "ImeLogs", @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs", LogFilePatterns, false,
"Intune Management Extension logs"),
// Active event-log channels hold their .evtx exclusively locked; the agent exports
// the channel via wevtutil, which costs seconds — only worth it on WDP enrollments,
// where this channel IS the provisioning record (batches, workloads, errors).
new DiagnosticsBuiltInSection(
"ImeBootstrapperEventLog", "ImeLogs", @"C:\Windows\System32\winevt\Logs",
new[] { "BootstrapperAgentServiceLogProvider.evtx" }, false,
"Device Preparation event log (IME bootstrapper agent)",
DiagnosticsSectionCondition.DevicePreparation),
new DiagnosticsBuiltInSection(
"AgentState", "AgentState", Constants.StateDirectory, StateFilePatterns, true,
"Agent decision-engine state, journal and markers (incl. quarantine and WhiteGlove Part-1 buckets)"),
new DiagnosticsBuiltInSection(
"AgentSpool", "AgentSpool", Constants.SpoolDirectory, SpoolFilePatterns, false,
"Pending telemetry uploads and upload cursor"),
new DiagnosticsBuiltInSection(
"AgentMarkers", "AgentMarkers", Constants.AgentDataDirectory, RootMarkerPatterns, false,
"Top-level completion and clean-exit markers"),
// RealmJoin — opt-in per tenant (RealmJoin Watcher). ZIP layout mirrors the disk:
// C:\Windows\Logs\{realmjoin*.log, RealmJoin\...} → RealmJoinLogs/Windows/...,
// %LOCALAPPDATA%\RealmJoin\{tray*.log, Logs\...} → RealmJoinLogs/User/...
new DiagnosticsBuiltInSection(
"RealmJoinWindows", "RealmJoinLogs/Windows", @"C:\Windows\Logs",
new[] { "realmjoin*.log" }, false,
"RealmJoin client logs",
DiagnosticsSectionCondition.RealmJoinWatcher),
new DiagnosticsBuiltInSection(
"RealmJoinPackages", "RealmJoinLogs/Windows/RealmJoin", @"C:\Windows\Logs\RealmJoin",
new[] { "*.log" }, true,
"RealmJoin package logs",
DiagnosticsSectionCondition.RealmJoinWatcher),
new DiagnosticsBuiltInSection(
"RealmJoinChoco", "RealmJoinLogs/Choco", @"%ProgramData%\RealmJoin\choco\logs",
new[] { "*.log" }, true,
"RealmJoin Chocolatey install logs",
DiagnosticsSectionCondition.RealmJoinWatcher),
new DiagnosticsBuiltInSection(
"RealmJoinUserTray", "RealmJoinLogs/User", UserProfileToken + @"\AppData\Local\RealmJoin",
new[] { "tray*.log" }, false,
"RealmJoin tray logs (signed-in user)",
DiagnosticsSectionCondition.RealmJoinWatcher),
new DiagnosticsBuiltInSection(
"RealmJoinUserLogs", "RealmJoinLogs/User/Logs", UserProfileToken + @"\AppData\Local\RealmJoin\Logs",
new[] { "*.log" }, true,
"RealmJoin per-user logs (signed-in user)",
DiagnosticsSectionCondition.RealmJoinWatcher),
};
}
}

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