Initial commit
This commit is contained in:
@@ -0,0 +1,923 @@
|
||||
using Appccelerate.StateMachine;
|
||||
using Appccelerate.StateMachine.Machine;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.Script;
|
||||
using RobotNet10.ScriptEngine.Helpers;
|
||||
using RobotNet10.ScriptEngine.HubContexts;
|
||||
using RobotNet10.ScriptEngine.Data;
|
||||
using RobotNet10.ScriptEngine.Models;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using RobotNet10.Shared;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RobotNet10.ScriptEngine;
|
||||
|
||||
/// <summary>
|
||||
/// State enum for MissionManager.
|
||||
/// </summary>
|
||||
public enum MissionManagerState
|
||||
{
|
||||
Idle = 0,
|
||||
Running,
|
||||
Stopping,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers for MissionManager state machine.
|
||||
/// </summary>
|
||||
public enum MissionManagerTrigger
|
||||
{
|
||||
Start,
|
||||
Stop,
|
||||
StoppingCompleted,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manages script missions with state machine support.
|
||||
/// </summary>
|
||||
public class MissionManager : IDisposable
|
||||
{
|
||||
private readonly PassiveStateMachine<MissionManagerState, MissionManagerTrigger> _stateMachine;
|
||||
private readonly ConcurrentDictionary<string, ScriptMissionModel> _missionModels = new();
|
||||
private readonly ConcurrentQueue<ScriptMission> _idleMissions = new();
|
||||
private readonly ConcurrentQueue<ScriptMission> _runningMissions = new();
|
||||
private readonly ConcurrentDictionary<Guid, ScriptMission> _allMissions = new();
|
||||
private readonly Lock _lockObject = new();
|
||||
private readonly Lock _stateLockObject = new();
|
||||
private readonly Lock _queueLockObject = new();
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly VariableManager _variableManager;
|
||||
private readonly IScriptEngineResource _scriptResource;
|
||||
private readonly ILogger<ScriptEngineGlobals> _logger;
|
||||
private readonly ConsoleHubContext _consoleHubContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
private Task? _runningHandlerTask;
|
||||
private CancellationTokenSource? _runningHandlerCts;
|
||||
private readonly ManualResetEventSlim _stoppedWaitHandle = new(false);
|
||||
private bool _disposed;
|
||||
private MissionManagerState _currentState = MissionManagerState.Idle;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current state of the MissionManager.
|
||||
/// </summary>
|
||||
public MissionManagerState State => _currentState;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all mission models.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ScriptMissionModel> MissionModels => _missionModels;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of MissionManager.
|
||||
/// </summary>
|
||||
public MissionManager(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
VariableManager variableManager,
|
||||
IScriptEngineResource scriptResource,
|
||||
ILogger<ScriptEngineGlobals> logger,
|
||||
ConsoleHubContext consoleHubContext,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
_variableManager = variableManager ?? throw new ArgumentNullException(nameof(variableManager));
|
||||
_scriptResource = scriptResource ?? throw new ArgumentNullException(nameof(scriptResource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_consoleHubContext = consoleHubContext ?? throw new ArgumentNullException(nameof(consoleHubContext));
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
|
||||
var builder = new StateMachineDefinitionBuilder<MissionManagerState, MissionManagerTrigger>();
|
||||
|
||||
// Idle state - can add/remove mission models
|
||||
builder.In(MissionManagerState.Idle)
|
||||
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Idle; } })
|
||||
.On(MissionManagerTrigger.Start)
|
||||
.Goto(MissionManagerState.Running)
|
||||
.Execute(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Running; } OnEnterRunning(); });
|
||||
|
||||
// Running state - can create missions
|
||||
builder.In(MissionManagerState.Running)
|
||||
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Running; } })
|
||||
.On(MissionManagerTrigger.Stop)
|
||||
.Goto(MissionManagerState.Stopping)
|
||||
.Execute(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Stopping; } OnEnterStopping(); });
|
||||
|
||||
// Stopping state - waiting for all missions to complete
|
||||
builder.In(MissionManagerState.Stopping)
|
||||
.ExecuteOnEntry(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Stopping; } })
|
||||
.On(MissionManagerTrigger.StoppingCompleted)
|
||||
.Goto(MissionManagerState.Idle)
|
||||
.Execute(() => { lock (_stateLockObject) { _currentState = MissionManagerState.Idle; } });
|
||||
|
||||
_stateMachine = builder
|
||||
.WithInitialState(MissionManagerState.Idle)
|
||||
.Build()
|
||||
.CreatePassiveStateMachine();
|
||||
|
||||
_stateMachine.Start();
|
||||
}
|
||||
|
||||
private void OnEnterRunning()
|
||||
{
|
||||
// Start running handler thread
|
||||
_runningHandlerCts = new CancellationTokenSource();
|
||||
_stoppedWaitHandle.Reset();
|
||||
|
||||
// Use standard thread pool
|
||||
_runningHandlerTask = Task.Run(() => RunningHandlerAsync(_runningHandlerCts.Token));
|
||||
|
||||
// Create missions for models with AutoStart == true
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var model in _missionModels.Values)
|
||||
{
|
||||
if (model.AutoStart)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate auto-start mission parameters: must be empty or have exactly one CancellationToken parameter
|
||||
var paramList = model.Parameters.ToList();
|
||||
if (paramList.Count > 1)
|
||||
{
|
||||
var message = $"Mission '{model.Name}' has AutoStart=true but has {paramList.Count} parameters. Auto-start missions must have 0 or 1 parameter (CancellationToken). Skipping auto-start.";
|
||||
_logger.LogWarning(message);
|
||||
_consoleHubContext.LogWarning(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (paramList.Count == 1)
|
||||
{
|
||||
var param = paramList[0];
|
||||
if (param.Type != typeof(CancellationToken))
|
||||
{
|
||||
var message = $"Mission '{model.Name}' has AutoStart=true but parameter '{param.Name}' is not CancellationToken. Auto-start missions must have 0 or 1 CancellationToken parameter. Skipping auto-start.";
|
||||
_logger.LogWarning(message);
|
||||
_consoleHubContext.LogWarning(message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate mission ID first
|
||||
var missionId = Guid.NewGuid();
|
||||
|
||||
// Create mission instance with default parameters
|
||||
var parameters = model.Parameters.Select(p =>
|
||||
{
|
||||
object? value = p.Type == typeof(CancellationToken)
|
||||
? CancellationToken.None
|
||||
: p.DefaultValue;
|
||||
return new ScriptMissionParameterModel(p.Name, p.Type, value);
|
||||
});
|
||||
|
||||
var result = CreateMissionInternal(model.Name, parameters, missionId);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
// Log error but continue creating other missions
|
||||
_logger.LogError($"Failed to auto-start mission '{model.Name}': {result.Message}");
|
||||
_consoleHubContext.LogError($"Failed to auto-start mission '{model.Name}': {result.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log error but continue creating other missions
|
||||
_logger.LogError(ex, $"Failed to auto-start mission '{model.Name}'");
|
||||
_consoleHubContext.LogError($"Failed to auto-start mission '{model.Name}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnterStopping()
|
||||
{
|
||||
// Fire and forget async operation with proper error handling
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Stop running handler
|
||||
_runningHandlerCts?.Cancel();
|
||||
|
||||
// Wait for running handler to complete (with timeout)
|
||||
if (_runningHandlerTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _runningHandlerTask.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogWarning("MissionManager running handler task did not complete within timeout. Proceeding anyway.");
|
||||
_consoleHubContext.LogWarning("MissionManager running handler task did not complete within timeout. Proceeding anyway.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error waiting for running handler task to complete");
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for stopped signal (with timeout)
|
||||
// If timeout, check if missions are actually executing
|
||||
if (!_stoppedWaitHandle.Wait(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
// Check if there are actually running missions
|
||||
var runningMissions = GetAllMissions().Where(m => m.IsExecuting).ToList();
|
||||
if (runningMissions.Count > 0)
|
||||
{
|
||||
_logger.LogWarning($"Timeout waiting for {runningMissions.Count} mission(s) to stop. ScriptRunner may be blocking. Proceeding anyway.");
|
||||
_consoleHubContext.LogWarning($"Timeout waiting for {runningMissions.Count} mission(s) to stop. ScriptRunner may be blocking. Proceeding anyway.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("All missions stopped. Proceeding with state transition.");
|
||||
_consoleHubContext.LogInfo("All missions stopped. Proceeding with state transition.");
|
||||
}
|
||||
}
|
||||
|
||||
// Fire StoppingCompleted trigger (always proceed)
|
||||
_stateMachine.Fire(MissionManagerTrigger.StoppingCompleted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error during MissionManager stopping");
|
||||
_consoleHubContext.LogError($"MissionManager stopping error: {ex.Message}");
|
||||
|
||||
// Ensure WaitHandle is set even on error to prevent deadlock
|
||||
try
|
||||
{
|
||||
_stoppedWaitHandle.Set();
|
||||
}
|
||||
catch { /* Ignore */ }
|
||||
|
||||
// Fire trigger to proceed
|
||||
_stateMachine.Fire(MissionManagerTrigger.StoppingCompleted);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task RunningHandlerAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
int elapsed;
|
||||
int remaining;
|
||||
int interval = 1000;
|
||||
int processTime = (int)(interval * 0.8);
|
||||
int count;
|
||||
|
||||
_stoppedWaitHandle.Reset();
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
stopwatch.Restart();
|
||||
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScriptEngineDbContext>();
|
||||
|
||||
// Process idle queue: transition to running or complete immediately
|
||||
count = _idleMissions.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!_idleMissions.TryDequeue(out var mission)) break;
|
||||
|
||||
// Find corresponding database record
|
||||
var dbMission = await dbContext.InstanceMissions.FindAsync([mission.Id], cancellationToken);
|
||||
if (dbMission == null)
|
||||
{
|
||||
mission.Dispose();
|
||||
_allMissions.TryRemove(mission.Id, out _);
|
||||
continue; // Skip if mission not found in database
|
||||
}
|
||||
|
||||
if (mission.State == ScriptMissionState.Idle)
|
||||
{
|
||||
// Start Mission and move to running queue
|
||||
mission.Start();
|
||||
_runningMissions.Enqueue(mission);
|
||||
dbMission.State = mission.State;
|
||||
}
|
||||
else if (mission.State == ScriptMissionState.Completed
|
||||
|| mission.State == ScriptMissionState.Canceled
|
||||
|| mission.State == ScriptMissionState.Error)
|
||||
{
|
||||
// Mission completed/canceled/errored before running
|
||||
dbMission.State = mission.State;
|
||||
dbMission.Log += $"{Environment.NewLine}{mission.GetLog()}";
|
||||
dbMission.Score = mission.CurrentScore;
|
||||
dbMission.StoppedAt = DateTime.UtcNow;
|
||||
mission.Dispose();
|
||||
_allMissions.TryRemove(mission.Id, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Invalid state
|
||||
dbMission.Log += $"{Environment.NewLine}{mission.GetLog()}{Environment.NewLine}{DateTime.UtcNow:O}: Mission is not in idle state. [{mission.State}]";
|
||||
dbMission.State = ScriptMissionState.Error;
|
||||
mission.Dispose();
|
||||
_allMissions.TryRemove(mission.Id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// Process running queue: check completion or keep
|
||||
count = _runningMissions.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (!_runningMissions.TryDequeue(out var mission)) break;
|
||||
|
||||
// Find corresponding database record
|
||||
var dbMission = await dbContext.InstanceMissions.FindAsync([mission.Id], cancellationToken);
|
||||
if (dbMission == null)
|
||||
{
|
||||
mission.Dispose();
|
||||
_allMissions.TryRemove(mission.Id, out _);
|
||||
continue; // Skip if mission not found in database
|
||||
}
|
||||
|
||||
if (mission.State == ScriptMissionState.Completed
|
||||
|| mission.State == ScriptMissionState.Canceled
|
||||
|| mission.State == ScriptMissionState.Error)
|
||||
{
|
||||
// Mission completed - save results to database
|
||||
dbMission.State = mission.State;
|
||||
dbMission.Log += $"{Environment.NewLine}{mission.GetLog()}";
|
||||
dbMission.Score = mission.CurrentScore;
|
||||
dbMission.StoppedAt = DateTime.UtcNow;
|
||||
mission.Dispose();
|
||||
_allMissions.TryRemove(mission.Id, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Mission still running - update log and state, keep in queue
|
||||
dbMission.State = mission.State;
|
||||
var newLog = mission.GetLog();
|
||||
if (!string.IsNullOrEmpty(newLog))
|
||||
{
|
||||
dbMission.Log += $"{Environment.NewLine}{newLog}";
|
||||
}
|
||||
dbMission.Score = mission.CurrentScore;
|
||||
_runningMissions.Enqueue(mission);
|
||||
}
|
||||
}
|
||||
|
||||
// Save all changes to database
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// Collect memory after each cycle
|
||||
GC.Collect();
|
||||
|
||||
stopwatch.Stop();
|
||||
elapsed = (int)stopwatch.ElapsedMilliseconds;
|
||||
remaining = interval - elapsed;
|
||||
|
||||
// If execution time exceeds 80% of interval, add another cycle
|
||||
if (elapsed > processTime)
|
||||
{
|
||||
remaining += interval;
|
||||
}
|
||||
|
||||
if (remaining > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(remaining, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on stop: dispose all unstarted Missions
|
||||
while (_idleMissions.TryDequeue(out var mission))
|
||||
{
|
||||
mission.Dispose();
|
||||
_allMissions.TryRemove(mission.Id, out _);
|
||||
}
|
||||
|
||||
// Cancel and save state of all running Missions
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScriptEngineDbContext>();
|
||||
while (_runningMissions.TryDequeue(out var mission))
|
||||
{
|
||||
try
|
||||
{
|
||||
mission.Cancel("engin is stopping");
|
||||
// Wait for stop, but only timeout if actually executing
|
||||
mission.WaitForStop();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log error but continue disposing
|
||||
_logger.LogError(ex, $"Error stopping mission '{mission.Name}'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Update final state to database
|
||||
try
|
||||
{
|
||||
var dbMission = await dbContext.InstanceMissions.FindAsync([mission.Id], CancellationToken.None);
|
||||
if (dbMission != null)
|
||||
{
|
||||
dbMission.State = mission.State;
|
||||
dbMission.Log += $"{Environment.NewLine}{mission.GetLog()}";
|
||||
dbMission.Score = mission.CurrentScore;
|
||||
dbMission.StoppedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log error but continue
|
||||
_logger.LogError(ex, $"Error updating mission '{mission.Name}' state in database");
|
||||
}
|
||||
|
||||
mission.Dispose();
|
||||
_allMissions.TryRemove(mission.Id, out _);
|
||||
}
|
||||
}
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Signal that stopping is complete
|
||||
_stoppedWaitHandle.Set();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all mission models. Clears all mission models.
|
||||
/// Only allowed when state is Idle.
|
||||
/// </summary>
|
||||
public MessageResult Reset()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_stateLockObject)
|
||||
{
|
||||
if (_currentState != MissionManagerState.Idle)
|
||||
return new MessageResult(false, $"Cannot reset mission models when MissionManager is in state: {_currentState}");
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
var count = _missionModels.Count;
|
||||
_missionModels.Clear();
|
||||
return new MessageResult(true, $"Reset {count} mission model(s) successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MessageResult(false, $"Failed to reset mission models: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads all mission models from a collection of ScriptMissionModel. This clears existing mission models first.
|
||||
/// Only allowed when state is Idle.
|
||||
/// </summary>
|
||||
/// <param name="missionModels">The collection of mission models to load.</param>
|
||||
public MessageResult Load(IEnumerable<ScriptMissionModel> missionModels)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (missionModels == null)
|
||||
throw new ArgumentNullException(nameof(missionModels));
|
||||
|
||||
lock (_stateLockObject)
|
||||
{
|
||||
if (_currentState != MissionManagerState.Idle)
|
||||
return new MessageResult(false, $"Cannot load mission models when MissionManager is in state: {_currentState}");
|
||||
}
|
||||
|
||||
// Reset existing mission models first
|
||||
var resetResult = Reset();
|
||||
if (!resetResult.IsSuccess)
|
||||
return resetResult;
|
||||
|
||||
// Load all mission models
|
||||
lock (_lockObject)
|
||||
{
|
||||
var loadedCount = 0;
|
||||
var errorCount = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var model in missionModels)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.Name))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
_missionModels.TryAdd(model.Name, model);
|
||||
loadedCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorCount++;
|
||||
errors.Add($"Failed to load mission model '{model.Name}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount > 0)
|
||||
{
|
||||
return new MessageResult(false,
|
||||
$"Loaded {loadedCount} mission model(s) successfully, {errorCount} failed. Errors: {string.Join("; ", errors)}");
|
||||
}
|
||||
|
||||
return new MessageResult(true, $"Loaded {loadedCount} mission model(s) successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MessageResult(false, $"Failed to load mission models: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mission instance with parameters provided as a dictionary (matched by parameter name).
|
||||
/// Only allowed when state is Running.
|
||||
/// </summary>
|
||||
/// <param name="missionName">The name of the mission model.</param>
|
||||
/// <param name="parameters">Dictionary of parameter values keyed by parameter name.</param>
|
||||
/// <returns>MessageResult containing the mission ID if successful.</returns>
|
||||
public MessageResult<Guid> CreateMission(string missionName, Dictionary<string, object?> parameters)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(missionName))
|
||||
return new MessageResult<Guid>(false, default, "Mission name cannot be null or empty");
|
||||
|
||||
ArgumentNullException.ThrowIfNull(parameters);
|
||||
|
||||
lock (_stateLockObject)
|
||||
{
|
||||
if (_currentState != MissionManagerState.Running)
|
||||
return new MessageResult<Guid>(false, default, $"Cannot create mission when MissionManager is in state: {_currentState}");
|
||||
}
|
||||
|
||||
if (!_missionModels.TryGetValue(missionName, out var model))
|
||||
return new MessageResult<Guid>(false, default, $"Mission model '{missionName}' not found");
|
||||
|
||||
// Convert dictionary to ScriptMissionParameterModel list
|
||||
var parameterModels = new List<ScriptMissionParameterModel>();
|
||||
foreach (var paramModel in model.Parameters)
|
||||
{
|
||||
// Skip CancellationToken parameters
|
||||
if (paramModel.Type == typeof(CancellationToken))
|
||||
continue;
|
||||
|
||||
// Get value from dictionary or use default
|
||||
object? value = parameters.TryGetValue(paramModel.Name, out var paramValue)
|
||||
? paramValue
|
||||
: paramModel.DefaultValue;
|
||||
|
||||
parameterModels.Add(new ScriptMissionParameterModel(paramModel.Name, paramModel.Type, value));
|
||||
}
|
||||
|
||||
var missionId = Guid.NewGuid();
|
||||
return CreateMissionInternal(missionName, parameterModels, missionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MessageResult<Guid>(false, default, $"Failed to create mission: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mission instance with parameters provided as an object array (matched by order, skipping CancellationToken).
|
||||
/// Only allowed when state is Running.
|
||||
/// </summary>
|
||||
/// <param name="missionName">The name of the mission model.</param>
|
||||
/// <param name="parameters">Array of parameter values in order (CancellationToken parameters in model are skipped).</param>
|
||||
/// <returns>MessageResult containing the mission ID if successful.</returns>
|
||||
public MessageResult<Guid> CreateMission(string missionName, object[] parameters)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(missionName))
|
||||
return new MessageResult<Guid>(false, default, "Mission name cannot be null or empty");
|
||||
|
||||
ArgumentNullException.ThrowIfNull(parameters);
|
||||
|
||||
lock (_stateLockObject)
|
||||
{
|
||||
if (_currentState != MissionManagerState.Running)
|
||||
return new MessageResult<Guid>(false, default, $"Cannot create mission when MissionManager is in state: {_currentState}");
|
||||
}
|
||||
|
||||
if (!_missionModels.TryGetValue(missionName, out var model))
|
||||
return new MessageResult<Guid>(false, default, $"Mission model '{missionName}' not found");
|
||||
|
||||
// Convert object array to ScriptMissionParameterModel list
|
||||
// Skip CancellationToken parameters when mapping
|
||||
var parameterModels = new List<ScriptMissionParameterModel>();
|
||||
var paramIndex = 0;
|
||||
foreach (var paramModel in model.Parameters)
|
||||
{
|
||||
// Skip CancellationToken parameters
|
||||
if (paramModel.Type == typeof(CancellationToken))
|
||||
continue;
|
||||
|
||||
// Get value from array or use default
|
||||
object? value = paramIndex < parameters.Length
|
||||
? parameters[paramIndex]
|
||||
: paramModel.DefaultValue;
|
||||
|
||||
parameterModels.Add(new ScriptMissionParameterModel(paramModel.Name, paramModel.Type, value));
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Validate that all provided parameters were used
|
||||
if (paramIndex < parameters.Length)
|
||||
{
|
||||
return new MessageResult<Guid>(false, default, $"Too many parameters provided. Expected {paramIndex} parameters (excluding CancellationToken), but got {parameters.Length}.");
|
||||
}
|
||||
|
||||
var missionId = Guid.NewGuid();
|
||||
return CreateMissionInternal(missionName, parameterModels, missionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MessageResult<Guid>(false, default, $"Failed to create mission: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private MessageResult<Guid> CreateMissionInternal(string missionName, IEnumerable<ScriptMissionParameterModel> parameters, Guid missionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_missionModels.TryGetValue(missionName, out var model))
|
||||
return new MessageResult<Guid>(false, default, $"Mission model '{missionName}' not found");
|
||||
|
||||
// Populate MissionParameters dictionary with provided parameters
|
||||
var missionParameters = new Dictionary<string, object?>();
|
||||
foreach (var param in parameters)
|
||||
{
|
||||
// Use DefaultValue from parameter (which may have been set to actual value by caller)
|
||||
missionParameters[param.Name] = param.DefaultValue;
|
||||
}
|
||||
|
||||
// Add CancellationToken parameters from model if they exist
|
||||
// (These are skipped in CreateMission overloads but need to be in MissionParameters for script execution)
|
||||
foreach (var paramModel in model.Parameters)
|
||||
{
|
||||
if (paramModel.Type == typeof(CancellationToken))
|
||||
{
|
||||
// CancellationToken will be provided by mission's internal token source
|
||||
missionParameters[paramModel.Name] = CancellationToken.None;
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize parameters to JSON (include all parameters from model, including CancellationToken)
|
||||
var allParametersForJson = model.Parameters.Select(p => new ScriptMissionParameterDto(p.Name, p.Type.FullName ?? "", p.DefaultValue?.ToString() ?? "null"));
|
||||
var parametersJson = JsonSerializer.Serialize(allParametersForJson);
|
||||
|
||||
// Create InstanceMission in database FIRST with initial values
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScriptEngineDbContext>();
|
||||
var dbMission = new InstanceMission
|
||||
{
|
||||
Id = missionId,
|
||||
MissionName = missionName,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Parameters = parametersJson,
|
||||
TotalScore = model.TotalScore,
|
||||
State = ScriptMissionState.Idle,
|
||||
Score = 0,
|
||||
StoppedAt = DateTime.UtcNow,
|
||||
Log = string.Empty
|
||||
};
|
||||
|
||||
dbContext.InstanceMissions.Add(dbMission);
|
||||
dbContext.SaveChanges();
|
||||
}
|
||||
|
||||
// Create LoggerMission with mission ID
|
||||
var loggerMission = new LoggerMission(missionId, _consoleHubContext);
|
||||
|
||||
// Create ScriptEngineGlobals with LoggerMission
|
||||
var scriptEngineGlobals = new ScriptEngineGlobals(loggerMission, _scopeFactory);
|
||||
var robotNetDict = ScriptHelper.ConvertGlobalsToDictionary(scriptEngineGlobals, typeof(IScriptGlobals));
|
||||
|
||||
// Get AppApis with mission ID
|
||||
var appApisDict = _scriptResource.GetMissionGlobals(missionId, CancellationToken.None);
|
||||
|
||||
// Get GlobalVariables from VariableManager
|
||||
var globalVariablesDict = _variableManager.Globals;
|
||||
|
||||
// Create ScriptGlobals with LoggerMission, mission ID, GlobalVariables, and MissionParameters
|
||||
var populatedGlobals = new ScriptGlobals(
|
||||
robotNetDict,
|
||||
appApisDict,
|
||||
globalVariablesDict,
|
||||
missionParameters
|
||||
);
|
||||
|
||||
// Create ScriptMission instance
|
||||
var mission = new ScriptMission(missionId, model, populatedGlobals);
|
||||
|
||||
// Add to idle queue
|
||||
_idleMissions.Enqueue(mission);
|
||||
_allMissions.TryAdd(missionId, mission);
|
||||
|
||||
return new MessageResult<Guid>(true, missionId, $"Mission '{missionName}' created successfully with ID: {missionId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MessageResult<Guid>(false, default, $"Failed to create mission '{missionName}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a mission instance by ID.
|
||||
/// </summary>
|
||||
public ScriptMission? GetMission(Guid missionId)
|
||||
{
|
||||
_allMissions.TryGetValue(missionId, out var mission);
|
||||
return mission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a mission model by name.
|
||||
/// </summary>
|
||||
public ScriptMissionModel? GetMissionModel(string name)
|
||||
{
|
||||
_missionModels.TryGetValue(name, out var model);
|
||||
return model;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all mission instances.
|
||||
/// </summary>
|
||||
public IEnumerable<ScriptMission> GetAllMissions()
|
||||
{
|
||||
return _allMissions.Values;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all mission models as ScriptMissionDto array.
|
||||
/// </summary>
|
||||
public ScriptMissionDto[] GetScriptMissions()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _missionModels.Values.Select(m => new ScriptMissionDto(
|
||||
m.Name,
|
||||
m.Parameters.Select(p => new ScriptMissionParameterDto(
|
||||
p.Name,
|
||||
p.Type.FullName ?? p.Type.Name,
|
||||
p.DefaultValue?.ToString())).ToArray())).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds specific mission models by names as ScriptMissionDto array.
|
||||
/// </summary>
|
||||
public ScriptMissionDto[] FindScriptMissions(string[] names)
|
||||
{
|
||||
if (names == null)
|
||||
throw new ArgumentNullException(nameof(names));
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _missionModels.Values
|
||||
.Where(m => names.Contains(m.Name))
|
||||
.Select(m => new ScriptMissionDto(
|
||||
m.Name,
|
||||
m.Parameters.Select(p => new ScriptMissionParameterDto(
|
||||
p.Name,
|
||||
p.Type.FullName ?? p.Type.Name,
|
||||
p.DefaultValue?.ToString())).ToArray())).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the MissionManager. This will start missions with AutoStart == true.
|
||||
/// </summary>
|
||||
public MessageResult Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
_stateMachine.Fire(MissionManagerTrigger.Start);
|
||||
return new MessageResult(true, "MissionManager started successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MessageResult(false, $"Failed to start MissionManager: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the MissionManager. This will ensure all missions are stopped.
|
||||
/// </summary>
|
||||
public MessageResult Stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
_stateMachine.Fire(MissionManagerTrigger.Stop);
|
||||
return new MessageResult(true, "MissionManager stop initiated");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MessageResult(false, $"Failed to stop MissionManager: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if all missions are not running.
|
||||
/// </summary>
|
||||
public bool AreAllMissionsNotRunning()
|
||||
{
|
||||
lock (_queueLockObject)
|
||||
{
|
||||
return _idleMissions.IsEmpty && _runningMissions.IsEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of mission models.
|
||||
/// </summary>
|
||||
public int MissionModelCount => _missionModels.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of active missions.
|
||||
/// </summary>
|
||||
public int ActiveMissionCount => _allMissions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the MissionManager and all missions.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
// Stop state machine first
|
||||
try
|
||||
{
|
||||
_stateMachine.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors when stopping state machine
|
||||
}
|
||||
|
||||
// Stop running handler
|
||||
_runningHandlerCts?.Cancel();
|
||||
|
||||
if (_runningHandlerTask != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_runningHandlerTask.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Dispose all missions
|
||||
lock (_queueLockObject)
|
||||
{
|
||||
while (_idleMissions.TryDequeue(out var mission))
|
||||
{
|
||||
try
|
||||
{
|
||||
mission.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore disposal errors
|
||||
}
|
||||
}
|
||||
|
||||
while (_runningMissions.TryDequeue(out var mission))
|
||||
{
|
||||
try
|
||||
{
|
||||
mission.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore disposal errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_allMissions.Clear();
|
||||
_missionModels.Clear();
|
||||
|
||||
_runningHandlerCts?.Dispose();
|
||||
_stoppedWaitHandle.Dispose();
|
||||
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user