mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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\<id> + the HKEY_USERS\<sid>\... 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) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// <c>EnrollmentTerminationHandler</c> drains the spool. Plan §5 Fix 6.
|
||||
/// </summary>
|
||||
public const string FinalizingGrace = "finalizing_grace";
|
||||
|
||||
/// <summary>
|
||||
/// Hard 60-min timeout from RealmJoin-detected. When fired without phase 110 being
|
||||
/// observed, the gate closes anyway with <c>realmjoinOutcome=Timeout</c> so the
|
||||
/// session can complete (Classic Finalizing or SelfDeploying-direct path).
|
||||
/// </summary>
|
||||
public const string RealmJoinTimeout = "realmjoin_timeout";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a compact per-package summary list for the <c>realmjoinPackages</c> audit-trail
|
||||
/// field. Each entry carries the identifiers + completion state but omits the per-package
|
||||
/// timestamps to keep the payload size predictable.
|
||||
/// </summary>
|
||||
private static List<Dictionary<string, object>> BuildRealmJoinPackageSummary(
|
||||
IReadOnlyList<State.RealmJoinPackageFact> packages)
|
||||
{
|
||||
var result = new List<Dictionary<string, object>>(packages.Count);
|
||||
for (var i = 0; i < packages.Count; i++)
|
||||
{
|
||||
var p = packages[i];
|
||||
var row = new Dictionary<string, object>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shorthand to wrap a classifier-verdict signal payload (the parameter dict carried by
|
||||
/// <see cref="Signals.DecisionSignalKind.ClassifierVerdictIssued"/>) into a typed
|
||||
|
||||
@@ -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<DecisionEffect>();
|
||||
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<DecisionEffect>();
|
||||
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<DecisionEffect>();
|
||||
return new DecisionStep(deferredState, deferredTransition, deferredEffects);
|
||||
}
|
||||
|
||||
// Desktop came first: keep current stage (AwaitingHello or EspAccountSetup) until
|
||||
|
||||
@@ -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\<sid>\... 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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> 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 <see cref="TransitionToFinalizing"/> and the SelfDeploying
|
||||
/// terminal transition.
|
||||
/// </summary>
|
||||
internal static bool RealmJoinGateOpen(DecisionState state) =>
|
||||
state.RealmJoinFacts.DetectedUtc == null
|
||||
|| state.RealmJoinFacts.ResolvedUtc != null
|
||||
|| state.RealmJoinFacts.Outcome != null;
|
||||
|
||||
/// <summary>
|
||||
/// Build the 60-min RJ hard-timeout deadline. Floored at <see cref="DecisionState.AgentBootUtc"/>
|
||||
/// via <see cref="EffectiveDeadlineBase"/> so a replayed RealmJoinDetected signal cannot
|
||||
/// collapse the timer into immediate-fire at boot.
|
||||
/// </summary>
|
||||
private static ActiveDeadline BuildRealmJoinTimeoutDeadline(DateTime fromUtc) =>
|
||||
new ActiveDeadline(
|
||||
name: DeadlineNames.RealmJoinTimeout,
|
||||
dueAtUtc: fromUtc.Add(s_realmJoinHardTimeout),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// First-observation handler for the RealmJoin Parameters registry key. Records
|
||||
/// <see cref="RealmJoinFacts.DetectedUtc"/>, 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 <see cref="RealmJoinFacts"/> guard the writes).
|
||||
/// </summary>
|
||||
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<DecisionEffect>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RealmJoin reached <c>DeploymentPhase = CompletedFirstDeployment (110)</c>. Cancels
|
||||
/// the hard-timeout deadline (live scheduler + reducer view) and, when the other
|
||||
/// completion preconditions are already in, releases the deferred completion path:
|
||||
/// Classic → <see cref="TransitionToFinalizing"/>; SelfDeploying → direct
|
||||
/// <see cref="SessionStage.Completed"/> + <c>enrollment_complete</c>.
|
||||
/// </summary>
|
||||
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<DecisionEffect>());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 60-min hard-timeout fired without RJ reaching phase 110. Records
|
||||
/// <see cref="RealmJoinFacts.Outcome"/> = <c>"Timeout"</c>, emits a
|
||||
/// <c>realmjoin_timeout</c> timeline entry, and — when other completion preconditions
|
||||
/// are in — releases the deferred completion path.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-package install start. Observation-only: appends a row to
|
||||
/// <see cref="RealmJoinFacts.Packages"/>. Stage unchanged, no effects (the agent
|
||||
/// adapter dual-emits an <see cref="DecisionSignalKind.InformationalEvent"/> for the
|
||||
/// UI timeline).
|
||||
/// </summary>
|
||||
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<DecisionEffect>());
|
||||
}
|
||||
|
||||
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<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-package install terminal outcome. Updates the matching row in
|
||||
/// <see cref="RealmJoinFacts.Packages"/> with success / lastExitCode / completedUtc.
|
||||
/// Stage unchanged.
|
||||
/// </summary>
|
||||
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<DecisionEffect>());
|
||||
}
|
||||
|
||||
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<DecisionEffect>());
|
||||
}
|
||||
|
||||
// ============================================================== internal helpers
|
||||
|
||||
/// <summary>
|
||||
/// Shared completion-release helper used by <see cref="HandleRealmJoinResolvedV1"/> and
|
||||
/// <see cref="HandleRealmJoinTimeoutDeadlineFired"/>. Routes to one of three outcomes
|
||||
/// depending on which other completion preconditions are present:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>SelfDeploying deferred</b> (<see cref="RealmJoinFacts.SelfDeployingDeferredCompletion"/>):
|
||||
/// direct <see cref="SessionStage.Completed"/> + <c>enrollment_complete</c>.</item>
|
||||
/// <item><b>Classic both-resolved</b> (Hello + Desktop both in):
|
||||
/// <see cref="TransitionToFinalizing"/>.</item>
|
||||
/// <item><b>Neither</b>: bookkeeping only — the next Hello / Desktop / SelfDeploying
|
||||
/// signal will trigger completion through the standard AND-gate (which now reads
|
||||
/// the gate as open since <see cref="RealmJoinFacts.ResolvedUtc"/> or
|
||||
/// <see cref="RealmJoinFacts.Outcome"/> is set).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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<DecisionEffect>(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<DecisionEffect>();
|
||||
return new DecisionStep(newState, transition, bookkeepEffects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a scheduler-visible <see cref="DecisionEffectKind.CancelDeadline"/> for
|
||||
/// <see cref="DeadlineNames.RealmJoinTimeout"/> when it is actually armed in
|
||||
/// <paramref name="state"/>. Same pattern as <c>BuildHelloSafetyCancelEffectIfArmed</c>:
|
||||
/// avoids spurious scheduler noise on the cancel path when the timer was never armed.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the <c>realmjoin_timeout</c> timeline-entry effect emitted by the deadline-
|
||||
/// fired handler. Unlike <see cref="DecisionSignalKind.RealmJoinDetected"/> /
|
||||
/// <see cref="DecisionSignalKind.RealmJoinResolved"/> (which the agent dual-emits as
|
||||
/// InformationalEvent), the timeout is a synthetic deadline so the reducer owns its
|
||||
/// timeline visibility.
|
||||
/// </summary>
|
||||
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<string, string>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DecisionEffect>());
|
||||
}
|
||||
|
||||
var builder = state.ToBuilder()
|
||||
.WithStage(SessionStage.Completed)
|
||||
.WithOutcome(SessionOutcome.EnrollmentComplete)
|
||||
|
||||
@@ -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<DecisionEffect>());
|
||||
}
|
||||
|
||||
builder.WithStage(SessionStage.AwaitingDesktop);
|
||||
var newState = builder.Build();
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// </summary>
|
||||
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<bool>? helloPolicyEnabled = null,
|
||||
DateTime? agentBootUtc = null,
|
||||
SignalFact<string>? 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
|
||||
/// </summary>
|
||||
public SignalFact<string>? LastFailureTrigger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated facts about a RealmJoin (RJ) deployment observed during this session.
|
||||
/// Never null; defaults to <see cref="State.RealmJoinFacts.Empty"/>. The V2 AND-gate
|
||||
/// (Classic and SelfDeploying) extends the session lifetime while
|
||||
/// <see cref="State.RealmJoinFacts.DetectedUtc"/> is set and <see cref="State.RealmJoinFacts.ResolvedUtc"/>
|
||||
/// is not — driven by the new <c>HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters</c>
|
||||
/// + <c>HKLM\SOFTWARE\RealmJoin\Packages\*</c> registry collectors.
|
||||
/// </summary>
|
||||
public RealmJoinFacts RealmJoinFacts { get; }
|
||||
|
||||
public string SchemaVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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<bool>? HelloPolicyEnabled { get; set; }
|
||||
public DateTime? AgentBootUtc { get; set; }
|
||||
public SignalFact<string>? 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.State
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="DeploymentPhase.CompletedFirstDeployment"/>
|
||||
/// is observed or the 60-min hard timeout elapses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Invariants</b>:
|
||||
/// <list type="bullet">
|
||||
/// <item>Immutable; <see cref="With"/>-methods return new instances.</item>
|
||||
/// <item><see cref="Packages"/> is capped at <see cref="MaxPackages"/>; overflow is discarded.</item>
|
||||
/// <item><see cref="ResolvedUtc"/> is set once on observing phase 110; later phase readings update <see cref="LastDeploymentPhase"/> only.</item>
|
||||
/// <item><see cref="Outcome"/> is one of <c>"Resolved"</c> / <c>"Timeout"</c>; null while RJ is still running.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class RealmJoinFacts
|
||||
{
|
||||
/// <summary>Cap on <see cref="Packages"/> so pathological deployments cannot bloat state.</summary>
|
||||
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<RealmJoinPackageFact>());
|
||||
|
||||
public RealmJoinFacts(
|
||||
SignalFact<DateTime>? detectedUtc,
|
||||
SignalFact<DateTime>? resolvedUtc,
|
||||
SignalFact<int>? lastDeploymentPhase,
|
||||
SignalFact<string>? outcome,
|
||||
SignalFact<bool>? selfDeployingDeferredCompletion,
|
||||
IReadOnlyList<RealmJoinPackageFact> packages)
|
||||
{
|
||||
DetectedUtc = detectedUtc;
|
||||
ResolvedUtc = resolvedUtc;
|
||||
LastDeploymentPhase = lastDeploymentPhase;
|
||||
Outcome = outcome;
|
||||
SelfDeployingDeferredCompletion = selfDeployingDeferredCompletion;
|
||||
Packages = packages ?? Array.Empty<RealmJoinPackageFact>();
|
||||
}
|
||||
|
||||
/// <summary>First-observation timestamp of the <c>HKLM\SYSTEM\...\realmjoin\Parameters</c> key.</summary>
|
||||
public SignalFact<DateTime>? DetectedUtc { get; }
|
||||
|
||||
/// <summary>Set once when <see cref="LastDeploymentPhase"/> first reaches 110.</summary>
|
||||
public SignalFact<DateTime>? ResolvedUtc { get; }
|
||||
|
||||
/// <summary>Most recent <c>DeploymentPhase</c> DWORD observed in the Parameters key.</summary>
|
||||
public SignalFact<int>? LastDeploymentPhase { get; }
|
||||
|
||||
/// <summary>Terminal outcome string: <see cref="OutcomeResolved"/> or <see cref="OutcomeTimeout"/>.</summary>
|
||||
public SignalFact<string>? Outcome { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates the SelfDeploying terminal path (<see cref="Signals.DecisionSignalKind.DeviceSetupProvisioningComplete"/>)
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public SignalFact<bool>? SelfDeployingDeferredCompletion { get; }
|
||||
|
||||
/// <summary>Tracked per-package install rows (machine + user scope combined).</summary>
|
||||
public IReadOnlyList<RealmJoinPackageFact> Packages { get; }
|
||||
|
||||
public RealmJoinFacts WithDetected(DateTime utc, long sourceSignalOrdinal)
|
||||
{
|
||||
if (DetectedUtc != null) return this; // set-once
|
||||
return new RealmJoinFacts(
|
||||
detectedUtc: new SignalFact<DateTime>(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<DateTime>(utc, sourceSignalOrdinal),
|
||||
lastDeploymentPhase: new SignalFact<int>(phase, sourceSignalOrdinal),
|
||||
outcome: new SignalFact<string>(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<int>(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<string>(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<bool>(true, sourceSignalOrdinal),
|
||||
packages: Packages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append a new package row when the package is first observed (DisplayName seen,
|
||||
/// no Success/LastExitCode yet). Idempotent on (<paramref name="packageId"/>,
|
||||
/// <paramref name="scope"/>) — a second start for the same pair is ignored.
|
||||
/// </summary>
|
||||
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<RealmJoinPackageFact>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<RealmJoinPackageFact>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.State
|
||||
{
|
||||
/// <summary>
|
||||
/// Snapshot of a single RealmJoin package install observed via the
|
||||
/// <c>HKLM\SOFTWARE\RealmJoin\Packages\<packageId></c> (machine scope) or
|
||||
/// <c>HKEY_USERS\<sid>\SOFTWARE\RealmJoin\Packages\<packageId></c> (user scope)
|
||||
/// registry hierarchy. Held inside <see cref="RealmJoinFacts.Packages"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="DisplayName"/> is truncated to <see cref="MaxDisplayNameLength"/> characters
|
||||
/// by the adapter before being recorded. <see cref="CompletedUtc"/> / <see cref="Success"/>
|
||||
/// / <see cref="LastExitCode"/> are null while the package is still installing.
|
||||
/// </remarks>
|
||||
public sealed class RealmJoinPackageFact
|
||||
{
|
||||
public const int MaxDisplayNameLength = 256;
|
||||
|
||||
/// <summary>Machine-scope registration (<c>HKLM\SOFTWARE\RealmJoin\Packages</c>).</summary>
|
||||
public const string ScopeMachine = "machine";
|
||||
|
||||
/// <summary>User-scope registration (<c>HKU\<sid>\SOFTWARE\RealmJoin\Packages</c>).</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Either <see cref="ScopeMachine"/> or <see cref="ScopeUser"/>.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user