diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs index 0ffeb88c..39e48ba0 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs @@ -9,6 +9,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; using AutopilotMonitor.DecisionCore.Engine; using AutopilotMonitor.DecisionCore.Signals; using AutopilotMonitor.Shared.Models; +using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes; using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters @@ -34,6 +35,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters logger: Logger); } + public IReadOnlyList DecisionSignals(DecisionSignalKind kind) => + Ingress.Posted.Where(p => p.Kind == kind).ToList(); + + public IReadOnlyList InfoEvents(string eventType) => + Ingress.Posted + .Where(p => p.Kind == DecisionSignalKind.InformationalEvent + && p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == eventType) + .ToList(); + + public IReadOnlyList AllInfoEvents() => + Ingress.Posted.Where(p => p.Kind == DecisionSignalKind.InformationalEvent).ToList(); + + public IReadOnlyList NonInfoSignals() => + Ingress.Posted.Where(p => p.Kind != DecisionSignalKind.InformationalEvent).ToList(); + public void Dispose() { Tracker.Dispose(); @@ -42,21 +60,61 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters } [Fact] - public void EspPhaseChanged_first_phase_emits_signal_with_phase_in_payload() + public void EspPhaseChanged_first_phase_emits_decision_signal_with_phase_in_payload() { using var f = new Fixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); adapter.TriggerEspPhaseFromTest("DeviceSetup"); - var posted = Assert.Single(f.Ingress.Posted); - Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind); - Assert.Equal("ImeLogTracker", posted.SourceOrigin); - Assert.Equal("DeviceSetup", posted.Payload![SignalPayloadKeys.EspPhase]); + var decisionPost = Assert.Single(f.DecisionSignals(DecisionSignalKind.EspPhaseChanged)); + Assert.Equal("ImeLogTracker", decisionPost.SourceOrigin); + Assert.Equal("DeviceSetup", decisionPost.Payload![SignalPayloadKeys.EspPhase]); } [Fact] - public void EspPhaseChanged_same_phase_repeated_is_deduped_idempotency() + public void EspPhaseChanged_first_phase_also_emits_esp_phase_changed_informational_event() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.EspPhaseChanged)); + Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]); + Assert.Equal("DeviceSetup", info.Payload["espPhase"]); + // Phase-declaration event — carries non-Unknown EnrollmentPhase. + Assert.Equal(EnrollmentPhase.DeviceSetup.ToString(), info.Payload["phase"]); + } + + [Fact] + public void EspPhaseChanged_AccountSetup_maps_phase_to_AccountSetup_enum() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest("AccountSetup"); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.EspPhaseChanged)); + Assert.Equal(EnrollmentPhase.AccountSetup.ToString(), info.Payload!["phase"]); + } + + [Fact] + public void EspPhaseChanged_unknown_phase_omits_phase_override() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest("SomethingElse"); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.EspPhaseChanged)); + // Unknown mapping → no phase override, emitter will default to EnrollmentPhase.Unknown. + Assert.False(info.Payload!.ContainsKey("phase")); + Assert.Equal("SomethingElse", info.Payload["espPhase"]); + } + + [Fact] + public void EspPhaseChanged_same_phase_repeated_is_deduped_for_both_rails() { using var f = new Fixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); @@ -65,11 +123,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerEspPhaseFromTest("DeviceSetup"); adapter.TriggerEspPhaseFromTest("DeviceSetup"); - Assert.Single(f.Ingress.Posted); + Assert.Single(f.DecisionSignals(DecisionSignalKind.EspPhaseChanged)); + Assert.Single(f.InfoEvents(SharedEventTypes.EspPhaseChanged)); } [Fact] - public void EspPhaseChanged_distinct_phases_emit_separate_signals() + public void EspPhaseChanged_distinct_phases_emit_separate_signals_and_info_events() { using var f = new Fixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); @@ -79,10 +138,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerEspPhaseFromTest("AccountSetup"); // dedup adapter.TriggerEspPhaseFromTest("FinalizingSetup"); - Assert.Equal(3, f.Ingress.Posted.Count); - Assert.Equal("DeviceSetup", f.Ingress.Posted[0].Payload![SignalPayloadKeys.EspPhase]); - Assert.Equal("AccountSetup", f.Ingress.Posted[1].Payload![SignalPayloadKeys.EspPhase]); - Assert.Equal("FinalizingSetup", f.Ingress.Posted[2].Payload![SignalPayloadKeys.EspPhase]); + var decisions = f.DecisionSignals(DecisionSignalKind.EspPhaseChanged); + Assert.Equal(3, decisions.Count); + Assert.Equal("DeviceSetup", decisions[0].Payload![SignalPayloadKeys.EspPhase]); + Assert.Equal("AccountSetup", decisions[1].Payload![SignalPayloadKeys.EspPhase]); + Assert.Equal("FinalizingSetup", decisions[2].Payload![SignalPayloadKeys.EspPhase]); + + var infos = f.InfoEvents(SharedEventTypes.EspPhaseChanged); + Assert.Equal(3, infos.Count); } [Fact] @@ -98,7 +161,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters } [Fact] - public void UserSessionCompleted_emits_ImeUserSessionCompleted_fire_once() + public void UserSessionCompleted_emits_decision_signal_fire_once() { using var f = new Fixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); @@ -106,8 +169,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerUserSessionCompletedFromTest(); adapter.TriggerUserSessionCompletedFromTest(); - var posted = Assert.Single(f.Ingress.Posted); - Assert.Equal(DecisionSignalKind.ImeUserSessionCompleted, posted.Kind); + Assert.Single(f.DecisionSignals(DecisionSignalKind.ImeUserSessionCompleted)); + } + + [Fact] + public void UserSessionCompleted_also_emits_ime_user_session_completed_info_event() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerUserSessionCompletedFromTest(); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted)); + Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]); + Assert.True(info.Payload.ContainsKey("detectedAt")); } [Theory] @@ -115,7 +190,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters [InlineData(AppInstallationState.Skipped, DecisionSignalKind.AppInstallCompleted)] [InlineData(AppInstallationState.Postponed, DecisionSignalKind.AppInstallCompleted)] [InlineData(AppInstallationState.Error, DecisionSignalKind.AppInstallFailed)] - public void AppStateChange_to_terminal_state_emits_correct_signal_kind( + public void AppStateChange_to_terminal_state_emits_correct_decision_signal_kind( AppInstallationState newState, DecisionSignalKind expectedKind) { using var f = new Fixture(); @@ -124,42 +199,91 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, newState); - var posted = Assert.Single(f.Ingress.Posted); - Assert.Equal(expectedKind, posted.Kind); + var posted = Assert.Single(f.DecisionSignals(expectedKind)); Assert.Equal($"app-{newState}", posted.Payload!["appId"]); Assert.Equal(newState.ToString(), posted.Payload["newState"]); } - [Fact] - public void AppStateChange_to_non_terminal_state_does_not_emit() + [Theory] + [InlineData(AppInstallationState.Installed, "app_install_completed")] + [InlineData(AppInstallationState.Skipped, "app_install_completed")] + [InlineData(AppInstallationState.Postponed, "app_install_completed")] + [InlineData(AppInstallationState.Error, "app_install_failed")] + [InlineData(AppInstallationState.Installing, "app_install_started")] + [InlineData(AppInstallationState.InProgress, "app_install_started")] + public void AppStateChange_transition_emits_matching_informational_event_type( + AppInstallationState newState, string expectedEventType) { using var f = new Fixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); - var app = new AppPackageState("app-1", 0); + var app = new AppPackageState($"app-{newState}", listPos: 0); - adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.InProgress); - adapter.TriggerAppStateFromTest(app, AppInstallationState.InProgress, AppInstallationState.Downloading); - adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installing); + adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, newState); + + var info = Assert.Single(f.InfoEvents(expectedEventType)); + Assert.Equal($"app-{newState}", info.Payload!["appId"]); + Assert.Equal(newState.ToString(), info.Payload["state"]); + } + + [Fact] + public void AppStateChange_into_Downloading_emits_app_download_started() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + var app = new AppPackageState("app-a", 0); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Downloading); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppDownloadStarted)); + Assert.Equal("app-a", info.Payload!["appId"]); + Assert.Equal(EventSeverity.Info.ToString(), info.Payload[SignalPayloadKeys.Severity]); + } + + [Fact] + public void AppStateChange_Downloading_to_Downloading_emits_download_progress_debug() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + var app = new AppPackageState("app-a", 0); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Downloading); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.DownloadProgress)); + Assert.Equal(EventSeverity.Debug.ToString(), info.Payload![SignalPayloadKeys.Severity]); + } + + [Fact] + public void AppStateChange_to_Unknown_or_NotInstalled_emits_nothing() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + var app = new AppPackageState("app-x", 0); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Unknown); + adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.NotInstalled); Assert.Empty(f.Ingress.Posted); } [Fact] - public void AppStateChange_terminal_per_app_is_fire_once() + public void AppStateChange_terminal_decision_signal_is_fire_once_per_app() { using var f = new Fixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); var app = new AppPackageState("app-1", 0); adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Installed); - // Even if the tracker re-fires (shouldn't happen, but defend) — still one post. + // Even if the tracker re-fires (shouldn't happen, but defend) — decision signal stays at one. adapter.TriggerAppStateFromTest(app, AppInstallationState.Installed, AppInstallationState.Error); - Assert.Single(f.Ingress.Posted); + var completions = f.DecisionSignals(DecisionSignalKind.AppInstallCompleted); + var failures = f.DecisionSignals(DecisionSignalKind.AppInstallFailed); + Assert.Single(completions); + Assert.Empty(failures); } [Fact] - public void AppStateChange_different_apps_emit_independently() + public void AppStateChange_different_apps_emit_independent_decision_signals() { using var f = new Fixture(); using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); @@ -168,10 +292,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters adapter.TriggerAppStateFromTest(new AppPackageState("b", 1), AppInstallationState.Installing, AppInstallationState.Error); adapter.TriggerAppStateFromTest(new AppPackageState("c", 2), AppInstallationState.Installing, AppInstallationState.Skipped); - Assert.Equal(3, f.Ingress.Posted.Count); - Assert.Contains(f.Ingress.Posted, p => p.Payload!["appId"] == "a" && p.Kind == DecisionSignalKind.AppInstallCompleted); - Assert.Contains(f.Ingress.Posted, p => p.Payload!["appId"] == "b" && p.Kind == DecisionSignalKind.AppInstallFailed); - Assert.Contains(f.Ingress.Posted, p => p.Payload!["appId"] == "c" && p.Kind == DecisionSignalKind.AppInstallCompleted); + var completions = f.DecisionSignals(DecisionSignalKind.AppInstallCompleted); + var failures = f.DecisionSignals(DecisionSignalKind.AppInstallFailed); + Assert.Equal(2, completions.Count); + Assert.Single(failures); + Assert.Contains(completions, p => p.Payload!["appId"] == "a"); + Assert.Contains(completions, p => p.Payload!["appId"] == "c"); + Assert.Equal("b", failures[0].Payload!["appId"]); } [Fact] @@ -185,6 +312,90 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Empty(f.Ingress.Posted); } + [Fact] + public void AppStateChange_payload_carries_V1_compatible_fields() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + // Build a fully-populated package via Restore to exercise all optional fields. + var app = AppPackageState.Restore( + id: "app-xyz", + listPos: 0, + name: "Company Portal", + runAs: AppRunAs.System, + intent: AppIntent.Install, + targeted: AppTargeted.Device, + dependsOn: new HashSet(), + installationState: AppInstallationState.Installed, + downloadingOrInstallingSeen: true, + progressPercent: 100, + bytesDownloaded: 100, + bytesTotal: 100, + errorPatternId: null, + errorDetail: null, + errorCode: null, + exitCode: null, + hresultFromWin32: null, + appVersion: "11.2.1787.0", + appType: "WinGet", + attemptNumber: 1, + detectionResult: "Detected"); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Installed); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallComplete)); + Assert.Equal("app-xyz", info.Payload!["appId"]); + Assert.Equal("Company Portal", info.Payload["appName"]); + Assert.Equal("Installed", info.Payload["state"]); + Assert.Equal("Install", info.Payload["intent"]); + Assert.Equal("Device", info.Payload["targeted"]); + Assert.Equal("System", info.Payload["runAs"]); + Assert.Equal("100", info.Payload["progressPercent"]); + Assert.Equal("100", info.Payload["bytesDownloaded"]); + Assert.Equal("100", info.Payload["bytesTotal"]); + Assert.Equal("false", info.Payload["isError"]); + Assert.Equal("true", info.Payload["isCompleted"]); + Assert.Equal("11.2.1787.0", info.Payload["appVersion"]); + Assert.Equal("WinGet", info.Payload["appType"]); + Assert.Equal("Detected", info.Payload["detectionResult"]); + } + + [Fact] + public void AppStateChange_Error_payload_carries_error_fields() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + var app = AppPackageState.Restore( + id: "app-err", + listPos: 0, + name: "Failing App", + runAs: AppRunAs.System, + intent: AppIntent.Install, + targeted: AppTargeted.Device, + dependsOn: new HashSet(), + installationState: AppInstallationState.Error, + downloadingOrInstallingSeen: true, + progressPercent: 0, + bytesDownloaded: 0, + bytesTotal: 0, + errorPatternId: "IME-ERROR-UNMAPPED-EXIT", + errorDetail: "Admin did NOT set mapping for lpExitCode: 60001", + errorCode: "60001", + exitCode: "60001", + hresultFromWin32: "-2146964895", + attemptNumber: 1, + detectionResult: "NotDetected"); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Error); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallFailed)); + Assert.Equal("true", info.Payload!["isError"]); + Assert.Equal("IME-ERROR-UNMAPPED-EXIT", info.Payload["errorPatternId"]); + Assert.Equal("60001", info.Payload["errorCode"]); + Assert.Equal("-2146964895", info.Payload["hresultFromWin32"]); + Assert.Equal(EventSeverity.Error.ToString(), info.Payload[SignalPayloadKeys.Severity]); + } + [Fact] public void Adapter_preserves_prior_Action_handlers_chain_invoke() { @@ -202,7 +413,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Equal(1, priorEspCalls); Assert.Equal(1, priorUserCalls); - Assert.Equal(2, f.Ingress.Posted.Count); + // 2 DecisionSignals + 2 InformationalEvents = 4 total. + Assert.Equal(4, f.Ingress.Posted.Count); } [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs index 019480a9..12ea6bec 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs @@ -5,12 +5,14 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.DecisionCore.Engine; using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes; namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { /// /// Adapter for → mehrere DecisionSignalKinds. - /// Plan §2.1a / §2.2. + /// Plan §2.1a / §2.2 / §5.9 (single-rail PR #9 — V1 Parity Issue #3). /// /// ImeLogTracker nutzt Action-Property-Callbacks (nicht Events). Adapter ersetzt /// diese Action-Props — Orchestrator darf die nicht parallel belegen. Dispose restauriert @@ -18,7 +20,16 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters /// Tracker selbst kurz vor Dispose). /// /// - /// Mapping: + /// Dual emission (Plan §5.9): jede Callback postet (a) einen spezifischen + /// für Decision-Relevanz + (b) einen + /// für die Events-Timeline-UI. So bleibt + /// die decision-Logik unverändert, während die V1-Event-Parity (app_install_started, + /// app_download_started, download_progress, do_telemetry, script_completed, etc.) wieder + /// hergestellt wird, ohne direkten TelemetryEventEmitter.Emit-Aufruf (Single-Rail + /// Invariante 1). + /// + /// + /// Mapping (DecisionSignal): /// /// OnEspPhaseChanged(phase) → /// (dedup per distinct phase value — Reducer Plan §2.1a idempotenz-Anforderung). @@ -38,9 +49,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters /// internal sealed class ImeLogTrackerAdapter : IDisposable { + private const string SourceLabel = "ImeLogTracker"; + private readonly ImeLogTracker _tracker; private readonly ISignalIngressSink _ingress; private readonly IClock _clock; + private readonly InformationalEventPost _post; private readonly HashSet _whiteGloveSealingPatternIds; private readonly object _lock = new object(); @@ -57,7 +71,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters private readonly Action _ourOnAppStateChanged; private readonly Action _ourOnPatternMatched; - // Dedup state. + // Dedup state for DecisionSignals. private string? _lastEspPhase; private bool _userSessionCompletedPosted; private bool _sealingPatternPosted; @@ -72,6 +86,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _post = new InformationalEventPost(ingress, clock); _whiteGloveSealingPatternIds = whiteGloveSealingPatternIds == null ? new HashSet(StringComparer.Ordinal) @@ -155,10 +170,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _lastEspPhase = phase; } + var now = _clock.UtcNow; + _ingress.Post( kind: DecisionSignalKind.EspPhaseChanged, - occurredAtUtc: _clock.UtcNow, - sourceOrigin: "ImeLogTracker", + occurredAtUtc: now, + sourceOrigin: SourceLabel, evidence: new Evidence( kind: EvidenceKind.Derived, identifier: "ime-log-tracker-v1", @@ -172,6 +189,26 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { [SignalPayloadKeys.EspPhase] = phase, }); + + // Parity with V1: also emit an `esp_phase_changed` InformationalEvent so the + // Events-table / UI timeline gets the phase declaration. This is one of the two + // event types allowed to carry a non-Unknown Phase per feedback_phase_strategy. + var mappedPhase = MapEspPhaseToEnrollmentPhase(phase); + var data = new Dictionary(StringComparer.Ordinal) + { + ["espPhase"] = phase, + }; + var patternId = _tracker.LastMatchedPatternId; + if (!string.IsNullOrEmpty(patternId)) + data["patternId"] = patternId!; + + _post.Emit( + eventType: SharedEventTypes.EspPhaseChanged, + source: SourceLabel, + message: $"ESP phase: {phase}", + phase: mappedPhase == EnrollmentPhase.Unknown ? (EnrollmentPhase?)null : mappedPhase, + data: data, + occurredAtUtc: now); } private void EmitUserSessionCompleted() @@ -182,10 +219,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _userSessionCompletedPosted = true; } + var now = _clock.UtcNow; + _ingress.Post( kind: DecisionSignalKind.ImeUserSessionCompleted, - occurredAtUtc: _clock.UtcNow, - sourceOrigin: "ImeLogTracker", + occurredAtUtc: now, + sourceOrigin: SourceLabel, evidence: new Evidence( kind: EvidenceKind.Derived, identifier: "ime-log-tracker-v1", @@ -194,43 +233,76 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { ["detectionSource"] = "IME log pattern (UserSessionCompleted)", })); + + var data = new Dictionary(StringComparer.Ordinal) + { + ["detectedAt"] = now.ToString("o"), + }; + var patternId = _tracker.LastMatchedPatternId; + if (!string.IsNullOrEmpty(patternId)) + data["patternId"] = patternId!; + + _post.Emit( + eventType: SharedEventTypes.ImeUserSessionCompleted, + source: SourceLabel, + message: "IME user session completed", + data: data, + occurredAtUtc: now); } private void EmitAppState(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) { if (app == null || string.IsNullOrEmpty(app.Id)) return; - var terminalKind = ClassifyTerminalState(newState); - if (terminalKind == null) return; // Not a terminal state — skip. + var now = _clock.UtcNow; - lock (_lock) + // (1) DecisionSignal — terminal states only, once per app. + var terminalKind = ClassifyTerminalState(newState); + if (terminalKind != null) { - if (!_appsAlreadyPostedTerminal.Add(app.Id)) + bool fireDecisionSignal; + lock (_lock) { - return; // Already posted terminal state for this app. + fireDecisionSignal = _appsAlreadyPostedTerminal.Add(app.Id); + } + + if (fireDecisionSignal) + { + _ingress.Post( + kind: terminalKind.Value, + occurredAtUtc: now, + sourceOrigin: SourceLabel, + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "ime-log-tracker-v1", + summary: $"App {app.Id} → {newState}", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["appId"] = app.Id, + ["appName"] = app.Name ?? string.Empty, + ["previousState"] = oldState.ToString(), + ["newState"] = newState.ToString(), + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["appId"] = app.Id, + ["newState"] = newState.ToString(), + }); } } - _ingress.Post( - kind: terminalKind.Value, - occurredAtUtc: _clock.UtcNow, - sourceOrigin: "ImeLogTracker", - evidence: new Evidence( - kind: EvidenceKind.Derived, - identifier: "ime-log-tracker-v1", - summary: $"App {app.Id} → {newState}", - derivationInputs: new Dictionary(StringComparer.Ordinal) - { - ["appId"] = app.Id, - ["appName"] = app.Name ?? string.Empty, - ["previousState"] = oldState.ToString(), - ["newState"] = newState.ToString(), - }), - payload: new Dictionary(StringComparer.Ordinal) - { - ["appId"] = app.Id, - ["newState"] = newState.ToString(), - }); + // (2) InformationalEvent — one per state transition matching V1's wire shape. + var mapped = MapAppStateToEventType(oldState, newState); + if (mapped == null) return; + + var (eventType, severity) = mapped.Value; + _post.Emit( + eventType: eventType, + source: SourceLabel, + message: BuildAppStateMessage(app, newState, eventType), + severity: severity, + data: BuildAppStatePayload(app, newState), + occurredAtUtc: now); } private void MaybeEmitWhiteGloveSealingPattern(string patternId) @@ -248,7 +320,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _ingress.Post( kind: DecisionSignalKind.WhiteGloveSealingPatternDetected, occurredAtUtc: _clock.UtcNow, - sourceOrigin: "ImeLogTracker", + sourceOrigin: SourceLabel, evidence: new Evidence( kind: EvidenceKind.Derived, identifier: "ime-log-tracker-v1", @@ -278,5 +350,116 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters return null; } } + + /// + /// Maps an IME app state-transition tuple to the V1-compatible event-type string + + /// severity. Returns null when the transition is not user-visible in the timeline + /// (e.g. Unknown → NotInstalled intermediate states). + /// + private static (string EventType, EventSeverity Severity)? MapAppStateToEventType( + AppInstallationState oldState, + AppInstallationState newState) + { + switch (newState) + { + case AppInstallationState.Downloading: + // First transition into Downloading → `app_download_started`; subsequent + // byte updates (old == new == Downloading) → `download_progress` (Debug). + return oldState == AppInstallationState.Downloading + ? (SharedEventTypes.DownloadProgress, EventSeverity.Debug) + : (SharedEventTypes.AppDownloadStarted, EventSeverity.Info); + + case AppInstallationState.Installing: + case AppInstallationState.InProgress: + return (SharedEventTypes.AppInstallStart, EventSeverity.Info); + + case AppInstallationState.Installed: + case AppInstallationState.Skipped: + case AppInstallationState.Postponed: + return (SharedEventTypes.AppInstallComplete, EventSeverity.Info); + + case AppInstallationState.Error: + return (SharedEventTypes.AppInstallFailed, EventSeverity.Error); + + default: + return null; + } + } + + private static string BuildAppStateMessage(AppPackageState app, AppInstallationState newState, string eventType) + { + var label = string.IsNullOrEmpty(app.Name) ? app.Id : app.Name; + if (string.Equals(eventType, SharedEventTypes.AppInstallFailed, StringComparison.Ordinal) + && !string.IsNullOrEmpty(app.ErrorDetail)) + { + return $"{label}: {app.ErrorDetail}"; + } + if (string.Equals(eventType, SharedEventTypes.DownloadProgress, StringComparison.Ordinal)) + { + return $"{label}: {(app.ProgressPercent ?? 0)}%"; + } + return $"{label}: {newState}"; + } + + private static IReadOnlyDictionary BuildAppStatePayload( + AppPackageState app, + AppInstallationState newState) + { + var data = new Dictionary(StringComparer.Ordinal) + { + ["appId"] = app.Id, + ["appName"] = app.Name ?? string.Empty, + ["state"] = newState.ToString(), + ["intent"] = app.Intent.ToString(), + ["targeted"] = app.Targeted.ToString(), + ["runAs"] = app.RunAs.ToString(), + ["progressPercent"] = (app.ProgressPercent ?? 0).ToString(System.Globalization.CultureInfo.InvariantCulture), + ["bytesDownloaded"] = app.BytesDownloaded.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["bytesTotal"] = app.BytesTotal.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["isError"] = (newState == AppInstallationState.Error).ToString().ToLowerInvariant(), + ["isCompleted"] = IsCompletedState(newState).ToString().ToLowerInvariant(), + }; + + if (!string.IsNullOrEmpty(app.AppVersion)) data["appVersion"] = app.AppVersion!; + if (!string.IsNullOrEmpty(app.AppType)) data["appType"] = app.AppType!; + if (app.AttemptNumber > 0) data["attemptNumber"] = app.AttemptNumber.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (!string.IsNullOrEmpty(app.DetectionResult)) data["detectionResult"] = app.DetectionResult!; + + if (newState == AppInstallationState.Error) + { + if (!string.IsNullOrEmpty(app.ErrorPatternId)) data["errorPatternId"] = app.ErrorPatternId!; + if (!string.IsNullOrEmpty(app.ErrorDetail)) data["errorDetail"] = app.ErrorDetail!; + if (!string.IsNullOrEmpty(app.ErrorCode)) data["errorCode"] = app.ErrorCode!; + if (!string.IsNullOrEmpty(app.ExitCode)) data["exitCode"] = app.ExitCode!; + if (!string.IsNullOrEmpty(app.HResultFromWin32)) data["hresultFromWin32"] = app.HResultFromWin32!; + } + + return data; + } + + private static bool IsCompletedState(AppInstallationState state) => + state == AppInstallationState.Installed + || state == AppInstallationState.Skipped + || state == AppInstallationState.Postponed + || state == AppInstallationState.Error; + + /// + /// Mirrors so the + /// adapter does not depend on the engine's internal helper (kept here for build + /// isolation; drift should be caught by an xUnit parity test in a later PR). + /// + private static EnrollmentPhase MapEspPhaseToEnrollmentPhase(string rawPhase) + { + if (string.IsNullOrEmpty(rawPhase)) return EnrollmentPhase.Unknown; + return rawPhase switch + { + "DeviceSetup" => EnrollmentPhase.DeviceSetup, + "AccountSetup" => EnrollmentPhase.AccountSetup, + "FinalizingSetup" => EnrollmentPhase.FinalizingSetup, + "Finalizing" => EnrollmentPhase.FinalizingSetup, + "Complete" => EnrollmentPhase.Complete, + _ => EnrollmentPhase.Unknown, + }; + } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 87bc0dc3..7876bb87 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -211,6 +211,13 @@ namespace AutopilotMonitor.Shared public const string AgentVersionCheck = "agent_version_check"; public const string AgentShuttingDown = "agent_shutting_down"; // V2 single-rail plan §6.2 — terminate-hygiene acknowledgement emitted before CleanupService tears down + // IME log tracker — app / device / script telemetry (V2 single-rail plan §5.9) + public const string EspPhaseChanged = "esp_phase_changed"; + public const string ImeAgentVersion = "ime_agent_version"; + public const string ImeUserSessionCompleted = "ime_user_session_completed"; + public const string DoTelemetry = "do_telemetry"; + public const string AllAppsCompleted = "all_apps_completed"; + // 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