diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 7ed6ff82..7f8f011e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -5 +7 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 0eb5aea3..d0378c4c 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1047 +1049 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateBuilderTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateBuilderTests.cs new file mode 100644 index 00000000..2400d58e --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateBuilderTests.cs @@ -0,0 +1,81 @@ +using System; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + public sealed class DecisionStateBuilderTests + { + [Fact] + public void Build_withoutChanges_returnsEquivalentState() + { + var original = DecisionState.CreateInitial("s", "t"); + var rebuilt = original.ToBuilder().Build(); + + Assert.NotSame(original, rebuilt); + Assert.Equal(original.SessionId, rebuilt.SessionId); + Assert.Equal(original.Stage, rebuilt.Stage); + Assert.Equal(original.StepIndex, rebuilt.StepIndex); + Assert.Equal(original.LastAppliedSignalOrdinal, rebuilt.LastAppliedSignalOrdinal); + } + + [Fact] + public void WithStage_returnsNewInstance_originalUnchanged() + { + var original = DecisionState.CreateInitial("s", "t"); + var updated = original.ToBuilder().WithStage(SessionStage.AwaitingHello).Build(); + + Assert.Equal(SessionStage.SessionStarted, original.Stage); + Assert.Equal(SessionStage.AwaitingHello, updated.Stage); + } + + [Fact] + public void AddDeadline_sameName_replacesExisting() + { + var original = DecisionState.CreateInitial("s", "t"); + var dl1 = new ActiveDeadline( + name: "hello_safety", + dueAtUtc: new DateTime(2026, 4, 20, 10, 5, 0, DateTimeKind.Utc), + firesSignalKind: DecisionSignalKind.DeadlineFired); + var dl2 = new ActiveDeadline( + name: "hello_safety", + dueAtUtc: new DateTime(2026, 4, 20, 10, 10, 0, DateTimeKind.Utc), + firesSignalKind: DecisionSignalKind.DeadlineFired); + + var result = original.ToBuilder().AddDeadline(dl1).AddDeadline(dl2).Build(); + + Assert.Single(result.Deadlines); + Assert.Equal(dl2.DueAtUtc, result.Deadlines[0].DueAtUtc); + } + + [Fact] + public void CancelDeadline_removesByName() + { + var original = DecisionState.CreateInitial("s", "t"); + var dl = new ActiveDeadline( + name: "hello_safety", + dueAtUtc: DateTime.UtcNow.AddMinutes(5), + firesSignalKind: DecisionSignalKind.DeadlineFired); + + var withDeadline = original.ToBuilder().AddDeadline(dl).Build(); + var cancelled = withDeadline.ToBuilder().CancelDeadline("hello_safety").Build(); + + Assert.Single(withDeadline.Deadlines); + Assert.Empty(cancelled.Deadlines); + } + + [Fact] + public void WithCurrentEnrollmentPhase_stampsSourceOrdinal() + { + var original = DecisionState.CreateInitial("s", "t"); + var updated = original.ToBuilder() + .WithCurrentEnrollmentPhase(EnrollmentPhase.AccountSetup, sourceSignalOrdinal: 7) + .Build(); + + Assert.Equal(EnrollmentPhase.AccountSetup, updated.CurrentEnrollmentPhase!.Value); + Assert.Equal(7, updated.CurrentEnrollmentPhase!.SourceSignalOrdinal); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/FixtureLoader.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/FixtureLoader.cs new file mode 100644 index 00000000..fc8dfbe1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/FixtureLoader.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; + +namespace AutopilotMonitor.DecisionCore.Tests.Harness +{ + /// + /// Load a DecisionSignal JSONL fixture from disk. Plan §4 M2 harness integration. + /// Comment lines starting with '#' and blank lines are ignored. + /// + public static class FixtureLoader + { + public static IReadOnlyList LoadJsonl(string path) + { + var signals = new List(); + foreach (var rawLine in File.ReadAllLines(path)) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') continue; + signals.Add(SignalSerializer.Deserialize(line)); + } + return signals; + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClock.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClock.cs new file mode 100644 index 00000000..6d44d97c --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClock.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.DecisionCore.Engine; + +namespace AutopilotMonitor.DecisionCore.Tests.Harness +{ + /// + /// Deterministic for reducer / harness / scenario tests. + /// Plan §2.6 — replay advances the clock to signal.OccurredAtUtc before each + /// reduce call; every delay is instant. Never used at runtime. + /// + public sealed class VirtualClock : IClock + { + private DateTime _now; + + public VirtualClock(DateTime initialUtcNow) + { + if (initialUtcNow.Kind != DateTimeKind.Utc && initialUtcNow.Kind != DateTimeKind.Unspecified) + { + throw new ArgumentException("VirtualClock expects UTC/unspecified DateTime.", nameof(initialUtcNow)); + } + _now = DateTime.SpecifyKind(initialUtcNow, DateTimeKind.Utc); + } + + public DateTime UtcNow => _now; + + public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Advance(delay); + return Task.CompletedTask; + } + + public void SetUtcNow(DateTime utcNow) + { + utcNow = DateTime.SpecifyKind(utcNow, DateTimeKind.Utc); + if (utcNow < _now) + { + throw new InvalidOperationException( + $"VirtualClock cannot go backwards (current={_now:O}, requested={utcNow:O})."); + } + _now = utcNow; + } + + public void Advance(TimeSpan delta) + { + if (delta < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(delta), "Advance delta must be non-negative."); + } + _now = _now.Add(delta); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClockTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClockTests.cs new file mode 100644 index 00000000..9db44273 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Harness/VirtualClockTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Harness +{ + public sealed class VirtualClockTests + { + [Fact] + public void UtcNow_reflectsInitialValue() + { + var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(t0); + Assert.Equal(t0, clock.UtcNow); + } + + [Fact] + public void Advance_increasesUtcNow() + { + var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(t0); + + clock.Advance(TimeSpan.FromMinutes(5)); + + Assert.Equal(t0.AddMinutes(5), clock.UtcNow); + } + + [Fact] + public void Advance_negative_throws() + { + var clock = new VirtualClock(DateTime.UtcNow); + Assert.Throws(() => clock.Advance(TimeSpan.FromMinutes(-1))); + } + + [Fact] + public void SetUtcNow_backwards_throws() + { + var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(t0); + Assert.Throws(() => clock.SetUtcNow(t0.AddMinutes(-1))); + } + + [Fact] + public async Task Delay_completesImmediately_AndAdvancesClock() + { + var t0 = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); + var clock = new VirtualClock(t0); + var expectedNow = t0.AddHours(7); + + // In real wall-clock this would take 7 hours; VirtualClock returns instantly. + var start = DateTime.UtcNow; + await clock.Delay(TimeSpan.FromHours(7)); + var elapsed = DateTime.UtcNow - start; + + Assert.Equal(expectedNow, clock.UtcNow); + Assert.True(elapsed < TimeSpan.FromSeconds(1), + $"VirtualClock.Delay should be instant, took {elapsed.TotalMilliseconds} ms."); + } + + [Fact] + public async Task Delay_withCancelledToken_throwsOperationCanceled() + { + var clock = new VirtualClock(DateTime.UtcNow); + using (var cts = new CancellationTokenSource()) + { + cts.Cancel(); + await Assert.ThrowsAsync(() => + clock.Delay(TimeSpan.FromSeconds(1), cts.Token)); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs index 6a1cfc4c..6060bc30 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/HarnessTests.cs @@ -62,11 +62,8 @@ namespace AutopilotMonitor.DecisionCore.Tests } [Fact] - public void Replay_nonEmptyStream_M2Stub_throwsViaReducer() + public void Replay_singleSessionStartedSignal_recordsOneTakenTransition() { - // M1 stub: DecisionEngine.Reduce throws NotImplementedException. - // The harness should surface that faithfully — no silent swallowing. - // M3 replaces this test's expectation with real terminal-stage assertions. var harness = new ReplayHarness(new DecisionEngine()); var signal = new DecisionSignal( sessionSignalOrdinal: 0, @@ -77,8 +74,36 @@ namespace AutopilotMonitor.DecisionCore.Tests sourceOrigin: "harness", evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "init")); - Assert.Throws(() => - harness.Replay("s", "t", new List { signal })); + var result = harness.Replay("s", "t", new List { signal }); + + Assert.Single(result.Transitions); + Assert.True(result.Transitions[0].Taken); + Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage); + Assert.Equal(1, result.FinalState.StepIndex); + Assert.Equal(0, result.FinalState.LastAppliedSignalOrdinal); + } + + [Fact] + public void Replay_unknownSignalKind_recordsDeadEnd_stateUnchanged() + { + // An unhandled signal advances bookkeeping but leaves stage/outcome alone + // and records a DeadEndReason — plan §2.5 fail-safe shape for M3.0 non-handlers. + var harness = new ReplayHarness(new DecisionEngine()); + var signal = new DecisionSignal( + sessionSignalOrdinal: 0, + sessionTraceOrdinal: 0, + kind: DecisionSignalKind.EspPhaseChanged, // not wired until M3.1 + kindSchemaVersion: 1, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "harness", + evidence: new Evidence(EvidenceKind.Raw, "ESP:test", "test")); + + var result = harness.Replay("s", "t", new List { signal }); + + var t = Assert.Single(result.Transitions); + Assert.False(t.Taken); + Assert.StartsWith("unhandled_signal_kind", t.DeadEndReason); + Assert.Equal(SessionStage.SessionStarted, result.FinalState.Stage); } } } diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs new file mode 100644 index 00000000..1ef8eb7d --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerFailSafeTests.cs @@ -0,0 +1,120 @@ +using System; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Plan §2.5 L.16 — the reducer kernel catches handler exceptions, records them as + /// DeadEndReason="reducer_exception" transitions, and leaves state unchanged + /// except for bookkeeping. M3.0 has no handlers that throw naturally; we verify the + /// kernel's fail-safe path by forcing a handler exception through a broken state. + /// + public sealed class ReducerFailSafeTests + { + [Fact] + public void Reduce_nullState_throws_ArgumentNullException() + { + // This is a programmer-error path — distinct from reducer-internal exceptions. + var engine = new DecisionEngine(); + Assert.Throws(() => + engine.Reduce(null!, MakeSignal(DecisionSignalKind.SessionStarted))); + } + + [Fact] + public void Reduce_nullSignal_throws_ArgumentNullException() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + Assert.Throws(() => engine.Reduce(state, null!)); + } + + [Fact] + public void Reduce_sessionStartedInActiveState_producesDeadEnd_stateUnchanged() + { + // Build a state that is NOT at StepIndex 0 & SessionStarted — simulates mid-flight replay. + var engine = new DecisionEngine(); + var active = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .WithStage(SessionStage.AwaitingHello) + .WithStepIndex(5) + .WithLastAppliedSignalOrdinal(4) + .Build(); + + var signal = MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 5); + + var step = engine.Reduce(active, signal); + + Assert.False(step.Transition.Taken); + Assert.StartsWith("session_started_in_active_state", step.Transition.DeadEndReason); + Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage); + Assert.Equal(6, step.NewState.StepIndex); + Assert.Equal(5, step.NewState.LastAppliedSignalOrdinal); + } + + [Fact] + public void Reduce_unhandledKind_producesDeadEnd_withVersionedReason() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + var signal = MakeSignal(DecisionSignalKind.EspPhaseChanged, schemaVersion: 1); + + var step = engine.Reduce(state, signal); + + Assert.False(step.Transition.Taken); + Assert.Equal("unhandled_signal_kind:EspPhaseChanged:v1", step.Transition.DeadEndReason); + Assert.Equal(SessionStage.SessionStarted, step.NewState.Stage); + Assert.Equal(1, step.NewState.StepIndex); + } + + [Fact] + public void Reduce_sessionAborted_terminatesWithAbortedOutcome_clearsDeadlines() + { + var engine = new DecisionEngine(); + var withDeadline = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .AddDeadline(new ActiveDeadline( + name: "hello_safety", + dueAtUtc: DateTime.UtcNow.AddMinutes(5), + firesSignalKind: DecisionSignalKind.DeadlineFired)) + .WithStage(SessionStage.AwaitingHello) + .Build(); + + var step = engine.Reduce(withDeadline, MakeSignal(DecisionSignalKind.SessionAborted)); + + Assert.Equal(SessionStage.Failed, step.NewState.Stage); + Assert.Equal(SessionOutcome.Aborted, step.NewState.Outcome); + Assert.Empty(step.NewState.Deadlines); + Assert.True(step.Transition.Taken); + } + + [Fact] + public void ReducerVersion_matchesAssemblyVersion_stableAcrossReduces() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step1 = engine.Reduce(state, MakeSignal(DecisionSignalKind.SessionStarted, ordinal: 0)); + var step2 = engine.Reduce(step1.NewState, MakeSignal(DecisionSignalKind.SessionRecovered, ordinal: 1)); + + Assert.Equal("2.0.0.0", engine.ReducerVersion); + Assert.Equal("2.0.0.0", step1.Transition.ReducerVersion); + Assert.Equal("2.0.0.0", step2.Transition.ReducerVersion); + } + + private static DecisionSignal MakeSignal( + DecisionSignalKind kind, + long ordinal = 0, + int schemaVersion = 1) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: schemaVersion, + occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc), + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Synthetic, $"test:{kind}", "test")); + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs new file mode 100644 index 00000000..b5e68ddd --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + public sealed class SerializationTests + { + [Fact] + public void SignalSerializer_roundtrip_preservesAllFields() + { + var original = new DecisionSignal( + sessionSignalOrdinal: 42, + sessionTraceOrdinal: 99, + kind: DecisionSignalKind.EspPhaseChanged, + kindSchemaVersion: 1, + occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc), + sourceOrigin: "EspAndHelloTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "esp-phase-detector-v1", + summary: "AccountSetup observed in registry", + rawPointer: "blob://events/42", + derivationInputs: new Dictionary + { + ["registryKey"] = "HKLM\\...Autopilot\\EspStatus", + ["rawValue"] = "2", + }), + payload: new Dictionary + { + ["phase"] = "AccountSetup", + }); + + var json = SignalSerializer.Serialize(original); + var roundtripped = SignalSerializer.Deserialize(json); + + Assert.Equal(original.SessionSignalOrdinal, roundtripped.SessionSignalOrdinal); + Assert.Equal(original.SessionTraceOrdinal, roundtripped.SessionTraceOrdinal); + Assert.Equal(original.Kind, roundtripped.Kind); + Assert.Equal(original.KindSchemaVersion, roundtripped.KindSchemaVersion); + Assert.Equal(original.OccurredAtUtc, roundtripped.OccurredAtUtc); + Assert.Equal(DateTimeKind.Utc, roundtripped.OccurredAtUtc.Kind); + Assert.Equal(original.SourceOrigin, roundtripped.SourceOrigin); + Assert.Equal(original.Evidence.Kind, roundtripped.Evidence.Kind); + Assert.Equal(original.Evidence.Identifier, roundtripped.Evidence.Identifier); + Assert.Equal(original.Evidence.Summary, roundtripped.Evidence.Summary); + Assert.Equal(original.Evidence.RawPointer, roundtripped.Evidence.RawPointer); + Assert.Equal("HKLM\\...Autopilot\\EspStatus", roundtripped.Evidence.DerivationInputs!["registryKey"]); + Assert.Equal("AccountSetup", roundtripped.Payload!["phase"]); + } + + [Fact] + public void SignalSerializer_Deserialize_MissingEvidence_throws() + { + var json = "{\"SessionSignalOrdinal\":0,\"Kind\":\"SessionStarted\",\"KindSchemaVersion\":1,\"OccurredAtUtc\":\"2026-04-20T10:00:00Z\",\"SourceOrigin\":\"test\"}"; + Assert.Throws(() => SignalSerializer.Deserialize(json)); + } + + [Fact] + public void UnknownFallbackEnumConverter_unknownValue_mapsToFallback() + { + // An old backend reading a row produced by a newer DecisionCore that added a + // new Stage value must not crash — it reads it as Unknown instead. + var settings = DecisionCoreJsonSettings.Create(); + var json = "\"NeueUnbekannteStage\""; + + var result = JsonConvert.DeserializeObject(json, settings); + + Assert.Equal(SessionStage.Unknown, result); + } + + [Fact] + public void UnknownFallbackEnumConverter_knownValue_roundtripsByName() + { + var settings = DecisionCoreJsonSettings.Create(); + var original = SessionStage.AwaitingHello; + + var json = JsonConvert.SerializeObject(original, settings); + var roundtripped = JsonConvert.DeserializeObject(json, settings); + + Assert.Contains("AwaitingHello", json); + Assert.Equal(original, roundtripped); + } + + [Fact] + public void UnknownFallbackEnumConverter_legacyNumericValue_mapsIfDefined() + { + var settings = DecisionCoreJsonSettings.Create(); + // EnrollmentPhase.AccountSetup is integer value 4. + var json = "4"; + + var result = JsonConvert.DeserializeObject(json, settings); + + Assert.Equal(EnrollmentPhase.AccountSetup, result); + } + + [Fact] + public void UnknownFallbackEnumConverter_outOfRangeNumeric_mapsToFallback() + { + var settings = DecisionCoreJsonSettings.Create(); + var json = "9999"; + + var result = JsonConvert.DeserializeObject(json, settings); + + Assert.Equal(HypothesisLevel.Unknown, result); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs index 0cebb4df..d3dc23a9 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SmokeTests.cs @@ -67,8 +67,10 @@ namespace AutopilotMonitor.DecisionCore.Tests } [Fact] - public void DecisionEngine_Reduce_M1Stub_throwsNotImplemented() + public void DecisionEngine_Reduce_SessionStarted_advancesStep() { + // M3.0: Reduce is no longer a stub; SessionStarted handler advances StepIndex + // and records a taken transition. var engine = new DecisionEngine(); var state = DecisionState.CreateInitial("s", "t"); var signal = new DecisionSignal( @@ -80,7 +82,14 @@ namespace AutopilotMonitor.DecisionCore.Tests sourceOrigin: "test", evidence: new Evidence(EvidenceKind.Synthetic, "session:started", "test")); - Assert.Throws(() => engine.Reduce(state, signal)); + var step = engine.Reduce(state, signal); + + Assert.Equal(1, step.NewState.StepIndex); + Assert.Equal(0, step.NewState.LastAppliedSignalOrdinal); + Assert.True(step.Transition.Taken); + Assert.Equal("SessionStarted", step.Transition.Trigger); + Assert.Null(step.Transition.DeadEndReason); + Assert.Empty(step.Effects); } [Fact] diff --git a/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj index bd34b6db..bc6827a3 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj +++ b/src/Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj @@ -18,4 +18,9 @@ + + + + + diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs new file mode 100644 index 00000000..2a8c2b23 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // Lifecycle + cross-scenario shared handlers. Plan §2.5 partial-class layout. + public sealed partial class DecisionEngine + { + /// + /// Handle . Plan §2.7. + /// + /// For a fresh session this is the first signal and the state already equals + /// . The handler still runs + /// through the pipeline so the start is recorded as a journal transition (step 0) — + /// this anchors the Inspector timeline. + /// + /// + /// If the engine sees SessionStarted on a state whose stage is already + /// something other than , we treat it as a + /// defensive no-op (dead-end) rather than silently reinitializing — replay of a + /// truncated log should fail visibly, not reset hard-won hypotheses. + /// + /// + private DecisionStep HandleSessionStartedV1(DecisionState state, DecisionSignal signal) + { + if (state.Stage != SessionStage.SessionStarted && state.StepIndex != 0) + { + var bookkeptDead = BumpStepBookkeeping(state, signal); + return new DecisionStep( + bookkeptDead, + BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: bookkeptDead.StepIndex, + trigger: nameof(DecisionSignalKind.SessionStarted), + deadEndReason: $"session_started_in_active_state:{state.Stage}"), + Array.Empty()); + } + + // Stage stays SessionStarted; this transition is the "we saw the start" anchor. + var newState = state.ToBuilder() + .WithStage(SessionStage.SessionStarted) + .WithStepIndex(state.StepIndex + 1) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.SessionStarted, + nextStepIndex: newState.StepIndex, + trigger: nameof(DecisionSignalKind.SessionStarted)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + /// + /// Handle . Plan §2.7 sonder-case 1. + /// + /// In M3.0 scope this is a generic bookkeeping handler. The White-Glove Part-1 → + /// Part-2 post-reboot transition is implemented in DecisionEngine.WhiteGlovePart2.cs + /// (M3.4) and takes precedence there when the prior stage was + /// . + /// + /// + private DecisionStep HandleSessionRecoveredV1(DecisionState state, DecisionSignal signal) + { + // M3.4 will add WhiteGlove-Part-2 transition here. For M3.0 the handler is a + // neutral "observed a restart" step — stage unchanged, StepIndex advanced. + var newState = BumpStepBookkeeping(state, signal); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: newState.StepIndex, + trigger: nameof(DecisionSignalKind.SessionRecovered)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + /// + /// Handle . + /// + /// Emitted by the orchestrator, never by a collector. Stage transitions to + /// with . + /// This is a terminal event; the orchestrator uses it to record admin-kill / + /// override actions cleanly without going through the regular completion paths + /// (plan §2.7 admin-action audit). + /// + /// + private DecisionStep HandleSessionAbortedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var newState = state.ToBuilder() + .WithStage(SessionStage.Failed) + .WithOutcome(SessionOutcome.Aborted) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .ClearDeadlines() + .Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.Failed, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.SessionAborted)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + // ============================================================== shared helpers + // Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers + // as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies + // grow with each sub-milestone. + + /// + /// Determine the user-visible enrollment phase implied by an ESP phase-change signal. + /// Plan §2.3 phase-fact mapping. Populated in M3.1 as Classic handlers come online. + /// + internal static EnrollmentPhase MapEspPhaseToEnrollmentPhase(string rawPhase) + { + if (string.IsNullOrEmpty(rawPhase)) return EnrollmentPhase.Unknown; + return rawPhase switch + { + "DeviceSetup" => EnrollmentPhase.DeviceSetup, + "AccountSetup" => EnrollmentPhase.AccountSetup, + "FinalizingSetup" => EnrollmentPhase.FinalizingSetup, + "Finalizing" => EnrollmentPhase.FinalizingSetup, + "Complete" => EnrollmentPhase.Complete, + _ => EnrollmentPhase.Unknown, + }; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index d88f68ee..3bf188d8 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -1,23 +1,27 @@ using System; +using System.Collections.Generic; using System.Reflection; +using System.Security.Cryptography; +using System.Text; using AutopilotMonitor.DecisionCore.Signals; using AutopilotMonitor.DecisionCore.State; namespace AutopilotMonitor.DecisionCore.Engine { /// - /// Kernel stub for the Decision Engine. Plan §2.5 + M1 gate. + /// Decision Engine kernel. Plan §2.5 / L.2 / L.3. /// - /// M1 scaffolding — no handler dispatch implemented. throws - /// so that any accidental runtime call from - /// the Legacy-Agent or Backend fails loudly during the scaffolding phase (plan: - /// "Agent + Backend referenzieren, ohne Warnings, keine Call-Sites"). + /// Dispatches on (, ) + /// to partial-class handlers. All handlers live in sibling files named + /// DecisionEngine.Classic.cs, DecisionEngine.SelfDeploying.cs, + /// DecisionEngine.WhiteGlove.cs, DecisionEngine.WhiteGlovePart2.cs, + /// DecisionEngine.Shared.cs. /// /// - /// M3 replaces this body with the partial-class dispatcher - /// (DecisionEngine.Classic.cs, .SelfDeploying.cs, .WhiteGlove.cs, - /// .WhiteGlovePart2.cs, .Shared.cs) and the dispatch table is keyed on - /// (DecisionSignalKind, KindSchemaVersion). + /// Exception fail-safe (L.16): a handler exception never corrupts state; the kernel + /// catches it, advances , applies the signal ordinal, + /// and emits a dead-end tagged + /// DeadEndReason="reducer_exception". /// /// public sealed partial class DecisionEngine : IDecisionEngine @@ -29,19 +33,164 @@ namespace AutopilotMonitor.DecisionCore.Engine public DecisionStep Reduce(DecisionState oldState, DecisionSignal signal) { - if (oldState == null) - { - throw new ArgumentNullException(nameof(oldState)); - } + if (oldState == null) throw new ArgumentNullException(nameof(oldState)); + if (signal == null) throw new ArgumentNullException(nameof(signal)); - if (signal == null) + try { - throw new ArgumentNullException(nameof(signal)); + return Dispatch(oldState, signal); } + catch (Exception ex) + { + return BuildReducerExceptionStep(oldState, signal, ex); + } + } - throw new NotImplementedException( - "DecisionEngine.Reduce is a scaffolding stub (M1). " + - "Handler implementation lands in M3 (plan §2.5)."); + /// + /// Pure dispatch on (Kind, SchemaVersion) — no mutation here, no try/catch + /// (outer provides the fail-safe wrapper). Handlers return fully + /// constructed instances. + /// + private DecisionStep Dispatch(DecisionState state, DecisionSignal signal) + { + return (signal.Kind, signal.KindSchemaVersion) switch + { + // ----- Lifecycle (DecisionEngine.Shared.cs) ----- + (DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal), + (DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal), + (DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal), + + // ----- All other kinds (M3.1+) — fall through to Unhandled for M3.0 scope. ----- + _ => HandleUnhandledSignal(state, signal), + }; + } + + // ================================================================== helpers + + /// + /// Advance StepIndex / LastAppliedSignalOrdinal without touching the engine-visible + /// stage or facts. Reducer handlers call this when they record a step but don't + /// progress the state machine (e.g. hypothesis-only update, or dead-end). + /// + internal DecisionState BumpStepBookkeeping(DecisionState state, DecisionSignal signal) => + state.ToBuilder() + .WithStepIndex(state.StepIndex + 1) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .Build(); + + /// + /// Build a that records a state-changing step. Guards + /// default to empty (for handler-initiated changes that aren't guard-driven); callers + /// pass explicit guard evaluations when they matter to the Inspector. + /// + internal DecisionTransition BuildTakenTransition( + DecisionState before, + DecisionSignal signal, + SessionStage toStage, + int nextStepIndex, + string trigger, + IReadOnlyList? guards = null) + { + return new DecisionTransition( + stepIndex: nextStepIndex, + sessionTraceOrdinal: signal.SessionTraceOrdinal, + signalOrdinalRef: signal.SessionSignalOrdinal, + occurredAtUtc: signal.OccurredAtUtc, + trigger: trigger, + fromStage: before.Stage, + toStage: toStage, + taken: true, + deadEndReason: null, + reducerVersion: ReducerVersion, + guards: guards, + emittedEventSequences: null, + classifierVerdict: null, + errorMessage: null, + stackTraceHash: null); + } + + internal DecisionTransition BuildDeadEndTransition( + DecisionState state, + DecisionSignal signal, + int nextStepIndex, + string trigger, + string deadEndReason, + IReadOnlyList? guards = null) + { + return new DecisionTransition( + stepIndex: nextStepIndex, + sessionTraceOrdinal: signal.SessionTraceOrdinal, + signalOrdinalRef: signal.SessionSignalOrdinal, + occurredAtUtc: signal.OccurredAtUtc, + trigger: trigger, + fromStage: state.Stage, + toStage: state.Stage, + taken: false, + deadEndReason: deadEndReason, + reducerVersion: ReducerVersion, + guards: guards, + emittedEventSequences: null, + classifierVerdict: null, + errorMessage: null, + stackTraceHash: null); + } + + /// + /// Plan §2.5 L.16 — exception fail-safe. State is unchanged (no builder copy); only + /// bookkeeping (StepIndex, LastAppliedSignalOrdinal) advances so the signal counts as + /// processed. holds the original message + /// and a short SHA256 of the trace — + /// enough for an Inspector to link recurring-exception rows without dumping full PII. + /// + private DecisionStep BuildReducerExceptionStep( + DecisionState state, + DecisionSignal signal, + Exception exception) + { + var bookkept = BumpStepBookkeeping(state, signal); + var transition = new DecisionTransition( + stepIndex: bookkept.StepIndex, + sessionTraceOrdinal: signal.SessionTraceOrdinal, + signalOrdinalRef: signal.SessionSignalOrdinal, + occurredAtUtc: signal.OccurredAtUtc, + trigger: signal.Kind.ToString(), + fromStage: state.Stage, + toStage: state.Stage, + taken: false, + deadEndReason: "reducer_exception", + reducerVersion: ReducerVersion, + guards: null, + emittedEventSequences: null, + classifierVerdict: null, + errorMessage: exception.Message, + stackTraceHash: HashStackTrace(exception)); + + return new DecisionStep(bookkept, transition, Array.Empty()); + } + + private DecisionStep HandleUnhandledSignal(DecisionState state, DecisionSignal signal) + { + var bookkept = BumpStepBookkeeping(state, signal); + var transition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: bookkept.StepIndex, + trigger: signal.Kind.ToString(), + deadEndReason: $"unhandled_signal_kind:{signal.Kind}:v{signal.KindSchemaVersion}"); + + return new DecisionStep(bookkept, transition, Array.Empty()); + } + + private static string HashStackTrace(Exception exception) + { + var raw = exception.StackTrace ?? exception.GetType().FullName ?? "unknown"; + using (var sha = SHA256.Create()) + { + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(raw)); + var sb = new StringBuilder(16); + for (int i = 0; i < 8; i++) sb.Append(bytes[i].ToString("x2")); + return sb.ToString(); + } } } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/IClock.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IClock.cs new file mode 100644 index 00000000..fed19cf0 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/IClock.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Pluggable clock abstraction. Plan §2.6 L.7. + /// + /// Time is consumed exclusively through this interface by every time-dependent + /// component in DecisionCore and Agent.V2.Core (Deadline-Scheduler, + /// ClassifierTick-Timer, recovery logic, …). Agent runtime uses ; + /// the replay harness uses VirtualClock so 420-second timeouts run in + /// milliseconds and are deterministic. + /// + /// + public interface IClock + { + /// Current UTC time. + DateTime UtcNow { get; } + + /// Async delay; virtualized in tests. + Task Delay(TimeSpan delay, CancellationToken cancellationToken = default); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/SystemClock.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/SystemClock.cs new file mode 100644 index 00000000..6d6ecba1 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/SystemClock.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + /// + /// Default implementation for the agent runtime — wraps + /// and . + /// Tests must use a virtual clock implementation, never this one. + /// + public sealed class SystemClock : IClock + { + public static readonly SystemClock Instance = new SystemClock(); + + public DateTime UtcNow => DateTime.UtcNow; + + public Task Delay(TimeSpan delay, CancellationToken cancellationToken = default) => + Task.Delay(delay, cancellationToken); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs new file mode 100644 index 00000000..de7e539d --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs @@ -0,0 +1,57 @@ +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.DecisionCore.Serialization +{ + /// + /// Centralized factory for DecisionCore DTOs. + /// Plan §2.8 / §2.15 L.14. + /// + /// Produces a instance with: + /// + /// All DecisionCore enums registered via + /// so unknown values land on Unknown instead of throwing. + /// — no polymorphism loopholes over the wire. + /// — preserve nullable fact/hypothesis fields. + /// UTC round-trip for DateTime — , + /// ISO-8601 format. + /// + /// + /// + public static class DecisionCoreJsonSettings + { + /// + /// Create a fresh settings instance. Not a static singleton — Newtonsoft mutates + /// internal state during serialization (binder caches, etc.), so handing out + /// shared instances across threads is a well-known footgun. + /// + public static JsonSerializerSettings Create() + { + var settings = new JsonSerializerSettings + { + TypeNameHandling = TypeNameHandling.None, + NullValueHandling = NullValueHandling.Include, + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + DateParseHandling = DateParseHandling.DateTime, + Formatting = Formatting.None, + MissingMemberHandling = MissingMemberHandling.Ignore, + }; + + // Enum converters with Unknown-fallback — plan §2.15 L.14. + settings.Converters.Add(new UnknownFallbackEnumConverter(DecisionSignalKind.SessionStarted)); + // ^ SessionStarted is a neutral fallback; unknown signal kinds should be rare (SchemaVersion gate), + // and when they do occur we prefer a benign no-op over a crash. + settings.Converters.Add(new UnknownFallbackEnumConverter(EvidenceKind.Synthetic)); + settings.Converters.Add(new UnknownFallbackEnumConverter(HypothesisLevel.Unknown)); + settings.Converters.Add(new UnknownFallbackEnumConverter(SessionStage.Unknown)); + settings.Converters.Add(new UnknownFallbackEnumConverter(SessionOutcome.Unknown)); + settings.Converters.Add(new UnknownFallbackEnumConverter(DecisionEffectKind.PersistSnapshot)); + settings.Converters.Add(new UnknownFallbackEnumConverter(EnrollmentPhase.Unknown)); + + return settings; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs new file mode 100644 index 00000000..14990a02 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.DecisionCore.Serialization +{ + /// + /// Serialize / deserialize JSONL records. + /// Plan §2.7 / §4 M2 harness integration. + /// + /// Uses Newtonsoft.Json with . The reader + /// tolerates missing optional fields but enforces the evidence-kind mandatory-field + /// contract (plan §2.2) — a line that would fail new Evidence(...) also fails + /// here with the same . + /// + /// + public static class SignalSerializer + { + public static string Serialize(DecisionSignal signal) + { + if (signal == null) throw new ArgumentNullException(nameof(signal)); + return JsonConvert.SerializeObject(signal, DecisionCoreJsonSettings.Create()); + } + + public static DecisionSignal Deserialize(string line) + { + if (line == null) throw new ArgumentNullException(nameof(line)); + + var settings = DecisionCoreJsonSettings.Create(); + var obj = JsonConvert.DeserializeObject(line, settings) + ?? throw new JsonReaderException($"Failed to parse line as JSON object: {line}"); + + // Evidence is required. + var evidenceTok = obj["Evidence"] ?? throw new JsonSerializationException("Signal is missing Evidence block."); + var evidenceKind = evidenceTok.Value("Kind") ?? throw new JsonSerializationException("Evidence.Kind missing."); + var evidenceIdentifier = evidenceTok.Value("Identifier") + ?? throw new JsonSerializationException("Evidence.Identifier missing."); + var evidenceSummary = evidenceTok.Value("Summary") ?? string.Empty; + var rawPointer = evidenceTok.Value("RawPointer"); + + IReadOnlyDictionary? derivationInputs = null; + if (evidenceTok["DerivationInputs"] is JObject diObj) + { + var d = new Dictionary(); + foreach (var prop in diObj.Properties()) + { + d[prop.Name] = prop.Value?.ToString() ?? string.Empty; + } + derivationInputs = d; + } + + var evidence = new Evidence( + kind: ParseEnum(evidenceKind, EvidenceKind.Synthetic), + identifier: evidenceIdentifier, + summary: evidenceSummary, + rawPointer: rawPointer, + derivationInputs: derivationInputs); + + IReadOnlyDictionary? payload = null; + if (obj["Payload"] is JObject payloadObj) + { + var p = new Dictionary(); + foreach (var prop in payloadObj.Properties()) + { + p[prop.Name] = prop.Value?.ToString() ?? string.Empty; + } + payload = p; + } + + return new DecisionSignal( + sessionSignalOrdinal: obj.Value("SessionSignalOrdinal"), + sessionTraceOrdinal: obj.Value("SessionTraceOrdinal"), + kind: ParseEnum(obj.Value("Kind"), DecisionSignalKind.SessionStarted), + kindSchemaVersion: obj.Value("KindSchemaVersion") ?? 1, + occurredAtUtc: obj.Value("OccurredAtUtc"), + sourceOrigin: obj.Value("SourceOrigin") ?? "unknown", + evidence: evidence, + payload: payload); + } + + private static T ParseEnum(string? raw, T fallback) where T : struct, Enum + { + if (string.IsNullOrEmpty(raw)) return fallback; + return Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed) + ? parsed + : fallback; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs new file mode 100644 index 00000000..c807e3b4 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs @@ -0,0 +1,82 @@ +using System; +using Newtonsoft.Json; + +namespace AutopilotMonitor.DecisionCore.Serialization +{ + /// + /// Forward-compatibility converter for enums in DecisionCore DTOs. Plan §2.15 L.14. + /// + /// Serializes as its name. + /// Deserialization maps unknown / unrecognized string values to a caller-provided + /// default — typically Unknown — instead of throwing. This lets an older + /// consumer (e.g. a backend built against an older DecisionCore version) read rows + /// written by a newer producer (new enum values) without crashing; the consumer + /// flags those rows as VerificationStatus=drift_tolerated per plan §2.10. + /// + /// + public sealed class UnknownFallbackEnumConverter : JsonConverter + where T : struct, Enum + { + private readonly T _unknownFallback; + + public UnknownFallbackEnumConverter(T unknownFallback) + { + _unknownFallback = unknownFallback; + } + + public override bool CanConvert(Type objectType) + { + var t = Nullable.GetUnderlyingType(objectType) ?? objectType; + return t == typeof(T); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + writer.WriteValue(value.ToString()); + } + + public override object? ReadJson( + JsonReader reader, + Type objectType, + object? existingValue, + JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return Nullable.GetUnderlyingType(objectType) != null ? (object?)null : _unknownFallback; + } + + if (reader.TokenType == JsonToken.Integer) + { + // Legacy / numeric payloads. Try to cast; fallback if out of range. + var numeric = Convert.ToInt32(reader.Value); + if (Enum.IsDefined(typeof(T), numeric)) + { + return (T)Enum.ToObject(typeof(T), numeric); + } + return _unknownFallback; + } + + if (reader.TokenType == JsonToken.String) + { + var raw = reader.Value as string; + if (string.IsNullOrEmpty(raw)) + { + return _unknownFallback; + } + if (Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(typeof(T), parsed)) + { + return parsed; + } + return _unknownFallback; + } + + return _unknownFallback; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs index d1a85cd2..4bd5ea8f 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs @@ -131,6 +131,13 @@ namespace AutopilotMonitor.DecisionCore.State public string SchemaVersion { get; } + /// + /// Produce a mutable builder pre-populated with this state's values. + /// Reducer handlers call state.ToBuilder().WithStage(...).Build() to + /// express immutable "copy with changes" ergonomically (plan §2.3 / L.3). + /// + public DecisionStateBuilder ToBuilder() => new DecisionStateBuilder(this); + /// /// Construct the initial non-terminal state for a new session. /// Used by the reducer's SessionStarted handler in M3. diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs new file mode 100644 index 00000000..daf0e8f5 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Mutable construction helper for producing a new immutable . + /// + /// Plan §2.3 / L.3 — DecisionState itself is immutable; reducer handlers use the + /// builder to express "copy with changes" without typing all 20+ constructor arguments. + /// Call to materialize a new . The original + /// state is never mutated. + /// + /// + public sealed class DecisionStateBuilder + { + public DecisionStateBuilder(DecisionState source) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + + SessionId = source.SessionId; + TenantId = source.TenantId; + Stage = source.Stage; + Outcome = source.Outcome; + EnrollmentType = source.EnrollmentType; + WhiteGloveSealing = source.WhiteGloveSealing; + WhiteGlovePart2Completion = source.WhiteGlovePart2Completion; + DeviceOnlyDeployment = source.DeviceOnlyDeployment; + CurrentEnrollmentPhase = source.CurrentEnrollmentPhase; + DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc; + AccountSetupEnteredUtc = source.AccountSetupEnteredUtc; + FinalizingEnteredUtc = source.FinalizingEnteredUtc; + EspFinalExitUtc = source.EspFinalExitUtc; + DesktopArrivedUtc = source.DesktopArrivedUtc; + HelloResolvedUtc = source.HelloResolvedUtc; + SystemRebootUtc = source.SystemRebootUtc; + HelloOutcome = source.HelloOutcome; + AadJoinedWithUser = source.AadJoinedWithUser; + ImeMatchedPatternId = source.ImeMatchedPatternId; + Deadlines = new List(source.Deadlines); + LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal; + StepIndex = source.StepIndex; + SchemaVersion = source.SchemaVersion; + } + + public string SessionId { get; set; } + public string TenantId { get; set; } + public SessionStage Stage { get; set; } + public SessionOutcome? Outcome { get; set; } + public Hypothesis EnrollmentType { get; set; } + public Hypothesis WhiteGloveSealing { get; set; } + public Hypothesis WhiteGlovePart2Completion { get; set; } + public Hypothesis DeviceOnlyDeployment { get; set; } + public SignalFact? CurrentEnrollmentPhase { get; set; } + public SignalFact? DeviceSetupEnteredUtc { get; set; } + public SignalFact? AccountSetupEnteredUtc { get; set; } + public SignalFact? FinalizingEnteredUtc { get; set; } + public SignalFact? EspFinalExitUtc { get; set; } + public SignalFact? DesktopArrivedUtc { get; set; } + public SignalFact? HelloResolvedUtc { get; set; } + public SignalFact? SystemRebootUtc { get; set; } + public SignalFact? HelloOutcome { get; set; } + public SignalFact? AadJoinedWithUser { get; set; } + public SignalFact? ImeMatchedPatternId { get; set; } + public List Deadlines { get; set; } + public long LastAppliedSignalOrdinal { get; set; } + public int StepIndex { get; set; } + public string SchemaVersion { get; set; } + + // ---------- fluent helpers for the most common reducer operations ---------- + + public DecisionStateBuilder WithStage(SessionStage stage) { Stage = stage; return this; } + + public DecisionStateBuilder WithOutcome(SessionOutcome? outcome) { Outcome = outcome; return this; } + + public DecisionStateBuilder WithStepIndex(int stepIndex) { StepIndex = stepIndex; return this; } + + public DecisionStateBuilder WithLastAppliedSignalOrdinal(long ordinal) { LastAppliedSignalOrdinal = ordinal; return this; } + + public DecisionStateBuilder WithCurrentEnrollmentPhase(EnrollmentPhase phase, long sourceSignalOrdinal) + { + CurrentEnrollmentPhase = new SignalFact(phase, sourceSignalOrdinal); + return this; + } + + public DecisionStateBuilder AddDeadline(ActiveDeadline deadline) + { + if (deadline == null) throw new ArgumentNullException(nameof(deadline)); + // Replace-if-same-name semantic: deadlines identified by Name. Plan §2.6. + for (int i = 0; i < Deadlines.Count; i++) + { + if (string.Equals(Deadlines[i].Name, deadline.Name, StringComparison.Ordinal)) + { + Deadlines[i] = deadline; + return this; + } + } + Deadlines.Add(deadline); + return this; + } + + public DecisionStateBuilder CancelDeadline(string name) + { + if (name == null) throw new ArgumentNullException(nameof(name)); + Deadlines.RemoveAll(d => string.Equals(d.Name, name, StringComparison.Ordinal)); + return this; + } + + public DecisionStateBuilder ClearDeadlines() { Deadlines.Clear(); return this; } + + public DecisionState Build() => + new DecisionState( + sessionId: SessionId, + tenantId: TenantId, + stage: Stage, + outcome: Outcome, + enrollmentType: EnrollmentType, + whiteGloveSealing: WhiteGloveSealing, + whiteGlovePart2Completion: WhiteGlovePart2Completion, + deviceOnlyDeployment: DeviceOnlyDeployment, + currentEnrollmentPhase: CurrentEnrollmentPhase, + deviceSetupEnteredUtc: DeviceSetupEnteredUtc, + accountSetupEnteredUtc: AccountSetupEnteredUtc, + finalizingEnteredUtc: FinalizingEnteredUtc, + espFinalExitUtc: EspFinalExitUtc, + desktopArrivedUtc: DesktopArrivedUtc, + helloResolvedUtc: HelloResolvedUtc, + systemRebootUtc: SystemRebootUtc, + helloOutcome: HelloOutcome, + aadJoinedWithUser: AadJoinedWithUser, + imeMatchedPatternId: ImeMatchedPatternId, + deadlines: Deadlines.ToArray(), + lastAppliedSignalOrdinal: LastAppliedSignalOrdinal, + stepIndex: StepIndex, + schemaVersion: SchemaVersion); + } +}