using System; using System.Collections.Generic; using System.Linq; namespace StellaOps.Scheduler.Models; /// /// Encapsulates allowed transitions and invariants. /// public static class RunStateMachine { private static readonly IReadOnlyDictionary Adjacency = new Dictionary { [RunState.Planning] = new[] { RunState.Planning, RunState.Queued, RunState.Cancelled }, [RunState.Queued] = new[] { RunState.Queued, RunState.Running, RunState.Cancelled }, [RunState.Running] = new[] { RunState.Running, RunState.Completed, RunState.Error, RunState.Cancelled }, [RunState.Completed] = new[] { RunState.Completed }, [RunState.Error] = new[] { RunState.Error }, [RunState.Cancelled] = new[] { RunState.Cancelled }, }; public static bool CanTransition(RunState from, RunState to) { if (!Adjacency.TryGetValue(from, out var allowed)) { return false; } return allowed.Contains(to); } public static bool IsTerminal(RunState state) => state is RunState.Completed or RunState.Error or RunState.Cancelled; /// /// Applies a state transition ensuring timestamps, stats, and error contracts stay consistent. /// public static Run EnsureTransition( Run run, RunState next, DateTimeOffset timestamp, Action? mutateStats = null, string? errorMessage = null) { ArgumentNullException.ThrowIfNull(run); var normalizedTimestamp = Validation.NormalizeTimestamp(timestamp); var current = run.State; if (!CanTransition(current, next)) { throw new InvalidOperationException($"Run state transition from '{current}' to '{next}' is not allowed."); } var statsBuilder = new RunStatsBuilder(run.Stats); mutateStats?.Invoke(statsBuilder); var newStats = statsBuilder.Build(); var startedAt = run.StartedAt; var finishedAt = run.FinishedAt; if (current != RunState.Running && next == RunState.Running && startedAt is null) { startedAt = normalizedTimestamp; } if (IsTerminal(next)) { finishedAt ??= normalizedTimestamp; } if (startedAt is { } start && start < run.CreatedAt) { throw new InvalidOperationException("Run started time cannot be earlier than created time."); } if (finishedAt is { } finish) { if (startedAt is { } startTime && finish < startTime) { throw new InvalidOperationException("Run finished time cannot be earlier than start time."); } if (!IsTerminal(next)) { throw new InvalidOperationException("Finished time present but next state is not terminal."); } } string? nextError = null; if (next == RunState.Error) { var effectiveError = string.IsNullOrWhiteSpace(errorMessage) ? run.Error : errorMessage.Trim(); if (string.IsNullOrWhiteSpace(effectiveError)) { throw new InvalidOperationException("Transitioning to Error requires a non-empty error message."); } nextError = effectiveError; } else if (!string.IsNullOrWhiteSpace(errorMessage)) { throw new InvalidOperationException("Error message can only be provided when transitioning to Error state."); } var updated = run with { State = next, Stats = newStats, StartedAt = startedAt, FinishedAt = finishedAt, Error = nextError, }; Validate(updated); return updated; } public static void Validate(Run run) { ArgumentNullException.ThrowIfNull(run); if (run.StartedAt is { } started && started < run.CreatedAt) { throw new InvalidOperationException("Run.StartedAt cannot be earlier than CreatedAt."); } if (run.FinishedAt is { } finished) { if (run.StartedAt is { } startedAt && finished < startedAt) { throw new InvalidOperationException("Run.FinishedAt cannot be earlier than StartedAt."); } if (!IsTerminal(run.State)) { throw new InvalidOperationException("Run.FinishedAt set while state is not terminal."); } } else if (IsTerminal(run.State)) { throw new InvalidOperationException("Terminal run states must include FinishedAt."); } if (run.State == RunState.Error) { if (string.IsNullOrWhiteSpace(run.Error)) { throw new InvalidOperationException("Run.Error must be populated when state is Error."); } } else if (!string.IsNullOrWhiteSpace(run.Error)) { throw new InvalidOperationException("Run.Error must be null for non-error states."); } } }