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:
Oliver Kieselbach
2026-05-21 18:16:02 +02:00
parent e8e062d24a
commit 98b173a858
12 changed files with 990 additions and 15 deletions
@@ -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\&lt;packageId&gt;</c> (machine scope) or
/// <c>HKEY_USERS\&lt;sid&gt;\SOFTWARE\RealmJoin\Packages\&lt;packageId&gt;</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\&lt;sid&gt;\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);
}
}