mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fix(v2): 882fef64 followup — restore V1 audit trail on enrollment_complete
V2's reducer-emitted enrollment_complete event landed in the backend with
empty Data{} — V1 had carried a signal audit trail (signalsSeen,
signalTimestamps, completionSource, helloOutcome) that downstream consumers
relied on to reconstruct "which signals drove completion" without re-reading
the agent's local final-status.json.
BuildEnrollmentCompleteEffect now takes (state, completionTrigger) and
attaches a structured TypedPayload. EventTimelineEmitter passes that dict
verbatim into EnrollmentEvent.Data, so the wire shape mirrors V1's audit
trail (plus enrollmentMode/JoinMode from the V2 ScenarioProfile).
Both kernel-driven completion paths now carry the trail:
- DecisionEngine.Classic: DeadlineFired:FinalizingGrace
- DecisionEngine.SelfDeploying: DeviceSetupProvisioningComplete
AdminPreemptionDetected keeps its existing adminAction/source params —
different completion semantics, leave alone.
Tests: DecisionCore 232 (+1), V2.Core 732, V1.Core 507, all green.
Solution build 0 warnings, 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
1166
|
||||
1168
|
||||
|
||||
@@ -129,6 +129,47 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.NotNull(terminalEffect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinalizingGraceDeadline_enrollment_complete_carries_v1_audit_trail_in_typed_payload()
|
||||
{
|
||||
// 882fef64 debrief follow-up: V2 had regressed by emitting enrollment_complete with
|
||||
// empty Data — V1's audit trail (signalsSeen, signalTimestamps, completionSource,
|
||||
// helloOutcome) had only been written to the local final-status.json. The reducer
|
||||
// now restores parity by attaching a structured TypedPayload that the
|
||||
// EventTimelineEmitter passes verbatim to EnrollmentEvent.Data.
|
||||
var engine = new DecisionEngine();
|
||||
var state = InitialAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.DesktopArrived, T0.AddMinutes(5), null)).NewState;
|
||||
|
||||
var step = engine.Reduce(
|
||||
state,
|
||||
MakeSignal(6, DecisionSignalKind.DeadlineFired, T0.AddMinutes(5).AddSeconds(5),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.FinalizingGrace }));
|
||||
|
||||
var terminalEffect = Assert.Single(
|
||||
step.Effects,
|
||||
e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
|
||||
|
||||
var payload = Assert.IsType<Dictionary<string, object>>(terminalEffect.TypedPayload);
|
||||
Assert.Equal("DecisionEngine", payload["completionSource"]);
|
||||
Assert.Equal($"DeadlineFired:{DeadlineNames.FinalizingGrace}", payload["completionTrigger"]);
|
||||
Assert.Equal(nameof(SessionStage.Completed), payload["sessionStage"]);
|
||||
|
||||
var signalsSeen = Assert.IsType<List<string>>(payload["signalsSeen"]);
|
||||
Assert.Contains("hello_resolved", signalsSeen);
|
||||
Assert.Contains("desktop_arrived", signalsSeen);
|
||||
|
||||
var timestamps = Assert.IsType<Dictionary<string, object>>(payload["signalTimestamps"]);
|
||||
// Hello resolved at T0+4min, Desktop arrived at T0+5min — both ISO-8601 round-trip strings.
|
||||
Assert.Equal(T0.AddMinutes(4).ToString("o", System.Globalization.CultureInfo.InvariantCulture), timestamps["helloResolved"]);
|
||||
Assert.Equal(T0.AddMinutes(5).ToString("o", System.Globalization.CultureInfo.InvariantCulture), timestamps["desktopArrived"]);
|
||||
|
||||
// HelloOutcome payload was supplied as "Success" by the InitialAwaitingDesktop fixture.
|
||||
Assert.Equal("Success", payload["helloOutcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloResolved_when_Desktop_already_arrived_also_routes_through_Finalizing()
|
||||
{
|
||||
|
||||
@@ -437,7 +437,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.FinalizingGrace}");
|
||||
|
||||
var effects = new[] { BuildEnrollmentCompleteEffect() };
|
||||
var effects = new[] { BuildEnrollmentCompleteEffect(newState, $"DeadlineFired:{DeadlineNames.FinalizingGrace}") };
|
||||
|
||||
return new DecisionStep(newState, transition, effects);
|
||||
}
|
||||
@@ -464,12 +464,98 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
["message"] = $"Phase: {phase}",
|
||||
});
|
||||
|
||||
private static DecisionEffect BuildEnrollmentCompleteEffect() =>
|
||||
new DecisionEffect(
|
||||
/// <summary>
|
||||
/// Terminal <c>enrollment_complete</c> effect emitted by the Classic FinalizingGrace
|
||||
/// deadline path and the SelfDeploying DeviceSetupProvisioningComplete path. Restores
|
||||
/// V1 backend-side audit-trail parity (882fef64 debrief follow-up): Parameters carry
|
||||
/// just <c>eventType</c> (legacy contract), and a structured <see cref="DecisionEffect.TypedPayload"/>
|
||||
/// dictionary feeds <c>EnrollmentEvent.Data</c> with <c>signalsSeen</c>,
|
||||
/// <c>signalTimestamps</c>, <c>completionSource</c>, <c>completionTrigger</c>, plus the
|
||||
/// resolved Hello / scenario context. Lets backend consumers reconstruct "which signals
|
||||
/// drove completion" without re-reading the agent's <c>final-status.json</c>.
|
||||
/// </summary>
|
||||
/// <param name="state">Post-transition state (Stage=Completed, Outcome=EnrollmentComplete).</param>
|
||||
/// <param name="completionTrigger">
|
||||
/// The trigger label that was passed to <see cref="DecisionEngine.BuildTakenTransition"/>
|
||||
/// for this step (e.g. <c>"DeadlineFired:FinalizingGrace"</c> or
|
||||
/// <c>"DeviceSetupProvisioningComplete"</c>) — keeps the audit trail self-describing
|
||||
/// without requiring the consumer to re-derive it from the StepIndex.
|
||||
/// </param>
|
||||
internal static DecisionEffect BuildEnrollmentCompleteEffect(DecisionState state, string completionTrigger)
|
||||
{
|
||||
return new DecisionEffect(
|
||||
kind: DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = "enrollment_complete",
|
||||
});
|
||||
},
|
||||
typedPayload: BuildEnrollmentCompleteAuditTrail(state, completionTrigger));
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> BuildEnrollmentCompleteAuditTrail(
|
||||
DecisionState state,
|
||||
string completionTrigger)
|
||||
{
|
||||
var signalsSeen = new List<string>(8);
|
||||
var signalTimestamps = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
|
||||
if (state.HelloResolvedUtc != null)
|
||||
{
|
||||
signalsSeen.Add("hello_resolved");
|
||||
signalTimestamps["helloResolved"] = FormatUtc(state.HelloResolvedUtc.Value);
|
||||
}
|
||||
if (state.DesktopArrivedUtc != null)
|
||||
{
|
||||
signalsSeen.Add("desktop_arrived");
|
||||
signalTimestamps["desktopArrived"] = FormatUtc(state.DesktopArrivedUtc.Value);
|
||||
}
|
||||
if (state.EspFinalExitUtc != null)
|
||||
{
|
||||
signalsSeen.Add("esp_final_exit");
|
||||
signalTimestamps["espFinalExit"] = FormatUtc(state.EspFinalExitUtc.Value);
|
||||
}
|
||||
if (state.ImeMatchedPatternId != null)
|
||||
{
|
||||
signalsSeen.Add("ime_pattern_matched");
|
||||
}
|
||||
if (state.UserAadSignInCompleteUtc != null)
|
||||
{
|
||||
signalsSeen.Add("user_aad_sign_in_complete");
|
||||
signalTimestamps["userAadSignInComplete"] = FormatUtc(state.UserAadSignInCompleteUtc.Value);
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["completionSource"] = "DecisionEngine",
|
||||
["completionTrigger"] = completionTrigger,
|
||||
["signalsSeen"] = signalsSeen,
|
||||
["signalTimestamps"] = signalTimestamps,
|
||||
["sessionStage"] = state.Stage.ToString(),
|
||||
};
|
||||
|
||||
if (state.HelloOutcome != null)
|
||||
{
|
||||
data["helloOutcome"] = state.HelloOutcome.Value;
|
||||
}
|
||||
if (state.HelloPolicyEnabled != null)
|
||||
{
|
||||
data["helloPolicyEnabled"] = state.HelloPolicyEnabled.Value;
|
||||
}
|
||||
if (state.ImeMatchedPatternId != null)
|
||||
{
|
||||
data["imePatternMatchedPatternId"] = state.ImeMatchedPatternId.Value;
|
||||
}
|
||||
if (state.ScenarioProfile != null)
|
||||
{
|
||||
data["enrollmentMode"] = state.ScenarioProfile.Mode.ToString();
|
||||
data["enrollmentJoinMode"] = state.ScenarioProfile.JoinMode.ToString();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static string FormatUtc(DateTime utc) =>
|
||||
DateTime.SpecifyKind(utc, DateTimeKind.Utc)
|
||||
.ToString("o", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete));
|
||||
|
||||
var effects = new[] { BuildEnrollmentCompleteEffect() };
|
||||
var effects = new[] { BuildEnrollmentCompleteEffect(newState, nameof(DecisionSignalKind.DeviceSetupProvisioningComplete)) };
|
||||
|
||||
return new DecisionStep(newState, transition, effects);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user