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:
Oliver Kieselbach
2026-04-23 15:45:29 +02:00
parent be9070a68f
commit b7f74dbd35
3 changed files with 468 additions and 66 deletions
@@ -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