using Appccelerate.StateMachine; using Appccelerate.StateMachine.Machine; using RobotNet10.Script; using RobotNet10.ScriptEngine.Shared; namespace RobotNet10.ScriptEngine.Models; /// /// Represents a mission instance with state machine management. /// public class ScriptMission : IDisposable { private readonly PassiveStateMachine _stateMachine; private readonly ScriptMissionModel _model; private readonly ScriptGlobals _globals; private readonly ILogger _logger; private readonly CancellationTokenSource _internalCts; private CancellationTokenSource? _executionCts; private Task? _executionTask; private bool _isPaused; private bool _isCanceling; private bool _disposed; private Exception? _lastError; private readonly Lock _lockObject = new(); private ScriptMissionState _currentState; private int _currentScore; /// /// Mission triggers for state machine transitions. /// public enum MissionTrigger { Start, Cancel, Pause, Resume, CompleteCanceling, CompletePausing, CompleteResuming, CompleteRunning, ErrorOccurred, } /// /// Gets the unique identifier of the mission instance. /// public Guid Id { get; } /// /// Gets the name of the mission. /// public string Name => _model.Name; /// /// Gets the total score for progress tracking. /// public int TotalScore => _model.TotalScore; /// /// Gets the current score. /// public int CurrentScore => _currentScore; /// /// Gets the current state of the mission. /// public ScriptMissionState State => _currentState; /// /// Gets the last error that occurred during mission execution. /// public Exception? LastError => _lastError; /// /// Gets the log message from mission execution. /// public string LogMessage => _logger.GetLog(); /// /// Gets the log message from ILogger. /// public string GetLog() { return _logger.GetLog(); } /// /// Gets whether the mission is currently executing. /// public bool IsExecuting => _executionTask != null && !_executionTask.IsCompleted; /// /// Initializes a new instance of the ScriptMission class. /// /// The unique identifier for this mission instance. /// The mission model containing mission metadata and runner. /// The script globals dictionary. public ScriptMission( Guid id, ScriptMissionModel model, ScriptGlobals globals) { Id = id; _model = model ?? throw new ArgumentNullException(nameof(model)); _globals = globals ?? throw new ArgumentNullException(nameof(globals)); _internalCts = new CancellationTokenSource(); // Get logger from globals.ScriptRobotNet if (_globals.RobotNet.TryGetValue("get_Logger", out object? getLogger) && getLogger is Func getLoggerFunc) { _logger = getLoggerFunc.Invoke(); } else { throw new InvalidOperationException($"Failed to get Logger from ScriptRobotNet globals for mission '{model.Name}'"); } var builder = new StateMachineDefinitionBuilder(); // Configure state machine transitions according to StateMachine_Design.md ConfigureStateMachine(builder); _stateMachine = builder .WithInitialState(ScriptMissionState.Idle) .Build() .CreatePassiveStateMachine(); _currentState = ScriptMissionState.Idle; _stateMachine.Start(); } private void ConfigureStateMachine(StateMachineDefinitionBuilder builder) { // Idle state builder.In(ScriptMissionState.Idle) .On(MissionTrigger.Start) .Goto(ScriptMissionState.Running); // Running state builder.In(ScriptMissionState.Running) .ExecuteOnEntry(() => { _currentState = ScriptMissionState.Running; OnEnterRunning(); }) .On(MissionTrigger.Cancel) .Goto(ScriptMissionState.Canceling) .On(MissionTrigger.Pause) .Goto(ScriptMissionState.Pausing) .On(MissionTrigger.CompleteRunning) .Goto(ScriptMissionState.Completed) .On(MissionTrigger.ErrorOccurred) .Goto(ScriptMissionState.Error) .Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); }); // Canceling state builder.In(ScriptMissionState.Canceling) .ExecuteOnEntry(() => { _currentState = ScriptMissionState.Canceling; OnEnterCanceling(); }) .On(MissionTrigger.CompleteCanceling) .Goto(ScriptMissionState.Canceled) .On(MissionTrigger.ErrorOccurred) .Goto(ScriptMissionState.Error) .Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); }); // Pausing state builder.In(ScriptMissionState.Pausing) .ExecuteOnEntry(() => { _currentState = ScriptMissionState.Pausing; OnEnterPausing(); }) .On(MissionTrigger.CompletePausing) .Goto(ScriptMissionState.Paused) .On(MissionTrigger.ErrorOccurred) .Goto(ScriptMissionState.Error) .Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); }); // Paused state builder.In(ScriptMissionState.Paused) .ExecuteOnEntry(() => { _currentState = ScriptMissionState.Paused; OnEnterPaused(); }) .On(MissionTrigger.Resume) .Goto(ScriptMissionState.Resuming) .On(MissionTrigger.Cancel) .Goto(ScriptMissionState.Canceling); // Resuming state builder.In(ScriptMissionState.Resuming) .ExecuteOnEntry(() => { _currentState = ScriptMissionState.Resuming; OnEnterResuming(); }) .On(MissionTrigger.CompleteResuming) .Goto(ScriptMissionState.Running) .On(MissionTrigger.ErrorOccurred) .Goto(ScriptMissionState.Error) .Execute(() => { _currentState = ScriptMissionState.Error; OnEnterError(); }); // Canceled state (terminal) builder.In(ScriptMissionState.Canceled) .ExecuteOnEntry(() => { _currentState = ScriptMissionState.Canceled; OnEnterCanceled(); }); // Completed state (terminal) builder.In(ScriptMissionState.Completed) .ExecuteOnEntry(() => { _currentState = ScriptMissionState.Completed; OnEnterCompleted(); }); // Error state (terminal) builder.In(ScriptMissionState.Error) .ExecuteOnEntry(() => { _currentState = ScriptMissionState.Error; OnEnterError(); }); } #region State Machine Event Handlers private void OnEnterRunning() { lock (_lockObject) { if (_executionTask == null || _executionTask.IsCompleted) { // Create new execution task _executionCts = CancellationTokenSource.CreateLinkedTokenSource(_internalCts.Token); _isPaused = false; _isCanceling = false; _currentScore = 0; // Update CancellationToken parameters in MissionParameters to link with mission's cancellation token // This allows script runner to receive cancellation when Cancel() is called foreach (var paramModel in _model.Parameters) { if (paramModel.Type == typeof(CancellationToken)) { // Link CancellationToken parameter with mission's internal cancellation token _globals.MissionParameters[paramModel.Name] = _executionCts.Token; } } // Use standard thread pool _executionTask = Task.Run(() => ExecuteMissionAsync(_executionCts.Token), _executionCts.Token); _logger.LogInfo($"Mission '{_model.Name}' started running."); } } } private void OnEnterCanceling() { lock (_lockObject) { _isCanceling = true; _executionCts?.Cancel(); _logger.LogInfo($"Mission '{_model.Name}' is canceling..."); } Task.Run(async () => { // Wait for execution to complete cancellation if (_executionTask != null) { try { await _executionTask; } catch (OperationCanceledException) { // Expected when canceling } catch (Exception ex) { _logger.LogError($"Mission '{_model.Name}' cancellation error: {ex.Message}"); } } _stateMachine.Fire(MissionTrigger.CompleteCanceling); }); } private void OnEnterPausing() { lock (_lockObject) { _isPaused = true; _logger.LogInfo($"Mission '{_model.Name}' is pausing..."); } Task.Run(async () => { // Wait for current step to complete (check in NextStepHandler) await Task.Delay(100); // Small delay to allow current step to check pause flag _stateMachine.Fire(MissionTrigger.CompletePausing); }); } private void OnEnterPaused() { _logger.LogInfo($"Mission '{_model.Name}' is paused."); } private void OnEnterResuming() { lock (_lockObject) { _isPaused = false; _logger.LogInfo($"Mission '{_model.Name}' is resuming..."); } Task.Run(async () => { // Small delay to ensure state transition await Task.Delay(50); _stateMachine.Fire(MissionTrigger.CompleteResuming); }); } private void OnEnterCanceled() { _logger.LogInfo($"Mission '{_model.Name}' was canceled. Final score: {_currentScore}/{TotalScore}"); } private void OnEnterCompleted() { _logger.LogInfo($"Mission '{_model.Name}' completed successfully. Final score: {_currentScore}/{TotalScore}"); } private void OnEnterError() { _logger.LogError($"Mission '{_model.Name}' entered error state. Last error: {_lastError?.Message}"); } #endregion #region Public Control Methods /// /// Starts the mission (transitions from Idle to Running). /// public void Start() { try { _stateMachine.Fire(MissionTrigger.Start); } catch (Exception ex) { _logger.LogError($"Failed to start mission '{_model.Name}': {ex.Message}"); throw; } } /// /// Cancels the mission (transitions from Running/Paused to Canceling → Canceled). /// public void Cancel(string reason) { try { _logger.LogWarning($"Cancellation requested with reason: {reason}"); _stateMachine.Fire(MissionTrigger.Cancel); } catch (Exception ex) { _logger.LogError($"Failed to cancel mission '{_model.Name}': {ex.Message}"); throw; } } /// /// Pauses the mission (transitions from Running to Pausing → Paused). /// public void Pause() { try { _stateMachine.Fire(MissionTrigger.Pause); } catch (Exception ex) { _logger.LogError($"Failed to pause mission '{_model.Name}': {ex.Message}"); throw; } } /// /// Resumes the mission (transitions from Paused to Resuming → Running). /// public void Resume() { try { _stateMachine.Fire(MissionTrigger.Resume); } catch (Exception ex) { _logger.LogError($"Failed to resume mission '{_model.Name}': {ex.Message}"); throw; } } /// /// Waits for the mission to reach a terminal state (Completed, Canceled, or Error). /// public void WaitForStop(int timeoutMs = 10000) { var startTime = DateTime.UtcNow; // Only wait if mission is actually executing - if not executing, state machine issue, proceed anyway while (IsExecuting && _currentState != ScriptMissionState.Completed && _currentState != ScriptMissionState.Canceled && _currentState != ScriptMissionState.Error) { if ((DateTime.UtcNow - startTime).TotalMilliseconds > timeoutMs) { // Timeout - mission is still executing (ScriptRunner blocking) _logger.LogWarning($"Mission '{_model.Name}' is still executing after {timeoutMs}ms timeout. ScriptRunner may be blocking."); break; } Thread.Sleep(50); // Check every 50ms } // If mission is not executing, it's effectively stopped (even if state machine didn't transition) if (!IsExecuting) { return; // Mission is not executing, proceed } } #endregion #region Mission Execution private async Task ExecuteMissionAsync(CancellationToken cancellationToken) { try { // Execute the script runner with globals // The CancellationToken parameter in MissionParameters is already linked to _executionCts.Token // so the script runner will receive cancellation when Cancel() is called var result = await _model.Runner(_globals, cancellationToken); if (result is IAsyncEnumerable statusEnumerable) { var enumerator = statusEnumerable.GetAsyncEnumerator(cancellationToken); try { while (await enumerator.MoveNextAsync()) { var status = enumerator.Current; // Update score and log message _currentScore += status.Score; var progress = TotalScore > 0 ? 100.0 * _currentScore / TotalScore : 0.0; _logger.LogInfo($"Mission '{_model.Name}' progress: {progress:0.##}% - {status.Message}"); // Check for pause/resume/cancel/stop via NextStepHandler if (!await NextStepHandlerAsync(cancellationToken)) { // Mission was canceled or stopped return; } } } finally { await enumerator.DisposeAsync(); } // Mission completed successfully _stateMachine.Fire(MissionTrigger.CompleteRunning); } else { _logger.LogError($"Mission '{_model.Name}' runner did not return IAsyncEnumerable."); _lastError = new InvalidOperationException("Mission runner must return IAsyncEnumerable"); _stateMachine.Fire(MissionTrigger.ErrorOccurred); } } catch (OperationCanceledException) { // Expected when canceling if (_isCanceling) { // Cancellation was requested, state machine will handle transition return; } else { _lastError = new OperationCanceledException("Mission execution was canceled"); _stateMachine.Fire(MissionTrigger.ErrorOccurred); } } catch (Exception ex) { _lastError = ex; _logger.LogError($"Mission '{_model.Name}' execution error: {ex.Message}"); _stateMachine.Fire(MissionTrigger.ErrorOccurred); } } /// /// Handles state transitions during mission execution (pause/resume/cancel/stop). /// Returns false if execution should stop, true if execution should continue. /// private async Task NextStepHandlerAsync(CancellationToken cancellationToken) { // Check for cancellation if (cancellationToken.IsCancellationRequested || _isCanceling) { return false; } // Check for pause - wait until resumed or canceled while (_isPaused && !cancellationToken.IsCancellationRequested && !_isCanceling) { // Use async delay to avoid blocking await Task.Delay(1000, cancellationToken); } // Check again after pause if (cancellationToken.IsCancellationRequested || _isCanceling) { return false; } return true; } #endregion #region IDisposable /// /// Disposes the mission. Can be called from any state. /// public void Dispose() { if (_disposed) { return; } lock (_lockObject) { if (_disposed) { return; } _disposed = true; } // Cancel execution if running try { if (_currentState == ScriptMissionState.Running || _currentState == ScriptMissionState.Paused || _currentState == ScriptMissionState.Pausing || _currentState == ScriptMissionState.Resuming) { _internalCts.Cancel(); _executionCts?.Cancel(); // Wait a bit for cancellation to complete var cancelTimeout = TimeSpan.FromSeconds(2); var startTime = DateTime.UtcNow; while ((_currentState == ScriptMissionState.Running || _currentState == ScriptMissionState.Paused || _currentState == ScriptMissionState.Pausing || _currentState == ScriptMissionState.Resuming || _currentState == ScriptMissionState.Canceling) && (DateTime.UtcNow - startTime) < cancelTimeout) { Thread.Sleep(50); } } } catch { // Ignore errors during cancellation } // Wait for execution task to complete try { if (_executionTask != null && !_executionTask.IsCompleted) { _executionTask.Wait(TimeSpan.FromSeconds(5)); } } catch { // Ignore errors } // Dispose resources try { _internalCts?.Dispose(); _executionCts?.Dispose(); } catch { // Ignore errors } // Stop state machine try { _stateMachine.Stop(); } catch { // Ignore errors } GC.SuppressFinalize(this); } #endregion }