mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
feat(v2): PR2 — wire DecisionState snapshot into EventTimelineEmitter
Plan §A enrichment (Edge-Triggered State Snapshots, 2026-05-03). For events on the lifecycle-anchor allowlist, EventTimelineEmitter.Emit now attaches the current DecisionState snapshot under data["decisionState"] after ResolveData(). Existing Data fields are always preserved — both the parameter-rebuilt path and the typedPayload-merged path mutate a fresh dict, so adding the new key is a strict merge, not a replace. Behavior: - 13 anchor event types get decisionState (full allowlist from PR1's LifecycleAnchorEventTypes). - Non-anchor events (enrollment_complete/_failed, app_install_*, performance_snapshot, download_progress) are unchanged. - Idempotent: an existing data["decisionState"] (defensive — should never occur in practice since only the emitter writes that key) is not clobbered. Why Emitter, not Reducer (per Codex rev2 review): - EffectRunner.cs:114 already passes stateAfterReduce to the emitter, so no new dependency is needed. - Reducer-side enrichment via typedPayload would have replaced existing Data via the typedPayload-as-dict branch in ResolveData — wiping espPhase, patternId, network state etc. The emitter-seam is the only place that runs after ResolveData and sees a dict ready to merge into. Tests (V2.Core +22 tests, suite 920 → 942): - All 13 anchor event types via Theory: each gets decisionState with schemaVersion matching the builder's const. - Parameter-based path: esp_phase_changed preserves reason+espPhase. - typedPayload path: network_state_change preserves from/to/transition. - Idempotence: prebuilt sentinel decisionState in typedPayload survives. - Non-anchor exclusion: 5 representative non-anchors confirmed null. - Top-level allowlist sanity-check (delegates full coverage to PR1's DecisionStateSnapshotBuilderTests). Tests (regression-clean across all suites): - V2.Core: 920 → 942 (+22) - DecisionCore: 293 (no change) - Backend Functions: 862 (no change) - Existing EventTimelineEmitterTests: 26/26 still pass. Plan: .claude/plans/v2-zero-periodicity-observability.md (rev7). PR3 (Death-Rattle / prior_run_died_with_state) is next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+238
@@ -0,0 +1,238 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Events;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the decisionState-snapshot enrichment in
|
||||
/// <see cref="EventTimelineEmitter.Emit"/> (Plan §A — Edge-Triggered State
|
||||
/// Snapshots, 2026-05-03). The enrichment runs after <c>ResolveData</c> so it
|
||||
/// works for both event paths (parameter-rebuilt Data and typedPayload-merged
|
||||
/// Data) and never clobbers existing event-specific fields.
|
||||
/// </summary>
|
||||
public sealed class EventTimelineEmitterDecisionStateEnrichmentTests
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 5, 1, 13, 45, 32, DateTimeKind.Utc);
|
||||
|
||||
private sealed class Rig : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public FakeTelemetryTransport Transport { get; } = new FakeTelemetryTransport();
|
||||
public EventSequenceCounter Counter { get; }
|
||||
public TelemetryEventEmitter Inner { get; }
|
||||
public EventTimelineEmitter Sut { get; }
|
||||
|
||||
public Rig()
|
||||
{
|
||||
Counter = new EventSequenceCounter(new EventSequencePersistence(Tmp.File("seq.json")));
|
||||
Inner = new TelemetryEventEmitter(Transport, Counter, "S1", "T1");
|
||||
Sut = new EventTimelineEmitter(Inner);
|
||||
}
|
||||
|
||||
public JObject EmittedData() =>
|
||||
(JObject)JObject.Parse(Transport.Enqueued[0].PayloadJson)["Data"]!;
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
private static DecisionState State() => DecisionState.CreateInitial("S1", "T1");
|
||||
|
||||
// ============================================================================
|
||||
// Full-coverage: every anchor event-type gets decisionState enrichment
|
||||
// ============================================================================
|
||||
|
||||
public static IEnumerable<object[]> AllAnchorEventTypes => new[]
|
||||
{
|
||||
new object[] { SharedConstants.EventTypes.AgentStarted },
|
||||
new object[] { SharedConstants.EventTypes.EspPhaseChanged },
|
||||
new object[] { SharedConstants.EventTypes.NetworkStateChange },
|
||||
new object[] { SharedConstants.EventTypes.DesktopArrived },
|
||||
new object[] { SharedConstants.EventTypes.AadPlaceholderUserDetected },
|
||||
new object[] { SharedConstants.EventTypes.AadUserJoinedLate },
|
||||
new object[] { SharedConstants.EventTypes.UserAadSignInComplete },
|
||||
new object[] { SharedConstants.EventTypes.HybridLoginPending },
|
||||
new object[] { SharedConstants.EventTypes.AgentShuttingDown },
|
||||
new object[] { SharedConstants.EventTypes.SystemRebootDetected },
|
||||
new object[] { SharedConstants.EventTypes.PerformanceCollectorStopped },
|
||||
new object[] { SharedConstants.EventTypes.AgentMetricsCollectorStopped },
|
||||
new object[] { SharedConstants.EventTypes.PriorRunDiedWithState },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllAnchorEventTypes))]
|
||||
public void Anchor_event_gets_decisionState_added_to_Data(string anchorEventType)
|
||||
{
|
||||
using var r = new Rig();
|
||||
var parameters = new Dictionary<string, string> { ["eventType"] = anchorEventType };
|
||||
|
||||
r.Sut.Emit(parameters, State(), At);
|
||||
|
||||
var data = r.EmittedData();
|
||||
Assert.NotNull(data["decisionState"]);
|
||||
// schemaVersion proves we wrote the actual builder output, not just any key
|
||||
Assert.Equal(
|
||||
DecisionStateSnapshotBuilder.SchemaVersion,
|
||||
(string?)data["decisionState"]!["schemaVersion"]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parameter-based path — existing Data fields preserved
|
||||
// ============================================================================
|
||||
|
||||
[Fact]
|
||||
public void Anchor_with_parameter_data_preserves_event_specific_fields()
|
||||
{
|
||||
using var r = new Rig();
|
||||
// esp_phase_changed historically carries reason + custom keys via parameters
|
||||
// (not typedPayload). Verify those parameters survive into the wire Data
|
||||
// alongside the new decisionState.
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.EspPhaseChanged,
|
||||
["reason"] = "IME-ESP-PHASE",
|
||||
["espPhase"] = "AccountSetup",
|
||||
};
|
||||
|
||||
r.Sut.Emit(parameters, State(), At);
|
||||
|
||||
var data = r.EmittedData();
|
||||
// Original parameter fields stay intact.
|
||||
Assert.Equal("IME-ESP-PHASE", (string?)data["reason"]);
|
||||
Assert.Equal("AccountSetup", (string?)data["espPhase"]);
|
||||
// Plus enrichment.
|
||||
Assert.NotNull(data["decisionState"]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// typedPayload-based path — existing Data fields preserved
|
||||
// ============================================================================
|
||||
|
||||
[Fact]
|
||||
public void Anchor_with_typedPayload_dict_preserves_existing_fields()
|
||||
{
|
||||
using var r = new Rig();
|
||||
// network_state_change is the canonical typedPayload-bearing anchor — it
|
||||
// flows via InformationalEventPost.Emit(EnrollmentEvent) which routes
|
||||
// EnrollmentEvent.Data through typedPayload. Verify the original payload
|
||||
// survives the enrichment.
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.NetworkStateChange,
|
||||
};
|
||||
var typedPayload = new Dictionary<string, object>
|
||||
{
|
||||
["from"] = "WiFi 'GBM_Wireless'",
|
||||
["to"] = "WiFi 'GBM_Guests'",
|
||||
["transition"] = "wifi_change",
|
||||
};
|
||||
|
||||
r.Sut.Emit(parameters, State(), At, typedPayload);
|
||||
|
||||
var data = r.EmittedData();
|
||||
Assert.Equal("WiFi 'GBM_Wireless'", (string?)data["from"]);
|
||||
Assert.Equal("WiFi 'GBM_Guests'", (string?)data["to"]);
|
||||
Assert.Equal("wifi_change", (string?)data["transition"]);
|
||||
Assert.NotNull(data["decisionState"]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Non-anchor — no enrichment
|
||||
// ============================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("enrollment_complete")] // already carries DecisionAuditTrail
|
||||
[InlineData("enrollment_failed")] // already carries DecisionAuditTrail
|
||||
[InlineData("app_install_completed")] // hochfrequent, intentionally excluded
|
||||
[InlineData("performance_snapshot")] // hochfrequent, intentionally excluded
|
||||
[InlineData("download_progress")] // hochfrequent, intentionally excluded
|
||||
public void Non_anchor_event_does_NOT_get_decisionState(string nonAnchorEventType)
|
||||
{
|
||||
using var r = new Rig();
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = nonAnchorEventType,
|
||||
["reason"] = "test",
|
||||
};
|
||||
|
||||
r.Sut.Emit(parameters, State(), At);
|
||||
|
||||
var data = r.EmittedData();
|
||||
Assert.Null(data["decisionState"]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Idempotence — pre-existing decisionState in typedPayload is not clobbered
|
||||
// ============================================================================
|
||||
|
||||
[Fact]
|
||||
public void Anchor_with_decisionState_already_in_typedPayload_is_NOT_clobbered()
|
||||
{
|
||||
using var r = new Rig();
|
||||
// Defensive contract: if for some reason an upstream caller already put a
|
||||
// "decisionState" key in the typedPayload, the emitter must not overwrite
|
||||
// it. This shouldn't occur in production (only the emitter writes that
|
||||
// key) but documents the merge invariant.
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.AgentStarted,
|
||||
};
|
||||
var prebuilt = new Dictionary<string, object?>
|
||||
{
|
||||
["sentinel"] = "prebuilt-snapshot",
|
||||
};
|
||||
var typedPayload = new Dictionary<string, object>
|
||||
{
|
||||
["decisionState"] = prebuilt,
|
||||
["original"] = "value",
|
||||
};
|
||||
|
||||
r.Sut.Emit(parameters, State(), At, typedPayload);
|
||||
|
||||
var data = r.EmittedData();
|
||||
// The prebuilt sentinel survives — emitter respected the existing key.
|
||||
Assert.Equal("prebuilt-snapshot", (string?)data["decisionState"]!["sentinel"]);
|
||||
// Original sibling field also survives.
|
||||
Assert.Equal("value", (string?)data["original"]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// decisionState shape sanity — reads through to the actual builder output
|
||||
// ============================================================================
|
||||
|
||||
[Fact]
|
||||
public void Anchor_decisionState_carries_top_level_allowlist_keys()
|
||||
{
|
||||
using var r = new Rig();
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.DesktopArrived,
|
||||
};
|
||||
|
||||
r.Sut.Emit(parameters, State(), At);
|
||||
|
||||
var data = r.EmittedData();
|
||||
var snap = (JObject)data["decisionState"]!;
|
||||
// Spot-check the top-level allowlist contract — full coverage lives in
|
||||
// DecisionStateSnapshotBuilderTests.
|
||||
Assert.NotNull(snap["schemaVersion"]);
|
||||
Assert.NotNull(snap["stepIndex"]);
|
||||
Assert.NotNull(snap["lastAppliedSignalOrdinal"]);
|
||||
Assert.NotNull(snap["stage"]);
|
||||
Assert.NotNull(snap["facts"]);
|
||||
Assert.NotNull(snap["scenario"]);
|
||||
Assert.NotNull(snap["activeDeadlines"]);
|
||||
// Outcome is null on a fresh state — must be present as JSON null,
|
||||
// not missing (consumer differentiates "no outcome" from "missing field").
|
||||
Assert.True(snap.ContainsKey("outcome"));
|
||||
Assert.Equal(JTokenType.Null, snap["outcome"]!.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -101,6 +102,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
nameof(parameters));
|
||||
}
|
||||
|
||||
var data = ResolveData(parameters, typedPayload);
|
||||
|
||||
// Plan A — Edge-Triggered State Snapshots (2026-05-03): for events on the
|
||||
// lifecycle-anchor allowlist, attach the current DecisionState snapshot under
|
||||
// data["decisionState"] for post-mortem diagnosis without client-side logs.
|
||||
// Mutation is safe: ResolveData always returns a fresh Dictionary instance
|
||||
// (both the typedPayload-as-dict copy path and the parameter-rebuild path
|
||||
// construct new dicts). The defensive ContainsKey-check prevents clobbering
|
||||
// a snapshot already in the typedPayload — should never occur in practice
|
||||
// but documents the merge contract.
|
||||
if (LifecycleAnchorEventTypes.Contains(eventType) && !data.ContainsKey("decisionState"))
|
||||
{
|
||||
data["decisionState"] = DecisionStateSnapshotBuilder.Build(currentState);
|
||||
}
|
||||
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
SessionId = currentState.SessionId,
|
||||
@@ -112,7 +128,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
Message = BuildMessage(eventType, parameters),
|
||||
Timestamp = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
|
||||
ImmediateUpload = ParseImmediateUpload(parameters),
|
||||
Data = ResolveData(parameters, typedPayload),
|
||||
Data = data,
|
||||
|
||||
// Codex follow-up #3 forward-link: every EmitEventTimelineEntry effect runs
|
||||
// as part of a reducer step, so currentState.StepIndex == the emitting
|
||||
|
||||
@@ -1 +1 @@
|
||||
544
|
||||
547
|
||||
|
||||
Reference in New Issue
Block a user