From 98b173a858965ca8249d3851c64e2818efd00fb2 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 21 May 2026 18:16:02 +0200 Subject: [PATCH] feat(decisioncore): RealmJoin deployment gate + per-package facts (Phase A) Add a new completion-gate to the V2 enrollment AND-gate so the session keeps running while a RealmJoin (RJ) deployment is in flight. The agent detects RJ via HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters and tracks per-package installs from HKLM\SOFTWARE\RealmJoin\Packages\ + the HKEY_USERS\\... counterpart; runtime wiring lands in Phase B. Decision-layer changes: - 5 new DecisionSignalKinds (RealmJoinDetected/Resolved/Timeout/PackageStarted/ PackageCompleted) routed through the existing reducer dispatch. - New DeadlineNames.RealmJoinTimeout (60-min hard cap, hardcoded). - New immutable RealmJoinFacts + RealmJoinPackageFact on DecisionState; schema bumped v2 -> v3 (old snapshots load with RealmJoinFacts.Empty default). - Classic Hello+Desktop AND-gate (HandleHelloResolvedV1, HandleDesktopArrivedV1 Hello-disabled fast-path + helloAlreadyResolved branch, HelloSafety deadline) and SelfDeploying terminal path now AND with RealmJoinGateOpen(). When closed, the prerequisite facts are still recorded but TransitionToFinalizing / WithStage(Completed) is deferred until the RJ resolved/timeout handler releases the gate via CompleteIfDeferredOrBookkeep. - Per-package facts capped at 200 rows; DisplayName truncated to 256 chars. - Audit-trail attaches realmjoinDetectedUtc/Outcome/LastPhase/ResolvedUtc/Packages on terminal events when RJ was active. Verified: DecisionCore tests 313/313, Agent V2.Core tests 1125/1125. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Engine/DeadlineNames.cs | 7 + .../Engine/DecisionAuditTrailBuilder.cs | 55 +++ .../Engine/DecisionEngine.Classic.cs | 82 +++- .../Engine/DecisionEngine.RealmJoin.cs | 441 ++++++++++++++++++ .../Engine/DecisionEngine.SelfDeploying.cs | 25 + .../Engine/DecisionEngine.Shared.cs | 20 +- .../Engine/DecisionEngine.cs | 8 + .../Signals/DecisionSignalKind.cs | 11 + .../State/DecisionState.cs | 14 +- .../State/DecisionStateBuilder.cs | 9 + .../State/RealmJoinFacts.cs | 256 ++++++++++ .../State/RealmJoinPackageFact.cs | 77 +++ 12 files changed, 990 insertions(+), 15 deletions(-) create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs create mode 100644 src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinPackageFact.cs diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs index b82596c7..1c7787fc 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs @@ -38,6 +38,13 @@ namespace AutopilotMonitor.DecisionCore.Engine /// EnrollmentTerminationHandler drains the spool. Plan §5 Fix 6. /// public const string FinalizingGrace = "finalizing_grace"; + + /// + /// Hard 60-min timeout from RealmJoin-detected. When fired without phase 110 being + /// observed, the gate closes anyway with realmjoinOutcome=Timeout so the + /// session can complete (Classic Finalizing or SelfDeploying-direct path). + /// + public const string RealmJoinTimeout = "realmjoin_timeout"; } /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs index 1431e4a2..26c1b0bc 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionAuditTrailBuilder.cs @@ -131,9 +131,64 @@ namespace AutopilotMonitor.DecisionCore.Engine data["imePatternMatchedPatternId"] = postState.ImeMatchedPatternId.Value; } + if (postState.RealmJoinFacts.DetectedUtc != null) + { + data["realmjoinDetectedUtc"] = FormatUtc(postState.RealmJoinFacts.DetectedUtc.Value); + data["realmjoinOutcome"] = postState.RealmJoinFacts.Outcome?.Value ?? "Pending"; + if (postState.RealmJoinFacts.LastDeploymentPhase != null) + { + data["realmjoinLastPhase"] = postState.RealmJoinFacts.LastDeploymentPhase.Value; + } + if (postState.RealmJoinFacts.ResolvedUtc != null) + { + data["realmjoinResolvedUtc"] = FormatUtc(postState.RealmJoinFacts.ResolvedUtc.Value); + } + data["realmjoinPackages"] = BuildRealmJoinPackageSummary(postState.RealmJoinFacts.Packages); + } + return data; } + /// + /// Build a compact per-package summary list for the realmjoinPackages audit-trail + /// field. Each entry carries the identifiers + completion state but omits the per-package + /// timestamps to keep the payload size predictable. + /// + private static List> BuildRealmJoinPackageSummary( + IReadOnlyList packages) + { + var result = new List>(packages.Count); + for (var i = 0; i < packages.Count; i++) + { + var p = packages[i]; + var row = new Dictionary(capacity: 6, StringComparer.Ordinal) + { + ["packageId"] = p.PackageId, + ["displayName"] = p.DisplayName, + ["scope"] = p.Scope, + }; + if (p.Version != null) + { + row["version"] = p.Version; + } + if (p.CompletedUtc != null) + { + row["completedUtc"] = FormatUtc(p.CompletedUtc.Value); + row["durationSeconds"] = (int)Math.Max(0, (p.CompletedUtc.Value - p.StartedUtc).TotalSeconds); + } + if (p.Success != null) + { + row["success"] = p.Success.Value; + } + if (p.LastExitCode != null) + { + row["lastExitCode"] = p.LastExitCode.Value; + } + result.Add(row); + } + return result; + } + /// /// Shorthand to wrap a classifier-verdict signal payload (the parameter dict carried by /// ) into a typed diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index 18756ab6..ce6b1a72 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -228,7 +228,12 @@ namespace AutopilotMonitor.DecisionCore.Engine // non-terminal Finalizing stage with a short grace period before Completed. // This lets the backend see phase_transition(FinalizingSetup) + enrollment_complete // before IsTerminal() fires and EnrollmentTerminationHandler tears down the spool. - if (desktopAlreadyArrived) + // + // RealmJoin gate: while RJ is detected and unresolved, the Finalizing transition + // is deferred. Hello+Desktop facts are still recorded; the next RealmJoinResolved + // or RealmJoinTimeout signal releases the deferred completion via + // CompleteIfDeferredOrBookkeep(). + if (desktopAlreadyArrived && RealmJoinGateOpen(state)) { return TransitionToFinalizing( state: state, @@ -241,6 +246,23 @@ namespace AutopilotMonitor.DecisionCore.Engine : null); } + if (desktopAlreadyArrived) + { + // RJ gate closed: defer Finalizing. Stay in the current stage; the RJ + // resolved/timeout handler will route through CompleteIfDeferredOrBookkeep(). + var deferredState = builder.Build(); + var deferredTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.HelloResolved) + ":RealmJoinGateClosed"); + var deferredEffects = helloSafetyCancelEffect != null + ? new[] { helloSafetyCancelEffect } + : Array.Empty(); + return new DecisionStep(deferredState, deferredTransition, deferredEffects); + } + builder.WithStage(SessionStage.AwaitingDesktop); var newState = builder.Build(); @@ -340,13 +362,30 @@ namespace AutopilotMonitor.DecisionCore.Engine }; } - return TransitionToFinalizing( - state: state, + if (RealmJoinGateOpen(state)) + { + return TransitionToFinalizing( + state: state, + signal: signal, + preparedBuilder: builder, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath", + extraLeadingEffects: extraEffects); + } + + // RJ gate closed: synthetic Hello + Desktop are recorded; defer Finalizing + // until the RJ resolved/timeout handler releases the gate. + var fastPathDeferredState = builder.Build(); + var fastPathDeferredTransition = BuildTakenTransition( + before: state, signal: signal, - preparedBuilder: builder, + toStage: state.Stage, nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath", - extraLeadingEffects: extraEffects); + trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath:RealmJoinGateClosed"); + var fastPathDeferredEffects = extraEffects != null + ? extraEffects.ToArray() + : Array.Empty(); + return new DecisionStep(fastPathDeferredState, fastPathDeferredTransition, fastPathDeferredEffects); } // Plan §5 Fix 6: mirror of HandleHelloResolvedV1. When both prerequisites are in, @@ -365,15 +404,32 @@ namespace AutopilotMonitor.DecisionCore.Engine { builder.CancelDeadline(DeadlineNames.HelloSafety); } - return TransitionToFinalizing( - state: state, + + if (RealmJoinGateOpen(state)) + { + return TransitionToFinalizing( + state: state, + signal: signal, + preparedBuilder: builder, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.DesktopArrived), + extraLeadingEffects: helloSafetyCancelEffect != null + ? new[] { helloSafetyCancelEffect } + : null); + } + + // RJ gate closed: Desktop fact is recorded; defer Finalizing. + var deferredState = builder.Build(); + var deferredTransition = BuildTakenTransition( + before: state, signal: signal, - preparedBuilder: builder, + toStage: state.Stage, nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.DesktopArrived), - extraLeadingEffects: helloSafetyCancelEffect != null - ? new[] { helloSafetyCancelEffect } - : null); + trigger: nameof(DecisionSignalKind.DesktopArrived) + ":RealmJoinGateClosed"); + var deferredEffects = helloSafetyCancelEffect != null + ? new[] { helloSafetyCancelEffect } + : Array.Empty(); + return new DecisionStep(deferredState, deferredTransition, deferredEffects); } // Desktop came first: keep current stage (AwaitingHello or EspAccountSetup) until diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs new file mode 100644 index 00000000..94b75e81 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs @@ -0,0 +1,441 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // RealmJoin (RJ) deployment-tracking handlers. Plan: tasks/zany-gathering-oasis plan. + // The agent posts five new DecisionSignalKinds when it observes the + // HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters key + HKLM\SOFTWARE\RealmJoin\Packages + // (and the HKU\\... user-scope counterpart). Detection arms a 60-min hard timeout + // and gates the enrollment-completion AND-gate. Resolution (phase 110) or timeout + // releases the gate so the session can complete. + public sealed partial class DecisionEngine + { + // Hard 60-minute deadline from RJ-detected. Not configurable (per design choice — + // the trigger is intentionally aggressive, the timeout has to bound it). + private static readonly TimeSpan s_realmJoinHardTimeout = TimeSpan.FromMinutes(60); + + // Payload keys consumed by the new handlers (set by the agent's RealmJoinWatcherAdapter). + internal static class RealmJoinPayloadKeys + { + public const string DeploymentPhase = "deploymentPhase"; + public const string PackageId = "packageId"; + public const string DisplayName = "displayName"; + public const string Version = "version"; + public const string Scope = "scope"; // "machine" | "user" + public const string Success = "success"; // "true" | "false" + public const string LastExitCode = "lastExitCode"; + } + + /// + /// Returns true when the RJ gate is OPEN — either RJ was never detected or it + /// has already resolved / timed out (Outcome set). The Classic and SelfDeploying + /// completion paths AND this with their existing predicates so an active RJ + /// deployment blocks and the SelfDeploying + /// terminal transition. + /// + internal static bool RealmJoinGateOpen(DecisionState state) => + state.RealmJoinFacts.DetectedUtc == null + || state.RealmJoinFacts.ResolvedUtc != null + || state.RealmJoinFacts.Outcome != null; + + /// + /// Build the 60-min RJ hard-timeout deadline. Floored at + /// via so a replayed RealmJoinDetected signal cannot + /// collapse the timer into immediate-fire at boot. + /// + private static ActiveDeadline BuildRealmJoinTimeoutDeadline(DateTime fromUtc) => + new ActiveDeadline( + name: DeadlineNames.RealmJoinTimeout, + dueAtUtc: fromUtc.Add(s_realmJoinHardTimeout), + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary + { + [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout, + }); + + /// + /// First-observation handler for the RealmJoin Parameters registry key. Records + /// , captures the initial DeploymentPhase + /// observation (if present in the payload), and arms the 60-min hard timeout. + /// Idempotent — a second RealmJoinDetected signal in the same session is a bookkeeping + /// no-op (the set-once helpers on guard the writes). + /// + private DecisionStep HandleRealmJoinDetectedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + var alreadyDetected = state.RealmJoinFacts.DetectedUtc != null; + + var phase = TryReadPhase(signal); + var updatedFacts = state.RealmJoinFacts.WithDetected(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + if (phase.HasValue) + { + updatedFacts = updatedFacts.WithLastPhase(phase.Value, signal.SessionSignalOrdinal); + } + builder.RealmJoinFacts = updatedFacts; + + var effects = Array.Empty(); + if (!alreadyDetected) + { + var deadline = BuildRealmJoinTimeoutDeadline(EffectiveDeadlineBase(state, signal)); + builder.AddDeadline(deadline); + effects = new[] + { + new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: deadline), + }; + } + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.RealmJoinDetected)); + + return new DecisionStep(newState, transition, effects); + } + + /// + /// RealmJoin reached DeploymentPhase = CompletedFirstDeployment (110). Cancels + /// the hard-timeout deadline (live scheduler + reducer view) and, when the other + /// completion preconditions are already in, releases the deferred completion path: + /// Classic → ; SelfDeploying → direct + /// + enrollment_complete. + /// + private DecisionStep HandleRealmJoinResolvedV1(DecisionState state, DecisionSignal signal) + { + if (state.RealmJoinFacts.DetectedUtc == null) + { + // Defensive: Resolved without Detected. Record the resolution so the audit trail + // captures the pre-existing-110 case (RJ already done before agent boot), but + // skip the deadline-cancel since none was armed. + var preBuilder = state.ToBuilder() + .WithStepIndex(state.StepIndex + 1) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + preBuilder.RealmJoinFacts = state.RealmJoinFacts + .WithDetected(signal.OccurredAtUtc, signal.SessionSignalOrdinal) + .WithResolved(signal.OccurredAtUtc, 110, signal.SessionSignalOrdinal); + var preState = preBuilder.Build(); + var preTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: preState.StepIndex, + trigger: nameof(DecisionSignalKind.RealmJoinResolved) + ":WithoutDetected"); + return new DecisionStep(preState, preTransition, Array.Empty()); + } + + var nextStep = state.StepIndex + 1; + var phase = TryReadPhase(signal) ?? 110; + + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.RealmJoinTimeout); + builder.RealmJoinFacts = state.RealmJoinFacts.WithResolved(signal.OccurredAtUtc, phase, signal.SessionSignalOrdinal); + + var cancelEffect = BuildRealmJoinTimeoutCancelEffectIfArmed(state); + + return CompleteIfDeferredOrBookkeep( + state: state, + signal: signal, + preparedBuilder: builder, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.RealmJoinResolved), + leadingEffect: cancelEffect); + } + + /// + /// 60-min hard-timeout fired without RJ reaching phase 110. Records + /// = "Timeout", emits a + /// realmjoin_timeout timeline entry, and — when other completion preconditions + /// are in — releases the deferred completion path. + /// + private DecisionStep HandleRealmJoinTimeoutDeadlineFired(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .CancelDeadline(DeadlineNames.RealmJoinTimeout); + + builder.RealmJoinFacts = state.RealmJoinFacts.WithTimeoutOutcome(signal.SessionSignalOrdinal); + + var timeoutEffect = BuildRealmJoinTimeoutEvent(state); + + return CompleteIfDeferredOrBookkeep( + state: state, + signal: signal, + preparedBuilder: builder, + nextStepIndex: nextStep, + trigger: $"DeadlineFired:{DeadlineNames.RealmJoinTimeout}", + leadingEffect: timeoutEffect); + } + + /// + /// Per-package install start. Observation-only: appends a row to + /// . Stage unchanged, no effects (the agent + /// adapter dual-emits an for the + /// UI timeline). + /// + private DecisionStep HandleRealmJoinPackageStartedV1(DecisionState state, DecisionSignal signal) + { + var packageId = TryReadString(signal, RealmJoinPayloadKeys.PackageId); + if (string.IsNullOrEmpty(packageId)) + { + var deadEnd = BumpStepBookkeeping(state, signal); + var deadEndTransition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: deadEnd.StepIndex, + trigger: nameof(DecisionSignalKind.RealmJoinPackageStarted), + deadEndReason: "realmjoin_package_started_missing_packageId"); + return new DecisionStep(deadEnd, deadEndTransition, Array.Empty()); + } + + var displayName = TryReadString(signal, RealmJoinPayloadKeys.DisplayName) ?? string.Empty; + var version = TryReadString(signal, RealmJoinPayloadKeys.Version); + var scope = TryReadString(signal, RealmJoinPayloadKeys.Scope) ?? RealmJoinPackageFact.ScopeMachine; + + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + builder.RealmJoinFacts = state.RealmJoinFacts.WithPackageStarted( + packageId: packageId!, + displayName: displayName, + version: version, + scope: scope, + startedUtc: signal.OccurredAtUtc); + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.RealmJoinPackageStarted)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + /// + /// Per-package install terminal outcome. Updates the matching row in + /// with success / lastExitCode / completedUtc. + /// Stage unchanged. + /// + private DecisionStep HandleRealmJoinPackageCompletedV1(DecisionState state, DecisionSignal signal) + { + var packageId = TryReadString(signal, RealmJoinPayloadKeys.PackageId); + if (string.IsNullOrEmpty(packageId)) + { + var deadEnd = BumpStepBookkeeping(state, signal); + var deadEndTransition = BuildDeadEndTransition( + state: state, + signal: signal, + nextStepIndex: deadEnd.StepIndex, + trigger: nameof(DecisionSignalKind.RealmJoinPackageCompleted), + deadEndReason: "realmjoin_package_completed_missing_packageId"); + return new DecisionStep(deadEnd, deadEndTransition, Array.Empty()); + } + + var displayName = TryReadString(signal, RealmJoinPayloadKeys.DisplayName) ?? string.Empty; + var version = TryReadString(signal, RealmJoinPayloadKeys.Version); + var scope = TryReadString(signal, RealmJoinPayloadKeys.Scope) ?? RealmJoinPackageFact.ScopeMachine; + var success = TryReadBool(signal, RealmJoinPayloadKeys.Success) ?? false; + var lastExitCode = TryReadInt(signal, RealmJoinPayloadKeys.LastExitCode) ?? 0; + + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + builder.RealmJoinFacts = state.RealmJoinFacts.WithPackageCompleted( + packageId: packageId!, + displayName: displayName, + version: version, + scope: scope, + completedUtc: signal.OccurredAtUtc, + success: success, + lastExitCode: lastExitCode); + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.RealmJoinPackageCompleted)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + // ============================================================== internal helpers + + /// + /// Shared completion-release helper used by and + /// . Routes to one of three outcomes + /// depending on which other completion preconditions are present: + /// + /// SelfDeploying deferred (): + /// direct + enrollment_complete. + /// Classic both-resolved (Hello + Desktop both in): + /// . + /// Neither: bookkeeping only — the next Hello / Desktop / SelfDeploying + /// signal will trigger completion through the standard AND-gate (which now reads + /// the gate as open since or + /// is set). + /// + /// + private DecisionStep CompleteIfDeferredOrBookkeep( + DecisionState state, + DecisionSignal signal, + DecisionStateBuilder preparedBuilder, + int nextStepIndex, + string trigger, + DecisionEffect? leadingEffect) + { + var selfDeployingDeferred = state.RealmJoinFacts.SelfDeployingDeferredCompletion?.Value == true; + var classicReady = state.HelloResolvedUtc != null && state.DesktopArrivedUtc != null; + + if (selfDeployingDeferred) + { + preparedBuilder + .WithStage(SessionStage.Completed) + .WithOutcome(SessionOutcome.EnrollmentComplete) + .ClearDeadlines(); + var completedState = preparedBuilder.Build(); + var completedTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.Completed, + nextStepIndex: nextStepIndex, + trigger: trigger + ":SelfDeployingDeferred"); + + var effects = new List(capacity: 2); + if (leadingEffect != null) effects.Add(leadingEffect); + effects.Add(BuildEnrollmentCompleteEffect(completedState, trigger + ":SelfDeployingDeferred")); + + return new DecisionStep(completedState, completedTransition, effects.ToArray()); + } + + if (classicReady) + { + var extra = leadingEffect != null ? new[] { leadingEffect } : null; + return TransitionToFinalizing( + state: state, + signal: signal, + preparedBuilder: preparedBuilder, + nextStepIndex: nextStepIndex, + trigger: trigger, + extraLeadingEffects: extra); + } + + // Bookkeeping only — defer to the next Hello / Desktop / SelfDeploying signal. + var newState = preparedBuilder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStepIndex, + trigger: trigger); + + var bookkeepEffects = leadingEffect != null + ? new[] { leadingEffect } + : Array.Empty(); + return new DecisionStep(newState, transition, bookkeepEffects); + } + + /// + /// Emit a scheduler-visible for + /// when it is actually armed in + /// . Same pattern as BuildHelloSafetyCancelEffectIfArmed: + /// avoids spurious scheduler noise on the cancel path when the timer was never armed. + /// + private static DecisionEffect? BuildRealmJoinTimeoutCancelEffectIfArmed(DecisionState state) + { + foreach (var d in state.Deadlines) + { + if (d.Name == DeadlineNames.RealmJoinTimeout) + { + return new DecisionEffect( + DecisionEffectKind.CancelDeadline, + cancelDeadlineName: DeadlineNames.RealmJoinTimeout); + } + } + return null; + } + + /// + /// Build the realmjoin_timeout timeline-entry effect emitted by the deadline- + /// fired handler. Unlike / + /// (which the agent dual-emits as + /// InformationalEvent), the timeout is a synthetic deadline so the reducer owns its + /// timeline visibility. + /// + private static DecisionEffect BuildRealmJoinTimeoutEvent(DecisionState state) + { + var facts = state.RealmJoinFacts; + var lastPhase = facts.LastDeploymentPhase?.Value ?? 0; + var tracked = facts.Packages.Count; + var completed = 0; + for (var i = 0; i < facts.Packages.Count; i++) + { + if (facts.Packages[i].CompletedUtc != null) completed++; + } + + return new DecisionEffect( + kind: DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary(StringComparer.Ordinal) + { + [SignalPayloadKeys.EventType] = "realmjoin_timeout", + [SignalPayloadKeys.Source] = "DecisionEngine", + [SignalPayloadKeys.Severity] = "Warning", + [SignalPayloadKeys.Message] = $"RealmJoin did not reach phase 110 within 60 min (last phase: {lastPhase}).", + ["lastSeenPhase"] = lastPhase.ToString(CultureInfo.InvariantCulture), + ["packagesTracked"] = tracked.ToString(CultureInfo.InvariantCulture), + ["packagesCompleted"] = completed.ToString(CultureInfo.InvariantCulture), + }); + } + + // ---- payload helpers ------------------------------------------------------------- + + private static int? TryReadPhase(DecisionSignal signal) + { + if (signal.Payload == null) return null; + if (!signal.Payload.TryGetValue(RealmJoinPayloadKeys.DeploymentPhase, out var raw)) return null; + if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var phase)) return phase; + return null; + } + + private static string? TryReadString(DecisionSignal signal, string key) + { + if (signal.Payload == null) return null; + return signal.Payload.TryGetValue(key, out var v) ? v : null; + } + + private static bool? TryReadBool(DecisionSignal signal, string key) + { + if (signal.Payload == null) return null; + if (!signal.Payload.TryGetValue(key, out var raw)) return null; + return bool.TryParse(raw, out var b) ? (bool?)b : null; + } + + private static int? TryReadInt(DecisionSignal signal, string key) + { + if (signal.Payload == null) return null; + if (!signal.Payload.TryGetValue(key, out var raw)) return null; + return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? (int?)v : null; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs index 774ce25c..b590bbfa 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs @@ -83,6 +83,31 @@ namespace AutopilotMonitor.DecisionCore.Engine score: 100, lastUpdatedUtc: signal.OccurredAtUtc); + // RealmJoin gate: when RJ is detected and unresolved, defer the SelfDeploying + // terminal transition. Record the classifier verdict + the deferred-completion + // marker on RealmJoinFacts so HandleRealmJoinResolvedV1 / HandleRealmJoinTimeoutDeadlineFired + // can short-circuit straight to Completed without re-routing through Classic + // Finalizing. + if (!RealmJoinGateOpen(state)) + { + var deferredBuilder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + deferredBuilder.ClassifierOutcomes = state.ClassifierOutcomes.WithDeviceOnlyDeployment(updatedDeviceOnly); + deferredBuilder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyDeviceSetupProvisioningComplete( + deferredBuilder.ScenarioProfile, signal); + deferredBuilder.RealmJoinFacts = state.RealmJoinFacts.WithSelfDeployingDeferred(signal.SessionSignalOrdinal); + + var deferredState = deferredBuilder.Build(); + var deferredTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete) + ":RealmJoinGateClosed"); + return new DecisionStep(deferredState, deferredTransition, Array.Empty()); + } + var builder = state.ToBuilder() .WithStage(SessionStage.Completed) .WithOutcome(SessionOutcome.EnrollmentComplete) diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index 8fa814de..7a29040f 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -344,6 +344,8 @@ namespace AutopilotMonitor.DecisionCore.Engine return HandleClassifierTickDeadlineFired(state, signal); case DeadlineNames.FinalizingGrace: return HandleFinalizingGraceDeadlineFired(state, signal); + case DeadlineNames.RealmJoinTimeout: + return HandleRealmJoinTimeoutDeadlineFired(state, signal); default: // Deadline name not recognized in this sub-milestone. Cancel it from state // and record a neutral taken transition — M3.3+ adds handlers for @@ -392,7 +394,11 @@ namespace AutopilotMonitor.DecisionCore.Engine // Plan §5 Fix 6: route the both-prerequisites case through Finalizing so the // synthetic-timeout terminal event shares the same grace window + phase-declaration // pathway as the happy-path handlers. AwaitingDesktop path is unchanged. - if (desktopAlreadyArrived) + // + // RealmJoin gate: while RJ is detected and unresolved, defer Finalizing. The + // synthetic Hello-timeout fact stays recorded; the RJ resolved/timeout handler + // routes through CompleteIfDeferredOrBookkeep when both gates are ready. + if (desktopAlreadyArrived && RealmJoinGateOpen(state)) { return TransitionToFinalizing( state: state, @@ -402,6 +408,18 @@ namespace AutopilotMonitor.DecisionCore.Engine trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}"); } + if (desktopAlreadyArrived) + { + var deferredState = builder.Build(); + var deferredTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}:RealmJoinGateClosed"); + return new DecisionStep(deferredState, deferredTransition, Array.Empty()); + } + builder.WithStage(SessionStage.AwaitingDesktop); var newState = builder.Build(); diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index bfd4b000..3a35f828 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -120,6 +120,14 @@ namespace AutopilotMonitor.DecisionCore.Engine (DecisionSignalKind.AppInstallCompleted, 1) => HandleAppInstallCompletedV1(state, signal), (DecisionSignalKind.AppInstallFailed, 1) => HandleAppInstallFailedV1(state, signal), + // ----- RealmJoin deployment tracking (DecisionEngine.RealmJoin.cs) ----- + (DecisionSignalKind.RealmJoinDetected, 1) => HandleRealmJoinDetectedV1(state, signal), + (DecisionSignalKind.RealmJoinResolved, 1) => HandleRealmJoinResolvedV1(state, signal), + (DecisionSignalKind.RealmJoinPackageStarted, 1) => HandleRealmJoinPackageStartedV1(state, signal), + (DecisionSignalKind.RealmJoinPackageCompleted, 1) => HandleRealmJoinPackageCompletedV1(state, signal), + // Note: RealmJoinTimeout is a deadline-only kind (no direct adapter signal) — + // dispatched via DeadlineFiredV1 switch in DecisionEngine.Shared.cs. + // ----- Fall-through: unknown (kind, schemaVersion) pair → dead-end journal entry ----- _ => HandleUnhandledSignal(state, signal), }; diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs index 8e9e0f74..68c4169d 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs @@ -94,5 +94,16 @@ namespace AutopilotMonitor.DecisionCore.Signals // and add a state-mutating reducer case. Emission shape and UI contract stay the // same because the effect parameters carry the same EnrollmentEvent fields. InformationalEvent, + + // --- RealmJoin (RJ) deployment tracking --- + // RealmJoin is a third-party deployment agent that installs additional software + // after Autopilot reaches the desktop. When detected, the V2 engine keeps the + // session non-terminal until DeploymentPhase reaches CompletedFirstDeployment (110) + // or the 60-min hard timeout fires. + RealmJoinDetected, + RealmJoinResolved, + RealmJoinTimeout, + RealmJoinPackageStarted, + RealmJoinPackageCompleted, } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs index 700fc0d7..7a74507f 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs @@ -30,7 +30,7 @@ namespace AutopilotMonitor.DecisionCore.State /// public sealed class DecisionState { - public const string CurrentSchemaVersion = "v2"; + public const string CurrentSchemaVersion = "v3"; public DecisionState( string sessionId, @@ -58,6 +58,7 @@ namespace AutopilotMonitor.DecisionCore.State SignalFact? helloPolicyEnabled = null, DateTime? agentBootUtc = null, SignalFact? lastFailureTrigger = null, + RealmJoinFacts? realmJoinFacts = null, string? schemaVersion = null) { if (string.IsNullOrEmpty(sessionId)) @@ -95,6 +96,7 @@ namespace AutopilotMonitor.DecisionCore.State HelloPolicyEnabled = helloPolicyEnabled; AgentBootUtc = agentBootUtc; LastFailureTrigger = lastFailureTrigger; + RealmJoinFacts = realmJoinFacts ?? RealmJoinFacts.Empty; SchemaVersion = schemaVersion ?? CurrentSchemaVersion; } @@ -219,6 +221,16 @@ namespace AutopilotMonitor.DecisionCore.State /// public SignalFact? LastFailureTrigger { get; } + /// + /// Aggregated facts about a RealmJoin (RJ) deployment observed during this session. + /// Never null; defaults to . The V2 AND-gate + /// (Classic and SelfDeploying) extends the session lifetime while + /// is set and + /// is not — driven by the new HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters + /// + HKLM\SOFTWARE\RealmJoin\Packages\* registry collectors. + /// + public RealmJoinFacts RealmJoinFacts { get; } + public string SchemaVersion { get; } /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs index 53a242c5..eb089f6a 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs @@ -44,6 +44,7 @@ namespace AutopilotMonitor.DecisionCore.State HelloPolicyEnabled = source.HelloPolicyEnabled; AgentBootUtc = source.AgentBootUtc; LastFailureTrigger = source.LastFailureTrigger; + RealmJoinFacts = source.RealmJoinFacts; SchemaVersion = source.SchemaVersion; } @@ -72,6 +73,7 @@ namespace AutopilotMonitor.DecisionCore.State public SignalFact? HelloPolicyEnabled { get; set; } public DateTime? AgentBootUtc { get; set; } public SignalFact? LastFailureTrigger { get; set; } + public RealmJoinFacts RealmJoinFacts { get; set; } = RealmJoinFacts.Empty; public string SchemaVersion { get; set; } // ---------- fluent helpers for the most common reducer operations ---------- @@ -121,6 +123,12 @@ namespace AutopilotMonitor.DecisionCore.State return this; } + public DecisionStateBuilder WithRealmJoinFacts(RealmJoinFacts facts) + { + RealmJoinFacts = facts ?? throw new ArgumentNullException(nameof(facts)); + return this; + } + public DecisionStateBuilder WithScenarioProfile(EnrollmentScenarioProfile profile) { ScenarioProfile = profile ?? throw new ArgumentNullException(nameof(profile)); @@ -200,6 +208,7 @@ namespace AutopilotMonitor.DecisionCore.State helloPolicyEnabled: HelloPolicyEnabled, agentBootUtc: AgentBootUtc, lastFailureTrigger: LastFailureTrigger, + realmJoinFacts: RealmJoinFacts, schemaVersion: SchemaVersion); } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs new file mode 100644 index 00000000..1d9cc768 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinFacts.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Aggregated facts about a RealmJoin (RJ) deployment observed during a session. + /// Drives the V2 completion-gate extension: when RJ is detected the AND-gate keeps + /// the session non-terminal until either + /// is observed or the 60-min hard timeout elapses. + /// + /// + /// Invariants: + /// + /// Immutable; -methods return new instances. + /// is capped at ; overflow is discarded. + /// is set once on observing phase 110; later phase readings update only. + /// is one of "Resolved" / "Timeout"; null while RJ is still running. + /// + /// + public sealed class RealmJoinFacts + { + /// Cap on so pathological deployments cannot bloat state. + public const int MaxPackages = 200; + + public const string OutcomeResolved = "Resolved"; + public const string OutcomeTimeout = "Timeout"; + + public static readonly RealmJoinFacts Empty = new RealmJoinFacts( + detectedUtc: null, + resolvedUtc: null, + lastDeploymentPhase: null, + outcome: null, + selfDeployingDeferredCompletion: null, + packages: Array.Empty()); + + public RealmJoinFacts( + SignalFact? detectedUtc, + SignalFact? resolvedUtc, + SignalFact? lastDeploymentPhase, + SignalFact? outcome, + SignalFact? selfDeployingDeferredCompletion, + IReadOnlyList packages) + { + DetectedUtc = detectedUtc; + ResolvedUtc = resolvedUtc; + LastDeploymentPhase = lastDeploymentPhase; + Outcome = outcome; + SelfDeployingDeferredCompletion = selfDeployingDeferredCompletion; + Packages = packages ?? Array.Empty(); + } + + /// First-observation timestamp of the HKLM\SYSTEM\...\realmjoin\Parameters key. + public SignalFact? DetectedUtc { get; } + + /// Set once when first reaches 110. + public SignalFact? ResolvedUtc { get; } + + /// Most recent DeploymentPhase DWORD observed in the Parameters key. + public SignalFact? LastDeploymentPhase { get; } + + /// Terminal outcome string: or . + public SignalFact? Outcome { get; } + + /// + /// Indicates the SelfDeploying terminal path () + /// was observed but blocked by an active RealmJoin gate. The RealmJoin + /// resolved/timeout handlers use this flag to complete the SelfDeploying path + /// directly without re-entering the Classic Finalizing pipeline. + /// + public SignalFact? SelfDeployingDeferredCompletion { get; } + + /// Tracked per-package install rows (machine + user scope combined). + public IReadOnlyList Packages { get; } + + public RealmJoinFacts WithDetected(DateTime utc, long sourceSignalOrdinal) + { + if (DetectedUtc != null) return this; // set-once + return new RealmJoinFacts( + detectedUtc: new SignalFact(utc, sourceSignalOrdinal), + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: LastDeploymentPhase, + outcome: Outcome, + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + packages: Packages); + } + + public RealmJoinFacts WithResolved(DateTime utc, int phase, long sourceSignalOrdinal) + { + if (ResolvedUtc != null) return this; // set-once + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: new SignalFact(utc, sourceSignalOrdinal), + lastDeploymentPhase: new SignalFact(phase, sourceSignalOrdinal), + outcome: new SignalFact(OutcomeResolved, sourceSignalOrdinal), + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + packages: Packages); + } + + public RealmJoinFacts WithLastPhase(int phase, long sourceSignalOrdinal) + { + if (LastDeploymentPhase != null && LastDeploymentPhase.Value == phase) return this; + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: new SignalFact(phase, sourceSignalOrdinal), + outcome: Outcome, + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + packages: Packages); + } + + public RealmJoinFacts WithTimeoutOutcome(long sourceSignalOrdinal) + { + if (Outcome != null) return this; // resolved already wins + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: LastDeploymentPhase, + outcome: new SignalFact(OutcomeTimeout, sourceSignalOrdinal), + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + packages: Packages); + } + + public RealmJoinFacts WithSelfDeployingDeferred(long sourceSignalOrdinal) + { + if (SelfDeployingDeferredCompletion != null) return this; + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: LastDeploymentPhase, + outcome: Outcome, + selfDeployingDeferredCompletion: new SignalFact(true, sourceSignalOrdinal), + packages: Packages); + } + + /// + /// Append a new package row when the package is first observed (DisplayName seen, + /// no Success/LastExitCode yet). Idempotent on (, + /// ) — a second start for the same pair is ignored. + /// + public RealmJoinFacts WithPackageStarted( + string packageId, + string displayName, + string? version, + string scope, + DateTime startedUtc) + { + if (string.IsNullOrEmpty(packageId) || string.IsNullOrEmpty(scope)) return this; + + for (var i = 0; i < Packages.Count; i++) + { + var p = Packages[i]; + if (string.Equals(p.PackageId, packageId, StringComparison.Ordinal) && + string.Equals(p.Scope, scope, StringComparison.Ordinal)) + { + return this; // already tracked + } + } + + if (Packages.Count >= MaxPackages) return this; + + var truncated = displayName ?? string.Empty; + if (truncated.Length > RealmJoinPackageFact.MaxDisplayNameLength) + { + truncated = truncated.Substring(0, RealmJoinPackageFact.MaxDisplayNameLength); + } + + var copy = new List(Packages.Count + 1); + copy.AddRange(Packages); + copy.Add(new RealmJoinPackageFact( + packageId: packageId, + displayName: truncated, + version: version, + scope: scope, + startedUtc: startedUtc, + completedUtc: null, + success: null, + lastExitCode: null)); + + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: LastDeploymentPhase, + outcome: Outcome, + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + packages: copy); + } + + /// + /// Update an existing package row with its terminal outcome. If the package wasn't + /// previously seen (rare race), a new row is created with Started = Completed and + /// only the terminal fields populated. + /// + public RealmJoinFacts WithPackageCompleted( + string packageId, + string displayName, + string? version, + string scope, + DateTime completedUtc, + bool success, + int lastExitCode) + { + if (string.IsNullOrEmpty(packageId) || string.IsNullOrEmpty(scope)) return this; + + var truncated = displayName ?? string.Empty; + if (truncated.Length > RealmJoinPackageFact.MaxDisplayNameLength) + { + truncated = truncated.Substring(0, RealmJoinPackageFact.MaxDisplayNameLength); + } + + var copy = new List(Packages.Count); + var updated = false; + for (var i = 0; i < Packages.Count; i++) + { + var p = Packages[i]; + if (!updated && + string.Equals(p.PackageId, packageId, StringComparison.Ordinal) && + string.Equals(p.Scope, scope, StringComparison.Ordinal)) + { + if (p.CompletedUtc != null) + { + return this; // already completed; ignore duplicate + } + copy.Add(p.WithCompletion(completedUtc, success, lastExitCode)); + updated = true; + } + else + { + copy.Add(p); + } + } + + if (!updated) + { + if (Packages.Count >= MaxPackages) return this; + copy.Add(new RealmJoinPackageFact( + packageId: packageId, + displayName: truncated, + version: version, + scope: scope, + startedUtc: completedUtc, + completedUtc: completedUtc, + success: success, + lastExitCode: lastExitCode)); + } + + return new RealmJoinFacts( + detectedUtc: DetectedUtc, + resolvedUtc: ResolvedUtc, + lastDeploymentPhase: LastDeploymentPhase, + outcome: Outcome, + selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion, + packages: copy); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinPackageFact.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinPackageFact.cs new file mode 100644 index 00000000..597df334 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/RealmJoinPackageFact.cs @@ -0,0 +1,77 @@ +using System; + +namespace AutopilotMonitor.DecisionCore.State +{ + /// + /// Snapshot of a single RealmJoin package install observed via the + /// HKLM\SOFTWARE\RealmJoin\Packages\<packageId> (machine scope) or + /// HKEY_USERS\<sid>\SOFTWARE\RealmJoin\Packages\<packageId> (user scope) + /// registry hierarchy. Held inside . + /// + /// + /// is truncated to characters + /// by the adapter before being recorded. / + /// / are null while the package is still installing. + /// + public sealed class RealmJoinPackageFact + { + public const int MaxDisplayNameLength = 256; + + /// Machine-scope registration (HKLM\SOFTWARE\RealmJoin\Packages). + public const string ScopeMachine = "machine"; + + /// User-scope registration (HKU\<sid>\SOFTWARE\RealmJoin\Packages). + public const string ScopeUser = "user"; + + public RealmJoinPackageFact( + string packageId, + string displayName, + string? version, + string scope, + DateTime startedUtc, + DateTime? completedUtc, + bool? success, + int? lastExitCode) + { + if (string.IsNullOrEmpty(packageId)) throw new ArgumentException("PackageId is mandatory.", nameof(packageId)); + if (string.IsNullOrEmpty(scope)) throw new ArgumentException("Scope is mandatory.", nameof(scope)); + + PackageId = packageId; + DisplayName = displayName ?? string.Empty; + Version = version; + Scope = scope; + StartedUtc = startedUtc; + CompletedUtc = completedUtc; + Success = success; + LastExitCode = lastExitCode; + } + + public string PackageId { get; } + + public string DisplayName { get; } + + public string? Version { get; } + + /// Either or . + public string Scope { get; } + + public DateTime StartedUtc { get; } + + public DateTime? CompletedUtc { get; } + + public bool? Success { get; } + + public int? LastExitCode { get; } + + public RealmJoinPackageFact WithCompletion(DateTime completedUtc, bool success, int lastExitCode) => + new RealmJoinPackageFact( + packageId: PackageId, + displayName: DisplayName, + version: Version, + scope: Scope, + startedUtc: StartedUtc, + completedUtc: completedUtc, + success: success, + lastExitCode: lastExitCode); + } +}