diff --git a/rules/analyze/ANALYZE-OFFICE-001.json b/rules/analyze/ANALYZE-OFFICE-001.json new file mode 100644 index 00000000..ad48e06c --- /dev/null +++ b/rules/analyze/ANALYZE-OFFICE-001.json @@ -0,0 +1,39 @@ +{ + "$schema": "../schema/analyze-rule.schema.json", + "ruleId": "ANALYZE-OFFICE-001", + "title": "Microsoft 365 Apps Install Failed", + "description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) background install did not reach completion. The V2 OfficeInstallDetector watches the C2R client directly (registry + process) and emits office_install_failed when the install scenario was abandoned (worker gone and streaming never finished) or a C2R failure code was observed. This catches Office failures that the Intune integrated app type hides, because IME reports the app done within 1-2 minutes while C2R keeps installing in the background.", + "severity": "warning", + "category": "apps", + "version": "1.0.0", + "enabled": true, + "baseConfidence": 75, + "conditions": [ + { + "signal": "office_install_failed", + "source": "event_type", + "eventType": "office_install_failed", + "operator": "exists", + "value": "", + "required": true + } + ], + "confidenceFactors": [ + { "signal": "error_code_present", "condition": "exists", "weight": 15 } + ], + "confidenceThreshold": 40, + "explanation": "The Microsoft 365 Apps (Office Click-to-Run) install did not finish.\n\nThe OfficeInstallDetector watches the C2R client directly and reports the real background install lifecycle. The Intune \"integrated\" Microsoft 365 Apps app type only pushes the configuration XML and starts the C2R bootstrapper, so IME marks the app done within 1-2 minutes while C2R keeps streaming and laying down the product for several more minutes. This event means C2R activity ended (the OfficeC2RClient worker exited and the install scenario cleared) without the StreamingFinished flag being set, or a C2R failure code was observed.\n\nReview the office_install_failed event payload: `products`, `channel`, `phase`, `streamingFinished`, `errorCode` (best-effort — may be empty) and `durationSeconds`.", + "remediation": [ + { + "title": "Investigate the Office C2R install failure", + "steps": [ + "Open the office_install_failed event and review products, channel, phase, streamingFinished and errorCode", + "Check the Click-to-Run logs on the device (%windir%\\Temp and %temp%, files named -yyyymmdd-nnnn.log) for the failing operation", + "Verify the device had network reachability to the Office CDN / Microsoft Connected Cache during the enrollment window", + "Confirm the Intune Microsoft 365 Apps app configuration (products, channel, architecture) matches what the device can install", + "Re-check the session timeline for a reboot or agent shutdown that may have interrupted the background C2R install" + ] + } + ], + "tags": ["apps", "office", "microsoft-365-apps", "click-to-run", "install-failure"] +} diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json index 1a9cfb60..ded71eb8 100644 --- a/rules/dist/analyze-rules.json +++ b/rules/dist/analyze-rules.json @@ -1351,6 +1351,54 @@ "security" ] }, + { + "ruleId": "ANALYZE-OFFICE-001", + "title": "Microsoft 365 Apps Install Failed", + "description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) background install did not reach completion. The V2 OfficeInstallDetector watches the C2R client directly (registry + process) and emits office_install_failed when the install scenario was abandoned (worker gone and streaming never finished) or a C2R failure code was observed. This catches Office failures that the Intune integrated app type hides, because IME reports the app done within 1-2 minutes while C2R keeps installing in the background.", + "severity": "warning", + "category": "apps", + "version": "1.0.0", + "enabled": true, + "baseConfidence": 75, + "conditions": [ + { + "signal": "office_install_failed", + "source": "event_type", + "eventType": "office_install_failed", + "operator": "exists", + "value": "", + "required": true + } + ], + "confidenceFactors": [ + { + "signal": "error_code_present", + "condition": "exists", + "weight": 15 + } + ], + "confidenceThreshold": 40, + "explanation": "The Microsoft 365 Apps (Office Click-to-Run) install did not finish.\n\nThe OfficeInstallDetector watches the C2R client directly and reports the real background install lifecycle. The Intune \"integrated\" Microsoft 365 Apps app type only pushes the configuration XML and starts the C2R bootstrapper, so IME marks the app done within 1-2 minutes while C2R keeps streaming and laying down the product for several more minutes. This event means C2R activity ended (the OfficeC2RClient worker exited and the install scenario cleared) without the StreamingFinished flag being set, or a C2R failure code was observed.\n\nReview the office_install_failed event payload: `products`, `channel`, `phase`, `streamingFinished`, `errorCode` (best-effort — may be empty) and `durationSeconds`.", + "remediation": [ + { + "title": "Investigate the Office C2R install failure", + "steps": [ + "Open the office_install_failed event and review products, channel, phase, streamingFinished and errorCode", + "Check the Click-to-Run logs on the device (%windir%\\Temp and %temp%, files named -yyyymmdd-nnnn.log) for the failing operation", + "Verify the device had network reachability to the Office CDN / Microsoft Connected Cache during the enrollment window", + "Confirm the Intune Microsoft 365 Apps app configuration (products, channel, architecture) matches what the device can install", + "Re-check the session timeline for a reboot or agent shutdown that may have interrupted the background C2R install" + ] + } + ], + "tags": [ + "apps", + "office", + "microsoft-365-apps", + "click-to-run", + "install-failure" + ] + }, { "ruleId": "ANALYZE-SEC-001", "title": "Secure Boot UEFI CA 2023 certificate not deployed", diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs new file mode 100644 index 00000000..eb3a8a32 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs @@ -0,0 +1,340 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office; +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.Shared; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office +{ + /// + /// OfficeInstallDetector (Rev 2, event-driven) — the pure decision core driven via its On* + /// entry points with synthetic snapshots (no real WMI / RegNotify / registry). Asserts: events + /// only after a worker start, a single started, progress on real change only (no heartbeat), + /// DO-sample folding (download-%), and completed / failed terminal resolution with version + duration. + /// + public sealed class OfficeInstallDetectorTests + { + private static readonly DateTime At = new DateTime(2026, 6, 8, 12, 0, 0, DateTimeKind.Utc); + + private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info); + + private sealed class Rig : IDisposable + { + public FakeSignalIngressSink Sink { get; } + public VirtualClock Clock { get; } + public OfficeInstallDetector Sut { get; } + public OfficeC2RSnapshot Current { get; set; } + private readonly TempDirectory _tmp; + + public Rig(OfficeC2RSnapshot initial) + { + _tmp = new TempDirectory(); + Sink = new FakeSignalIngressSink(); + Clock = new VirtualClock(At); + Current = initial; + var post = new InformationalEventPost(Sink, Clock); + Sut = new OfficeInstallDetector("S1", "T1", post, NewLogger(_tmp.Path), Clock) + { + SnapshotProvider = () => Current, + }; + } + + public List OfficeEvents() => Sink.Posted + .Where(p => p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et != null && et.StartsWith("office_install_", StringComparison.Ordinal)) + .ToList(); + + public void Dispose() => _tmp.Dispose(); + } + + private static OfficeC2RSnapshot ActiveStreaming() + { + var s = new OfficeC2RSnapshot + { + ConfigurationKeyPresent = true, + Channel = "Current", + Platform = "x64", + StreamingFinished = false, + ActiveScenarioPresent = true, + ActiveScenarioName = "INSTALL", + OfficeC2RClientRunning = true, + }; + s.Products.Add("O365ProPlusRetail"); + return s; + } + + private static OfficeC2RSnapshot CompletedIdle(string version = "16.0.17628.20144") + { + var s = new OfficeC2RSnapshot + { + ConfigurationKeyPresent = true, + Channel = "Current", + Platform = "x64", + StreamingFinished = true, + ActiveScenarioPresent = false, + ActiveScenarioName = null, + OfficeC2RClientRunning = false, + VersionToReport = version, + }; + s.Products.Add("O365ProPlusRetail"); + return s; + } + + private static string EventType(FakeSignalIngressSink.PostedSignal p) => p.Payload![SignalPayloadKeys.EventType]; + private static string Severity(FakeSignalIngressSink.PostedSignal p) => p.Payload![SignalPayloadKeys.Severity]; + private static Dictionary Data(FakeSignalIngressSink.PostedSignal p) + => Assert.IsType>(p.TypedPayload); + + // ---------------------------------------------------------------- idle guard + + [Fact] + public void Events_only_after_worker_started() + { + using var rig = new Rig(CompletedIdle()); + + // No worker-start signal yet → registry/DO/stop are no-ops. + rig.Sut.OnRegistryChanged(); + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 1, FileSize = 100, TotalBytesDownloaded = 50 }); + rig.Sut.OnWorkerStopped(); + + Assert.Empty(rig.OfficeEvents()); + } + + // ---------------------------------------------------------------- started + + [Fact] + public void Worker_started_emits_exactly_one_started_with_products_and_phase() + { + using var rig = new Rig(ActiveStreaming()); + + rig.Sut.OnWorkerStarted(); + + var started = Assert.Single(rig.OfficeEvents()); + Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(started)); + Assert.Equal("Info", Severity(started)); + + var data = Data(started); + Assert.Equal("Streaming", data["phase"]); + Assert.Equal("INSTALL", data["scenario"]); + Assert.Contains("O365ProPlusRetail", (List)data["products"]); + Assert.Equal("Current", data["channel"]); + Assert.Equal("x64", data["platform"]); + } + + [Fact] + public void Second_worker_started_is_ignored_one_lifecycle() + { + using var rig = new Rig(ActiveStreaming()); + + rig.Sut.OnWorkerStarted(); + rig.Sut.OnWorkerStarted(); // already Active — must not emit a second started + + Assert.Single(rig.OfficeEvents()); + } + + // ---------------------------------------------------------------- progress (no heartbeat) + + [Fact] + public void Registry_change_without_real_delta_emits_no_progress_heartbeat() + { + using var rig = new Rig(ActiveStreaming()); + + rig.Sut.OnWorkerStarted(); // started + rig.Sut.OnRegistryChanged(); // identical snapshot — must NOT emit progress + rig.Sut.OnRegistryChanged(); + + var events = rig.OfficeEvents(); + Assert.Single(events); + Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(events[0])); + } + + [Fact] + public void Scenario_value_change_emits_one_progress_event() + { + var s1 = ActiveStreaming(); + s1.ScenarioValues["INSTALL\\CurrentOperation"] = "Downloading"; + using var rig = new Rig(s1); + + rig.Sut.OnWorkerStarted(); // started + + var s2 = ActiveStreaming(); + s2.ScenarioValues["INSTALL\\CurrentOperation"] = "Applying"; // changed value + rig.Current = s2; + rig.Sut.OnRegistryChanged(); // progress + + var events = rig.OfficeEvents(); + Assert.Equal(2, events.Count); + Assert.Equal(Constants.EventTypes.OfficeInstallProgress, EventType(events[1])); + } + + // ---------------------------------------------------------------- DO folding + + [Fact] + public void Office_do_sample_folds_download_percent_into_progress() + { + using var rig = new Rig(ActiveStreaming()); + + rig.Sut.OnWorkerStarted(); // started + + rig.Sut.OnOfficeDoSample(new OfficeDoSample + { + JobCount = 2, + FileSize = 1000, + TotalBytesDownloaded = 250, + BytesFromPeers = 100, + BytesFromHttp = 150, + PercentPeerCaching = 40, + DownloadMode = 1, + }); + + var events = rig.OfficeEvents(); + Assert.Equal(2, events.Count); + var progress = events[1]; + Assert.Equal(Constants.EventTypes.OfficeInstallProgress, EventType(progress)); + + var data = Data(progress); + Assert.Equal(250L, data["doTotalBytesDownloaded"]); + Assert.Equal(25, data["downloadPercent"]); + Assert.Equal(2, data["doJobCount"]); + } + + [Fact] + public void Repeated_identical_do_sample_emits_no_progress() + { + using var rig = new Rig(ActiveStreaming()); + rig.Sut.OnWorkerStarted(); + + var sample = new OfficeDoSample { JobCount = 1, FileSize = 1000, TotalBytesDownloaded = 250 }; + rig.Sut.OnOfficeDoSample(sample); // progress (bytes advanced) + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 1, FileSize = 1000, TotalBytesDownloaded = 250 }); // same bytes — no progress + + var events = rig.OfficeEvents(); + Assert.Equal(2, events.Count); // started + one progress only + } + + // ---------------------------------------------------------------- completed + + [Fact] + public void Worker_stopped_with_streaming_finished_emits_completed_with_version_and_duration() + { + using var rig = new Rig(ActiveStreaming()); + + rig.Sut.OnWorkerStarted(); // started + rig.Clock.Advance(TimeSpan.FromSeconds(412)); + rig.Current = CompletedIdle("16.0.17628.20144"); + rig.Sut.OnWorkerStopped(); // completed + + var events = rig.OfficeEvents(); + Assert.Equal(2, events.Count); + var completed = events[1]; + Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, EventType(completed)); + Assert.Equal("Info", Severity(completed)); + + var data = Data(completed); + Assert.Equal("Completed", data["phase"]); + Assert.Equal("16.0.17628.20144", data["versionReached"]); + Assert.Equal(412, data["durationSeconds"]); + } + + [Fact] + public void Terminal_state_latches_no_further_events() + { + using var rig = new Rig(ActiveStreaming()); + + rig.Sut.OnWorkerStarted(); + rig.Current = CompletedIdle(); + rig.Sut.OnWorkerStopped(); // completed + rig.Current = ActiveStreaming(); + rig.Sut.OnWorkerStarted(); // must NOT re-trigger (one lifecycle) + rig.Sut.OnRegistryChanged(); + + var events = rig.OfficeEvents(); + Assert.Equal(2, events.Count); + Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, EventType(events[1])); + } + + // ---------------------------------------------------------------- failed + + [Fact] + public void Worker_stopped_without_streaming_finished_emits_failed_warning() + { + using var rig = new Rig(ActiveStreaming()); + + rig.Sut.OnWorkerStarted(); + + rig.Current = new OfficeC2RSnapshot + { + ConfigurationKeyPresent = true, + StreamingFinished = false, + ActiveScenarioPresent = false, + OfficeC2RClientRunning = false, + }; + rig.Sut.OnWorkerStopped(); // failed (abandoned) + + var events = rig.OfficeEvents(); + Assert.Equal(2, events.Count); + Assert.Equal(Constants.EventTypes.OfficeInstallFailed, EventType(events[1])); + Assert.Equal("Warning", Severity(events[1])); + } + + [Fact] + public void Observed_error_code_during_progress_emits_failed_error_with_code() + { + using var rig = new Rig(ActiveStreaming()); + + rig.Sut.OnWorkerStarted(); + + var withError = ActiveStreaming(); + withError.ErrorCode = "0x80070005"; + rig.Current = withError; + rig.Sut.OnRegistryChanged(); // failed (error code) + + var events = rig.OfficeEvents(); + Assert.Equal(2, events.Count); + var failed = events[1]; + Assert.Equal(Constants.EventTypes.OfficeInstallFailed, EventType(failed)); + Assert.Equal("Error", Severity(failed)); + Assert.Equal("0x80070005", Data(failed)["errorCode"]); + } + + // ---------------------------------------------------------------- error-code heuristic + + [Theory] + [InlineData("0x80070005", true)] // hex HRESULT + [InlineData("17002", true)] // decimal C2R exit code + [InlineData("-2147024891", true)] // negative decimal HRESULT + [InlineData("0x0", false)] // zero hex + [InlineData("0", false)] // zero decimal + [InlineData("Success", false)] // benign textual value (e.g. Result=Success) + [InlineData("Completed", false)] + [InlineData("InProgress", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsNonZeroNumericCode_only_treats_nonzero_numbers_as_errors(string? value, bool expected) + { + Assert.Equal(expected, OfficeInstallDetector.IsNonZeroNumericCode(value!)); + } + + // ---------------------------------------------------------------- ctor + + [Fact] + public void Ctor_rejects_null_dependencies() + { + using var tmp = new TempDirectory(); + var logger = NewLogger(tmp.Path); + var clock = new VirtualClock(At); + var post = new InformationalEventPost(new FakeSignalIngressSink(), clock); + + Assert.Throws(() => new OfficeInstallDetector(null!, "T1", post, logger, clock)); + Assert.Throws(() => new OfficeInstallDetector("S1", "T1", post, logger, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs new file mode 100644 index 00000000..b1fe5d79 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs @@ -0,0 +1,514 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office +{ + /// + /// Surfaces the REAL Microsoft 365 Apps (Office Click-to-Run / C2R) background install lifecycle + /// — even when the Intune "integrated" Office app reports done to IME within 1-2 minutes while C2R + /// keeps streaming/laying down the product for many more minutes. + /// + /// Event-driven, no idle polling (Rev 2). This class is the pure decision core; the + /// OfficeInstallDetectorHost owns the OS watchers and drives it: + /// + /// — Office C2R worker (OfficeC2RClient.exe) started + /// (WMI Win32_ProcessStartTrace push or startup probe) → emit + /// . + /// — RegNotifyChangeKeyValue push on the ClickToRun + /// key (Configuration value / Scenario subkey churn) → emit + /// on real change only (no heartbeat). + /// — aggregated Delivery-Optimization stats for Office's CDN + /// download (from the extended DeliveryOptimizationCollector). DO has no push API, so it is + /// sampled while the worker is alive; a byte advance folds a real download-% into progress. + /// — last worker exited () → + /// emit / + /// with version + duration + DO summary. + /// + /// + /// + /// Registry reads use the forced Registry64 view (AnyCPU net48 may otherwise read the stale + /// WOW6432Node mirror). One install lifecycle per detector (latches Terminal); a later C2R update + /// in the same session is out of scope for v1. + /// + /// + public class OfficeInstallDetector + { + internal const string SourceName = "OfficeInstallDetector"; + internal const string AppName = "Microsoft 365 Apps"; + + private const string ConfigurationSubKey = @"SOFTWARE\Microsoft\Office\ClickToRun\Configuration"; + private const string ScenarioSubKey = @"SOFTWARE\Microsoft\Office\ClickToRun\Scenario"; + + // Value-name substrings scanned (case-insensitive) for a best-effort failure code in the + // undocumented Scenario value set. Deliberately NARROW (no bare "result" — that would match a + // benign "Result=Success"); a hit is only treated as a failure when the VALUE is a non-zero + // numeric/hex code (see IsNonZeroNumericCode). TODO(office-followup): validate the real value + // names against a field enrollment and widen only if needed. + private static readonly string[] ErrorValueHints = { "errorcode", "hresult", "exitcode", "lasterror", "failurecode" }; + + private readonly string _sessionId; + private readonly string _tenantId; + private readonly InformationalEventPost _post; + private readonly AgentLogger _logger; + private readonly IClock _clock; + private readonly object _lock = new object(); + + // Test seam: when set, used instead of reading the live registry/process state. Production + // never assigns it. Surfaced to the test assembly via InternalsVisibleTo. + internal Func? SnapshotProvider { get; set; } + + private enum DetectorState { Idle, Active, Terminal } + + private DetectorState _state = DetectorState.Idle; + private DateTime? _startedAtUtc; + private string? _lastProgressSignature; + private OfficeDoSample? _lastDo; + + public OfficeInstallDetector( + string sessionId, + string tenantId, + InformationalEventPost post, + AgentLogger logger, + IClock clock) + { + _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)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + // ----------------------------------------------------------------------- + // Event entry points (driven by the host's process + registry watchers) + // ----------------------------------------------------------------------- + + /// Office C2R worker started — open the install window. + public void OnWorkerStarted() + { + lock (_lock) + { + if (_state != DetectorState.Idle) return; + var snap = ReadSnapshotSafe(); + if (snap == null) return; + + _state = DetectorState.Active; + _startedAtUtc = _clock.UtcNow; + _lastProgressSignature = BuildProgressSignature(snap, _lastDo); + EmitLifecycle(Constants.EventTypes.OfficeInstallStarted, snap, EventSeverity.Info, PhaseOf(snap), isTerminal: false); + } + } + + /// ClickToRun registry changed — progress or a discovered error code. + public void OnRegistryChanged() + { + lock (_lock) + { + if (_state != DetectorState.Active) return; + var snap = ReadSnapshotSafe(); + if (snap == null) return; + EvaluateActive(snap); + } + } + + /// Latest aggregated Office DO stats — folds a real download-% into progress. + public void OnOfficeDoSample(OfficeDoSample sample) + { + if (sample == null) return; + lock (_lock) + { + _lastDo = sample; + if (_state != DetectorState.Active) return; + var snap = ReadSnapshotSafe(); + if (snap == null) return; + EvaluateActive(snap); + } + } + + /// Last Office C2R worker exited — resolve terminal completed / failed. + public void OnWorkerStopped() + { + lock (_lock) + { + if (_state != DetectorState.Active) return; + var snap = ReadSnapshotSafe(); + if (snap == null) snap = new OfficeC2RSnapshot(); + Finalize(snap); + } + } + + // ----------------------------------------------------------------------- + // State machine + // ----------------------------------------------------------------------- + + private void EvaluateActive(OfficeC2RSnapshot snap) + { + if (!string.IsNullOrEmpty(snap.ErrorCode)) + { + Finalize(snap); + return; + } + + var sig = BuildProgressSignature(snap, _lastDo); + if (!string.Equals(sig, _lastProgressSignature, StringComparison.Ordinal)) + { + _lastProgressSignature = sig; + EmitLifecycle(Constants.EventTypes.OfficeInstallProgress, snap, EventSeverity.Info, PhaseOf(snap), isTerminal: false); + } + } + + private void Finalize(OfficeC2RSnapshot snap) + { + if (_state == DetectorState.Terminal) return; + _state = DetectorState.Terminal; + + string eventType; + EventSeverity severity; + string phase; + if (!string.IsNullOrEmpty(snap.ErrorCode)) + { + eventType = Constants.EventTypes.OfficeInstallFailed; severity = EventSeverity.Error; phase = "Failed"; + } + else if (snap.StreamingFinished) + { + eventType = Constants.EventTypes.OfficeInstallCompleted; severity = EventSeverity.Info; phase = "Completed"; + } + else + { + // Worker gone without StreamingFinished → abandoned install. + eventType = Constants.EventTypes.OfficeInstallFailed; severity = EventSeverity.Warning; phase = "Failed"; + } + + EmitLifecycle(eventType, snap, severity, phase, isTerminal: true); + } + + private static string PhaseOf(OfficeC2RSnapshot snap) + => snap.StreamingFinished ? "Finalizing" : "Streaming"; + + /// + /// Stable signature of everything we treat as "progress". A change in any of these (registry + /// phase/version/scenario-value, or a DO byte advance) emits one progress event; an unchanged + /// signature emits nothing — this keeps the detector heartbeat-free. + /// + private static string BuildProgressSignature(OfficeC2RSnapshot snap, OfficeDoSample? doSample) + { + var parts = new List + { + "phase=" + PhaseOf(snap), + "streaming=" + snap.StreamingFinished, + "version=" + (snap.VersionToReport ?? string.Empty), + "scenario=" + (snap.ActiveScenarioName ?? string.Empty), + }; + if (snap.ScenarioValues != null && snap.ScenarioValues.Count > 0) + { + foreach (var kv in snap.ScenarioValues.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) + parts.Add(kv.Key + "=" + kv.Value); + } + if (doSample != null) + { + parts.Add("doBytes=" + doSample.TotalBytesDownloaded); + parts.Add("doPct=" + doSample.DownloadPercent); + } + return string.Join("|", parts); + } + + // ----------------------------------------------------------------------- + // Payload + // ----------------------------------------------------------------------- + + private void EmitLifecycle(string eventType, OfficeC2RSnapshot snap, EventSeverity severity, string phase, bool isTerminal) + { + var data = BuildPayload(snap, phase, isTerminal, _lastDo); + var message = BuildMessage(eventType, snap, phase, _lastDo); + + _post.Emit(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = eventType, + Severity = severity, + Source = SourceName, + Phase = EnrollmentPhase.Unknown, + Message = message, + Data = data, + ImmediateUpload = true, + }); + } + + internal Dictionary BuildPayload(OfficeC2RSnapshot snap, string phase, bool isTerminal, OfficeDoSample? doSample) + { + var elapsedSeconds = _startedAtUtc.HasValue + ? Math.Max(0, (int)(_clock.UtcNow - _startedAtUtc.Value).TotalSeconds) + : 0; + + var data = new Dictionary + { + { "appName", AppName }, + { "products", snap.Products ?? new List() }, + { "channel", snap.Channel ?? string.Empty }, + { "platform", snap.Platform ?? string.Empty }, + { "scenario", snap.ActiveScenarioName ?? string.Empty }, + { "phase", phase }, + { "versionReached", snap.VersionToReport ?? string.Empty }, + { "streamingFinished", snap.StreamingFinished }, + { "officeC2RClientRunning", snap.OfficeC2RClientRunning }, + { "errorCode", (object?)snap.ErrorCode ?? string.Empty }, + { "scanErrors", (snap.Errors ?? new List()).ToList() }, + }; + + if (doSample != null) + { + // Delivery Optimization download telemetry for Office's CDN content (folded in to + // avoid creating a phantom app in the backend AppInstallSummary — Office is not an IME app). + data["doJobCount"] = doSample.JobCount; + data["doFileSize"] = doSample.FileSize; + data["doTotalBytesDownloaded"] = doSample.TotalBytesDownloaded; + data["doBytesFromPeers"] = doSample.BytesFromPeers; + data["doBytesFromHttp"] = doSample.BytesFromHttp; + data["doBytesFromCacheServer"] = doSample.BytesFromCacheServer; + data["doPercentPeerCaching"] = doSample.PercentPeerCaching; + data["doDownloadMode"] = doSample.DownloadMode; + if (doSample.DownloadPercent.HasValue) + data["downloadPercent"] = doSample.DownloadPercent.Value; + } + + if (isTerminal) + data["durationSeconds"] = elapsedSeconds; + else + data["elapsedSeconds"] = elapsedSeconds; + + return data; + } + + private static string BuildMessage(string eventType, OfficeC2RSnapshot snap, string phase, OfficeDoSample? doSample) + { + var product = snap.Products != null && snap.Products.Count > 0 ? string.Join(",", snap.Products) : AppName; + var pct = doSample?.DownloadPercent; + if (eventType == Constants.EventTypes.OfficeInstallStarted) + return $"{SourceName}: {product} install detected (phase={phase})"; + if (eventType == Constants.EventTypes.OfficeInstallProgress) + return pct.HasValue + ? $"{SourceName}: {product} install progress (phase={phase}, download {pct.Value}%)" + : $"{SourceName}: {product} install progress (phase={phase})"; + if (eventType == Constants.EventTypes.OfficeInstallCompleted) + return $"{SourceName}: {product} install completed{(string.IsNullOrEmpty(snap.VersionToReport) ? string.Empty : $" (v{snap.VersionToReport})")}"; + return $"{SourceName}: {product} install failed{(string.IsNullOrEmpty(snap.ErrorCode) ? string.Empty : $" (code {snap.ErrorCode})")}"; + } + + // ----------------------------------------------------------------------- + // Registry + process read. Fail-soft — errors captured, never thrown. + // ----------------------------------------------------------------------- + + private OfficeC2RSnapshot? ReadSnapshotSafe() + { + try { return SnapshotProvider != null ? SnapshotProvider() : ReadSnapshot(); } + catch (Exception ex) { _logger.Warning($"[{SourceName}] snapshot read failed: {ex.Message}"); return null; } + } + + internal virtual OfficeC2RSnapshot ReadSnapshot() + { + var snap = new OfficeC2RSnapshot(); + try + { + using (var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64)) + { + ReadConfiguration(hklm, snap); + ReadScenarios(hklm, snap); + } + } + catch (Exception ex) + { + snap.Errors.Add($"registry:{ex.GetType().Name}: {ex.Message}"); + } + + snap.OfficeC2RClientRunning = IsOfficeC2RClientRunning(snap); + return snap; + } + + private static void ReadConfiguration(RegistryKey hklm, OfficeC2RSnapshot snap) + { + try + { + using (var key = hklm.OpenSubKey(ConfigurationSubKey, writable: false)) + { + if (key == null) return; + snap.ConfigurationKeyPresent = true; + + var products = ReadString(key, "ProductReleaseIds"); + if (!string.IsNullOrEmpty(products)) + { + snap.Products = products! + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .ToList(); + } + + snap.Platform = ReadString(key, "Platform"); + snap.Channel = ReadString(key, "UpdateChannel") ?? ReadString(key, "CDNBaseUrl"); + snap.VersionToReport = ReadString(key, "VersionToReport") ?? ReadString(key, "ClientVersionToReport"); + snap.StreamingFinished = IsTruthy(ReadString(key, "StreamingFinished")); + } + } + catch (Exception ex) + { + snap.Errors.Add($"configuration:{ex.GetType().Name}: {ex.Message}"); + } + } + + private static void ReadScenarios(RegistryKey hklm, OfficeC2RSnapshot snap) + { + try + { + using (var scenarioRoot = hklm.OpenSubKey(ScenarioSubKey, writable: false)) + { + if (scenarioRoot == null) return; + + var scenarioNames = scenarioRoot.GetSubKeyNames(); + if (scenarioNames.Length == 0) return; + + snap.ActiveScenarioPresent = true; + snap.ActiveScenarioName = scenarioNames[0]; // primary scenario (INSTALL / FIRSTRUN / UPDATE …) + + foreach (var scenarioName in scenarioNames) + { + try + { + using (var scenarioKey = scenarioRoot.OpenSubKey(scenarioName, writable: false)) + { + if (scenarioKey == null) continue; + foreach (var valueName in scenarioKey.GetValueNames()) + { + var value = ReadString(scenarioKey, valueName); + if (value == null) continue; + snap.ScenarioValues[$"{scenarioName}\\{valueName}"] = value; + + if (snap.ErrorCode == null + && ErrorValueHints.Any(h => valueName.IndexOf(h, StringComparison.OrdinalIgnoreCase) >= 0) + && IsNonZeroNumericCode(value)) + { + snap.ErrorCode = value; + } + } + } + } + catch (Exception ex) + { + snap.Errors.Add($"scenario[{scenarioName}]:{ex.GetType().Name}: {ex.Message}"); + } + } + } + } + catch (Exception ex) + { + snap.Errors.Add($"scenario:{ex.GetType().Name}: {ex.Message}"); + } + } + + private static bool IsOfficeC2RClientRunning(OfficeC2RSnapshot snap) + { + Process[]? procs = null; + try + { + procs = Process.GetProcessesByName("OfficeC2RClient"); + return procs.Length > 0; + } + catch (Exception ex) + { + snap.Errors.Add($"process:{ex.GetType().Name}: {ex.Message}"); + return false; + } + finally + { + if (procs != null) + foreach (var p in procs) { try { p.Dispose(); } catch { } } + } + } + + private static string? ReadString(RegistryKey key, string valueName) + { + try + { + var v = key.GetValue(valueName); + if (v == null) return null; + var s = v.ToString(); + return string.IsNullOrEmpty(s) ? null : s; + } + catch { return null; } + } + + /// StreamingFinished is REG_SZ "True"/"False" or REG_DWORD 0/1 across versions. + private static bool IsTruthy(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + var v = value!.Trim(); + return v == "1" || v.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + /// + /// True only when is a non-zero numeric or hex (0x…) code. Textual + /// values such as "Success" / "Completed" / "InProgress" are NOT error codes and return false, + /// so a benign "Result=Success" never masquerades as a failure. + /// + internal static bool IsNonZeroNumericCode(string value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + var v = value.Trim(); + if (v.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + return long.TryParse(v.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hex) && hex != 0; + } + // Decimal (HRESULTs can be large or negative); reject anything non-numeric. + return long.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dec) && dec != 0; + } + } + + // -------------------------------------------------------------------------------------- + // Raw scan model (surfaced to the test assembly via InternalsVisibleTo). + // -------------------------------------------------------------------------------------- + + /// One read's worth of Office C2R facts (registry + process). + public sealed class OfficeC2RSnapshot + { + public bool ConfigurationKeyPresent { get; set; } + public List Products { get; set; } = new List(); + public string? Channel { get; set; } + public string? Platform { get; set; } + public string? VersionToReport { get; set; } + public bool StreamingFinished { get; set; } + public bool ActiveScenarioPresent { get; set; } + public string? ActiveScenarioName { get; set; } + public Dictionary ScenarioValues { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public string? ErrorCode { get; set; } + public bool OfficeC2RClientRunning { get; set; } + public List Errors { get; } = new List(); + } + + /// + /// Aggregated Delivery-Optimization stats across all Office CDN download jobs in one DO poll, + /// produced by the extended DeliveryOptimizationCollector and folded into the office_install_* events. + /// + public sealed class OfficeDoSample + { + public int JobCount { get; set; } + public long FileSize { get; set; } + public long TotalBytesDownloaded { get; set; } + public long BytesFromPeers { get; set; } + public long BytesFromHttp { get; set; } + public long BytesFromCacheServer { get; set; } + public int PercentPeerCaching { get; set; } + public int DownloadMode { get; set; } + + /// Whole-percent download progress, or null when total file size is unknown. + public int? DownloadPercent => FileSize > 0 ? (int?)Math.Min(100, (int)((TotalBytesDownloaded * 100) / FileSize)) : null; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeProcessWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeProcessWatcher.cs new file mode 100644 index 00000000..a88e49a6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeProcessWatcher.cs @@ -0,0 +1,291 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Management; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office +{ + /// + /// Event-driven watcher for the Office Click-to-Run worker process (OfficeC2RClient.exe), + /// the process C2R spawns for an active install/update operation (NOT the always-running + /// OfficeClickToRun.exe service). Drives the without any + /// idle polling: + /// + /// Start — a true ETW-backed push via WMI Win32_ProcessStartTrace + /// (), plus a one-shot startup probe so an install already + /// in flight when the agent boots (realistic during OOBE) is still caught. + /// Stop — on each tracked worker (clean OS event, same + /// pattern as ImeProcessWatcher). + /// + /// + /// fires once when the worker count transitions 0→1; + /// once when it transitions back to 0 (multiple short-lived workers during one install collapse to + /// a single active window). Fail-soft: if the WMI watcher cannot start, it is logged and the + /// startup probe still covers the already-running case — no polling fallback by design. + /// + /// + public sealed class OfficeProcessWatcher : IDisposable + { + internal const string WorkerProcessName = "OfficeC2RClient.exe"; + private const string WorkerProcessNameNoExt = "OfficeC2RClient"; + + private readonly AgentLogger _logger; + private readonly int _settleSeconds; + private readonly object _lock = new object(); + private readonly Dictionary _workers = new Dictionary(); + + private ManagementEventWatcher? _startWatcher; + private Timer? _settleTimer; + private bool _started; + private bool _disposed; + private bool _active; // "an install window is open" — stays true across short worker gaps (settle) + + /// Raised once when the first Office worker appears (install window opens). + public event EventHandler? Started; + + /// + /// Raised once when the install window closes — i.e. no worker has run for the settle window. + /// C2R spawns several short-lived OfficeC2RClient.exe processes (with small gaps) during one + /// install, so the last-worker-exit is debounced to avoid a premature terminal decision. + /// + public event EventHandler? Stopped; + + /// Grace period after the last worker exits before the window is + /// declared closed; a new worker within it keeps the window open. <=0 closes immediately. + public OfficeProcessWatcher(AgentLogger logger, int settleSeconds = 10) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _settleSeconds = settleSeconds; + } + + /// True while at least one Office worker is being tracked. + public bool IsActive + { + get { lock (_lock) { return _active; } } + } + + public void Start() + { + lock (_lock) + { + if (_started || _disposed) return; + _started = true; + } + + // 1) Startup probe — catch a worker already running before the watcher armed. + try + { + foreach (var proc in Process.GetProcessesByName(WorkerProcessNameNoExt)) + { + TrackWorker(proc); + } + } + catch (Exception ex) + { + _logger.Warning($"[OfficeProcessWatcher] startup probe failed: {ex.Message}"); + } + + // 2) ETW-backed push on worker creation — no polling. + try + { + var query = new WqlEventQuery( + $"SELECT * FROM Win32_ProcessStartTrace WHERE ProcessName = '{WorkerProcessName}'"); + _startWatcher = new ManagementEventWatcher(query); + _startWatcher.EventArrived += OnProcessStartTrace; + _startWatcher.Start(); + _logger.Info($"[OfficeProcessWatcher] watching Win32_ProcessStartTrace for {WorkerProcessName}"); + } + catch (Exception ex) + { + // No polling fallback by design — the startup probe already covers the in-flight case. + _logger.Warning($"[OfficeProcessWatcher] could not start WMI process-start watcher " + + $"({ex.GetType().Name}: {ex.Message}); relying on the startup probe only."); + _startWatcher = null; + } + } + + private void OnProcessStartTrace(object sender, EventArrivedEventArgs e) + { + try + { + var pidObj = e.NewEvent?["ProcessID"]; + if (pidObj == null) return; + var pid = Convert.ToInt32(pidObj); + + Process? proc = null; + try { proc = Process.GetProcessById(pid); } + catch { /* already gone — ignore */ } + if (proc != null) TrackWorker(proc); + } + catch (Exception ex) + { + _logger.Debug($"[OfficeProcessWatcher] start-trace handler error: {ex.Message}"); + } + } + + private void TrackWorker(Process proc) + { + bool raiseStarted = false; + try + { + lock (_lock) + { + if (_disposed) { try { proc.Dispose(); } catch { } return; } + if (_workers.ContainsKey(proc.Id)) { try { proc.Dispose(); } catch { } return; } + + try + { + proc.EnableRaisingEvents = true; + proc.Exited += OnWorkerExited; + } + catch + { + // Process exited between discovery and Exited-hookup — drop it. + try { proc.Dispose(); } catch { } + return; + } + + _workers[proc.Id] = proc; + // A worker is back — cancel any pending settle (the window must stay open). + CancelSettleTimer(); + if (!_active) + { + _active = true; + raiseStarted = true; + } + } + } + catch (Exception ex) + { + _logger.Debug($"[OfficeProcessWatcher] TrackWorker error: {ex.Message}"); + return; + } + + if (raiseStarted) + { + _logger.Info($"[OfficeProcessWatcher] Office worker started (PID={SafePid(proc)}) — install active"); + RaiseStarted(); + } + } + + private void OnWorkerExited(object? sender, EventArgs e) + { + if (!(sender is Process proc)) return; + bool raiseStoppedNow = false; + + lock (_lock) + { + if (_workers.Remove(SafePid(proc))) + { + try { proc.Exited -= OnWorkerExited; } catch { } + try { proc.Dispose(); } catch { } + } + if (_active && _workers.Count == 0) + { + if (_settleSeconds <= 0) + { + _active = false; + raiseStoppedNow = true; + } + else + { + // Debounce: C2R may start another short-lived worker after a small gap. Only + // close the window if none reappears within the settle period. + _logger.Debug($"[OfficeProcessWatcher] last worker exited — settling {_settleSeconds}s before closing the install window"); + StartSettleTimer(); + } + } + } + + if (raiseStoppedNow) + { + _logger.Info("[OfficeProcessWatcher] last Office worker exited — install window closed"); + RaiseStopped(); + } + } + + // Settle-window helpers — all callers hold _lock except the timer callback, which re-locks. + private void StartSettleTimer() + { + _settleTimer?.Dispose(); + _settleTimer = new Timer(OnSettleElapsed, null, TimeSpan.FromSeconds(_settleSeconds), Timeout.InfiniteTimeSpan); + } + + private void CancelSettleTimer() + { + _settleTimer?.Dispose(); + _settleTimer = null; + } + + private void OnSettleElapsed(object? state) + { + bool raiseStopped = false; + lock (_lock) + { + if (_disposed) return; + _settleTimer?.Dispose(); + _settleTimer = null; + // A worker may have reappeared during settle (which cancels the timer); re-check. + if (_active && _workers.Count == 0) + { + _active = false; + raiseStopped = true; + } + } + + if (raiseStopped) + { + _logger.Info($"[OfficeProcessWatcher] no Office worker for {_settleSeconds}s — install window closed"); + RaiseStopped(); + } + } + + private void RaiseStarted() + { + try { Started?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Warning($"[OfficeProcessWatcher] Started handler threw: {ex.Message}"); } + } + + private void RaiseStopped() + { + try { Stopped?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Warning($"[OfficeProcessWatcher] Stopped handler threw: {ex.Message}"); } + } + + private static int SafePid(Process proc) + { + try { return proc.Id; } catch { return -1; } + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + } + + if (_startWatcher != null) + { + try { _startWatcher.EventArrived -= OnProcessStartTrace; } catch { } + try { _startWatcher.Stop(); } catch { } + try { _startWatcher.Dispose(); } catch { } + _startWatcher = null; + } + + lock (_lock) + { + CancelSettleTimer(); + foreach (var kv in _workers) + { + try { kv.Value.Exited -= OnWorkerExited; } catch { } + try { kv.Value.Dispose(); } catch { } + } + _workers.Clear(); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/RegistryChangeWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/RegistryChangeWatcher.cs new file mode 100644 index 00000000..cda860ad --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/RegistryChangeWatcher.cs @@ -0,0 +1,241 @@ +#nullable enable +using System; +using System.Runtime.InteropServices; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office +{ + /// + /// Push-based registry-change notifier built on RegNotifyChangeKeyValue (async). Signals + /// whenever a value is set or a subkey is added/removed anywhere under the + /// target key (watch-subtree) — used by the to react to C2R + /// Configuration value changes (VersionToReport / StreamingFinished) and Scenario + /// subkey churn without polling. + /// + /// Bootstrap fallback: on a clean first install the target key (e.g. + /// …\Office\ClickToRun) may not exist yet when the C2R worker starts. Instead of giving up, + /// the watcher arms a cheap watch on the nearest existing ancestor — non-recursive + /// (bWatchSubtree=FALSE) and name-only (REG_NOTIFY_CHANGE_NAME) — so it fires + /// ONLY when a direct child key is created/deleted there (rare; it ignores the noisy value churn in + /// the subtree below). When the target finally appears it promotes to the full target watch and + /// raises one . This avoids both "no signal until process end" and the high + /// event volume that a recursive watch on a broad parent (e.g. …\Microsoft) would cause. + /// + /// One-shot per OS contract, so it re-arms after each fire. Fail-soft: any failure is logged and + /// the watcher stays quiet (the detector still gets the process Started/Stopped boundary events). + /// + public sealed class RegistryChangeWatcher : IDisposable + { + [Flags] + private enum RegNotifyFilter : uint + { + ChangeName = 0x00000001, // subkey added/removed (ClickToRun appearing; Scenario\* churn) + ChangeLastSet = 0x00000004, // value set (Configuration: VersionToReport / StreamingFinished) + } + + private const RegNotifyFilter PrimaryFilter = RegNotifyFilter.ChangeName | RegNotifyFilter.ChangeLastSet; + private const RegNotifyFilter BootstrapFilter = RegNotifyFilter.ChangeName; // cheap: direct-child create/delete only + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern int RegNotifyChangeKeyValue( + SafeRegistryHandle hKey, + [MarshalAs(UnmanagedType.Bool)] bool bWatchSubtree, + RegNotifyFilter dwNotifyFilter, + SafeWaitHandle hEvent, + [MarshalAs(UnmanagedType.Bool)] bool fAsynchronous); + + private enum WatchMode { None, Primary, Bootstrap } + + private readonly string _targetSubKey; + private readonly AgentLogger _logger; + private readonly object _lock = new object(); + + private RegistryKey? _baseKey; + private RegistryKey? _watchedKey; + private WatchMode _mode; + private AutoResetEvent? _signal; + private RegisteredWaitHandle? _registeredWait; + private bool _started; + private bool _disposed; + + /// Raised (on a ThreadPool thread) when the target subtree meaningfully changes. + public event EventHandler? Changed; + + /// HKLM-relative key, e.g. SOFTWARE\Microsoft\Office\ClickToRun. + public RegistryChangeWatcher(string targetSubKey, AgentLogger logger) + { + _targetSubKey = targetSubKey ?? throw new ArgumentNullException(nameof(targetSubKey)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Start() + { + lock (_lock) + { + if (_started || _disposed) return; + _started = true; + try + { + // Forced Registry64 view — consistent with the C2R reads (AnyCPU net48 may otherwise + // resolve the stale WOW6432Node mirror). + _baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + _signal = new AutoResetEvent(false); + ArmBestEffort(raiseOnPromote: false); + } + catch (Exception ex) + { + _logger.Warning($"[RegistryChangeWatcher] failed to start on HKLM\\{_targetSubKey}: {ex.Message}"); + } + } + } + + /// + /// Arms the best available watch: the target key (full, subtree) if it exists, otherwise a cheap + /// name-only watch on the nearest existing ancestor until the target appears. Must hold . + /// + private void ArmBestEffort(bool raiseOnPromote) + { + if (_disposed || _baseKey == null) return; + + CloseWatchedKey(); + + var target = TryOpen(_targetSubKey); + if (target != null) + { + _watchedKey = target; + _mode = WatchMode.Primary; + if (!ArmNotify(subtree: true, PrimaryFilter)) return; + if (raiseOnPromote) RaiseChanged(); + return; + } + + // Target absent — watch the nearest existing ancestor (non-recursive, name-only = cheap). + var ancestor = OpenNearestExistingAncestor(out var ancestorPath); + if (ancestor == null) + { + _logger.Debug($"[RegistryChangeWatcher] no existing ancestor for HKLM\\{_targetSubKey}; not arming"); + return; + } + _watchedKey = ancestor; + _mode = WatchMode.Bootstrap; + _logger.Debug($"[RegistryChangeWatcher] target HKLM\\{_targetSubKey} absent — bootstrap-watching HKLM\\{ancestorPath} (name-only)"); + if (!ArmNotify(subtree: false, BootstrapFilter)) return; + + // TOCTOU: the target may have been created between TryOpen and arming the ancestor watch. + using (var recheck = TryOpen(_targetSubKey)) + { + if (recheck != null) ArmBestEffort(raiseOnPromote: true); + } + } + + private bool ArmNotify(bool subtree, RegNotifyFilter filter) + { + if (_disposed || _watchedKey == null || _signal == null) return false; + try + { + int rc = RegNotifyChangeKeyValue(_watchedKey.Handle, subtree, filter, _signal.SafeWaitHandle, fAsynchronous: true); + if (rc != 0) + { + _logger.Warning($"[RegistryChangeWatcher] RegNotifyChangeKeyValue failed rc={rc} (HKLM\\{_targetSubKey})"); + return false; + } + UnregisterWait(); + _registeredWait = ThreadPool.RegisterWaitForSingleObject( + _signal, OnSignalled, state: null, millisecondsTimeOutInterval: Timeout.Infinite, executeOnlyOnce: true); + return true; + } + catch (Exception ex) + { + _logger.Warning($"[RegistryChangeWatcher] arm failed (HKLM\\{_targetSubKey}): {ex.Message}"); + return false; + } + } + + private void OnSignalled(object? state, bool timedOut) + { + lock (_lock) + { + UnregisterWait(); + if (_disposed || timedOut) return; + + if (_mode == WatchMode.Primary) + { + RaiseChanged(); + ArmNotify(subtree: true, PrimaryFilter); // re-arm on the same target key + } + else + { + // A direct child changed under the ancestor — promote to the target watch if it now + // exists (raises Changed on promotion), else keep the cheap bootstrap watch. + ArmBestEffort(raiseOnPromote: true); + } + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private RegistryKey? TryOpen(string subKey) + { + try { return _baseKey?.OpenSubKey(subKey, writable: false); } + catch { return null; } + } + + /// Walks up the target path and opens the first ancestor that exists. + private RegistryKey? OpenNearestExistingAncestor(out string ancestorPath) + { + ancestorPath = string.Empty; + var path = _targetSubKey; + while (true) + { + var idx = path.LastIndexOf('\\'); + if (idx <= 0) return null; + path = path.Substring(0, idx); + var key = TryOpen(path); + if (key != null) { ancestorPath = path; return key; } + } + } + + private void RaiseChanged() + { + var handler = Changed; + if (handler == null) return; + try { handler(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Warning($"[RegistryChangeWatcher] Changed handler threw: {ex.Message}"); } + } + + private void UnregisterWait() + { + try { _registeredWait?.Unregister(null); } catch { } + _registeredWait = null; + } + + private void CloseWatchedKey() + { + try { _watchedKey?.Dispose(); } catch { } + _watchedKey = null; + _mode = WatchMode.None; + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + + UnregisterWait(); + try { _signal?.Set(); } catch { } // release any pending wait + try { _signal?.Dispose(); } catch { } + _signal = null; + CloseWatchedKey(); + try { _baseKey?.Dispose(); } catch { } + _baseKey = null; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs index 4419fd73..c494d52d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs @@ -15,6 +15,7 @@ using Newtonsoft.Json.Linq; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office; namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic { @@ -36,6 +37,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic private readonly Action _onDoTelemetryReceived; private readonly string _logFilePath; + // Office C2R DO support: while an Office worker is active we keep polling even with no IME + // downloads, classify the non-IME Office-CDN jobs and hand aggregated stats to the + // OfficeInstallDetector (which folds them into the office_install_* events). Office is not an + // IME app, so we deliberately do NOT emit download_progress/do_telemetry for it (that would + // create a phantom app in the backend AppInstallSummary). + private readonly Action _onOfficeDoSample; + private volatile bool _officeActive; + private Runspace _runspace; private bool _permanentlyDisabled; private int _consecutiveErrors; @@ -59,12 +68,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic int intervalSeconds, Func getPackageStates, Action onDoTelemetryReceived, - string logDirectory) + string logDirectory, + Action onOfficeDoSample = null) : base(sessionId, tenantId, post, logger, intervalSeconds) { _getPackageStates = getPackageStates ?? throw new ArgumentNullException(nameof(getPackageStates)); _onDoTelemetryReceived = onDoTelemetryReceived; _logFilePath = Path.Combine(logDirectory, LogFileName); + _onOfficeDoSample = onOfficeDoSample; } /// Start dormant — timer does not fire until WakeUp() is called. @@ -93,6 +104,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic ResumeTimer(); } + /// + /// Office C2R wake source: the OfficeProcessWatcher reports an Office worker started/stopped. + /// While active, the collector keeps polling (even with no IME downloads) so it can capture + /// Office's DO jobs. Called from any thread. + /// + public void NotifyOfficeActive(bool active) + { + _officeActive = active; + if (active) + { + Logger.Info("[DeliveryOptimizationCollector] Office install active — sampling DO for Office CDN jobs"); + WakeUp(); + } + } + protected override void Collect() { if (_permanentlyDisabled || _dormant) return; @@ -116,29 +142,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic private void CollectCore() { - // Guard: check if there's still work to do + // Guard: check if there's still work to do. Office C2R has no IME packages, so an active + // Office install (from the OfficeProcessWatcher) keeps the collector polling on its own. var packageStates = _getPackageStates(); - if (packageStates == null || packageStates.Count == 0) return; var hasActiveDownloads = false; var hasPendingEnrichment = false; - for (int i = 0; i < packageStates.Count; i++) + if (packageStates != null) { - var p = packageStates[i]; - if (p.InstallationState == AppInstallationState.Downloading || - p.InstallationState == AppInstallationState.Installing) - hasActiveDownloads = true; + for (int i = 0; i < packageStates.Count; i++) + { + var p = packageStates[i]; + if (p.InstallationState == AppInstallationState.Downloading || + p.InstallationState == AppInstallationState.Installing) + hasActiveDownloads = true; - if (!p.HasDoTelemetry && !_enrichedAppIds.Contains(p.Id) && - p.DownloadingOrInstallingSeen && - (p.InstallationState == AppInstallationState.Installed || - p.InstallationState == AppInstallationState.Error)) - hasPendingEnrichment = true; + if (!p.HasDoTelemetry && !_enrichedAppIds.Contains(p.Id) && + p.DownloadingOrInstallingSeen && + (p.InstallationState == AppInstallationState.Installed || + p.InstallationState == AppInstallationState.Error)) + hasPendingEnrichment = true; + } } - if (!hasActiveDownloads && !hasPendingEnrichment) + if (!hasActiveDownloads && !hasPendingEnrichment && !_officeActive) { - Logger.Info("[DeliveryOptimizationCollector] Going dormant — no active downloads or pending enrichment"); + Logger.Info("[DeliveryOptimizationCollector] Going dormant — no active downloads, pending enrichment or Office install"); _dormant = true; PauseTimer(); return; @@ -284,6 +313,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic int progressCount = 0; int matchCount = 0; + // Office C2R DO aggregation across all Office-CDN jobs in this poll. + int officeJobCount = 0; + long officeFileSize = 0, officeTotalBytes = 0, officeBytesFromPeers = 0, + officeBytesFromHttp = 0, officeBytesFromCacheServer = 0; + int officeDownloadMode = -1; + // Build JSONL snapshot for log (only if fingerprint changes) var logEntries = new JArray(); @@ -323,10 +358,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic // Try to match to an IME-tracked app var appId = ImeLogTracker.ExtractAppIdFromDoFileId(fileId); - if (string.IsNullOrEmpty(appId)) continue; - - var pkg = packageStates.GetPackage(appId); - if (pkg == null) continue; + var pkg = (!string.IsNullOrEmpty(appId) && packageStates != null) + ? packageStates.GetPackage(appId) + : null; + if (pkg == null) + { + // Not an IME app. While an Office install is active, accumulate the Office CDN + // jobs for the OfficeInstallDetector (folded into office_install_*; NOT emitted as + // download_progress to avoid a phantom app in the backend AppInstallSummary). + if (_officeActive && IsOfficeCdnJob(sourceUrl)) + { + officeJobCount++; + officeFileSize += fileSize; + officeTotalBytes += totalBytes; + officeBytesFromPeers += bytesFromPeers; + officeBytesFromHttp += bytesFromHttp; + officeBytesFromCacheServer += bytesFromCacheServer; + if (downloadMode >= 0) officeDownloadMode = downloadMode; + } + continue; + } // --- Live download_progress (every poll where bytes changed) --- long lastBytes; @@ -382,6 +433,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic } } + // Hand aggregated Office DO stats to the OfficeInstallDetector (folded into office_install_*). + if (_officeActive && officeJobCount > 0 && _onOfficeDoSample != null) + { + int officePeerPct = officeTotalBytes > 0 ? (int)((officeBytesFromPeers * 100) / officeTotalBytes) : 0; + try + { + _onOfficeDoSample(new OfficeDoSample + { + JobCount = officeJobCount, + FileSize = officeFileSize, + TotalBytesDownloaded = officeTotalBytes, + BytesFromPeers = officeBytesFromPeers, + BytesFromHttp = officeBytesFromHttp, + BytesFromCacheServer = officeBytesFromCacheServer, + PercentPeerCaching = officePeerPct, + DownloadMode = officeDownloadMode, + }); + } + catch (Exception ex) + { + Logger.Warning($"[DeliveryOptimizationCollector] onOfficeDoSample threw: {ex.Message}"); + } + } + // Write JSONL log only when overall state changed var fingerprint = ComputeFingerprint(logEntries); if (fingerprint != _lastSnapshotFingerprint) @@ -394,6 +469,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic $"{progressCount} progress updates, {matchCount} new matches ({_enrichedAppIds.Count} total enriched)"); } + /// + /// True when a DO job's SourceURL points at the Office CDN (Office C2R content), i.e. a job + /// that is not an IME Win32 app. TODO(office-followup): validate the exact SourceURL/FileId + /// shape against a real enrollment's do-status.jsonl and tighten if needed. + /// + private static bool IsOfficeCdnJob(string sourceUrl) + { + if (string.IsNullOrEmpty(sourceUrl)) return false; + return sourceUrl.IndexOf("officecdn", StringComparison.OrdinalIgnoreCase) >= 0 + || sourceUrl.IndexOf("officeclient.microsoft.com", StringComparison.OrdinalIgnoreCase) >= 0; + } + // ----------------------------------------------------------------------- // PSObject property helpers (safe extraction, no exceptions on missing props) // ----------------------------------------------------------------------- diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index ff462691..5a64b065 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -348,9 +348,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration logger: logger, apiBaseUrl: _agentConfig.ApiBaseUrl)); - // M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: only polls when the - // IME log tracker reports an app entering Downloading/Installing (see AppStateChanged - // chain below). Needs the IME tracker's PackageStates + OnDoTelemetryReceived hook. + // Microsoft 365 Apps (Office C2R) install detector — event-driven (Rev 2): woken by a WMI + // Win32_ProcessStartTrace push on OfficeC2RClient.exe (+ startup probe), progress via + // RegNotifyChangeKeyValue, stop via Process.Exited. No idle polling. Constructed first so + // the DO host can share its process-start signal and feed Office DO stats back to it. + // Kill-switchable via remote config. + OfficeInstallDetectorHost officeHost = null; + if (collectors.EnableOfficeInstallDetector) + { + officeHost = new OfficeInstallDetectorHost( + sessionId: sessionId, + tenantId: tenantId, + ingress: ingress, + clock: clock, + logger: logger, + settleSeconds: collectors.OfficeInstallSettleSeconds); + } + + // M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: polls only when the IME log + // tracker reports an app entering Downloading/Installing (AppStateChanged chain below) OR + // an Office C2R install is active (officeHost signal) — the latter lets it capture Office's + // DO CDN jobs and fold them into the office_install_* events. if (collectors.EnableDeliveryOptimizationCollector && _imeLogHost != null) { var doHost = new DeliveryOptimizationHost( @@ -360,10 +378,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration clock: clock, logger: logger, intervalSeconds: collectors.DeliveryOptimizationIntervalSeconds, - imeHost: _imeLogHost); + imeHost: _imeLogHost, + officeProcessWatcher: officeHost?.ProcessWatcher, + onOfficeDoSample: officeHost != null ? officeHost.SubmitDoSample : (Action)null); hosts.Add(doHost); } + // Add the Office host AFTER the DO host so the DO host has subscribed to the process-start + // signal before the watcher starts (avoids missing the wake for an install already in flight). + if (officeHost != null) hosts.Add(officeHost); + // M4.6.δ — Gather-rules runtime executor. Runs the backend-defined rules whose // Trigger is "startup" once the agent is up; signal / event / periodic triggers // remain supported inside the executor itself. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DeliveryOptimizationHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DeliveryOptimizationHost.cs index 7279d772..ed4da5b1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DeliveryOptimizationHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/DeliveryOptimizationHost.cs @@ -3,6 +3,7 @@ using System; using System.Threading; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office; using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic; using AutopilotMonitor.DecisionCore.Engine; using AutopilotMonitor.Shared; @@ -16,6 +17,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private readonly DeliveryOptimizationCollector _collector; private readonly ImeLogHost _imeHost; private readonly AgentLogger _logger; + private readonly OfficeProcessWatcher? _officeProcessWatcher; private Action? _prevStateChanged; private Action? _chainedHandler; private int _disposed; @@ -27,12 +29,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration IClock clock, AgentLogger logger, int intervalSeconds, - ImeLogHost imeHost) + ImeLogHost imeHost, + OfficeProcessWatcher? officeProcessWatcher = null, + Action? onOfficeDoSample = null) { if (ingress == null) throw new ArgumentNullException(nameof(ingress)); if (clock == null) throw new ArgumentNullException(nameof(clock)); _imeHost = imeHost; _logger = logger; + _officeProcessWatcher = officeProcessWatcher; var post = new InformationalEventPost(ingress, clock); _collector = new DeliveryOptimizationCollector( @@ -47,7 +52,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); } catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); } }, - logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory)); + logDirectory: Environment.ExpandEnvironmentVariables(Constants.LogDirectory), + onOfficeDoSample: onOfficeDoSample); } public void Start() @@ -70,10 +76,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration }; _imeHost.Tracker.OnAppStateChanged = _chainedHandler; + // Second wake source: an Office C2R install (no IME package) keeps the collector polling + // so it can capture Office's DO CDN jobs. + if (_officeProcessWatcher != null) + { + _officeProcessWatcher.Started += OnOfficeStarted; + _officeProcessWatcher.Stopped += OnOfficeStopped; + } + _collector.Start(); - _logger.Info($"DeliveryOptimizationHost: started dormant (interval={_collector}, wakes on Downloading/Installing)."); + _logger.Info("DeliveryOptimizationHost: started dormant (wakes on Downloading/Installing or an Office C2R install)."); } + private void OnOfficeStarted(object? sender, EventArgs e) => _collector.NotifyOfficeActive(true); + private void OnOfficeStopped(object? sender, EventArgs e) => _collector.NotifyOfficeActive(false); + public void Stop() { // Restore the previous handler only if we're still the current one — otherwise @@ -86,6 +103,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration { _imeHost.Tracker.OnAppStateChanged = _prevStateChanged; } + if (_officeProcessWatcher != null) + { + try { _officeProcessWatcher.Started -= OnOfficeStarted; } catch { } + try { _officeProcessWatcher.Stopped -= OnOfficeStopped; } catch { } + } _collector.Stop(); } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs new file mode 100644 index 00000000..8a3475ce --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs @@ -0,0 +1,107 @@ +#nullable enable +using System; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office; +using AutopilotMonitor.DecisionCore.Engine; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Lifecycle host for the event-driven (Rev 2). Owns the OS + /// watchers and orchestrates the pure detector core — no idle polling: + /// + /// Started → arm a fresh + /// on the ClickToRun key + OnWorkerStarted. + /// RegistryChangeWatcher Changed → OnRegistryChanged (push, no poll). + /// OfficeProcessWatcher Stopped → OnWorkerStopped (terminal) + dispose the reg watcher. + /// + /// The is exposed so the DeliveryOptimizationHost can subscribe + /// to the same start/stop signal (to wake DO sampling for Office), and + /// lets the DO collector fold aggregated Office DO stats into the office_install_* events. + /// + internal sealed class OfficeInstallDetectorHost : ICollectorHost + { + private const string ClickToRunSubKey = @"SOFTWARE\Microsoft\Office\ClickToRun"; + + public string Name => OfficeInstallDetector.SourceName; + + private readonly OfficeInstallDetector _detector; + private readonly OfficeProcessWatcher _processWatcher; + private readonly AgentLogger _logger; + private readonly object _lock = new object(); + private RegistryChangeWatcher? _registryWatcher; + private int _disposed; + + public OfficeInstallDetectorHost( + string sessionId, + string tenantId, + ISignalIngressSink ingress, + IClock clock, + AgentLogger logger, + int settleSeconds) + { + 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); + _detector = new OfficeInstallDetector(sessionId, tenantId, post, logger, clock); + _processWatcher = new OfficeProcessWatcher(logger, settleSeconds); + _processWatcher.Started += OnWorkerStarted; + _processWatcher.Stopped += OnWorkerStopped; + } + + /// The shared Office worker start/stop signal — the DeliveryOptimizationHost subscribes. + public OfficeProcessWatcher ProcessWatcher => _processWatcher; + + /// Fold aggregated Office DO stats (from the DO collector) into the office_install_* events. + public void SubmitDoSample(OfficeDoSample sample) => _detector.OnOfficeDoSample(sample); + + public void Start() => _processWatcher.Start(); + + private void OnWorkerStarted(object? sender, EventArgs e) + { + lock (_lock) + { + // Fresh registry watcher per install window (RegNotify re-arms internally per fire). + _registryWatcher?.Dispose(); + _registryWatcher = new RegistryChangeWatcher(ClickToRunSubKey, _logger); + _registryWatcher.Changed += OnRegistryChanged; + _registryWatcher.Start(); + } + _detector.OnWorkerStarted(); + } + + private void OnRegistryChanged(object? sender, EventArgs e) => _detector.OnRegistryChanged(); + + private void OnWorkerStopped(object? sender, EventArgs e) + { + _detector.OnWorkerStopped(); + lock (_lock) + { + if (_registryWatcher != null) + { + try { _registryWatcher.Changed -= OnRegistryChanged; } catch { } + _registryWatcher.Dispose(); + _registryWatcher = null; + } + } + } + + public void Stop() => Dispose(); + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + try { _processWatcher.Started -= OnWorkerStarted; } catch { } + try { _processWatcher.Stopped -= OnWorkerStopped; } catch { } + try { _processWatcher.Dispose(); } catch { } + lock (_lock) + { + try { _registryWatcher?.Dispose(); } catch { } + _registryWatcher = null; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index ebf8123d..2554b1bd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -181,7 +181,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 28, // V2 DAD-liveness threshold (DesktopDetectorNoCandidateTimeoutMinutes, default 10 min); admin-config global setting + ConfigVersion = 29, // V2 OfficeInstallDetector (EnableOfficeInstallDetector + OfficeInstallDetectorIntervalSeconds; C2R install lifecycle via registry+process) UploadIntervalSeconds = 10, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 2cf47867..44fa0b76 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -63,6 +63,10 @@ export const EVENT_TYPES_CATALOG = { 'do_telemetry', 'all_apps_completed', 'app_tracking_summary', + 'office_install_started', + 'office_install_progress', + 'office_install_completed', + 'office_install_failed', ], script_events: ['script_started', 'script_completed', 'script_failed'], network_events: [ diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 847e239f..af31801f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -296,6 +296,17 @@ namespace AutopilotMonitor.Shared public const string AllAppsCompleted = "all_apps_completed"; public const string AppTrackingSummary = "app_tracking_summary"; // Plan §5 Fix 4b — terminal per-session app summary + // Microsoft 365 Apps (Office Click-to-Run) install lifecycle — emitted by the V2 + // OfficeInstallDetector which watches the C2R client (registry + process) directly. + // The Intune "integrated" Microsoft 365 Apps app reports done to IME within 1-2 min + // while C2R keeps streaming/installing in the background; these events surface the + // REAL install progress (phase-granular — no percentage is exposed by Office) plus + // products, channel, version reached, duration and a best-effort failure code. + public const string OfficeInstallStarted = "office_install_started"; + public const string OfficeInstallProgress = "office_install_progress"; + public const string OfficeInstallCompleted = "office_install_completed"; + public const string OfficeInstallFailed = "office_install_failed"; + // Stall detection (Ebene 2 — StallProbeCollector) public const string StallProbeCheck = "stall_probe_check"; // Trace heartbeat from Probe 2 (15 min) when no anomaly found public const string StallProbeResult = "stall_probe_result"; // Warning when a probe found an anomaly diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs index 98684220..e6cc1918 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs @@ -284,6 +284,26 @@ namespace AutopilotMonitor.Shared.Models /// public int DeliveryOptimizationIntervalSeconds { get; set; } = 3; + /// + /// Enable the Microsoft 365 Apps (Office Click-to-Run) install detector. Event-driven (no idle + /// polling): woken by a WMI Win32_ProcessStartTrace push on OfficeC2RClient.exe, progress via + /// RegNotifyChangeKeyValue, stop via Process.Exited. Surfaces the real background install + /// lifecycle (+ Office's Delivery-Optimization download telemetry) even when the Intune + /// "integrated" Office app reports done to IME within 1-2 min. High-value package → on by + /// default, kept as a kill-switch. DO sampling reuses DeliveryOptimizationIntervalSeconds. + /// Default: true + /// + public bool EnableOfficeInstallDetector { get; set; } = true; + + /// + /// Settle window (seconds) for the Office install detector. C2R spawns several short-lived + /// OfficeC2RClient.exe workers (with small gaps) during one install; after the last worker + /// exits the detector waits this long before deciding terminal completed/failed, so a brief + /// gap before the next worker does not produce a premature office_install_failed. A new worker + /// within the window keeps the install window open. 0 = decide immediately. Default: 10. + /// + public int OfficeInstallSettleSeconds { get; set; } = 10; + // ----------------------------------------------------------------------- // Stall detection (Ebene 2 — StallProbeCollector) // ----------------------------------------------------------------------- diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index 1821f184..f6c685da 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -84,6 +84,14 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ description: "Download progress update (throttled)." }, { value: "do_telemetry", label: "do_telemetry", category: "app", description: "Delivery Optimization peer/mode telemetry for an application download." }, + { value: "office_install_started", label: "office_install_started", category: "app", + description: "Microsoft 365 Apps (Office Click-to-Run) background install detected by the C2R detector." }, + { value: "office_install_progress", label: "office_install_progress", category: "app", + description: "Office C2R install phase changed (Streaming/Finalizing) — emitted only on real state change, not periodically." }, + { value: "office_install_completed", label: "office_install_completed", category: "app", + description: "Office C2R install completed (streaming finished, scenario cleared); carries version reached and duration." }, + { value: "office_install_failed", label: "office_install_failed", category: "app", + description: "Office C2R install ended without reaching completion, or a failure code was observed." }, // -------- Scripts -------- { value: "script_started", label: "script_started", category: "script",