mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
refactor(v2core): M4.3.4 signal adapter (ime log tracker)
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) <noreply@anthropic.com>
This commit is contained in:
+232
@@ -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<ImeLogPattern>(),
|
||||
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<string> 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<ArgumentNullException>(() => new ImeLogTrackerAdapter(null!, f.Ingress, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new ImeLogTrackerAdapter(f.Tracker, null!, f.Clock));
|
||||
Assert.Throws<ArgumentNullException>(() => new ImeLogTrackerAdapter(f.Tracker, f.Ingress, null!));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Adapter for <see cref="ImeLogTracker"/> → mehrere DecisionSignalKinds.
|
||||
/// Plan §2.1a / §2.2.
|
||||
/// <para>
|
||||
/// ImeLogTracker nutzt Action-Property-Callbacks (nicht Events). Adapter <b>ersetzt</b>
|
||||
/// 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Mapping:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OnEspPhaseChanged(phase)</c> → <see cref="DecisionSignalKind.EspPhaseChanged"/>
|
||||
/// (dedup per distinct phase value — Reducer Plan §2.1a idempotenz-Anforderung).</item>
|
||||
/// <item><c>OnUserSessionCompleted()</c> → <see cref="DecisionSignalKind.ImeUserSessionCompleted"/> (fire-once).</item>
|
||||
/// <item><c>OnAppStateChanged(app, old, new)</c> → <see cref="DecisionSignalKind.AppInstallCompleted"/>
|
||||
/// (state=Installed/Skipped/Postponed) oder <see cref="DecisionSignalKind.AppInstallFailed"/> (state=Error).
|
||||
/// Dedup pro (AppId, terminal-state-tuple).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nicht Teil von M4.3.4:</b> <c>WhiteGloveSealingPatternDetected</c> — 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).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<string>? _prevOnEspPhaseChanged;
|
||||
private readonly Action? _prevOnUserSessionCompleted;
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevOnAppStateChanged;
|
||||
|
||||
// Our own delegate instances — stored once so Dispose can compare by reference.
|
||||
private readonly Action<string> _ourOnEspPhaseChanged;
|
||||
private readonly Action _ourOnUserSessionCompleted;
|
||||
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
|
||||
|
||||
// Dedup state.
|
||||
private string? _lastEspPhase;
|
||||
private bool _userSessionCompletedPosted;
|
||||
private readonly HashSet<string> _appsAlreadyPostedTerminal = new HashSet<string>(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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["detectionSource"] = "IME log regex pattern",
|
||||
["phase"] = phase,
|
||||
}),
|
||||
payload: new Dictionary<string, string>(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<string, string>(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<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(),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user