v2 single-rail 5.9b: ImeLogTrackerAdapter wire missing ImeLog callbacks (B29 part 2)

Plan §5.9 / PR #9 — V1 Parity Issue #3 completion: wire the three tracker
callbacks the adapter previously did not touch so the full V1-compatible
wire shape reaches the Events table via Signal-Post (Rail A single-rail).

Adapter additions:
- OnImeAgentVersion → ime_agent_version InformationalEvent (Source=ImeLogTracker);
  no dedup — matches V1 behavior where the same version fires multiple times
  per session at each pattern-match ingress.
- OnDoTelemetryReceived → do_telemetry InformationalEvent carrying the full
  DO payload (doFileSize / doTotalBytesDownloaded / doBytesFromPeers /
  doPercentPeerCaching / doDownloadMode / doDownloadDuration / doBytesFromHttp +
  app state/intent/targeted/runAs/progress). Tracker guards with HasDoTelemetry,
  so each app fires at most once per "DO complete" pattern match.
- OnScriptCompleted → script_completed InformationalEvent with Source="IME"
  (not "ImeLogTracker" — matches V1 contract observed in the Events table for
  both platform and remediation scripts); Severity auto-derives to Error when
  Result="Failed" or ExitCode != 0. Supports both scriptType=platform (Result
  + exitCode) and scriptType=remediation (ScriptPart + ComplianceResult).

All three callbacks preserve prior handlers via the existing chain-invoke
pattern and are restored on Dispose. Tracker's LastMatchedPatternId flows
into the payload's "patternId" key to match the V1 shape.

Tests: +11 new adapter tests covering each callback (payload/fields/severity,
null-guards, prior-handler chaining, Dispose restore). Suite passes 48/48
for ImeLogTrackerAdapterTests and 562/562 for the V2.Core project. Full
solution build clean.

Parity Issue #3 (V1 reference e99a6805 → V2 Beleg-Session e259c121): the
IME-driven events app_install_started / app_install_completed /
app_install_failed / app_download_started / download_progress / do_telemetry /
script_completed / ime_agent_version / ime_user_session_completed /
esp_phase_changed are now emitted by V2 via the single-rail Signal-Post path.

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