mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
d16b60f14c
Bundled change covering three orthogonal pieces of work that all landed in
the same working tree before the next PR-style commit could be split out.
## WG Part-2 architectural cleanup (PR-A through PR-E)
Plan: ~/.claude/plans/wg-part2-architectural-cleanup.md.
The V2 agent carried a full separate "Part-2 apparatus" (4 special signal
kinds, 2 special stages, a special outcome, a special classifier, a 24h
safety deadline, a dedicated reducer file, adapter `part2Mode` switches,
a SessionRecovered bridge). This did not match OS reality: post-reseal
Part 2 is just a normal user-driven OOBE running through the Classic
flow. The apparatus also carried a real risk (24h false-positive
enrollment_failed mid-success) and was not even active in practice
(observed in session bdb3cf9d-541b-4c44-aebe-5b9e72743b0a, 2026-05-04).
V2 is now V1-symmetric:
PR-A — Orchestrator Resume Mechanic. EnrollmentOrchestrator detects a
persisted Stage=WhiteGloveSealed snapshot at Start(), archives the
reducer-state segments to state/.part1-<utc-ts>/, runs as a fresh
first-boot through the normal recovery branches, and posts a single
whiteglove_resumed InformationalEvent after the onIngressReady hook.
New helper class StateArchiver.cs + StateArchiverTests +
EnrollmentOrchestratorPart2EmissionTests.
PR-B — DecisionCore Cleanup. Removed:
- DecisionSignalKind.UserAadSignInComplete / HelloResolvedPart2 /
DesktopArrivedPart2 / AccountSetupCompletedPart2 / SessionRecovered
- SessionStage.WhiteGloveAwaitingUserSignIn / WhiteGloveCompletedPart2
- SessionOutcome.WhiteGlovePart2Complete
- DeadlineNames.WhiteGlovePart2Safety
- DecisionState's 4 Part-2 SignalFacts (+ builder + snapshot slots)
- WhiteGlovePart2CompletionClassifier + WhiteGlovePart2CompletionSnapshot
- DecisionEngine.WhiteGlovePart2.cs (entire reducer file)
- HandleSessionRecoveredV1 dispatcher + handler
PR-C — Adapter Cleanup. Dropped the inert `part2Mode` ctor parameter
from AadJoinWatcherAdapter, DesktopArrivalDetectorAdapter,
HelloTrackerAdapter, EspAndHelloTrackerAdapter (and their tests).
AadJoinHost is the only production caller using a named-arg form,
unaffected.
PR-D — Backend + Web. Removed `WhiteGloveCompletedPart2` cases from
TelemetryPayloadParser.IsTerminalStage and DecisionGraphBuilder
(IsTerminalStage + DeriveTerminalOutcome) plus their tests. Dropped
the dead `UserAadSignInComplete` constant from Shared.Constants.
Extracted pure helper computeWhiteGloveSplitSequence(events) in
app/sessions/[sessionId]/utils/eventHelpers.ts; useSessionDerivedData +
utils/sessionExportUtils now share it. New 6-case vitest covering
splitter behaviour (no marker, primary trigger via whiteglove_resumed,
agent_started fallback, Pending-only variants, primary-vs-fallback
race). PhaseTimeline + eventTypes were already on the new model.
PR-E — Tests + Fixtures Final Cleanup. Deleted the two
whiteglove-part2-*.jsonl fixtures + EdgeScenarioTests references;
removed dead `whiteglove_part2` case + test from SummaryDialog
OutcomeMapper; trimmed PR-B history breadcrumbs that still embedded
removed symbol names so the final grep is clean. Updated MEMORY
entries (project_v2_completion_location, project_wg_part2_simple_model,
project_wg_signal_validation_pending, project_bdb3cf9d_wg_followups,
MEMORY.md index).
What stayed: SessionStage.WhiteGloveSealed (Part-1 marker), the
`_isWhiteGlovePart2` Hint-Bool (V1-symmetric, plan §11) wired only into
EnrollmentTerminationHandler / SoftwareInventoryAnalyzer for shutdown-
analyzer phase=2 tagging, the `whiteglove_part1_complete` event-type +
`whiteglove_part1` outcome-string, `PausedForPart2` terminal-outcome
label for the Inspector, the `whiteglove_resumed` event as UI-splitter
trigger.
Final acceptance: grep for the removed symbol set in src/ excluding V1
(Agent.Core/) is empty. V2.Core.Tests 991 / DecisionCore.Tests 282 /
Functions.Tests 862 / SummaryDialog.Tests 44 / Web vitest 227 — all
green. Solution build clean.
## Resume-path correctness fixes (Codex review of the cleanup)
Three findings on the new resume mechanic:
P1a — AgentRuntimeHost was still skipping LifecycleEmitters
PostEnrollmentFactsObserved + PostSessionStarted on isWhiteGloveResume.
After PR-A's archive-and-reset the decision state is empty, so the
Classic flow needs both anchors (otherwise ScenarioProfile stays at
default and the Hello+Desktop AND-gate misclassifies). Fixed: post
unconditionally for facts; SessionStarted is now only gated by an
AdminAction, not by the resume flag. The orchestrator's
whiteglove_resumed lands first (smaller sequence) so the Web splitter
(`splitSequence = resumed.sequence - 1`) still puts SessionStarted
into the Part-2 timeline block.
P1b — StateArchiver was archiving event-sequence.json along with the
reducer state. Part 2 reuses the same SessionId and the backend orders
events session-wide by (SessionId, Sequence); the Web split also uses
`splitSequence = resumed.sequence - 1`. Resetting the counter at the
resume boundary would collide Part-1 and Part-2 sequences and break
the splitter. event-sequence.json is now intentionally preserved; the
three reducer-state segments still archive. StateArchiverTests +
RecoveryPathTests asserting the new contract.
P2 — V1 reset the local session-age clock on Part-2 resume
(SessionPersistence.ResetSessionCreatedAt, MonitoringService.cs:530).
V2 was clearing only the WhiteGlove marker, so a crash/reboot during
Part-2 AccountSetup would let the next start run the emergency-break
watchdog against the original Part-1 timestamp and trip too early.
AgentRuntimeHost now also calls SessionIdPersistence.SaveSessionCreatedAt
(DateTime.UtcNow) right after ClearWhiteGloveComplete. New
ProgramGuardsTests test asserting the V1-symmetric resume contract.
## Install-mode handoff hardening
Plan reference (memory): project_v2_install_runtime_handoff.md.
Bootstrap script + Program.InstallMode + InstallModeTaskXmlTests.
PR1 — Replace `schtasks /Run` with WMI Win32_Process.Create for the
install-time runtime handoff. schtasks /Run reports SUCCESS as soon
as the run is queued; the same battery / AV / OOBE-defer paths that
motivate PR2 also queue a manual /Run (observed WinPE 2026-05-04,
task stuck in Queued state). WMI also gives the runtime process the
same isolation Task Scheduler uses, without the Job-Object inheritance
risk of a direct Process.Start.
PR2 — Register the BootTrigger Scheduled Task from a hardened XML
definition rather than `schtasks /Create`. The CLI form inherits XML
schema defaults DisallowStartIfOnBatteries=true and
StopIfGoingOnBatteries=true, which queue the BootTrigger run
indefinitely on a laptop that boots on battery in OOBE / WinPE. The
XML disables both, sets StartWhenAvailable, and pins
ExecutionTimeLimit=PT0S so the long-running agent is never killed at
the schtasks 72h CLI default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
258 lines
11 KiB
C#
258 lines
11 KiB
C#
using AutopilotMonitor.Functions.Functions.Ingest;
|
|
using AutopilotMonitor.Shared.Models;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Pure parsing tests for <see cref="TelemetryPayloadParser"/>. Verifies the wire → storage
|
|
/// mapping the <see cref="IngestTelemetryFunction"/> relies on: defensive null handling, typed
|
|
/// column extraction, and the index-discriminator projection (IsTerminal, ClassifierVerdictId,
|
|
/// ClassifierHypothesisLevel).
|
|
/// </summary>
|
|
public class TelemetryPayloadParserTests
|
|
{
|
|
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
|
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
|
|
|
private static TelemetryItemDto Dto(string kind, string payloadJson, long? traceOrdinal = null)
|
|
=> new TelemetryItemDto
|
|
{
|
|
Kind = kind,
|
|
PartitionKey = $"{TenantId}_{SessionId}",
|
|
RowKey = "0000000001",
|
|
TelemetryItemId = 1,
|
|
SessionTraceOrdinal = traceOrdinal,
|
|
PayloadJson = payloadJson,
|
|
RequiresImmediateFlush = false,
|
|
EnqueuedAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc),
|
|
RetryCount = 0,
|
|
};
|
|
|
|
// ============================================================ Defensive parsing
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("{ this is not json }")]
|
|
[InlineData("null")]
|
|
public void ParseEvent_returns_null_for_missing_or_malformed_payload(string payload)
|
|
{
|
|
var result = TelemetryPayloadParser.ParseEvent(Dto("Event", payload), TenantId, SessionId);
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("{ this is not json }")]
|
|
public void ParseSignal_returns_null_for_missing_or_malformed_payload(string payload)
|
|
{
|
|
var result = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload), TenantId, SessionId);
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("{ this is not json }")]
|
|
public void ParseTransition_returns_null_for_missing_or_malformed_payload(string payload)
|
|
{
|
|
var result = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId);
|
|
Assert.Null(result);
|
|
}
|
|
|
|
// ============================================================ Event
|
|
|
|
[Fact]
|
|
public void ParseEvent_deserialises_enrollment_event_shape()
|
|
{
|
|
var payload =
|
|
"{" +
|
|
"\"EventId\":\"evt-123\"," +
|
|
"\"EventType\":\"test_event\"," +
|
|
"\"Severity\":1," +
|
|
"\"Source\":\"test\"," +
|
|
"\"Phase\":2," +
|
|
"\"Message\":\"hello\"," +
|
|
"\"Sequence\":42," +
|
|
"\"Timestamp\":\"2026-04-21T10:00:00Z\"," +
|
|
"\"ReceivedAt\":\"2026-04-21T10:00:01Z\"" +
|
|
"}";
|
|
|
|
var evt = TelemetryPayloadParser.ParseEvent(Dto("Event", payload), TenantId, SessionId);
|
|
|
|
Assert.NotNull(evt);
|
|
Assert.Equal("evt-123", evt!.EventId);
|
|
Assert.Equal("test_event", evt.EventType);
|
|
Assert.Equal(42, evt.Sequence);
|
|
}
|
|
|
|
// ============================================================ Signal
|
|
|
|
[Fact]
|
|
public void ParseSignal_extracts_typed_columns_and_preserves_full_payload()
|
|
{
|
|
var payload =
|
|
"{" +
|
|
"\"SessionSignalOrdinal\":17," +
|
|
"\"SessionTraceOrdinal\":117," +
|
|
"\"Kind\":\"EspPhaseChanged\"," +
|
|
"\"KindSchemaVersion\":2," +
|
|
"\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"," +
|
|
"\"SourceOrigin\":\"EspAndHelloTrackerAdapter\"," +
|
|
"\"Evidence\":{\"Kind\":\"Raw\"}" +
|
|
"}";
|
|
|
|
var rec = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload), TenantId, SessionId);
|
|
|
|
Assert.NotNull(rec);
|
|
Assert.Equal(TenantId, rec!.TenantId);
|
|
Assert.Equal(SessionId, rec.SessionId);
|
|
Assert.Equal(17L, rec.SessionSignalOrdinal);
|
|
Assert.Equal(117L, rec.SessionTraceOrdinal);
|
|
Assert.Equal("EspPhaseChanged", rec.Kind);
|
|
Assert.Equal(2, rec.KindSchemaVersion);
|
|
Assert.Equal(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc), rec.OccurredAtUtc.ToUniversalTime());
|
|
Assert.Equal("EspAndHelloTrackerAdapter", rec.SourceOrigin);
|
|
Assert.Equal(payload, rec.PayloadJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseSignal_falls_back_to_envelope_SessionTraceOrdinal_when_payload_missing_field()
|
|
{
|
|
// Payload intentionally omits SessionTraceOrdinal; the envelope carries it.
|
|
var payload = "{\"SessionSignalOrdinal\":1,\"Kind\":\"SessionStarted\"}";
|
|
|
|
var rec = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload, traceOrdinal: 99), TenantId, SessionId);
|
|
|
|
Assert.NotNull(rec);
|
|
Assert.Equal(99L, rec!.SessionTraceOrdinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("{\"Kind\":\"SessionStarted\"}")] // key absent
|
|
[InlineData("{\"SessionSignalOrdinal\":null,\"Kind\":\"SessionStarted\"}")] // key present but null
|
|
public void ParseSignal_drops_item_when_SessionSignalOrdinal_is_missing_or_null(string payload)
|
|
{
|
|
// A missing ordinal would silently default to 0 → collide with a legitimate ordinal-0
|
|
// row on UpsertReplace. The parser must reject it so the caller loop drops the item.
|
|
var rec = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload), TenantId, SessionId);
|
|
Assert.Null(rec);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseSignal_accepts_explicit_ordinal_zero()
|
|
{
|
|
// 0 is a legal value (the agent seeds ordinals from 0 on a fresh SignalLog). Only
|
|
// absent/null is rejected — an explicit 0 must round-trip.
|
|
var payload = "{\"SessionSignalOrdinal\":0,\"Kind\":\"SessionStarted\"}";
|
|
|
|
var rec = TelemetryPayloadParser.ParseSignal(Dto("Signal", payload), TenantId, SessionId);
|
|
|
|
Assert.NotNull(rec);
|
|
Assert.Equal(0L, rec!.SessionSignalOrdinal);
|
|
}
|
|
|
|
// ============================================================ DecisionTransition
|
|
|
|
[Fact]
|
|
public void ParseTransition_extracts_core_columns_and_flags_taken_false_when_dead_end()
|
|
{
|
|
var payload =
|
|
"{" +
|
|
"\"StepIndex\":3," +
|
|
"\"SessionTraceOrdinal\":10," +
|
|
"\"SignalOrdinalRef\":9," +
|
|
"\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"," +
|
|
"\"Trigger\":\"EspExiting\"," +
|
|
"\"FromStage\":\"EspInProgress\"," +
|
|
"\"ToStage\":\"EspInProgress\"," +
|
|
"\"Taken\":false," +
|
|
"\"DeadEndReason\":\"hybrid_reboot_gate_blocking\"," +
|
|
"\"ReducerVersion\":\"1.0.0\"" +
|
|
"}";
|
|
|
|
var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId);
|
|
|
|
Assert.NotNull(rec);
|
|
Assert.False(rec!.Taken);
|
|
Assert.Equal("hybrid_reboot_gate_blocking", rec.DeadEndReason);
|
|
Assert.False(rec.IsTerminal);
|
|
Assert.Null(rec.ClassifierVerdictId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseTransition_projects_IsTerminal_true_for_terminal_stages()
|
|
{
|
|
var terminalStages = new[] { "Completed", "Failed", "WhiteGloveSealed" };
|
|
|
|
foreach (var stage in terminalStages)
|
|
{
|
|
var payload =
|
|
"{\"StepIndex\":1,\"SessionTraceOrdinal\":1,\"SignalOrdinalRef\":1," +
|
|
$"\"Trigger\":\"t\",\"FromStage\":\"X\",\"ToStage\":\"{stage}\",\"Taken\":true," +
|
|
"\"ReducerVersion\":\"1.0.0\",\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"}";
|
|
|
|
var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId);
|
|
|
|
Assert.NotNull(rec);
|
|
Assert.True(rec!.IsTerminal, $"Expected {stage} to be terminal");
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("{\"SessionTraceOrdinal\":1,\"ToStage\":\"EspInProgress\"}")] // key absent
|
|
[InlineData("{\"StepIndex\":null,\"SessionTraceOrdinal\":1,\"ToStage\":\"EspInProgress\"}")] // key present but null
|
|
public void ParseTransition_drops_item_when_StepIndex_is_missing_or_null(string payload)
|
|
{
|
|
// Same defense as ParseSignal: a missing StepIndex would default to 0 → collide with the
|
|
// legitimate step-0 row on UpsertReplace.
|
|
var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId);
|
|
Assert.Null(rec);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseTransition_accepts_explicit_stepIndex_zero()
|
|
{
|
|
// Step indices start at 0 (the first reducer call produces StepIndex=0). An explicit 0
|
|
// must not be confused with a missing key.
|
|
var payload =
|
|
"{\"StepIndex\":0,\"SessionTraceOrdinal\":1,\"SignalOrdinalRef\":1," +
|
|
"\"Trigger\":\"t\",\"FromStage\":\"Pending\",\"ToStage\":\"EspInProgress\",\"Taken\":true," +
|
|
"\"ReducerVersion\":\"1.0.0\",\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"}";
|
|
|
|
var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId);
|
|
|
|
Assert.NotNull(rec);
|
|
Assert.Equal(0, rec!.StepIndex);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParseTransition_extracts_classifier_verdict_nested_fields()
|
|
{
|
|
var payload =
|
|
"{\"StepIndex\":1,\"SessionTraceOrdinal\":1,\"SignalOrdinalRef\":1," +
|
|
"\"Trigger\":\"t\",\"FromStage\":\"X\",\"ToStage\":\"WhiteGloveSealed\",\"Taken\":true," +
|
|
"\"ReducerVersion\":\"1.0.0\",\"OccurredAtUtc\":\"2026-04-21T10:00:00Z\"," +
|
|
"\"ClassifierVerdict\":{\"ClassifierId\":\"whiteglove-sealing\",\"HypothesisLevel\":\"Strong\"}}";
|
|
|
|
var rec = TelemetryPayloadParser.ParseTransition(Dto("DecisionTransition", payload), TenantId, SessionId);
|
|
|
|
Assert.NotNull(rec);
|
|
Assert.Equal("whiteglove-sealing", rec!.ClassifierVerdictId);
|
|
Assert.Equal("Strong", rec.ClassifierHypothesisLevel);
|
|
}
|
|
|
|
// ============================================================ IsTerminalStage
|
|
|
|
[Theory]
|
|
[InlineData("Completed", true)]
|
|
[InlineData("Failed", true)]
|
|
[InlineData("WhiteGloveSealed", true)]
|
|
[InlineData("EspInProgress", false)]
|
|
[InlineData("AccountSetup", false)]
|
|
[InlineData("", false)]
|
|
[InlineData("completed", false)] // case-sensitive — enum names are PascalCase
|
|
public void IsTerminalStage_matches_DecisionCore_authoritative_list(string stage, bool expected)
|
|
{
|
|
Assert.Equal(expected, TelemetryPayloadParser.IsTerminalStage(stage));
|
|
}
|
|
}
|