From e97eb08ec5403bdde841436d9e262e4a3faf7f80 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 20 Apr 2026 16:56:40 +0200 Subject: [PATCH] refactor(v2core): M4.3.4 signal adapter (ime log tracker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §2.1a / §2.2 — schwerster Adapter: ImeLogTracker mit Action-Property- Callbacks (nicht Events); 3+ Signal-Mappings + Phase-Value-Dedup + per-App- Terminal-State-Dedup. New: - ImeLogTrackerAdapter - OnEspPhaseChanged(phase) → EspPhaseChanged (dedup per distinct phase value, §2.1a Idempotenz-Anforderung; null/empty → skip) - OnUserSessionCompleted() → ImeUserSessionCompleted (fire-once) - OnAppStateChanged(app, old, new) → AppInstallCompleted (Installed/Skipped/ Postponed) oder AppInstallFailed (Error); non-terminal states ignoriert; per-AppId Dedup - Chain-preserve: vorherige Action-Handler bleiben callable (Orchestrator kann diagnostische Callbacks parallel wiren); Dispose restauriert wenn wir noch der current handler sind - Delegate-instance caching (_our* Felder) weil implizite method-group-conversion jedes Mal neue Delegate-Instanz baut → ReferenceEquals in Dispose sonst fail NICHT Teil M4.3.4 (Tech Debt noted): - WhiteGloveSealingPatternDetected Signal-Emission — ImeLogTracker hat keinen öffentlichen Pattern-Match-Hook; LastMatchedPatternId ist Property. Re-Mapping braucht entweder Polling-Adapter oder Tracker-Action-Surface-Erweiterung. Reducer-M3-Szenarien laufen ohne das Signal grün; Add-on in M4.4/M4.5. Tests (+16, 123 total V2.Core.Tests): - EspPhase first-emit / repeat-dedup / distinct-values / null-skip - UserSessionCompleted fire-once - AppState Theory (4 terminal states → 2 kinds) - Non-terminal skipped; per-app fire-once - Different apps independent; null-app ignored - Prior-Action chain-preserve - Dispose restores prior handler - Null-args throw Testsuite: 1228 → 1244. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ImeLogTrackerAdapterTests.cs | 232 ++++++++++++++++++ .../SignalAdapters/ImeLogTrackerAdapter.cs | 230 +++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs new file mode 100644 index 00000000..0ffeb88c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.SignalAdapters; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters +{ + public sealed class ImeLogTrackerAdapterTests + { + private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + + private sealed class Fixture : IDisposable + { + public TempDirectory Tmp { get; } = new TempDirectory(); + public AgentLogger Logger { get; } + public ImeLogTracker Tracker { get; } + public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink(); + public VirtualClock Clock { get; } = new VirtualClock(Fixed); + + public Fixture() + { + Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); + Tracker = new ImeLogTracker( + logFolder: Tmp.Path, + patterns: new List(), + logger: Logger); + } + + public void Dispose() + { + Tracker.Dispose(); + Tmp.Dispose(); + } + } + + [Fact] + public void EspPhaseChanged_first_phase_emits_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]); + } + + [Fact] + public void EspPhaseChanged_same_phase_repeated_is_deduped_idempotency() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + + Assert.Single(f.Ingress.Posted); + } + + [Fact] + public void EspPhaseChanged_distinct_phases_emit_separate_signals() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest("DeviceSetup"); + adapter.TriggerEspPhaseFromTest("AccountSetup"); + 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]); + } + + [Fact] + public void EspPhaseChanged_null_or_empty_phase_is_skipped() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerEspPhaseFromTest(null!); + adapter.TriggerEspPhaseFromTest(""); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void UserSessionCompleted_emits_ImeUserSessionCompleted_fire_once() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerUserSessionCompletedFromTest(); + adapter.TriggerUserSessionCompletedFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.ImeUserSessionCompleted, posted.Kind); + } + + [Theory] + [InlineData(AppInstallationState.Installed, DecisionSignalKind.AppInstallCompleted)] + [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( + AppInstallationState newState, DecisionSignalKind expectedKind) + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + var app = new AppPackageState($"app-{newState}", listPos: 0); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, newState); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(expectedKind, posted.Kind); + 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() + { + 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.Unknown, AppInstallationState.InProgress); + adapter.TriggerAppStateFromTest(app, AppInstallationState.InProgress, AppInstallationState.Downloading); + adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installing); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void AppStateChange_terminal_per_app_is_fire_once() + { + 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. + adapter.TriggerAppStateFromTest(app, AppInstallationState.Installed, AppInstallationState.Error); + + Assert.Single(f.Ingress.Posted); + } + + [Fact] + public void AppStateChange_different_apps_emit_independently() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerAppStateFromTest(new AppPackageState("a", 0), AppInstallationState.Installing, AppInstallationState.Installed); + 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); + } + + [Fact] + public void AppStateChange_null_app_is_ignored() + { + using var f = new Fixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + adapter.TriggerAppStateFromTest(null!, AppInstallationState.Installing, AppInstallationState.Installed); + + Assert.Empty(f.Ingress.Posted); + } + + [Fact] + public void Adapter_preserves_prior_Action_handlers_chain_invoke() + { + using var f = new Fixture(); + int priorEspCalls = 0; + int priorUserCalls = 0; + f.Tracker.OnEspPhaseChanged = _ => priorEspCalls++; + f.Tracker.OnUserSessionCompleted = () => priorUserCalls++; + + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + + // Call through the tracker's Action (simulating what the tracker would do). + f.Tracker.OnEspPhaseChanged("DeviceSetup"); + f.Tracker.OnUserSessionCompleted(); + + Assert.Equal(1, priorEspCalls); + Assert.Equal(1, priorUserCalls); + Assert.Equal(2, f.Ingress.Posted.Count); + } + + [Fact] + public void Dispose_restores_prior_Action_handlers() + { + using var f = new Fixture(); + int priorCalls = 0; + Action priorAction = _ => priorCalls++; + f.Tracker.OnEspPhaseChanged = priorAction; + + var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + Assert.NotSame(priorAction, f.Tracker.OnEspPhaseChanged); + + adapter.Dispose(); + Assert.Same(priorAction, f.Tracker.OnEspPhaseChanged); + } + + [Fact] + public void Ctor_null_args_throw() + { + using var f = new Fixture(); + Assert.Throws(() => new ImeLogTrackerAdapter(null!, f.Ingress, f.Clock)); + Assert.Throws(() => new ImeLogTrackerAdapter(f.Tracker, null!, f.Clock)); + Assert.Throws(() => new ImeLogTrackerAdapter(f.Tracker, f.Ingress, null!)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs new file mode 100644 index 00000000..32cc1934 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs @@ -0,0 +1,230 @@ +#nullable enable +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters +{ + /// + /// Adapter for → mehrere DecisionSignalKinds. + /// Plan §2.1a / §2.2. + /// + /// ImeLogTracker nutzt Action-Property-Callbacks (nicht Events). Adapter ersetzt + /// diese Action-Props — Orchestrator darf die nicht parallel belegen. Dispose restauriert + /// die vorherigen Action-Werte (nur für Clean-Shutdown relevant; normalerweise ist der + /// Tracker selbst kurz vor Dispose). + /// + /// + /// Mapping: + /// + /// OnEspPhaseChanged(phase) → + /// (dedup per distinct phase value — Reducer Plan §2.1a idempotenz-Anforderung). + /// OnUserSessionCompleted() → (fire-once). + /// OnAppStateChanged(app, old, new) → + /// (state=Installed/Skipped/Postponed) oder (state=Error). + /// Dedup pro (AppId, terminal-state-tuple). + /// + /// + /// + /// Nicht Teil von M4.3.4: WhiteGloveSealingPatternDetected — ImeLogTracker + /// hat aktuell keinen öffentlichen Pattern-Match-Hook; Re-Mapping via LastMatchedPatternId + /// braucht entweder Polling-Adapter oder Modifikation der Tracker-Action-Surface. Dokumentiert + /// als M4-Follow-Up; Reducer behandelt es derzeit korrekt auch ohne diesen Signal-Typ (alle + /// M3-Szenarien grün). + /// + /// + internal sealed class ImeLogTrackerAdapter : IDisposable + { + private readonly ImeLogTracker _tracker; + private readonly ISignalIngressSink _ingress; + private readonly IClock _clock; + private readonly object _lock = new object(); + + // Previous action-handlers — restored on Dispose so we don't leave a dead callback + // hanging on the tracker. + private readonly Action? _prevOnEspPhaseChanged; + private readonly Action? _prevOnUserSessionCompleted; + private readonly Action? _prevOnAppStateChanged; + + // Our own delegate instances — stored once so Dispose can compare by reference. + private readonly Action _ourOnEspPhaseChanged; + private readonly Action _ourOnUserSessionCompleted; + private readonly Action _ourOnAppStateChanged; + + // Dedup state. + private string? _lastEspPhase; + private bool _userSessionCompletedPosted; + private readonly HashSet _appsAlreadyPostedTerminal = new HashSet(StringComparer.Ordinal); + + public ImeLogTrackerAdapter( + ImeLogTracker tracker, + ISignalIngressSink ingress, + IClock clock) + { + _tracker = tracker ?? throw new ArgumentNullException(nameof(tracker)); + _ingress = ingress ?? throw new ArgumentNullException(nameof(ingress)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + + // Chain-preserve: save any previously-wired handlers and invoke them from our + // dispatcher. Lets the orchestrator co-wire diagnostic callbacks (e.g. logging) + // without being blocked by the adapter. + _prevOnEspPhaseChanged = _tracker.OnEspPhaseChanged; + _prevOnUserSessionCompleted = _tracker.OnUserSessionCompleted; + _prevOnAppStateChanged = _tracker.OnAppStateChanged; + + // Store our delegate instances once — implicit method-group conversions + // create a new delegate each time, which would break Dispose's reference check. + _ourOnEspPhaseChanged = OnEspPhaseChanged; + _ourOnUserSessionCompleted = OnUserSessionCompleted; + _ourOnAppStateChanged = OnAppStateChanged; + + _tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged; + _tracker.OnUserSessionCompleted = _ourOnUserSessionCompleted; + _tracker.OnAppStateChanged = _ourOnAppStateChanged; + } + + public void Dispose() + { + // Restore only if we're still the current handler; otherwise leave it alone + // (someone else re-wired after us and owns it now). + if (ReferenceEquals(_tracker.OnEspPhaseChanged, _ourOnEspPhaseChanged)) + _tracker.OnEspPhaseChanged = _prevOnEspPhaseChanged; + if (ReferenceEquals(_tracker.OnUserSessionCompleted, _ourOnUserSessionCompleted)) + _tracker.OnUserSessionCompleted = _prevOnUserSessionCompleted; + if (ReferenceEquals(_tracker.OnAppStateChanged, _ourOnAppStateChanged)) + _tracker.OnAppStateChanged = _prevOnAppStateChanged; + } + + private void OnEspPhaseChanged(string phase) + { + _prevOnEspPhaseChanged?.Invoke(phase); + EmitEspPhase(phase); + } + + private void OnUserSessionCompleted() + { + _prevOnUserSessionCompleted?.Invoke(); + EmitUserSessionCompleted(); + } + + private void OnAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) + { + _prevOnAppStateChanged?.Invoke(app, oldState, newState); + EmitAppState(app, oldState, newState); + } + + internal void TriggerEspPhaseFromTest(string phase) => EmitEspPhase(phase); + internal void TriggerUserSessionCompletedFromTest() => EmitUserSessionCompleted(); + internal void TriggerAppStateFromTest(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) => + EmitAppState(app, oldState, newState); + + private void EmitEspPhase(string phase) + { + if (string.IsNullOrEmpty(phase)) return; + + lock (_lock) + { + if (string.Equals(_lastEspPhase, phase, StringComparison.Ordinal)) + { + return; // Idempotent — phase unchanged. + } + _lastEspPhase = phase; + } + + _ingress.Post( + kind: DecisionSignalKind.EspPhaseChanged, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ImeLogTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "ime-log-tracker-v1", + summary: $"IME log phase transition → {phase}", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["detectionSource"] = "IME log regex pattern", + ["phase"] = phase, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.EspPhase] = phase, + }); + } + + private void EmitUserSessionCompleted() + { + lock (_lock) + { + if (_userSessionCompletedPosted) return; + _userSessionCompletedPosted = true; + } + + _ingress.Post( + kind: DecisionSignalKind.ImeUserSessionCompleted, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ImeLogTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "ime-log-tracker-v1", + summary: "IME user session completed (all user-scope apps finished)", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["detectionSource"] = "IME log pattern (UserSessionCompleted)", + })); + } + + 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. + + lock (_lock) + { + if (!_appsAlreadyPostedTerminal.Add(app.Id)) + { + return; // Already posted terminal state for this app. + } + } + + _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(), + }); + } + + private static DecisionSignalKind? ClassifyTerminalState(AppInstallationState state) + { + switch (state) + { + case AppInstallationState.Installed: + case AppInstallationState.Skipped: + case AppInstallationState.Postponed: + return DecisionSignalKind.AppInstallCompleted; + case AppInstallationState.Error: + return DecisionSignalKind.AppInstallFailed; + default: + return null; + } + } + } +}