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 39e48ba0..d0d1d5d7 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs @@ -440,5 +440,239 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Throws(() => new ImeLogTrackerAdapter(f.Tracker, null!, f.Clock)); Assert.Throws(() => new ImeLogTrackerAdapter(f.Tracker, f.Ingress, null!)); } + + // -- 5.9b: OnImeAgentVersion / OnDoTelemetryReceived / OnScriptCompleted -------- + + [Fact] + public void ImeAgentVersion_emits_ime_agent_version_info_event() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerImeAgentVersionFromTest("1.101.109.0"); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeAgentVersion)); + Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]); + Assert.Equal("1.101.109.0", info.Payload["agentVersion"]); + Assert.Equal("IME Agent version: 1.101.109.0", info.Payload[SignalPayloadKeys.Message]); + } + + [Fact] + public void ImeAgentVersion_null_or_empty_is_skipped() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerImeAgentVersionFromTest(null!); + adapter.TriggerImeAgentVersionFromTest(""); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void ImeAgentVersion_same_version_twice_is_not_deduped_matches_V1_behavior() + { + // V1 reference session shows 2 ime_agent_version events for the same version + // in the same session (seq 24 and 60). Adapter must not dedup. + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerImeAgentVersionFromTest("1.101.109.0"); + adapter.TriggerImeAgentVersionFromTest("1.101.109.0"); + + Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeAgentVersion).Count); + } + + [Fact] + public void DoTelemetry_emits_do_telemetry_info_event_with_do_fields() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + var app = AppPackageState.Restore( + id: "0e3be4ce-790b-4081-831c-d72d92f3cc9b", + listPos: 0, + name: "SAP 2024 Patch 13", + runAs: AppRunAs.System, + intent: AppIntent.Install, + targeted: AppTargeted.Device, + dependsOn: new HashSet(), + installationState: AppInstallationState.Downloading, + downloadingOrInstallingSeen: true, + progressPercent: 97, + bytesDownloaded: 185_023_504, + bytesTotal: 190_266_384, + doFileSize: 190_266_384, + doTotalBytesDownloaded: 190_266_384, + doBytesFromPeers: 0, + doPercentPeerCaching: 0, + doBytesFromHttp: 190_266_384, + doDownloadMode: 1, + doDownloadDuration: "00:01:45.159", + hasDoTelemetry: true); + + adapter.TriggerDoTelemetryFromTest(app); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.DoTelemetry)); + Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]); + Assert.Equal(app.Id, info.Payload["appId"]); + Assert.Equal("SAP 2024 Patch 13", info.Payload["appName"]); + Assert.Equal("Downloading", info.Payload["state"]); + Assert.Equal("97", info.Payload["progressPercent"]); + Assert.Equal("185023504", info.Payload["bytesDownloaded"]); + Assert.Equal("190266384", info.Payload["doFileSize"]); + Assert.Equal("0", info.Payload["doBytesFromPeers"]); + Assert.Equal("190266384", info.Payload["doBytesFromHttp"]); + Assert.Equal("1", info.Payload["doDownloadMode"]); + Assert.Equal("00:01:45.159", info.Payload["doDownloadDuration"]); + } + + [Fact] + public void DoTelemetry_null_app_or_empty_id_is_ignored() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerDoTelemetryFromTest(null!); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void ScriptCompleted_platform_success_emits_script_completed_info_source_IME() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + var script = new ScriptExecutionState + { + PolicyId = "4453d1ad-8171-4459-ae6b-ad97f722ea11", + ScriptType = "platform", + ExitCode = 0, + RunContext = "System", + Result = "Success", + Stdout = "Script created at: C:\\ProgramData\\TPF IT\\ShowItemsOnDesktop\\ShowItemsOnDesktop.ps1", + }; + + adapter.TriggerScriptCompletedFromTest(script); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted)); + // V1 contract: Source="IME" (not "ImeLogTracker") for script_completed. + Assert.Equal("IME", info.Payload![SignalPayloadKeys.Source]); + Assert.Equal(script.PolicyId, info.Payload["policyId"]); + Assert.Equal("platform", info.Payload["scriptType"]); + Assert.Equal("0", info.Payload["exitCode"]); + Assert.Equal("System", info.Payload["runContext"]); + Assert.Equal("Success", info.Payload["result"]); + Assert.Equal(EventSeverity.Info.ToString(), info.Payload[SignalPayloadKeys.Severity]); + Assert.Contains("Platform script 4453d1ad: Success (exit: 0)", info.Payload[SignalPayloadKeys.Message]); + } + + [Fact] + public void ScriptCompleted_platform_failure_marks_severity_Error() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + var script = new ScriptExecutionState + { + PolicyId = "deadbeef-0000-0000-0000-000000000000", + ScriptType = "platform", + ExitCode = 1, + RunContext = "System", + Result = "Failed", + Stderr = "Script failed: access denied", + }; + + adapter.TriggerScriptCompletedFromTest(script); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted)); + Assert.Equal(EventSeverity.Error.ToString(), info.Payload![SignalPayloadKeys.Severity]); + Assert.Equal("Failed", info.Payload["result"]); + Assert.Equal("1", info.Payload["exitCode"]); + } + + [Fact] + public void ScriptCompleted_remediation_carries_compliance_result() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + var script = new ScriptExecutionState + { + PolicyId = "cafebabe-0000-0000-0000-000000000000", + ScriptType = "remediation", + ScriptPart = "detection", + ExitCode = 0, + RunContext = "System", + ComplianceResult = "True", + }; + + adapter.TriggerScriptCompletedFromTest(script); + + var info = Assert.Single(f.InfoEvents(SharedEventTypes.ScriptCompleted)); + Assert.Equal("remediation", info.Payload!["scriptType"]); + Assert.Equal("detection", info.Payload["scriptPart"]); + Assert.Equal("True", info.Payload["complianceResult"]); + Assert.Contains("Remediation script cafebabe: compliance=True", info.Payload[SignalPayloadKeys.Message]); + } + + [Fact] + public void ScriptCompleted_null_or_empty_policyId_is_ignored() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerScriptCompletedFromTest(null!); + adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState { PolicyId = null }); + adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState { PolicyId = "" }); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void New_callbacks_preserve_prior_action_handlers_chain_invoke() + { + using var f = new Fixture(); + int priorVersionCalls = 0; + int priorDoCalls = 0; + int priorScriptCalls = 0; + f.Tracker.OnImeAgentVersion = _ => priorVersionCalls++; + f.Tracker.OnDoTelemetryReceived = _ => priorDoCalls++; + f.Tracker.OnScriptCompleted = _ => priorScriptCalls++; + + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + f.Tracker.OnImeAgentVersion("1.0.0.0"); + f.Tracker.OnDoTelemetryReceived(new AppPackageState("app-1", 0)); + f.Tracker.OnScriptCompleted(new ScriptExecutionState { PolicyId = "p", ScriptType = "platform" }); + + Assert.Equal(1, priorVersionCalls); + Assert.Equal(1, priorDoCalls); + Assert.Equal(1, priorScriptCalls); + Assert.Equal(3, f.AllInfoEvents().Count); + } + + [Fact] + public void Dispose_restores_all_prior_action_handlers() + { + using var f = new Fixture(); + Action priorVersion = _ => { }; + Action priorDo = _ => { }; + Action priorScript = _ => { }; + f.Tracker.OnImeAgentVersion = priorVersion; + f.Tracker.OnDoTelemetryReceived = priorDo; + f.Tracker.OnScriptCompleted = priorScript; + + var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + Assert.NotSame(priorVersion, f.Tracker.OnImeAgentVersion); + Assert.NotSame(priorDo, f.Tracker.OnDoTelemetryReceived); + Assert.NotSame(priorScript, f.Tracker.OnScriptCompleted); + + adapter.Dispose(); + Assert.Same(priorVersion, f.Tracker.OnImeAgentVersion); + Assert.Same(priorDo, f.Tracker.OnDoTelemetryReceived); + Assert.Same(priorScript, f.Tracker.OnScriptCompleted); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs index 12ea6bec..ca637607 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs @@ -64,12 +64,18 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters private readonly Action? _prevOnUserSessionCompleted; private readonly Action? _prevOnAppStateChanged; private readonly Action? _prevOnPatternMatched; + private readonly Action? _prevOnImeAgentVersion; + private readonly Action? _prevOnDoTelemetryReceived; + private readonly Action? _prevOnScriptCompleted; // Our own delegate instances — stored once so Dispose can compare by reference. private readonly Action _ourOnEspPhaseChanged; private readonly Action _ourOnUserSessionCompleted; private readonly Action _ourOnAppStateChanged; private readonly Action _ourOnPatternMatched; + private readonly Action _ourOnImeAgentVersion; + private readonly Action _ourOnDoTelemetryReceived; + private readonly Action _ourOnScriptCompleted; // Dedup state for DecisionSignals. private string? _lastEspPhase; @@ -99,6 +105,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _prevOnUserSessionCompleted = _tracker.OnUserSessionCompleted; _prevOnAppStateChanged = _tracker.OnAppStateChanged; _prevOnPatternMatched = _tracker.OnPatternMatched; + _prevOnImeAgentVersion = _tracker.OnImeAgentVersion; + _prevOnDoTelemetryReceived = _tracker.OnDoTelemetryReceived; + _prevOnScriptCompleted = _tracker.OnScriptCompleted; // Store our delegate instances once — implicit method-group conversions // create a new delegate each time, which would break Dispose's reference check. @@ -106,11 +115,17 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _ourOnUserSessionCompleted = OnUserSessionCompleted; _ourOnAppStateChanged = OnAppStateChanged; _ourOnPatternMatched = OnPatternMatched; + _ourOnImeAgentVersion = OnImeAgentVersion; + _ourOnDoTelemetryReceived = OnDoTelemetryReceived; + _ourOnScriptCompleted = OnScriptCompleted; _tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged; _tracker.OnUserSessionCompleted = _ourOnUserSessionCompleted; _tracker.OnAppStateChanged = _ourOnAppStateChanged; _tracker.OnPatternMatched = _ourOnPatternMatched; + _tracker.OnImeAgentVersion = _ourOnImeAgentVersion; + _tracker.OnDoTelemetryReceived = _ourOnDoTelemetryReceived; + _tracker.OnScriptCompleted = _ourOnScriptCompleted; } public void Dispose() @@ -125,6 +140,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _tracker.OnAppStateChanged = _prevOnAppStateChanged; if (ReferenceEquals(_tracker.OnPatternMatched, _ourOnPatternMatched)) _tracker.OnPatternMatched = _prevOnPatternMatched; + if (ReferenceEquals(_tracker.OnImeAgentVersion, _ourOnImeAgentVersion)) + _tracker.OnImeAgentVersion = _prevOnImeAgentVersion; + if (ReferenceEquals(_tracker.OnDoTelemetryReceived, _ourOnDoTelemetryReceived)) + _tracker.OnDoTelemetryReceived = _prevOnDoTelemetryReceived; + if (ReferenceEquals(_tracker.OnScriptCompleted, _ourOnScriptCompleted)) + _tracker.OnScriptCompleted = _prevOnScriptCompleted; } private void OnEspPhaseChanged(string phase) @@ -151,11 +172,32 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters MaybeEmitWhiteGloveSealingPattern(patternId); } + private void OnImeAgentVersion(string version) + { + _prevOnImeAgentVersion?.Invoke(version); + EmitImeAgentVersion(version); + } + + private void OnDoTelemetryReceived(AppPackageState app) + { + _prevOnDoTelemetryReceived?.Invoke(app); + EmitDoTelemetry(app); + } + + private void OnScriptCompleted(ScriptExecutionState script) + { + _prevOnScriptCompleted?.Invoke(script); + EmitScriptCompleted(script); + } + internal void TriggerEspPhaseFromTest(string phase) => EmitEspPhase(phase); internal void TriggerUserSessionCompletedFromTest() => EmitUserSessionCompleted(); internal void TriggerAppStateFromTest(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) => EmitAppState(app, oldState, newState); internal void TriggerPatternMatchedFromTest(string patternId) => MaybeEmitWhiteGloveSealingPattern(patternId); + internal void TriggerImeAgentVersionFromTest(string version) => EmitImeAgentVersion(version); + internal void TriggerDoTelemetryFromTest(AppPackageState app) => EmitDoTelemetry(app); + internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script); private void EmitEspPhase(string phase) { @@ -336,6 +378,125 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters }); } + private void EmitImeAgentVersion(string version) + { + if (string.IsNullOrEmpty(version)) return; + + var data = new Dictionary(StringComparer.Ordinal) + { + ["agentVersion"] = version, + }; + var patternId = _tracker.LastMatchedPatternId; + if (!string.IsNullOrEmpty(patternId)) + data["patternId"] = patternId!; + + _post.Emit( + eventType: SharedEventTypes.ImeAgentVersion, + source: SourceLabel, + message: $"IME Agent version: {version}", + data: data, + occurredAtUtc: _clock.UtcNow); + } + + private void EmitDoTelemetry(AppPackageState app) + { + if (app == null || string.IsNullOrEmpty(app.Id)) return; + + var culture = System.Globalization.CultureInfo.InvariantCulture; + var data = new Dictionary(StringComparer.Ordinal) + { + ["appId"] = app.Id, + ["appName"] = app.Name ?? string.Empty, + ["state"] = app.InstallationState.ToString(), + ["intent"] = app.Intent.ToString(), + ["targeted"] = app.Targeted.ToString(), + ["runAs"] = app.RunAs.ToString(), + ["progressPercent"] = (app.ProgressPercent ?? 0).ToString(culture), + ["bytesDownloaded"] = app.BytesDownloaded.ToString(culture), + ["bytesTotal"] = app.BytesTotal.ToString(culture), + ["isError"] = "false", + ["isCompleted"] = IsCompletedState(app.InstallationState).ToString().ToLowerInvariant(), + ["doFileSize"] = app.DoFileSize.ToString(culture), + ["doTotalBytesDownloaded"] = app.DoTotalBytesDownloaded.ToString(culture), + ["doBytesFromPeers"] = app.DoBytesFromPeers.ToString(culture), + ["doPercentPeerCaching"] = app.DoPercentPeerCaching.ToString(culture), + ["doBytesFromLanPeers"] = app.DoBytesFromLanPeers.ToString(culture), + ["doBytesFromGroupPeers"] = app.DoBytesFromGroupPeers.ToString(culture), + ["doBytesFromInternetPeers"] = app.DoBytesFromInternetPeers.ToString(culture), + ["doDownloadMode"] = app.DoDownloadMode.ToString(culture), + ["doBytesFromHttp"] = app.DoBytesFromHttp.ToString(culture), + }; + if (!string.IsNullOrEmpty(app.DoDownloadDuration)) data["doDownloadDuration"] = app.DoDownloadDuration!; + if (!string.IsNullOrEmpty(app.DetectionResult)) data["detectionResult"] = app.DetectionResult!; + var patternId = _tracker.LastMatchedPatternId; + if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!; + + var label = string.IsNullOrEmpty(app.Name) ? app.Id : app.Name; + var msg = $"{label}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}"; + + _post.Emit( + eventType: SharedEventTypes.DoTelemetry, + source: SourceLabel, + message: msg, + data: data, + occurredAtUtc: _clock.UtcNow); + } + + private void EmitScriptCompleted(ScriptExecutionState script) + { + if (script == null || string.IsNullOrEmpty(script.PolicyId)) return; + + // V1 contract: Source="IME" (not "ImeLogTracker"), severity derived from result. + var isFailure = string.Equals(script.Result, "Failed", StringComparison.OrdinalIgnoreCase) + || (script.ExitCode.HasValue && script.ExitCode.Value != 0); + var severity = isFailure ? EventSeverity.Error : EventSeverity.Info; + + var culture = System.Globalization.CultureInfo.InvariantCulture; + var data = new Dictionary(StringComparer.Ordinal) + { + ["policyId"] = script.PolicyId, + }; + if (!string.IsNullOrEmpty(script.ScriptType)) data["scriptType"] = script.ScriptType!; + if (!string.IsNullOrEmpty(script.ScriptPart)) data["scriptPart"] = script.ScriptPart!; + if (script.ExitCode.HasValue) data["exitCode"] = script.ExitCode.Value.ToString(culture); + if (!string.IsNullOrEmpty(script.RunContext)) data["runContext"] = script.RunContext!; + if (!string.IsNullOrEmpty(script.Result)) data["result"] = script.Result!; + if (!string.IsNullOrEmpty(script.ComplianceResult)) data["complianceResult"] = script.ComplianceResult!; + if (!string.IsNullOrEmpty(script.Stdout)) data["stdout"] = script.Stdout!; + if (!string.IsNullOrEmpty(script.Stderr)) data["stderr"] = script.Stderr!; + var patternId = _tracker.LastMatchedPatternId; + if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!; + + var label = IsRemediation(script.ScriptType) ? "Remediation script" : "Platform script"; + var shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId; + string messageCore; + if (IsRemediation(script.ScriptType)) + { + messageCore = !string.IsNullOrEmpty(script.ComplianceResult) + ? $"compliance={script.ComplianceResult}" + : script.Result ?? ""; + } + else + { + messageCore = script.Result ?? ""; + } + var exitSuffix = script.ExitCode.HasValue + ? $" (exit: {script.ExitCode.Value.ToString(culture)})" + : string.Empty; + var msg = $"{label} {shortId}: {messageCore}{exitSuffix}"; + + _post.Emit( + eventType: SharedEventTypes.ScriptCompleted, + source: "IME", + message: msg, + severity: severity, + data: data, + occurredAtUtc: _clock.UtcNow); + } + + private static bool IsRemediation(string? scriptType) => + string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase); + private static DecisionSignalKind? ClassifyTerminalState(AppInstallationState state) { switch (state)