diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEmitModeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEmitModeTests.cs new file mode 100644 index 00000000..5955c8a9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorEmitModeTests.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather +{ + /// + /// EmitMode "on_change" on : poll on the trigger cadence but + /// emit only when the collected result changes. The dedup state machine is asserted + /// synchronously via the internal ShouldEmitOnChange / ComputeCanonicalHash + /// (no timer races); the ExecuteRule wiring (hash-before-injection, empty-result no-op, + /// emitOnlyIfExists composition) through the public startup path. + /// + [Collection("SerialThreading")] // startup rules execute on the shared ThreadPool + public sealed class GatherRuleExecutorEmitModeTests : IDisposable + { + private readonly TempDirectory _tmp = new TempDirectory(); + private readonly List _events = new List(); + private readonly object _eventsGate = new object(); + private readonly GatherRuleExecutor _executor; + + private const string AbsentPath = + "HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_7e3a1f4b-0000-0000-0000-000000000000"; + + public GatherRuleExecutorEmitModeTests() + { + var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info); + _executor = new GatherRuleExecutor( + "sess", "tenant", + evt => { lock (_eventsGate) _events.Add(evt); }, + logger) + { + UnrestrictedMode = true + }; + } + + public void Dispose() + { + _executor.Dispose(); + _tmp.Dispose(); + } + + private int EventCount { get { lock (_eventsGate) return _events.Count; } } + + private EnrollmentEvent SingleEvent() + { + lock (_eventsGate) return Assert.Single(_events); + } + + private bool WaitForEventCount(int count, int timeoutMs = 10000) + { + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < timeoutMs) + { + if (EventCount >= count) return true; + Thread.Sleep(25); + } + return EventCount >= count; + } + + private static GatherRule OnChangeRule(string id = "GATHER-OC-001") => new GatherRule + { + RuleId = id, + Title = id, + CollectorType = "registry", + Target = AbsentPath, + Trigger = "interval", + IntervalSeconds = 3600, + OutputEventType = "gather_test", + Enabled = true, + EmitMode = "on_change", + }; + + private static Dictionary Result(params (string Key, object Value)[] pairs) + { + var d = new Dictionary(); + foreach (var (key, value) in pairs) d[key] = value; + return d; + } + + // ── on_change state machine (synchronous) ────────────────────────── + + [Fact] + public void FirstResult_AlwaysEmits_ThenUnchangedIsSuppressed_ThenChangeReemits() + { + var rule = OnChangeRule(); + + Assert.True(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); // first + Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); // unchanged + Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); // unchanged + Assert.True(_executor.ShouldEmitOnChange(rule, Result(("exists", true), ("Version", "1")))); // changed + } + + [Fact] + public void EmitAfterSuppressionStreak_CarriesSuppressedPollCount_AndResets() + { + var rule = OnChangeRule(); + + Assert.True(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); + Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); + Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); + Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); + + var changed = Result(("exists", true)); + Assert.True(_executor.ShouldEmitOnChange(rule, changed)); + Assert.Equal(3, changed["suppressedPolls"]); + Assert.True(changed.ContainsKey("suppressedSinceUtc")); + + // Counter must reset: the next change emits without a suppression payload. + var changedAgain = Result(("exists", false)); + Assert.True(_executor.ShouldEmitOnChange(rule, changedAgain)); + Assert.False(changedAgain.ContainsKey("suppressedPolls")); + Assert.False(changedAgain.ContainsKey("suppressedSinceUtc")); + } + + [Fact] + public void SuppressionState_IsIsolatedPerRule() + { + var ruleA = OnChangeRule("GATHER-OC-A"); + var ruleB = OnChangeRule("GATHER-OC-B"); + var payload = Result(("exists", false)); + + Assert.True(_executor.ShouldEmitOnChange(ruleA, new Dictionary(payload))); + // Same content, different rule: B has no hash yet — must emit. + Assert.True(_executor.ShouldEmitOnChange(ruleB, new Dictionary(payload))); + Assert.False(_executor.ShouldEmitOnChange(ruleA, new Dictionary(payload))); + Assert.False(_executor.ShouldEmitOnChange(ruleB, new Dictionary(payload))); + } + + [Fact] + public void DedupState_SurvivesUpdateRulesRefresh() + { + var rule = OnChangeRule(); + Assert.True(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); + + // Config refresh mid-session must not reset the last-emitted hash (that would + // re-emit an unchanged result on every refresh). + _executor.UpdateRules(new List { rule }); + + Assert.False(_executor.ShouldEmitOnChange(rule, Result(("exists", false)))); + } + + // ── Canonical hashing ────────────────────────────────────────────── + + [Fact] + public void CanonicalHash_IsKeyOrderInsensitive() + { + var a = new Dictionary { ["alpha"] = 1, ["beta"] = "x" }; + var b = new Dictionary { ["beta"] = "x", ["alpha"] = 1 }; + + Assert.Equal( + GatherRuleExecutor.ComputeCanonicalHash(a), + GatherRuleExecutor.ComputeCanonicalHash(b)); + } + + [Fact] + public void CanonicalHash_DetectsValueChanges_IncludingNested() + { + var baseline = new Dictionary + { + ["exists"] = true, + ["entries"] = new List { new Dictionary { ["id"] = 1 } }, + }; + var changedScalar = new Dictionary + { + ["exists"] = false, + ["entries"] = new List { new Dictionary { ["id"] = 1 } }, + }; + var changedNested = new Dictionary + { + ["exists"] = true, + ["entries"] = new List { new Dictionary { ["id"] = 2 } }, + }; + + var baseHash = GatherRuleExecutor.ComputeCanonicalHash(baseline); + Assert.NotEqual(baseHash, GatherRuleExecutor.ComputeCanonicalHash(changedScalar)); + Assert.NotEqual(baseHash, GatherRuleExecutor.ComputeCanonicalHash(changedNested)); + } + + [Fact] + public void CanonicalHash_DistinguishesTypeShapes() + { + // "1" (string) vs 1 (int) and null vs ""-like strings must not collide. + Assert.NotEqual( + GatherRuleExecutor.ComputeCanonicalHash(new Dictionary { ["v"] = "1" }), + GatherRuleExecutor.ComputeCanonicalHash(new Dictionary { ["v"] = 1 })); + Assert.NotEqual( + GatherRuleExecutor.ComputeCanonicalHash(new Dictionary { ["v"] = null }), + GatherRuleExecutor.ComputeCanonicalHash(new Dictionary { ["v"] = "" })); + } + + // ── ExecuteRule wiring (async via startup path) ──────────────────── + + [Fact] + public void AlwaysMode_EmitsEveryCollection() + { + // EmitMode absent (legacy) — repeated identical results all emit. Two executions of + // the same collector via two rules sharing the target prove no dedup is applied. + var rule1 = OnChangeRule("GATHER-AL-1"); rule1.EmitMode = null; rule1.Trigger = "startup"; + var rule2 = OnChangeRule("GATHER-AL-2"); rule2.EmitMode = null; rule2.Trigger = "startup"; + + _executor.UpdateRules(new List { rule1, rule2 }); + + Assert.True(_executor.WaitForStartupRules(30)); + Assert.True(WaitForEventCount(2)); + } + + [Fact] + public void OnChange_FirstInScopePoll_EmitsExistsFalse_AsPollingIndicator() + { + // The first result on an absent key IS the visible "we're polling now" indicator. + var rule = OnChangeRule(); + rule.Trigger = "startup"; + + _executor.UpdateRules(new List { rule }); + + Assert.True(_executor.WaitForStartupRules(30)); + Assert.True(WaitForEventCount(1)); + var evt = SingleEvent(); + Assert.Equal(false, evt.Data["exists"]); + Assert.Equal("GATHER-OC-001", evt.Data["ruleId"]); // injection happened after hashing + } + + [Fact] + public void OnChange_ComposedWithEmitOnlyIfExists_EmptyResultDoesNotTouchHashState() + { + // emitOnlyIfExists miss → empty result → no emit AND no hash update: the eventual + // first real result must still count as "first emit". + var rule = OnChangeRule(); + rule.Trigger = "startup"; + rule.Parameters = new Dictionary { ["emitOnlyIfExists"] = "true" }; + + _executor.UpdateRules(new List { rule }); + Assert.True(_executor.WaitForStartupRules(30)); + Thread.Sleep(300); + Assert.Equal(0, EventCount); // zero noise while the key is absent + + // When the key appears (simulated via the state machine), the result emits as first. + Assert.True(_executor.ShouldEmitOnChange(rule, Result(("exists", true), ("Version", "1")))); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseScopeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseScopeTests.cs new file mode 100644 index 00000000..ab96ac22 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseScopeTests.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather +{ + /// + /// Phase scoping on (ActivePhases / ActiveFromPhase): + /// the scope matrix is asserted synchronously via the internal IsRuleInScope + /// (no timer races); the trigger-path wiring (deferred startup, phase_change / on_event + /// gating) is asserted through the public API with positive event waits. + /// Rules use the absent-registry-path pattern from + /// so no real system state is touched. + /// + [Collection("SerialThreading")] // deferred startup rules execute on the shared ThreadPool + public sealed class GatherRuleExecutorPhaseScopeTests : IDisposable + { + private readonly TempDirectory _tmp = new TempDirectory(); + private readonly List _events = new List(); + private readonly object _eventsGate = new object(); + private readonly GatherRuleExecutor _executor; + + private const string AbsentPath = + "HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_5b1c8d2e-0000-0000-0000-000000000000"; + + public GatherRuleExecutorPhaseScopeTests() + { + var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info); + _executor = new GatherRuleExecutor( + "sess", "tenant", + evt => { lock (_eventsGate) _events.Add(evt); }, + logger) + { + // Bypass the registry allowlist guard for the synthetic test path. + UnrestrictedMode = true + }; + } + + public void Dispose() + { + _executor.Dispose(); + _tmp.Dispose(); + } + + private int EventCount { get { lock (_eventsGate) return _events.Count; } } + + private bool WaitForEventCount(int count, int timeoutMs = 10000) + { + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < timeoutMs) + { + if (EventCount >= count) return true; + Thread.Sleep(25); + } + return EventCount >= count; + } + + /// Interval rule with a 1h period — the timer never ticks within a test run, + /// so these rules exist purely for synchronous scope-matrix assertions. + private static GatherRule ScopeProbeRule(string id, List activePhases = null, string fromPhase = null) + => new GatherRule + { + RuleId = id, + Title = id, + CollectorType = "registry", + Target = AbsentPath, + Trigger = "interval", + IntervalSeconds = 3600, + OutputEventType = "gather_test", + Enabled = true, + ActivePhases = activePhases, + ActiveFromPhase = fromPhase, + }; + + private static GatherRule StartupRule(string id, List activePhases = null, string fromPhase = null) + => new GatherRule + { + RuleId = id, + Title = id, + CollectorType = "registry", + Target = AbsentPath, + Trigger = "startup", + OutputEventType = "gather_test", + Enabled = true, + ActivePhases = activePhases, + ActiveFromPhase = fromPhase, + }; + + // ── Scope matrix (synchronous) ───────────────────────────────────── + + [Fact] + public void UnscopedRule_IsInScope_EvenBeforeAnyPhaseSignal() + { + var rule = ScopeProbeRule("GATHER-SCOPE-000"); + _executor.UpdateRules(new List { rule }); + + Assert.True(_executor.IsRuleInScope(rule)); + } + + [Fact] + public void ActivePhases_InactiveBeforeFirstPhaseSignal() + { + var rule = ScopeProbeRule("GATHER-SCOPE-001", activePhases: new List { "AccountSetup" }); + _executor.UpdateRules(new List { rule }); + + Assert.False(_executor.IsRuleInScope(rule)); + } + + [Fact] + public void ActivePhases_ActiveDuringListedPhase_InactiveAfterLeaving() + { + var rule = ScopeProbeRule("GATHER-SCOPE-002", activePhases: new List { "AccountSetup" }); + _executor.UpdateRules(new List { rule }); + + _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); + Assert.False(_executor.IsRuleInScope(rule)); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + Assert.True(_executor.IsRuleInScope(rule)); + + _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); + Assert.False(_executor.IsRuleInScope(rule)); + } + + [Fact] + public void ActivePhases_MatchIsCaseInsensitive() + { + var rule = ScopeProbeRule("GATHER-SCOPE-003", activePhases: new List { "accountsetup" }); + _executor.UpdateRules(new List { rule }); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + Assert.True(_executor.IsRuleInScope(rule)); + } + + [Fact] + public void FromPhase_LatchesAtThreshold_AndStaysSticky_IncludingThroughFailed() + { + var rule = ScopeProbeRule("GATHER-SCOPE-010", fromPhase: "AccountSetup"); + _executor.UpdateRules(new List { rule }); + + _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); + Assert.False(_executor.IsRuleInScope(rule)); // threshold not reached + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + Assert.True(_executor.IsRuleInScope(rule)); // latched + + _executor.OnPhaseChanged(EnrollmentPhase.Failed); + Assert.True(_executor.IsRuleInScope(rule)); // sticky through Failed + } + + [Fact] + public void FromPhase_NoSpuriousActivationViaFailed() + { + // Failed=99 would ordinal-satisfy every threshold — it must never latch. + var rule = ScopeProbeRule("GATHER-SCOPE-011", fromPhase: "AccountSetup"); + _executor.UpdateRules(new List { rule }); + + _executor.OnPhaseChanged(EnrollmentPhase.Failed); + Assert.False(_executor.IsRuleInScope(rule)); + } + + [Fact] + public void FromPhase_LatchesWhenThresholdIsSkippedOver() + { + // The session may never report the exact threshold phase — a later phase latches too. + var rule = ScopeProbeRule("GATHER-SCOPE-012", fromPhase: "AccountSetup"); + _executor.UpdateRules(new List { rule }); + + _executor.OnPhaseChanged(EnrollmentPhase.FinalizingSetup); + Assert.True(_executor.IsRuleInScope(rule)); + } + + [Fact] + public void FromPhase_RuleDeliveredByRefreshAfterPhaseReached_LatchesImmediately() + { + _executor.UpdateRules(new List()); + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + + // Config refresh delivers a new from-phase rule whose threshold already passed. + var rule = ScopeProbeRule("GATHER-SCOPE-013", fromPhase: "DeviceSetup"); + _executor.UpdateRules(new List { rule }); + + Assert.True(_executor.IsRuleInScope(rule)); + } + + [Fact] + public void BothFieldsSet_ActivePhasesWins() + { + // Backend validation rejects this shape; the agent defensively prefers ActivePhases. + var rule = ScopeProbeRule("GATHER-SCOPE-020", + activePhases: new List { "DeviceSetup" }, fromPhase: "Start"); + _executor.UpdateRules(new List { rule }); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + Assert.False(_executor.IsRuleInScope(rule)); // FromPhase(Start) would say yes — list wins + + _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); + Assert.True(_executor.IsRuleInScope(rule)); + } + + [Fact] + public void IgnorePhaseScope_BypassesScope_WithoutPhaseContext() + { + _executor.IgnorePhaseScope = true; + var during = ScopeProbeRule("GATHER-SCOPE-030", activePhases: new List { "AccountSetup" }); + var from = ScopeProbeRule("GATHER-SCOPE-031", fromPhase: "Complete"); + _executor.UpdateRules(new List { during, from }); + + Assert.True(_executor.IsRuleInScope(during)); + Assert.True(_executor.IsRuleInScope(from)); + } + + // ── Trigger-path wiring (async, positive waits) ──────────────────── + + [Fact] + public void UnscopedStartupRule_RunsImmediately() + { + _executor.UpdateRules(new List { StartupRule("GATHER-START-000") }); + + Assert.True(_executor.WaitForStartupRules(30)); + Assert.True(WaitForEventCount(1)); + } + + [Fact] + public void ScopedStartupRule_IsDeferred_ThenFiresExactlyOnce_WhenScopeActivates() + { + _executor.UpdateRules(new List + { + StartupRule("GATHER-START-001", fromPhase: "AccountSetup") + }); + + // Not in scope at UpdateRules: nothing queued, latch trivially satisfied. + Assert.True(_executor.WaitForStartupRules(30)); + Assert.Equal(0, EventCount); + + _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); + Assert.Equal(0, EventCount); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + Assert.True(WaitForEventCount(1), "deferred startup rule should fire when its scope activates"); + + // Later phase changes must not re-fire it (dedup via _startupRulesExecuted). + _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); + _executor.OnPhaseChanged(EnrollmentPhase.FinalizingSetup); + Thread.Sleep(300); + Assert.Equal(1, EventCount); + } + + [Fact] + public void PhaseChangeRule_OutOfScopePhase_DoesNotFire_InScopePhaseFires() + { + // TriggerPhase empty = fires on every phase change; scope narrows it to AccountSetup. + var rule = new GatherRule + { + RuleId = "GATHER-PC-001", + Title = "pc", + CollectorType = "registry", + Target = AbsentPath, + Trigger = "phase_change", + TriggerPhase = "", + OutputEventType = "gather_test", + Enabled = true, + ActivePhases = new List { "AccountSetup" }, + }; + _executor.UpdateRules(new List { rule }); + + _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); + Thread.Sleep(300); + Assert.Equal(0, EventCount); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + Assert.True(WaitForEventCount(1)); + + // Same phase again: the (rule, phase) dedup slot is now consumed. + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + Thread.Sleep(300); + Assert.Equal(1, EventCount); + } + + [Fact] + public void OnEventRule_GatedByScope() + { + var rule = new GatherRule + { + RuleId = "GATHER-OE-001", + Title = "oe", + CollectorType = "registry", + Target = AbsentPath, + Trigger = "on_event", + TriggerEventType = "app_install_failed", + OutputEventType = "gather_test", + Enabled = true, + ActivePhases = new List { "AccountSetup" }, + }; + _executor.UpdateRules(new List { rule }); + + _executor.OnEvent("app_install_failed"); // no phase signal yet → out of scope + Thread.Sleep(300); + Assert.Equal(0, EventCount); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + _executor.OnEvent("app_install_failed"); + Assert.True(WaitForEventCount(1)); + } + + [Fact] + public void IgnorePhaseScope_ScopedStartupRule_RunsImmediately() + { + // The --run-gather-rules diagnostic path: scoped rules must execute unconditionally. + _executor.IgnorePhaseScope = true; + _executor.UpdateRules(new List + { + StartupRule("GATHER-START-002", activePhases: new List { "AccountSetup" }) + }); + + Assert.True(_executor.WaitForStartupRules(30)); + Assert.True(WaitForEventCount(1)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs index 0ff8c842..29f157de 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Threading; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors; @@ -28,6 +31,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather private readonly HashSet _phaseRulesExecuted = new HashSet(); private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending + // Phase-scope + emit-mode state (ActivePhases / ActiveFromPhase / EmitMode on GatherRule). + // Guarded by _scopeLock; collector work always runs outside the lock. The state deliberately + // survives UpdateRules config refreshes — an agent restart causes at most one re-emit per + // on_change rule (acceptable under the per-enrollment lifecycle). + private readonly object _scopeLock = new object(); + private EnrollmentPhase _currentPhase = EnrollmentPhase.Unknown; + private readonly HashSet _fromPhaseLatched = new HashSet(); + private readonly Dictionary _lastEmittedHash = new Dictionary(); + private readonly Dictionary _suppressed = + new Dictionary(); + private readonly HashSet _scopeBypassLogged = new HashSet(); + + /// + /// When true, phase scoping (ActivePhases / ActiveFromPhase) is bypassed and scoped rules + /// execute unconditionally. Used by the standalone --run-gather-rules diagnostic + /// mode, which has no enrollment phase context. EmitMode is unaffected. + /// + public bool IgnorePhaseScope { get; set; } + /// /// When true, guardrails are relaxed: all registry, WMI, and command targets are allowed. /// File paths allow everything except C:\Users. Set from tenant configuration. @@ -89,10 +111,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather _activeRules = rules.Where(r => r.Enabled).ToList(); - // Execute startup rules — track completion via CountdownEvent so callers can wait - var pendingStartup = _activeRules - .Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId)) - .ToList(); + // Execute startup rules — track completion via CountdownEvent so callers can wait. + // Scoped startup rules that are not yet in scope are deferred: they run once from + // OnPhaseChanged when their scope activates (same _startupRulesExecuted dedup — a + // rule runs either here or there, never both). Unscoped rules behave as before. + List pendingStartup; + lock (_scopeLock) + { + // A config refresh may deliver new from-phase rules after their phase was + // already reached — evaluate latches against the current phase first. + EvaluateFromPhaseLatchesLocked(); + + pendingStartup = _activeRules + .Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId)) + .Where(r => IsRuleInScopeLocked(r)) + .ToList(); + + foreach (var rule in pendingStartup) + { + _startupRulesExecuted.Add(rule.RuleId); + } + } if (pendingStartup.Count > 0) { @@ -101,7 +140,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather foreach (var rule in pendingStartup) { - _startupRulesExecuted.Add(rule.RuleId); ThreadPool.QueueUserWorkItem(_ => { try { ExecuteRule(rule); } @@ -110,18 +148,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather } } - // Set up interval timers + // Set up interval timers. Timers for scoped rules keep running while out of scope — + // the tick gate makes an out-of-scope tick a free no-op, avoiding start/stop + // lifecycle races on phase transitions. foreach (var rule in _activeRules.Where(r => r.Trigger == "interval" && r.IntervalSeconds.HasValue)) { - var interval = TimeSpan.FromSeconds(rule.IntervalSeconds.Value); + var intervalRule = rule; + var interval = TimeSpan.FromSeconds(intervalRule.IntervalSeconds.Value); var timer = new Timer( - _ => ExecuteRule(rule), + _ => + { + if (!IsRuleInScope(intervalRule)) + return; + ExecuteRule(intervalRule); + }, null, interval, // Initial delay = one interval interval ); - _intervalTimers[rule.RuleId] = timer; - _logger.Info($" Interval rule {rule.RuleId} scheduled every {rule.IntervalSeconds}s"); + _intervalTimers[intervalRule.RuleId] = timer; + _logger.Info($" Interval rule {intervalRule.RuleId} scheduled every {intervalRule.IntervalSeconds}s"); } _logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " + @@ -135,11 +181,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather { var phaseName = newPhase.ToString(); + // Update the phase + from-phase latches FIRST so deferred startup activation and + // the phase_change rules below all evaluate against the NEW phase. + List deferredStartup; + lock (_scopeLock) + { + _currentPhase = newPhase; + EvaluateFromPhaseLatchesLocked(); + + deferredStartup = _activeRules + .Where(r => r.Trigger == "startup" && HasPhaseScope(r) + && !_startupRulesExecuted.Contains(r.RuleId) + && IsRuleInScopeLocked(r)) + .ToList(); + + foreach (var rule in deferredStartup) + { + _startupRulesExecuted.Add(rule.RuleId); + } + } + + foreach (var rule in deferredStartup) + { + var deferredRule = rule; + _logger.Info($"Phase {phaseName} activated deferred startup rule {deferredRule.RuleId}"); + ThreadPool.QueueUserWorkItem(_ => ExecuteRule(deferredRule)); + } + foreach (var rule in _activeRules.Where(r => r.Trigger == "phase_change")) { if (string.IsNullOrEmpty(rule.TriggerPhase) || string.Equals(rule.TriggerPhase, phaseName, StringComparison.OrdinalIgnoreCase)) { + // Scope gate BEFORE the dedup add — an out-of-scope pass must not consume + // the once-per-(rule, phase) execution slot. + if (!IsRuleInScope(rule)) + continue; + // Deduplicate: only fire once per (ruleId, phase) combination var deduplicationKey = $"{rule.RuleId}|{phaseName}"; if (!_phaseRulesExecuted.Add(deduplicationKey)) @@ -163,6 +241,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather { if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase)) { + if (!IsRuleInScope(rule)) + continue; + _logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})"); ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule)); } @@ -192,6 +273,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather if (result.Count > 0) { + // EmitMode "on_change": hash the raw collector result (before ruleId/title + // injection) and only emit when it differs from the last emitted one. An + // empty result never reaches this point, so an emitOnlyIfExists miss neither + // emits nor updates the hash — composing to "one event on appearance, then + // only on change". + if (IsOnChangeMode(rule) && !ShouldEmitOnChange(rule, result)) + return; + result["ruleId"] = rule.RuleId; result["ruleTitle"] = rule.Title; @@ -229,6 +318,199 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather } } + // ===== Phase scope (ActivePhases / ActiveFromPhase) ===== + + private static bool HasPhaseScope(GatherRule rule) + { + return (rule.ActivePhases != null && rule.ActivePhases.Count > 0) + || !string.IsNullOrEmpty(rule.ActiveFromPhase); + } + + // Internal so the scope matrix is testable synchronously (no timer/ThreadPool races). + internal bool IsRuleInScope(GatherRule rule) + { + lock (_scopeLock) + { + return IsRuleInScopeLocked(rule); + } + } + + private bool IsRuleInScopeLocked(GatherRule rule) + { + if (!HasPhaseScope(rule)) + return true; // unscoped — legacy behavior + + if (IgnorePhaseScope) + { + if (_scopeBypassLogged.Add(rule.RuleId)) + _logger.Info($"Gather rule {rule.RuleId}: phase scope ignored (diagnostic mode)"); + return true; + } + + // Both fields set is rejected by backend validation; defensively prefer ActivePhases. + if (rule.ActivePhases != null && rule.ActivePhases.Count > 0) + { + // No phase signal yet (or scope tokens that never match, e.g. "Unknown"): + // scoped rules stay inactive. + if (_currentPhase == EnrollmentPhase.Unknown) + return false; + + var phaseName = _currentPhase.ToString(); + foreach (var phase in rule.ActivePhases) + { + if (string.Equals(phase, phaseName, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + return _fromPhaseLatched.Contains(rule.RuleId); + } + + /// + /// Latches every from-phase rule whose threshold the current phase has reached. + /// Never latches on Unknown/Failed (Failed=99 would ordinal-satisfy every threshold); + /// once latched a rule stays active for the session — including through Failed. + /// Caller must hold . + /// + private void EvaluateFromPhaseLatchesLocked() + { + if (_currentPhase == EnrollmentPhase.Unknown || _currentPhase == EnrollmentPhase.Failed) + return; + + foreach (var rule in _activeRules) + { + if (string.IsNullOrEmpty(rule.ActiveFromPhase)) continue; + if (rule.ActivePhases != null && rule.ActivePhases.Count > 0) continue; // ActivePhases wins + if (_fromPhaseLatched.Contains(rule.RuleId)) continue; + + if (Enum.TryParse(rule.ActiveFromPhase, ignoreCase: true, out var fromPhase) + && fromPhase != EnrollmentPhase.Unknown + && fromPhase != EnrollmentPhase.Failed + && (int)_currentPhase >= (int)fromPhase) + { + _fromPhaseLatched.Add(rule.RuleId); + _logger.Info($"Gather rule {rule.RuleId}: from-phase scope latched at {_currentPhase} (activeFromPhase={rule.ActiveFromPhase})"); + } + } + } + + // ===== Emit mode (on_change) ===== + + private static bool IsOnChangeMode(GatherRule rule) + { + return string.Equals(rule.EmitMode, "on_change", StringComparison.OrdinalIgnoreCase); + } + + /// + /// on_change decision for a non-empty collector result: true = emit (first result for the + /// rule, or the result changed), false = suppress. Hashes the result BEFORE ruleId/title + /// injection. On emit after a suppression streak, injects suppressedPolls and + /// suppressedSinceUtc into the result so the gap is observable. + /// Internal so the state machine is testable synchronously (no timer/ThreadPool races). + /// + internal bool ShouldEmitOnChange(GatherRule rule, Dictionary result) + { + var hash = ComputeCanonicalHash(result); + bool emit; + lock (_scopeLock) + { + string lastHash; + if (_lastEmittedHash.TryGetValue(rule.RuleId, out lastHash) && lastHash == hash) + { + (int Count, DateTime SinceUtc) streak; + _suppressed[rule.RuleId] = _suppressed.TryGetValue(rule.RuleId, out streak) + ? (streak.Count + 1, streak.SinceUtc) + : (1, DateTime.UtcNow); + emit = false; + } + else + { + _lastEmittedHash[rule.RuleId] = hash; + (int Count, DateTime SinceUtc) streak; + if (_suppressed.TryGetValue(rule.RuleId, out streak) && streak.Count > 0) + { + result["suppressedPolls"] = streak.Count; + result["suppressedSinceUtc"] = streak.SinceUtc.ToString("o", CultureInfo.InvariantCulture); + } + _suppressed.Remove(rule.RuleId); + emit = true; + } + } + + if (!emit) + _logger.Debug($"Gather rule {rule.RuleId}: result unchanged, emit suppressed (on_change)"); + return emit; + } + + /// + /// Canonical hash of a collector result: keys ordinal-sorted at every dictionary level, + /// values rendered with invariant formatting, then SHA-256. Internal for direct test + /// coverage of the canonicalization. + /// + internal static string ComputeCanonicalHash(Dictionary result) + { + var sb = new StringBuilder(); + AppendCanonicalValue(sb, result); + using (var sha = SHA256.Create()) + { + var hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); + return Convert.ToBase64String(hashBytes); + } + } + + private static void AppendCanonicalValue(StringBuilder sb, object value) + { + if (value == null) + { + sb.Append(""); + return; + } + + // string first — it is IEnumerable and must not be treated as a sequence + if (value is string str) + { + sb.Append('"').Append(str).Append('"'); + return; + } + + if (value is System.Collections.IDictionary dict) + { + sb.Append('{'); + var keys = dict.Keys.Cast() + .Select(k => k?.ToString() ?? string.Empty) + .OrderBy(k => k, StringComparer.Ordinal); + foreach (var key in keys) + { + sb.Append(key).Append('='); + AppendCanonicalValue(sb, dict[key]); + sb.Append(';'); + } + sb.Append('}'); + return; + } + + if (value is System.Collections.IEnumerable sequence) + { + sb.Append('['); + foreach (var item in sequence) + { + AppendCanonicalValue(sb, item); + sb.Append(';'); + } + sb.Append(']'); + return; + } + + if (value is IFormattable formattable) + { + sb.Append(formattable.ToString(null, CultureInfo.InvariantCulture)); + return; + } + + sb.Append(value); + } + private EventSeverity ParseSeverity(string severity) { return GatherRuleContext.ParseSeverity(severity); @@ -245,7 +527,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather /// /// Blocks until all startup rules that were queued by the most recent - /// call have finished executing, or the timeout elapses. + /// call have finished executing, or the timeout elapses. Deferred scoped startup rules + /// (waiting for their phase scope) are not part of the latch. /// Returns true if all rules completed within the timeout; false if timed out. /// public bool WaitForStartupRules(int timeoutSeconds = 120) diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs index 14b4a082..b9ec9093 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs @@ -224,6 +224,11 @@ namespace AutopilotMonitor.Agent.V2 // Step 4 — run the executor and wait. using (var executor = new GatherRuleExecutor(sessionId, tenantId, emitEvent, logger, config.ImeLogPathOverride)) { + // Diagnostic mode has no enrollment phase context — scoped rules + // (activePhases / activeFromPhase) execute unconditionally here. + executor.IgnorePhaseScope = true; + logger.Info("--run-gather-rules: phase scoping bypassed (no phase context in diagnostic mode)."); + executor.UpdateRules(rules); if (consoleMode) Console.WriteLine($"Running {startupRules.Count} rule(s)...");