From dbf6e50e2dff9bb7c1f13f9343183edfd92f077c Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 21 Jul 2026 15:37:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(agent,web,shared):=20phase=5Fexit=20gather?= =?UTF-8?q?=20trigger=20=E2=80=94=20one-shot=20collection=20at=20a=20phase?= =?UTF-8?q?'s=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to gather-rule phase scoping. One-shot collection at a phase START already existed (phase_change dedups per (rule, phase)) and at enrollment END (on_event + enrollment_complete); the END of a specific phase had no trigger. New trigger value "phase_exit": fires once when TriggerPhase is LEFT (empty = every phase transition). Together with phase_change it forms the two one-shot bookends of a phase. Agent (GatherRuleExecutor.OnPhaseChanged): - Exit rules are evaluated BEFORE _currentPhase advances, so both the TriggerPhase match and the phase-scope gate see the phase being LEFT. A rule scoped to DeviceSetup therefore fires on its own exit even though the phase being entered is out of scope. - A transition into Failed fires the exit rules of the failing phase — the snapshot at a failure boundary is the point of the trigger. - Exit of Unknown never fires; a repeated identical phase signal is not a transition. - Dedup key "exit:{ruleId}|{phase}" shares _phaseRulesExecuted with the phase_change keys but stays in a disjoint key space, so an enter-rule and an exit-rule on the same phase cannot consume each other's slot. Portal: trigger labels are now "Phase Start" / "Phase End"; the canonical phase dropdown and the create/save payloads cover both via PHASE_TRIGGERS; the card renders "entering X" / "leaving X". No ConfigVersion bump: no new field is transported. Old agents do not know the value and simply never fire the rule (silent no-op). Tests: GatherRuleExecutorPhaseExitTests (11) — enter-vs-exit, no refire on re-entry, empty-triggerPhase, exit-of-Unknown, same-phase-signal, into-Failed, Complete, scope-against-left-phase, out-of-scope slot preservation, key-space disjointness, on_change interplay. Build 0 Errors; V2.Core.Tests 1884/1884; Functions.Tests 3099/3099; vitest 557/557; tsc silent. Co-Authored-By: Claude Fable 5 --- docs/index.md | 2 +- docs/log.md | 1 + docs/rules/gather-rule-phase-scoping.md | 33 ++- rules/schema/gather-rule.schema.json | 6 +- .../GatherRuleExecutorPhaseExitTests.cs | 246 ++++++++++++++++++ .../Telemetry/Gather/GatherRuleExecutor.cs | 49 +++- .../Models/Rules/GatherRule.cs | 11 +- .../components/GatherRuleCard.tsx | 11 +- .../components/GatherRuleFormFields.tsx | 11 +- .../app/gather-rules/page.tsx | 6 +- .../app/gather-rules/types.ts | 8 +- 11 files changed, 365 insertions(+), 19 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseExitTests.cs diff --git a/docs/index.md b/docs/index.md index 49178c41..09b28c8f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,7 +22,7 @@ https://docs.autopilotmonitor.com (separate repository). # Rules -* [Gather Rule Phase Scoping & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch) and emitMode "on_change" result-dedup — the anti-spam pair for interval rules. +* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup. # Backend diff --git a/docs/log.md b/docs/log.md index f07a33dc..97a0b941 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,7 @@ ## 2026-07-20 +* **Update**: Extended `rules/gather-rule-phase-scoping.md` with the `phase_exit` trigger — the closing bookend to `phase_change`, for one-shot collection when a phase is LEFT (e.g. "state at the end of Account Setup"). Documents the evaluation-before-phase-advance rule (triggerPhase match and scope gate see the phase being left), firing into `Failed`, the disjoint `exit:` dedup key space, and why no ConfigVersion bump is needed (no new field — old agents simply never fire the rule). * **Update**: Reworked `agent/mdm-reboot-coalescing.md` after the first real enrollment (session b2e890c1): 2800 records are now **aggregated per burst** into ONE neutral Info event (`rebootUris[]`/`uriCount`/`firstRebootUri`; watermark persisted at flush so a pre-emit reboot kill loses nothing); the raw event no longer claims a reboot — ANALYZE-ESP-005 is gated on an actually-observed `system_reboot_detected`. **Removed** the Shell-Core `esp_reboot_coalescing` event entirely: the "RebootCoalescing" token appears in the routine `SubcategoryProcessing_Started` bootstrap marker on every enrollment (false positive by construction). Verified 2800 description shape recorded. * **Creation**: Added `agent/mdm-reboot-coalescing.md` — the agent now attributes the "unexpected mid-ESP reboot + second sign-in" pattern (PMPC research) to the device-assigned policy URIs that forced it: new `MdmRebootPolicyTracker` (DM-Enterprise EventID 2800, per-URI `mdm_policy_reboot_required` events, cross-restart RecordId watermark), a `RebootCoalescing` branch in `ShellCoreTracker` (`esp_reboot_coalescing`, backfill-emitting by design), and the `ANALYZE-ESP-005` advisory (reassign device → user groups). ConfigVersion 35; event types deliberately distinct from `system_reboot_detected` so RebootCount stays uninflated. diff --git a/docs/rules/gather-rule-phase-scoping.md b/docs/rules/gather-rule-phase-scoping.md index b58ac26f..5d874b21 100644 --- a/docs/rules/gather-rule-phase-scoping.md +++ b/docs/rules/gather-rule-phase-scoping.md @@ -1,7 +1,7 @@ --- type: Concept -title: Gather Rule Phase Scoping & Emit-on-Change -description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase) and how emitMode "on_change" suppresses repeated identical results — the anti-spam pair for interval rules. +title: Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change +description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase), collect once at a phase boundary (phase_change / phase_exit), and how emitMode "on_change" suppresses repeated identical results. resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs tags: - agent @@ -35,6 +35,35 @@ Backend validation (`GatherRulesFunction.ValidateScopeAndEmitMode`) rejects `Unk numeric tokens, unknown emit modes, and both scope fields set at once (400). The agent defensively prefers `activePhases` if both arrive anyway. +# One-shot collection at phase boundaries + +Scoping answers "while *may* this rule run". The trigger answers "when does it fire". For +one-shot collection the two phase triggers are the bookends of a phase: + +| Trigger | Fires | +|---|---| +| `phase_change` | Once when `triggerPhase` is **entered** (dedup key `{ruleId}\|{phase}`). | +| `phase_exit` | Once when `triggerPhase` is **left** (dedup key `exit:{ruleId}\|{phase}`). | + +Empty `triggerPhase` means every transition. Both keys live in the same +`_phaseRulesExecuted` set; the `exit:` prefix keeps the two key spaces disjoint, so an +enter-rule and an exit-rule on the same phase never cannibalise each other's slot. + +`phase_exit` semantics that are easy to get wrong: + +* It is evaluated **before** `_currentPhase` advances, so both the `triggerPhase` match and + the phase-scope gate see the phase being **left**. A rule scoped to `DeviceSetup` therefore + still fires on its own exit, even though the phase being entered is outside that scope. +* A transition into `Failed` **does** fire the exit rules of the failing phase — capturing + state at the failure boundary is the point. +* Exit of `Unknown` never fires (nothing was entered yet), and a repeated identical phase + signal is not a transition. +* An exit rule on `Complete` only fires if something transitions *away* from Complete. For + "at the end of enrollment", use `phase_change` on `Complete` or `on_event` with + `enrollment_complete`. +* Old agents do not know the trigger value and simply never fire the rule (silent no-op) — + which is why `phase_exit` needs no ConfigVersion bump: no new field is transported. + # Scope semantics (agent, `GatherRuleExecutor`) * Scope applies to **all trigger types**: diff --git a/rules/schema/gather-rule.schema.json b/rules/schema/gather-rule.schema.json index 7908b6f3..ef03fb54 100644 --- a/rules/schema/gather-rule.schema.json +++ b/rules/schema/gather-rule.schema.json @@ -80,8 +80,8 @@ }, "trigger": { "type": "string", - "description": "When to collect data.", - "enum": ["startup", "phase_change", "interval", "on_event"] + "description": "When to collect data. \"phase_change\" fires once when triggerPhase is entered, \"phase_exit\" once when it is left.", + "enum": ["startup", "phase_change", "phase_exit", "interval", "on_event"] }, "intervalSeconds": { "type": "integer", @@ -90,7 +90,7 @@ }, "triggerPhase": { "type": "string", - "description": "Phase to trigger on (only used when trigger = \"phase_change\")." + "description": "Phase to trigger on (used when trigger = \"phase_change\" or \"phase_exit\"). Empty = every phase transition." }, "triggerEventType": { "type": "string", diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseExitTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseExitTests.cs new file mode 100644 index 00000000..94c467e6 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleExecutorPhaseExitTests.cs @@ -0,0 +1,246 @@ +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 +{ + /// + /// The phase_exit trigger: a one-shot collection when a phase is LEFT — the closing + /// bookend to phase_change (which fires on ENTER). The distinguishing invariant is that + /// both the TriggerPhase match and the phase-scope gate must evaluate against the phase being + /// left, i.e. before the executor's current-phase state moves forward. + /// Rules use the absent-registry-path pattern so no real system state is touched. + /// + [Collection("SerialThreading")] // rules execute on the shared ThreadPool + public sealed class GatherRuleExecutorPhaseExitTests : 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_9c4d2a7f-0000-0000-0000-000000000000"; + + public GatherRuleExecutorPhaseExitTests() + { + 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 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 ExitRule( + string id, string triggerPhase, List activePhases = null, string fromPhase = null) + => new GatherRule + { + RuleId = id, + Title = id, + CollectorType = "registry", + Target = AbsentPath, + Trigger = "phase_exit", + TriggerPhase = triggerPhase, + OutputEventType = "gather_test", + Enabled = true, + ActivePhases = activePhases, + ActiveFromPhase = fromPhase, + }; + + [Fact] + public void FiresOnceWhenTheNamedPhaseIsLeft_NotWhenItIsEntered() + { + _executor.UpdateRules(new List { ExitRule("GATHER-PX-001", "AccountSetup") }); + + _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); + Thread.Sleep(300); + Assert.Equal(0, EventCount); + + // Entering AccountSetup must NOT fire an exit rule. + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + Thread.Sleep(300); + Assert.Equal(0, EventCount); + + // Leaving it does. + _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); + Assert.True(WaitForEventCount(1)); + } + + [Fact] + public void DoesNotRefireWhenThePhaseIsLeftTwice() + { + _executor.UpdateRules(new List { ExitRule("GATHER-PX-002", "AccountSetup") }); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); + Assert.True(WaitForEventCount(1)); + + // A phase can be re-entered (ESP re-reporting); the one-shot slot stays consumed. + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); + Thread.Sleep(400); + Assert.Equal(1, EventCount); + } + + [Fact] + public void EmptyTriggerPhase_FiresOnEveryExit() + { + _executor.UpdateRules(new List { ExitRule("GATHER-PX-003", "") }); + + _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); // exit of Unknown — must NOT fire + Thread.Sleep(300); + Assert.Equal(0, EventCount); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup + Assert.True(WaitForEventCount(1)); + + _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup + Assert.True(WaitForEventCount(2)); + } + + [Fact] + public void NeverFiresOnExitOfUnknown() + { + // Before the first phase signal there is no phase to leave. + _executor.UpdateRules(new List { ExitRule("GATHER-PX-004", "") }); + + _executor.OnPhaseChanged(EnrollmentPhase.Start); + Thread.Sleep(300); + Assert.Equal(0, EventCount); + } + + [Fact] + public void RepeatedSamePhaseSignal_IsNotAnExit() + { + _executor.UpdateRules(new List { ExitRule("GATHER-PX-005", "") }); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // same phase — no transition + Thread.Sleep(300); + Assert.Equal(0, EventCount); + } + + [Fact] + public void FiresOnTransitionIntoFailed_CapturingStateAtTheFailurePoint() + { + _executor.UpdateRules(new List { ExitRule("GATHER-PX-006", "AccountSetup") }); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + _executor.OnPhaseChanged(EnrollmentPhase.Failed); + + Assert.True(WaitForEventCount(1), "leaving a phase into Failed must still snapshot it"); + } + + [Fact] + public void FiresOnExitOfCompleteOnly_WhenCompleteIsActuallyLeft() + { + // "End of enrollment" is better served by phase_change on Complete / on_event; an exit + // rule on Complete only fires if a later transition happens at all. + _executor.UpdateRules(new List { ExitRule("GATHER-PX-007", "Complete") }); + + _executor.OnPhaseChanged(EnrollmentPhase.FinalizingSetup); + _executor.OnPhaseChanged(EnrollmentPhase.Complete); + Thread.Sleep(300); + Assert.Equal(0, EventCount); + } + + [Fact] + public void ScopeGate_EvaluatesAgainstThePhaseBeingLeft() + { + // Scope = only during DeviceSetup. Leaving DeviceSetup must fire (the rule is still in + // scope at that instant) even though the NEW phase is outside the scope. + _executor.UpdateRules(new List + { + ExitRule("GATHER-PX-010", "DeviceSetup", activePhases: new List { "DeviceSetup" }) + }); + + _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + + Assert.True(WaitForEventCount(1), + "the scope gate must see the phase being left, not the one being entered"); + } + + [Fact] + public void OutOfScopeExit_DoesNotFire_AndDoesNotConsumeTheOneShotSlot() + { + // Scope = only during AccountSetup; the rule exits DeviceSetup while out of scope. + var rule = ExitRule("GATHER-PX-011", "", activePhases: new List { "AccountSetup" }); + _executor.UpdateRules(new List { rule }); + + _executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup: out of scope + Thread.Sleep(300); + Assert.Equal(0, EventCount); + + _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup: in scope + Assert.True(WaitForEventCount(1)); + } + + [Fact] + public void ExitAndChangeRulesForTheSamePhase_BothFireIndependently() + { + // The dedup key spaces must not collide: same ruleId shape, same phase, different trigger. + var enter = new GatherRule + { + RuleId = "GATHER-PX-020", + Title = "enter", + CollectorType = "registry", + Target = AbsentPath, + Trigger = "phase_change", + TriggerPhase = "AccountSetup", + OutputEventType = "gather_test", + Enabled = true, + }; + var exit = ExitRule("GATHER-PX-021", "AccountSetup"); + _executor.UpdateRules(new List { enter, exit }); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + Assert.True(WaitForEventCount(1)); // enter fired + + _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); + Assert.True(WaitForEventCount(2)); // exit fired + } + + [Fact] + public void ExitRuleWithEmitOnChange_StillEmitsItsFirstResult() + { + var rule = ExitRule("GATHER-PX-030", "AccountSetup"); + rule.EmitMode = "on_change"; + _executor.UpdateRules(new List { rule }); + + _executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); + _executor.OnPhaseChanged(EnrollmentPhase.AppsUser); + + 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 29f157de..d3fadff4 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 @@ -176,12 +176,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather /// /// Called when a phase change event occurs - executes rules triggered by phase changes + /// (phase_change on entering the new phase, phase_exit on leaving the old one). /// public void OnPhaseChanged(EnrollmentPhase newPhase) { var phaseName = newPhase.ToString(); - // Update the phase + from-phase latches FIRST so deferred startup activation and + // phase_exit runs FIRST and against the phase being LEFT: both the TriggerPhase match + // and the scope gate must see the old phase, so this block has to complete before + // _currentPhase moves below. A transition INTO Failed still fires the exit rules of the + // phase that failed — that snapshot is the whole point at a failure boundary. + List exitRules = null; + lock (_scopeLock) + { + var previousPhase = _currentPhase; + if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase) + { + var previousPhaseName = previousPhase.ToString(); + exitRules = new List(); + + foreach (var rule in _activeRules) + { + if (rule.Trigger != "phase_exit") continue; + if (!string.IsNullOrEmpty(rule.TriggerPhase) + && !string.Equals(rule.TriggerPhase, previousPhaseName, StringComparison.OrdinalIgnoreCase)) + continue; + + // Scope gate before the dedup add — an out-of-scope pass must not consume + // the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase. + if (!IsRuleInScopeLocked(rule)) continue; + + // "exit:" prefix keeps this key space disjoint from the phase_change keys. + if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}")) + { + _logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping"); + continue; + } + + _logger.Info($"Phase exit triggered rule {rule.RuleId} (left phase: {previousPhaseName}, now: {phaseName})"); + exitRules.Add(rule); + } + } + } + + if (exitRules != null) + { + foreach (var rule in exitRules) + { + var exitRule = rule; + ThreadPool.QueueUserWorkItem(_ => ExecuteRule(exitRule)); + } + } + + // Update the phase + from-phase latches so deferred startup activation and // the phase_change rules below all evaluate against the NEW phase. List deferredStartup; lock (_scopeLock) diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs index 2b861c41..d3f7f3e0 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/GatherRule.cs @@ -107,7 +107,11 @@ namespace AutopilotMonitor.Shared.Models // ===== WHEN TO COLLECT ===== /// - /// Trigger type: "startup", "phase_change", "interval", "on_event" + /// Trigger type: "startup", "phase_change", "phase_exit", "interval", "on_event". + /// + /// "phase_change" fires once when the phase in is ENTERED, + /// "phase_exit" once when it is LEFT — the two one-shot bookends of a phase. + /// /// public string Trigger { get; set; } = default!; @@ -117,8 +121,9 @@ namespace AutopilotMonitor.Shared.Models public int? IntervalSeconds { get; set; } /// - /// Phase to trigger on (only used when Trigger = "phase_change") - /// e.g., "Identity", "MdmEnrollment", "AppInstallation" + /// Phase to trigger on (used when Trigger = "phase_change" or "phase_exit"). + /// Canonical tokens are the enum names, e.g. + /// "DeviceSetup", "AccountSetup", "Complete". Empty = every phase transition. /// public string TriggerPhase { get; set; } = default!; diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx index f656c016..09a009da 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx @@ -293,7 +293,16 @@ export function GatherRuleCard({ )} {rule.trigger === "phase_change" && rule.triggerPhase && ( - on phase: {rule.triggerPhase} + entering {formatGatherPhase(rule.triggerPhase)} + + )} + {rule.trigger === "phase_exit" && ( + + {rule.triggerPhase ? ( + <>leaving {formatGatherPhase(rule.triggerPhase)} + ) : ( + "on every phase exit" + )} )} {rule.trigger === "on_event" && rule.triggerEventType && ( diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx index 962d7f49..078685a8 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx @@ -11,6 +11,7 @@ import { TARGET_PLACEHOLDERS, TARGET_HINTS, GATHER_PHASES, + PHASE_TRIGGERS, EMIT_MODES, formatTrigger, } from "../types"; @@ -425,7 +426,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo )} - {form.trigger === "phase_change" && ( + {PHASE_TRIGGERS.includes(form.trigger) && (
-

Run this rule when the enrollment reaches this phase. "Any phase change" fires once per phase transition.

+

+ {form.trigger === "phase_exit" + ? "Collect once when the enrollment LEAVES this phase — a snapshot of its end state. \"Any phase\" fires on every phase transition. Also fires when the phase is left because enrollment failed." + : "Collect once when the enrollment REACHES this phase. \"Any phase\" fires on every phase transition."} +

)} diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx index 00ebc46b..2ee5e025 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx +++ b/src/Web/autopilot-monitor-web/app/gather-rules/page.tsx @@ -16,7 +16,7 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; -import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, withDerivedScopeMode } from "./types"; +import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, PHASE_TRIGGERS, withDerivedScopeMode } from "./types"; import { GatherRuleFormFields } from "./components/GatherRuleFormFields"; import { GatherRuleCard } from "./components/GatherRuleCard"; @@ -209,7 +209,7 @@ export default function GatherRulesPage() { parameters: buildParameters(form), trigger: form.trigger, intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : null, - triggerPhase: form.trigger === "phase_change" ? form.triggerPhase : null, + triggerPhase: PHASE_TRIGGERS.includes(form.trigger) ? form.triggerPhase : null, triggerEventType: form.trigger === "on_event" ? form.triggerEventType : null, activePhases: form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null, activeFromPhase: form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null, @@ -296,7 +296,7 @@ export default function GatherRulesPage() { parameters: buildParameters(form), trigger: form.trigger, intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : null, - triggerPhase: form.trigger === "phase_change" ? form.triggerPhase : null, + triggerPhase: PHASE_TRIGGERS.includes(form.trigger) ? form.triggerPhase : null, triggerEventType: form.trigger === "on_event" ? form.triggerEventType : null, activePhases: form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null, activeFromPhase: form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null, diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/types.ts b/src/Web/autopilot-monitor-web/app/gather-rules/types.ts index 9930470c..23c88a46 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/types.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/types.ts @@ -61,7 +61,10 @@ export interface NewRuleForm { export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const; export const COLLECTOR_TYPES = ["registry", "eventlog", "wmi", "file", "command_allowlisted", "logparser", "json", "xml"] as const; -export const TRIGGERS = ["startup", "phase_change", "interval", "on_event"] as const; +export const TRIGGERS = ["startup", "phase_change", "phase_exit", "interval", "on_event"] as const; + +/** Triggers that collect at a phase boundary and therefore use the triggerPhase field. */ +export const PHASE_TRIGGERS: ReadonlyArray = ["phase_change", "phase_exit"]; export const SEVERITIES = ["info", "warning", "error", "critical"] as const; // Canonical phase-scope tokens: the backend EnrollmentPhase enum NAMES from Start(0) through @@ -168,7 +171,8 @@ export const EMPTY_FORM: NewRuleForm = { export function formatTrigger(trigger: string) { switch (trigger) { - case "phase_change": return "Phase Change"; + case "phase_change": return "Phase Start"; + case "phase_exit": return "Phase End"; case "on_event": return "On Event"; default: return trigger.charAt(0).toUpperCase() + trigger.slice(1); }