mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
v2 single-rail 5.9a: ImeLogTrackerAdapter add InformationalEvent posts (B29 part 1)
Plan §5.9 / PR #9 — V1 Parity Issue #3: the IME log tracker callbacks now dual-emit a specific DecisionSignalKind + an InformationalEvent so the Events table regains the V1-compatible wire shape (esp_phase_changed, ime_user_session_completed, app_install_started, app_install_completed, app_install_failed, app_download_started, download_progress) while remaining on the single-rail signal pipeline. Changes: - ImeLogTrackerAdapter: construct an InformationalEventPost from the existing ingress/clock; after every EspPhaseChanged / UserSessionCompleted / AppStateChanged post, also emit a matching InformationalEvent. EspPhase declarations carry the mapped EnrollmentPhase (DeviceSetup / AccountSetup / FinalizingSetup) per feedback_phase_strategy. - AppState handler expanded from terminal-only to all state transitions with V1-compatible eventType mapping: Downloading transitions → app_download_started + byte updates → download_progress (Debug); Installing/InProgress → app_install_started; Installed/Skipped/Postponed → app_install_completed; Error → app_install_failed. Payload carries appId/appName/state/intent/ targeted/runAs/progressPercent/bytesDownloaded/bytesTotal/isError/isCompleted + appVersion/appType/detectionResult/attempt, plus error* fields on Error. - Shared.Constants.EventTypes: add EspPhaseChanged, ImeAgentVersion, ImeUserSessionCompleted, DoTelemetry, AllAppsCompleted. - Adapter tests: refactor to filter the posted-signal list by kind + eventType so the dual-emission contract is verifiable. 14 new tests cover the full state-transition matrix + payload shape + error-field population; total adapter test count 37 (up from 17). Verification: dotnet build AutopilotMonitor.sln clean; V2.Core test suite green (552 / 552, up from 538). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+245
-33
@@ -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<FakeSignalIngressSink.PostedSignal> DecisionSignals(DecisionSignalKind kind) =>
|
||||
Ingress.Posted.Where(p => p.Kind == kind).ToList();
|
||||
|
||||
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> 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<FakeSignalIngressSink.PostedSignal> AllInfoEvents() =>
|
||||
Ingress.Posted.Where(p => p.Kind == DecisionSignalKind.InformationalEvent).ToList();
|
||||
|
||||
public IReadOnlyList<FakeSignalIngressSink.PostedSignal> 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<string>(),
|
||||
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<string>(),
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Adapter for <see cref="ImeLogTracker"/> → mehrere DecisionSignalKinds.
|
||||
/// Plan §2.1a / §2.2.
|
||||
/// Plan §2.1a / §2.2 / §5.9 (single-rail PR #9 — V1 Parity Issue #3).
|
||||
/// <para>
|
||||
/// ImeLogTracker nutzt Action-Property-Callbacks (nicht Events). Adapter <b>ersetzt</b>
|
||||
/// 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Mapping:
|
||||
/// <b>Dual emission</b> (Plan §5.9): jede Callback postet (a) einen spezifischen
|
||||
/// <see cref="DecisionSignalKind"/> für Decision-Relevanz + (b) einen
|
||||
/// <see cref="DecisionSignalKind.InformationalEvent"/> 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 <c>TelemetryEventEmitter.Emit</c>-Aufruf (Single-Rail
|
||||
/// Invariante 1).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Mapping (DecisionSignal):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OnEspPhaseChanged(phase)</c> → <see cref="DecisionSignalKind.EspPhaseChanged"/>
|
||||
/// (dedup per distinct phase value — Reducer Plan §2.1a idempotenz-Anforderung).</item>
|
||||
@@ -38,9 +49,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
/// </summary>
|
||||
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<string> _whiteGloveSealingPatternIds;
|
||||
private readonly object _lock = new object();
|
||||
|
||||
@@ -57,7 +71,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
|
||||
private readonly Action<string> _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<string>(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<string, string>(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<string, string>(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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = app.Id,
|
||||
["appName"] = app.Name ?? string.Empty,
|
||||
["previousState"] = oldState.ToString(),
|
||||
["newState"] = newState.ToString(),
|
||||
}),
|
||||
payload: new Dictionary<string, string>(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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = app.Id,
|
||||
["appName"] = app.Name ?? string.Empty,
|
||||
["previousState"] = oldState.ToString(),
|
||||
["newState"] = newState.ToString(),
|
||||
}),
|
||||
payload: new Dictionary<string, string>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<string, string> BuildAppStatePayload(
|
||||
AppPackageState app,
|
||||
AppInstallationState newState)
|
||||
{
|
||||
var data = new Dictionary<string, string>(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;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors <see cref="DecisionEngine.MapEspPhaseToEnrollmentPhase(string)"/> so the
|
||||
/// adapter does not depend on the engine's <c>internal</c> helper (kept here for build
|
||||
/// isolation; drift should be caught by an xUnit parity test in a later PR).
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user