chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee3e172314c274c1dae6c59aa9e4b049
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// UseCase responsible for temporal cohesion of Domain Reload recovery processing
|
||||
/// Processing sequence: 1. Pre-stop processing, 2. Recovery processing, 3. Notification processing
|
||||
/// Related classes: DomainReloadDetectionService, SessionRecoveryService, ClientNotificationService
|
||||
/// </summary>
|
||||
public class DomainReloadRecoveryUseCase
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute processing before Domain Reload starts
|
||||
/// </summary>
|
||||
/// <param name="currentServer">Current server instance</param>
|
||||
/// <returns>Processing result</returns>
|
||||
public ServiceResult<string> ExecuteBeforeDomainReload(McpBridgeServer currentServer)
|
||||
{
|
||||
// 1. Generate tracking ID for related operations
|
||||
string correlationId = VibeLogger.GenerateCorrelationId();
|
||||
|
||||
// 2. Check server state from instance
|
||||
bool serverRunning = currentServer?.IsRunning ?? false;
|
||||
int? serverPort = currentServer?.Port;
|
||||
|
||||
// 3. Fallback to session state if instance is null but session says server was running
|
||||
// Handles case where mcpServer instance became null unexpectedly
|
||||
if (currentServer == null && McpEditorSettings.GetIsServerRunning())
|
||||
{
|
||||
int sessionPort = McpEditorSettings.GetCustomPort();
|
||||
if (NetworkUtility.IsValidPort(sessionPort))
|
||||
{
|
||||
serverRunning = true;
|
||||
serverPort = sessionPort;
|
||||
VibeLogger.LogWarning(
|
||||
"domain_reload_session_fallback",
|
||||
"Server instance is null but session state indicates running. Using session state for recovery.",
|
||||
new { session_port = sessionPort },
|
||||
correlationId
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
VibeLogger.LogWarning(
|
||||
"domain_reload_session_fallback_invalid_port",
|
||||
"Session indicates running but port is invalid. Ignoring session state fallback.",
|
||||
new { session_port = sessionPort },
|
||||
correlationId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Detect and record Domain Reload start
|
||||
DomainReloadDetectionService.StartDomainReload(correlationId, serverRunning, serverPort);
|
||||
|
||||
// 4. If server is running, execute stop processing
|
||||
if (currentServer?.IsRunning == true)
|
||||
{
|
||||
int portToSave = currentServer.Port;
|
||||
|
||||
try
|
||||
{
|
||||
// 4.1. Notify client of server stop
|
||||
ClientNotificationService.LogServerStoppingBeforeDomainReload(correlationId, portToSave);
|
||||
|
||||
// 4.2. Stop server
|
||||
currentServer.StopServerBeforeDomainReload();
|
||||
|
||||
// 4.3. Notify client of stop completion
|
||||
ClientNotificationService.LogServerStoppedAfterDomainReload(correlationId);
|
||||
|
||||
return ServiceResult<string>.SuccessResult(correlationId);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// 4.4. Error notification
|
||||
ClientNotificationService.LogServerShutdownError(correlationId, ex, portToSave);
|
||||
DomainReloadDetectionService.RollbackDomainReloadStart(correlationId);
|
||||
|
||||
// Server stop failure is a critical error (causes port conflicts)
|
||||
throw new System.InvalidOperationException(
|
||||
$"Failed to properly shutdown MCP server before assembly reload. This may cause port conflicts on restart.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return ServiceResult<string>.SuccessResult(correlationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute recovery processing after Domain Reload completion
|
||||
/// </summary>
|
||||
/// <returns>Processing result</returns>
|
||||
public async Task<ServiceResult<string>> ExecuteAfterDomainReloadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. Generate tracking ID for related operations
|
||||
string correlationId = VibeLogger.GenerateCorrelationId();
|
||||
|
||||
// 2. Record Domain Reload completion
|
||||
DomainReloadDetectionService.CompleteDomainReload(correlationId);
|
||||
|
||||
// 3. Start timeout for reconnection UI display if needed
|
||||
if (DomainReloadDetectionService.ShouldShowReconnectingUI())
|
||||
{
|
||||
SessionRecoveryService.StartReconnectionUITimeoutAsync().Forget();
|
||||
}
|
||||
|
||||
// 4. Restore server state
|
||||
ValidationResult restoreResult = SessionRecoveryService.RestoreServerStateIfNeeded();
|
||||
if (!restoreResult.IsValid)
|
||||
{
|
||||
return ServiceResult<string>.FailureResult($"Server restoration failed: {restoreResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
// 5. Process pending compile requests (currently disabled)
|
||||
ProcessPendingCompileRequests(correlationId);
|
||||
|
||||
// 6. Send tool change notification if server is running
|
||||
if (McpServerController.IsServerRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ClientNotificationService.SendToolNotificationAfterCompilationAsync();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
VibeLogger.LogWarning("tool_notification_failed", $"Failed to send tool notification: {ex.Message}", correlationId: correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
return ServiceResult<string>.SuccessResult(correlationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process pending compile requests
|
||||
/// Note: Currently disabled by feature flag (to avoid main thread errors)
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Correlation ID for tracking related operations</param>
|
||||
private void ProcessPendingCompileRequests(string correlationId)
|
||||
{
|
||||
// Feature flag control - currently disabled, can be enabled via editor settings in the future
|
||||
// TODO: Add McpEditorSettings.GetEnablePendingCompileProcessing() when needed
|
||||
bool enablePendingCompileProcessing = false;
|
||||
|
||||
if (enablePendingCompileProcessing)
|
||||
{
|
||||
// Planned to be enabled after main thread issue resolution
|
||||
// CompileSessionState.StartForcedRecompile();
|
||||
VibeLogger.LogInfo(
|
||||
"pending_compile_processing",
|
||||
"Processing pending compile requests",
|
||||
correlationId: correlationId
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
VibeLogger.LogInfo(
|
||||
"pending_compile_processing_disabled",
|
||||
"Pending compile request processing is disabled via feature flag",
|
||||
correlationId: correlationId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1be0ac44f3434a16a6bd693cf4eaf41
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// UseCase responsible for temporal cohesion of server initialization processing
|
||||
/// Processing sequence: 1. Configuration validation, 2. Port allocation, 3. Server startup, 4. State update
|
||||
/// Related classes: McpServerConfigurationService, PortAllocationService, McpServerStartupService, SecurityValidationService
|
||||
/// </summary>
|
||||
public class McpServerInitializationUseCase : AbstractUseCase<ServerInitializationSchema, ServerInitializationResponse>
|
||||
{
|
||||
private readonly McpServerConfigurationService _configService;
|
||||
private readonly SecurityValidationService _securityService;
|
||||
private readonly PortAllocationService _portService;
|
||||
private readonly McpServerStartupService _startupService;
|
||||
private readonly InitializationNotificationService _notificationService;
|
||||
|
||||
public McpServerInitializationUseCase()
|
||||
{
|
||||
_configService = new McpServerConfigurationService();
|
||||
_securityService = new SecurityValidationService();
|
||||
_portService = new PortAllocationService();
|
||||
_startupService = new McpServerStartupService();
|
||||
_notificationService = new InitializationNotificationService();
|
||||
}
|
||||
|
||||
public McpServerInitializationUseCase(
|
||||
McpServerConfigurationService configService,
|
||||
SecurityValidationService securityService,
|
||||
PortAllocationService portService,
|
||||
McpServerStartupService startupService,
|
||||
InitializationNotificationService notificationService)
|
||||
{
|
||||
_configService = configService ?? throw new System.ArgumentNullException(nameof(configService));
|
||||
_securityService = securityService ?? throw new System.ArgumentNullException(nameof(securityService));
|
||||
_portService = portService ?? throw new System.ArgumentNullException(nameof(portService));
|
||||
_startupService = startupService ?? throw new System.ArgumentNullException(nameof(startupService));
|
||||
_notificationService = notificationService ?? throw new System.ArgumentNullException(nameof(notificationService));
|
||||
}
|
||||
/// <summary>
|
||||
/// Execute server initialization processing
|
||||
/// </summary>
|
||||
/// <param name="parameters">Initialization parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Initialization result</returns>
|
||||
public override Task<ServerInitializationResponse> ExecuteAsync(ServerInitializationSchema parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
ServerInitializationResponse response = new ServerInitializationResponse();
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Configuration validation - McpServerConfigurationService
|
||||
var portResult = _configService.ResolvePort(parameters.Port);
|
||||
if (!portResult.Success)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = portResult.ErrorMessage;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
int actualPort = portResult.Data;
|
||||
|
||||
var validationResult = _configService.ValidateConfiguration(actualPort);
|
||||
if (!validationResult.Success)
|
||||
{
|
||||
_notificationService.ShowInvalidPortDialog(actualPort);
|
||||
|
||||
response.Success = false;
|
||||
response.Message = validationResult.ErrorMessage;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
// 2. Security validation - SecurityValidationService
|
||||
var editorStateValidation = _securityService.ValidateEditorState();
|
||||
if (!editorStateValidation.IsValid)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = editorStateValidation.ErrorMessage;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
var portSecurityValidation = _securityService.ValidatePortSecurity(actualPort);
|
||||
if (!portSecurityValidation.IsValid)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = portSecurityValidation.ErrorMessage;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
// 3. Port allocation - PortAllocationService
|
||||
var availablePortResult = _portService.FindAvailablePort(actualPort);
|
||||
if (!availablePortResult.Success)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = availablePortResult.ErrorMessage;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
int availablePort = availablePortResult.Data;
|
||||
|
||||
// Handle port conflict
|
||||
if (availablePort != actualPort)
|
||||
{
|
||||
var conflictResult = _portService.HandlePortConflict(actualPort, availablePort);
|
||||
if (!conflictResult.Success || !conflictResult.Data)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = "Port conflict resolution cancelled by user";
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Server startup - McpServerStartupService
|
||||
var serverResult = _startupService.StartServer(
|
||||
availablePort,
|
||||
!parameters.PreserveStartupLockUntilExplicitRelease);
|
||||
if (!serverResult.Success)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = serverResult.ErrorMessage;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
McpBridgeServer serverInstance = serverResult.Data;
|
||||
|
||||
// 5. Session state update
|
||||
string projectRootPath = UnityMcpPathResolver.GetProjectRoot();
|
||||
var sessionUpdateResult =
|
||||
_startupService.UpdateSessionState(true, availablePort, projectRootPath);
|
||||
if (!sessionUpdateResult.Success)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = sessionUpdateResult.ErrorMessage;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
// Success response
|
||||
response.Success = true;
|
||||
response.ServerPort = availablePort;
|
||||
response.IsRunning = true;
|
||||
response.ServerInstance = serverInstance;
|
||||
response.Message = "Server initialization completed successfully";
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// Log the full exception for debugging
|
||||
UnityEngine.Debug.LogError($"Server initialization failed: {ex}");
|
||||
|
||||
response.Success = false;
|
||||
response.Message = "Server initialization failed. Please check the logs for details.";
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a7ed1e9e34fd422e840c465c156ca08
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// UseCase responsible for temporal cohesion of server shutdown processing
|
||||
/// Processing sequence: 1. Server stop, 2. Session state clear, 3. Resource disposal
|
||||
/// Related classes: McpServerStartupService, McpSessionManager
|
||||
/// </summary>
|
||||
public class McpServerShutdownUseCase : AbstractUseCase<ServerShutdownSchema, ServerShutdownResponse>
|
||||
{
|
||||
private readonly McpServerStartupService _startupService;
|
||||
|
||||
public McpServerShutdownUseCase(McpServerStartupService startupService)
|
||||
{
|
||||
_startupService = startupService ?? throw new System.ArgumentNullException(nameof(startupService));
|
||||
}
|
||||
/// <summary>
|
||||
/// Execute server shutdown processing
|
||||
/// </summary>
|
||||
/// <param name="parameters">Shutdown parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Shutdown result</returns>
|
||||
public override Task<ServerShutdownResponse> ExecuteAsync(ServerShutdownSchema parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
ServerShutdownResponse response = new ServerShutdownResponse();
|
||||
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 1. Get current server instance
|
||||
McpBridgeServer currentServer = McpServerController.CurrentServer;
|
||||
if (currentServer == null)
|
||||
{
|
||||
response.Success = true;
|
||||
response.Message = "Server was not running";
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 2. Server stop processing - McpServerStartupService
|
||||
var stopResult = _startupService.StopServer(currentServer);
|
||||
if (!stopResult.Success)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = stopResult.ErrorMessage;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 3. Session state clear
|
||||
var sessionUpdateResult = _startupService.UpdateSessionState(false);
|
||||
if (!sessionUpdateResult.Success)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = sessionUpdateResult.ErrorMessage;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 4. Session clear with SessionManager
|
||||
try
|
||||
{
|
||||
McpEditorSettings.ClearServerSession();
|
||||
}
|
||||
catch (System.Exception sessionEx)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = $"Failed to clear session: {sessionEx.Message}";
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
// Success response
|
||||
response.Success = true;
|
||||
response.Message = "Server shutdown completed successfully";
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
// Propagate cancellation exceptions
|
||||
throw;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// Log the full exception for debugging
|
||||
UnityEngine.Debug.LogError($"Server shutdown failed: {ex}");
|
||||
|
||||
response.Success = false;
|
||||
response.Message = "Server shutdown failed. Please check the logs for details.";
|
||||
}
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 409f66a22ed544772b7811a24e53ff26
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bf3ece38a78644e82931ca50dc3a9445
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Responsible for temporal cohesion of Console clear processing
|
||||
/// Processing sequence: 1. Get current log count, 2. Execute console clear, 3. Add confirmation message, 4. Create result
|
||||
/// Related classes: ClearConsoleTool, ConsoleUtility
|
||||
/// </summary>
|
||||
public class ClearConsoleUseCase : AbstractUseCase<ClearConsoleSchema, ClearConsoleResponse>
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute Console clear processing
|
||||
/// </summary>
|
||||
/// <param name="parameters">Clear configuration parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation control token</param>
|
||||
/// <returns>Clear execution result</returns>
|
||||
public override Task<ClearConsoleResponse> ExecuteAsync(ClearConsoleSchema parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (parameters == null)
|
||||
{
|
||||
throw new System.ArgumentNullException(nameof(parameters));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Get current log count
|
||||
ConsoleUtility.GetConsoleLogCounts(out int errorCount, out int warningCount, out int logCount);
|
||||
int totalLogCount = errorCount + warningCount + logCount;
|
||||
|
||||
ClearedLogCounts clearedCounts = new ClearedLogCounts(errorCount, warningCount, logCount);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 2. Execute console clear
|
||||
ConsoleUtility.ClearConsole();
|
||||
|
||||
// 3. Add confirmation message
|
||||
if (parameters.AddConfirmationMessage)
|
||||
{
|
||||
Debug.Log("=== Console cleared via MCP tool ===");
|
||||
}
|
||||
|
||||
// 4. Create result
|
||||
string message = totalLogCount > 0
|
||||
? $"Successfully cleared {totalLogCount} console logs (Errors: {errorCount}, Warnings: {warningCount}, Logs: {logCount})"
|
||||
: "Console was already empty";
|
||||
|
||||
ClearConsoleResponse response = new ClearConsoleResponse(
|
||||
success: true,
|
||||
clearedLogCount: totalLogCount,
|
||||
clearedCounts: clearedCounts,
|
||||
message: message
|
||||
);
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
throw; // Re-throw cancellation exceptions
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// Handle exceptions from console operations
|
||||
ClearConsoleResponse errorResponse = new ClearConsoleResponse(
|
||||
success: false,
|
||||
clearedLogCount: 0,
|
||||
clearedCounts: new ClearedLogCounts(0, 0, 0),
|
||||
message: $"Failed to clear console: {ex.Message}"
|
||||
);
|
||||
return Task.FromResult(errorResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f3dabb06ec2746b19fa4703e046223c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles temporal cohesion for compilation processing
|
||||
/// Processing sequence: 1. Play Mode preparation, 2. Compilation state validation, 3. Compilation execution, 4. Result formatting
|
||||
/// Related classes: CompileTool, PlayModeCompilationPreparationService, CompilationStateValidationService, CompilationExecutionService
|
||||
/// </summary>
|
||||
public class CompileUseCase : AbstractUseCase<CompileSchema, CompileResponse>
|
||||
{
|
||||
private const int MAX_WAIT_MS = 5000;
|
||||
private const int POLL_INTERVAL_MS = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Executes compilation processing
|
||||
/// </summary>
|
||||
/// <param name="parameters">Compilation parameters</param>
|
||||
/// <param name="ct">Cancellation control token</param>
|
||||
/// <returns>Compilation result</returns>
|
||||
public override async Task<CompileResponse> ExecuteAsync(CompileSchema parameters, CancellationToken ct)
|
||||
{
|
||||
PrepareResultStorage(parameters);
|
||||
|
||||
// 1. Play Mode preparation check
|
||||
PlayModeCompilationPreparationService preparationService = new();
|
||||
PreparationResult preparation = preparationService.DeterminePreparationAction();
|
||||
|
||||
if (!preparation.CanProceed)
|
||||
{
|
||||
CompileResponse response = new CompileResponse(
|
||||
success: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
errors: new[] { new CompileIssue(preparation.ErrorMessage, "", 0) },
|
||||
warnings: Array.Empty<CompileIssue>()
|
||||
);
|
||||
return PersistResponseIfNeeded(parameters, response);
|
||||
}
|
||||
|
||||
if (preparation.NeedsPlayModeStop)
|
||||
{
|
||||
preparationService.StopPlayMode();
|
||||
bool exited = await WaitForPlayModeExitAsync(ct);
|
||||
if (!exited)
|
||||
{
|
||||
CompileResponse response = new CompileResponse(
|
||||
success: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
errors: new[] { new CompileIssue("Play Mode did not exit within 5 seconds; compilation aborted.", "", 0) },
|
||||
warnings: Array.Empty<CompileIssue>()
|
||||
);
|
||||
return PersistResponseIfNeeded(parameters, response);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Compilation state validation
|
||||
CompilationStateValidationService validationService = new();
|
||||
ValidationResult validation = validationService.ValidateCompilationState();
|
||||
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
CompileResponse response = new CompileResponse(
|
||||
success: false,
|
||||
errorCount: 1,
|
||||
warningCount: 0,
|
||||
errors: new[] { new CompileIssue(validation.ErrorMessage, "", 0) },
|
||||
warnings: Array.Empty<CompileIssue>()
|
||||
);
|
||||
return PersistResponseIfNeeded(parameters, response);
|
||||
}
|
||||
|
||||
// 3. Compilation execution
|
||||
ct.ThrowIfCancellationRequested();
|
||||
CompilationExecutionService executionService = new();
|
||||
CompileResult result = await executionService.ExecuteCompilationAsync(parameters.ForceRecompile, ct);
|
||||
|
||||
// 4. Result formatting
|
||||
if (result.IsIndeterminate)
|
||||
{
|
||||
CompileResponse response = new CompileResponse(
|
||||
success: result.Success,
|
||||
errorCount: result.ErrorCount,
|
||||
warningCount: result.WarningCount,
|
||||
errors: null,
|
||||
warnings: null,
|
||||
message: result.Message ?? "Compilation status is indeterminate. Use get-logs tool to check results."
|
||||
);
|
||||
return PersistResponseIfNeeded(parameters, response);
|
||||
}
|
||||
|
||||
CompileIssue[] errors = result.error?.Select(e => new CompileIssue(e.message, e.file, e.line)).ToArray();
|
||||
CompileIssue[] warnings = result.warning?.Select(w => new CompileIssue(w.message, w.file, w.line)).ToArray();
|
||||
|
||||
CompileResponse successResponse = new CompileResponse(
|
||||
success: result.Success,
|
||||
errorCount: result.error?.Length ?? 0,
|
||||
warningCount: result.warning?.Length ?? 0,
|
||||
errors: errors,
|
||||
warnings: warnings
|
||||
);
|
||||
return PersistResponseIfNeeded(parameters, successResponse);
|
||||
}
|
||||
|
||||
private async Task<bool> WaitForPlayModeExitAsync(CancellationToken ct)
|
||||
{
|
||||
int waitedMs = 0;
|
||||
|
||||
while (EditorApplication.isPlaying && waitedMs < MAX_WAIT_MS)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await TimerDelay.Wait(POLL_INTERVAL_MS, ct);
|
||||
waitedMs += POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
return !EditorApplication.isPlaying;
|
||||
}
|
||||
|
||||
private static void PrepareResultStorage(CompileSchema parameters)
|
||||
{
|
||||
Debug.Assert(parameters != null, "parameters must not be null");
|
||||
|
||||
CompileResultPersistenceService.ClearStaleResults();
|
||||
|
||||
if (!parameters.WaitForDomainReload)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(parameters.RequestId) && IsRequestIdSafe(parameters.RequestId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
parameters.RequestId = CreateRequestId();
|
||||
}
|
||||
|
||||
private static bool IsRequestIdSafe(string requestId)
|
||||
{
|
||||
foreach (char c in requestId)
|
||||
{
|
||||
bool isSafe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9') || c == '_' || c == '-';
|
||||
if (!isSafe)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static CompileResponse PersistResponseIfNeeded(CompileSchema parameters, CompileResponse response)
|
||||
{
|
||||
Debug.Assert(parameters != null, "parameters must not be null");
|
||||
Debug.Assert(response != null, "response must not be null");
|
||||
|
||||
if (!parameters.WaitForDomainReload)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
response.ProjectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(parameters.RequestId))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
CompileResultPersistenceService.SaveResult(parameters.RequestId, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string CreateRequestId()
|
||||
{
|
||||
long unixTimeMilliseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
string correlationId = McpConstants.GenerateCorrelationId();
|
||||
return $"compile_{unixTimeMilliseconds}_{correlationId}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0d7adc07bd1f3444b9096a8c5fac399a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using UnityEditor;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
public class ControlPlayModeUseCase : AbstractUseCase<ControlPlayModeSchema, ControlPlayModeResponse>
|
||||
{
|
||||
public override Task<ControlPlayModeResponse> ExecuteAsync(ControlPlayModeSchema parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (parameters == null)
|
||||
{
|
||||
throw new System.ArgumentNullException(nameof(parameters));
|
||||
}
|
||||
|
||||
string message;
|
||||
bool wasPaused = EditorApplication.isPaused;
|
||||
|
||||
switch (parameters.Action)
|
||||
{
|
||||
case PlayModeAction.Play:
|
||||
if (wasPaused)
|
||||
{
|
||||
EditorApplication.isPaused = false;
|
||||
}
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
EditorApplication.isPlaying = true;
|
||||
}
|
||||
message = wasPaused ? "Play mode resumed" : "Play mode started";
|
||||
break;
|
||||
|
||||
case PlayModeAction.Stop:
|
||||
if (wasPaused)
|
||||
{
|
||||
EditorApplication.isPaused = false;
|
||||
}
|
||||
if (EditorApplication.isPlaying)
|
||||
{
|
||||
EditorApplication.isPlaying = false;
|
||||
}
|
||||
message = "Play mode stopped";
|
||||
break;
|
||||
|
||||
case PlayModeAction.Pause:
|
||||
EditorApplication.isPaused = true;
|
||||
message = "Play mode paused";
|
||||
break;
|
||||
|
||||
default:
|
||||
message = $"Unknown action: {parameters.Action}";
|
||||
break;
|
||||
}
|
||||
|
||||
ControlPlayModeResponse response = new ControlPlayModeResponse
|
||||
{
|
||||
IsPlaying = EditorApplication.isPlaying,
|
||||
IsPaused = EditorApplication.isPaused,
|
||||
Message = message
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc6933b2aa3e34c12850a16afff81ae8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,622 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Coordinates the execute-dynamic-code workflow while keeping the tool itself thin.
|
||||
/// Processing sequence: 1. Resolve security level and parameters, 2. Execute via runtime, 3. Retry missing-return cases, 4. Shape response
|
||||
/// </summary>
|
||||
internal sealed class ExecuteDynamicCodeUseCase : IExecuteDynamicCodeUseCase
|
||||
{
|
||||
private const string ForegroundWarmupCode =
|
||||
"using UnityEngine; LogType previous = Debug.unityLogger.filterLogType; Debug.unityLogger.filterLogType = LogType.Warning; try { Debug.Log(\"Unity CLI Loop dynamic code prewarm\"); return \"Unity CLI Loop dynamic code prewarm\"; } finally { Debug.unityLogger.filterLogType = previous; }";
|
||||
private readonly IDynamicCodeExecutionRuntime _runtime;
|
||||
private readonly UserFriendlyErrorConverter _errorHandler;
|
||||
|
||||
public ExecuteDynamicCodeUseCase(IDynamicCodeExecutionRuntime runtime)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_errorHandler = new UserFriendlyErrorConverter();
|
||||
}
|
||||
|
||||
public async Task<ExecuteDynamicCodeResponse> ExecuteAsync(
|
||||
ExecuteDynamicCodeSchema parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string correlationId = McpConstants.GenerateCorrelationId();
|
||||
DynamicCodeSecurityLevel editorLevel = DynamicCodeSecurityLevel.Restricted;
|
||||
|
||||
try
|
||||
{
|
||||
editorLevel = ULoopSettings.GetDynamicCodeSecurityLevel();
|
||||
object[] parametersArray = ConvertParameters(parameters.Parameters);
|
||||
string originalCode = parameters.Code ?? string.Empty;
|
||||
bool shouldWarmForegroundExecutionPath = ShouldWarmForegroundExecutionPath(parameters);
|
||||
|
||||
LogExecutionStart(parameters, editorLevel, correlationId);
|
||||
|
||||
DynamicCodeExecutionRequest request = CreateExecutionRequest(
|
||||
originalCode,
|
||||
parametersArray,
|
||||
parameters.CompileOnly,
|
||||
editorLevel,
|
||||
parameters.YieldToForegroundRequests);
|
||||
await WarmForegroundExecutionPathIfNeededAsync(parameters, editorLevel, cancellationToken);
|
||||
ExecutionResult executionResult = await ExecuteRequestAsync(request, cancellationToken);
|
||||
|
||||
ExecutionResult finalResult = await RetryMissingReturnIfNeeded(
|
||||
executionResult,
|
||||
originalCode,
|
||||
parametersArray,
|
||||
parameters.CompileOnly,
|
||||
editorLevel,
|
||||
parameters.YieldToForegroundRequests,
|
||||
cancellationToken);
|
||||
|
||||
if (shouldWarmForegroundExecutionPath && finalResult.Success)
|
||||
{
|
||||
DynamicCodeForegroundWarmupState.MarkCompletedByForegroundExecution();
|
||||
}
|
||||
|
||||
if (IsCancelledResult(finalResult))
|
||||
{
|
||||
ExecuteDynamicCodeResponse cancelledResponse = CreateCancelledResponse(editorLevel);
|
||||
cancelledResponse.Logs = finalResult.Logs ?? cancelledResponse.Logs;
|
||||
cancelledResponse.Timings = finalResult.Timings != null
|
||||
? new List<string>(finalResult.Timings)
|
||||
: cancelledResponse.Timings;
|
||||
return cancelledResponse;
|
||||
}
|
||||
|
||||
ExecuteDynamicCodeResponse response = ConvertExecutionResultToResponse(
|
||||
finalResult,
|
||||
originalCode);
|
||||
response.SecurityLevel = editorLevel.ToString();
|
||||
return response;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return CreateCancelledResponse(editorLevel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogExecutionException(ex, correlationId);
|
||||
return CreateExceptionResponse(ex, editorLevel);
|
||||
}
|
||||
}
|
||||
|
||||
private static void LogExecutionStart(
|
||||
ExecuteDynamicCodeSchema parameters,
|
||||
DynamicCodeSecurityLevel securityLevel,
|
||||
string correlationId)
|
||||
{
|
||||
VibeLogger.LogInfo(
|
||||
"execute_dynamic_code_start",
|
||||
"Dynamic code execution started (return optional)",
|
||||
new
|
||||
{
|
||||
correlationId,
|
||||
codeLength = parameters.Code?.Length ?? 0,
|
||||
compileOnly = parameters.CompileOnly,
|
||||
parametersCount = parameters.Parameters?.Count ?? 0,
|
||||
securityLevel = securityLevel.ToString()
|
||||
},
|
||||
correlationId,
|
||||
"Dynamic code execution request received (return is optional)",
|
||||
"Monitor execution flow and performance");
|
||||
}
|
||||
|
||||
private static object[] ConvertParameters(Dictionary<string, object> parameters)
|
||||
{
|
||||
if (parameters == null || parameters.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return parameters.Values.ToArray();
|
||||
}
|
||||
|
||||
private static DynamicCodeExecutionRequest CreateExecutionRequest(
|
||||
string code,
|
||||
object[] parameters,
|
||||
bool compileOnly,
|
||||
DynamicCodeSecurityLevel securityLevel,
|
||||
bool yieldToForegroundRequests = false)
|
||||
{
|
||||
return new DynamicCodeExecutionRequest
|
||||
{
|
||||
Code = code,
|
||||
ClassName = "DynamicCommand",
|
||||
Parameters = parameters,
|
||||
CompileOnly = compileOnly,
|
||||
SecurityLevel = securityLevel,
|
||||
YieldToForegroundRequests = yieldToForegroundRequests
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ExecutionResult> RetryMissingReturnIfNeeded(
|
||||
ExecutionResult executionResult,
|
||||
string originalCode,
|
||||
object[] parameters,
|
||||
bool compileOnly,
|
||||
DynamicCodeSecurityLevel securityLevel,
|
||||
bool yieldToForegroundRequests,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (executionResult.Success)
|
||||
{
|
||||
return executionResult;
|
||||
}
|
||||
|
||||
bool looksLikeMissingReturn = LooksLikeMissingReturn(executionResult);
|
||||
if (!looksLikeMissingReturn || !string.IsNullOrEmpty(executionResult.UpdatedCode))
|
||||
{
|
||||
return executionResult;
|
||||
}
|
||||
|
||||
string codeWithReturn = AppendReturnIfMissing(originalCode);
|
||||
DynamicCodeExecutionRequest retryRequest = CreateExecutionRequest(
|
||||
codeWithReturn,
|
||||
parameters,
|
||||
compileOnly,
|
||||
securityLevel,
|
||||
yieldToForegroundRequests);
|
||||
ExecutionResult retryResult = await ExecuteRequestAsync(retryRequest, cancellationToken);
|
||||
if (retryResult.Success)
|
||||
{
|
||||
return retryResult;
|
||||
}
|
||||
|
||||
if (retryResult.Logs?.Any() == true)
|
||||
{
|
||||
retryResult.Logs = MergeLogs(executionResult.Logs, retryResult.Logs);
|
||||
}
|
||||
else
|
||||
{
|
||||
retryResult.Logs = CloneLogs(executionResult.Logs);
|
||||
}
|
||||
|
||||
return retryResult;
|
||||
}
|
||||
|
||||
private static List<string> MergeLogs(List<string> originalLogs, List<string> retryLogs)
|
||||
{
|
||||
List<string> mergedLogs = CloneLogs(originalLogs);
|
||||
if (retryLogs == null || retryLogs.Count == 0)
|
||||
{
|
||||
return mergedLogs;
|
||||
}
|
||||
|
||||
if (mergedLogs == null)
|
||||
{
|
||||
return new List<string>(retryLogs);
|
||||
}
|
||||
|
||||
mergedLogs.AddRange(retryLogs);
|
||||
return mergedLogs;
|
||||
}
|
||||
|
||||
private static List<string> CloneLogs(List<string> logs)
|
||||
{
|
||||
return logs == null ? null : new List<string>(logs);
|
||||
}
|
||||
|
||||
private static bool LooksLikeMissingReturn(ExecutionResult executionResult)
|
||||
{
|
||||
if (executionResult.CompilationErrors?.Any() == true)
|
||||
{
|
||||
return executionResult.CompilationErrors.Any(error =>
|
||||
error.ErrorCode == "CS0161" || error.ErrorCode == "CS0127");
|
||||
}
|
||||
|
||||
if (executionResult.Logs?.Any() == true)
|
||||
{
|
||||
return executionResult.Logs.Any(log =>
|
||||
log.Contains("CS0161") ||
|
||||
log.Contains("CS0127") ||
|
||||
log.Contains("must return a value"));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<ExecutionResult> ExecuteRequestAsync(
|
||||
DynamicCodeExecutionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!request.YieldToForegroundRequests)
|
||||
{
|
||||
return await _runtime.ExecuteAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
(bool entered, ExecutionResult result) = await _runtime.TryExecuteIfIdleAsync(
|
||||
request,
|
||||
cancellationToken);
|
||||
if (entered)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return new ExecutionResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = McpConstants.ERROR_MESSAGE_EXECUTION_IN_PROGRESS
|
||||
};
|
||||
}
|
||||
|
||||
private async Task WarmForegroundExecutionPathIfNeededAsync(
|
||||
ExecuteDynamicCodeSchema parameters,
|
||||
DynamicCodeSecurityLevel securityLevel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ShouldWarmForegroundExecutionPath(parameters))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DynamicCodeForegroundWarmupState.TryBegin())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool completed = false;
|
||||
try
|
||||
{
|
||||
DynamicCodeExecutionRequest warmupRequest = CreateExecutionRequest(
|
||||
ForegroundWarmupCode,
|
||||
null,
|
||||
compileOnly: false,
|
||||
securityLevel,
|
||||
yieldToForegroundRequests: false);
|
||||
ExecutionResult warmupResult = await ExecuteRequestAsync(warmupRequest, cancellationToken);
|
||||
completed = warmupResult.Success;
|
||||
if (completed)
|
||||
{
|
||||
DynamicCodeForegroundWarmupState.MarkCompleted();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!completed)
|
||||
{
|
||||
DynamicCodeForegroundWarmupState.ResetAfterIncompleteAttempt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldWarmForegroundExecutionPath(ExecuteDynamicCodeSchema parameters)
|
||||
{
|
||||
if (parameters == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Why: this fallback only exists to protect the first real foreground execution that
|
||||
// users see after startup or reload.
|
||||
// Why not run it for compile-only or yield-to-foreground requests: compile validation
|
||||
// does not need the runtime hot path, and the yield-based startup prewarm already uses
|
||||
// those requests as background work that must stay cancellable.
|
||||
return !parameters.CompileOnly && !parameters.YieldToForegroundRequests;
|
||||
}
|
||||
|
||||
private static bool IsCancelledResult(ExecutionResult executionResult)
|
||||
{
|
||||
return executionResult != null
|
||||
&& !executionResult.Success
|
||||
&& string.Equals(
|
||||
executionResult.ErrorMessage,
|
||||
McpConstants.ERROR_MESSAGE_EXECUTION_CANCELLED,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private ExecuteDynamicCodeResponse CreateExceptionResponse(
|
||||
Exception ex,
|
||||
DynamicCodeSecurityLevel securityLevel)
|
||||
{
|
||||
UserFriendlyErrorDto exceptionResponse = _errorHandler.ProcessException(ex);
|
||||
if (exceptionResponse != null)
|
||||
{
|
||||
return new ExecuteDynamicCodeResponse
|
||||
{
|
||||
Success = false,
|
||||
Result = string.Empty,
|
||||
Logs = new List<string>
|
||||
{
|
||||
$"Original Error: {ex.Message}",
|
||||
string.IsNullOrEmpty(exceptionResponse.Explanation)
|
||||
? null
|
||||
: $"Explanation: {exceptionResponse.Explanation}"
|
||||
}.Where(message => !string.IsNullOrEmpty(message)).ToList(),
|
||||
CompilationErrors = new List<CompilationErrorDto>(),
|
||||
ErrorMessage = exceptionResponse.FriendlyMessage,
|
||||
SecurityLevel = securityLevel.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
return new ExecuteDynamicCodeResponse
|
||||
{
|
||||
Success = false,
|
||||
Result = string.Empty,
|
||||
Logs = new List<string>(),
|
||||
CompilationErrors = new List<CompilationErrorDto>(),
|
||||
ErrorMessage = ex.Message ?? "Unknown error occurred",
|
||||
SecurityLevel = securityLevel.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static ExecuteDynamicCodeResponse CreateCancelledResponse(
|
||||
DynamicCodeSecurityLevel securityLevel)
|
||||
{
|
||||
return new ExecuteDynamicCodeResponse
|
||||
{
|
||||
Success = false,
|
||||
Result = string.Empty,
|
||||
Logs = new List<string> { "Execution cancelled" },
|
||||
CompilationErrors = new List<CompilationErrorDto>(),
|
||||
ErrorMessage = McpConstants.ERROR_MESSAGE_EXECUTION_CANCELLED,
|
||||
SecurityLevel = securityLevel.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static void LogExecutionException(Exception ex, string correlationId)
|
||||
{
|
||||
VibeLogger.LogError(
|
||||
"execute_dynamic_code_error",
|
||||
"Dynamic code execution failed with exception",
|
||||
new
|
||||
{
|
||||
correlationId,
|
||||
error = ex.Message,
|
||||
stackTrace = ex.StackTrace
|
||||
},
|
||||
correlationId,
|
||||
"Unexpected error during dynamic code execution",
|
||||
"Investigate error cause and improve error handling");
|
||||
}
|
||||
|
||||
private ExecuteDynamicCodeResponse ConvertExecutionResultToResponse(
|
||||
ExecutionResult result,
|
||||
string originalCode)
|
||||
{
|
||||
ExecuteDynamicCodeResponse response = new ExecuteDynamicCodeResponse
|
||||
{
|
||||
Success = result.Success,
|
||||
Result = result.Result?.ToString() ?? string.Empty,
|
||||
Logs = result.Logs ?? new List<string>(),
|
||||
CompilationErrors = new List<CompilationErrorDto>(),
|
||||
ErrorMessage = result.ErrorMessage ?? string.Empty,
|
||||
Timings = result.Timings != null ? new List<string>(result.Timings) : new List<string>()
|
||||
};
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
UserFriendlyErrorDto enhancedError = _errorHandler.ProcessError(result, originalCode);
|
||||
response.ErrorMessage = enhancedError.FriendlyMessage;
|
||||
response.Logs = result.Logs != null ? new List<string>(result.Logs) : new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(enhancedError.Explanation))
|
||||
{
|
||||
response.Logs.Add($"Explanation: {enhancedError.Explanation}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(enhancedError.Example))
|
||||
{
|
||||
response.Logs.Add($"Example: {enhancedError.Example}");
|
||||
}
|
||||
|
||||
if (enhancedError.SuggestedSolutions?.Any() == true)
|
||||
{
|
||||
response.Logs.Add("Solutions:");
|
||||
foreach (string solution in enhancedError.SuggestedSolutions)
|
||||
{
|
||||
response.Logs.Add($"- {solution}");
|
||||
}
|
||||
}
|
||||
|
||||
if (enhancedError.LearningTips?.Any() == true)
|
||||
{
|
||||
response.Logs.Add("Tips:");
|
||||
foreach (string tip in enhancedError.LearningTips)
|
||||
{
|
||||
response.Logs.Add($"- {tip}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.CompilationErrors?.Any() == true)
|
||||
{
|
||||
response.Diagnostics = BuildDiagnostics(
|
||||
result.CompilationErrors,
|
||||
result.UpdatedCode,
|
||||
result.AmbiguousTypeCandidates);
|
||||
response.CompilationErrors = response.Diagnostics;
|
||||
|
||||
int total = response.Diagnostics.Count;
|
||||
int unique = response.Diagnostics
|
||||
.GroupBy(error => new { error.Line, error.Column, error.ErrorCode, error.Message })
|
||||
.Count();
|
||||
CompilationErrorDto first = response.Diagnostics.First();
|
||||
response.DiagnosticsSummary =
|
||||
$"Errors: {unique} unique ({total} total). First at L{first.Line}: {first.ErrorCode} {first.Message}";
|
||||
|
||||
response.Logs.Add(response.DiagnosticsSummary);
|
||||
}
|
||||
|
||||
response.UpdatedCode = result.UpdatedCode ?? response.UpdatedCode;
|
||||
}
|
||||
|
||||
if (result.Exception != null)
|
||||
{
|
||||
if (response.Logs == null)
|
||||
{
|
||||
response.Logs = new List<string>();
|
||||
}
|
||||
|
||||
response.Logs.Add($"Exception: {result.Exception.Message}");
|
||||
if (!string.IsNullOrEmpty(result.Exception.StackTrace))
|
||||
{
|
||||
response.Logs.Add($"Stack Trace: {result.Exception.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
if (result.AutoInjectedNamespaces != null && result.AutoInjectedNamespaces.Count > 0)
|
||||
{
|
||||
if (response.Logs == null)
|
||||
{
|
||||
response.Logs = new List<string>();
|
||||
}
|
||||
|
||||
string usingList = string.Join(" ", result.AutoInjectedNamespaces.Select(ns => $"using {ns};"));
|
||||
response.Logs.Add(
|
||||
$"Performance hint: Auto-resolved {result.AutoInjectedNamespaces.Count} missing using directive(s): "
|
||||
+ $"{usingList} — Include them in your code to skip auto-resolution and improve compilation speed.");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static List<CompilationErrorDto> BuildDiagnostics(
|
||||
List<CompilationError> errors,
|
||||
string updatedCode,
|
||||
Dictionary<string, List<string>> ambiguousCandidates = null)
|
||||
{
|
||||
List<CompilationErrorDto> list = new();
|
||||
string[] lines = string.IsNullOrEmpty(updatedCode)
|
||||
? Array.Empty<string>()
|
||||
: updatedCode.Split(new[] { '\n' }, StringSplitOptions.None);
|
||||
|
||||
foreach (CompilationError error in errors)
|
||||
{
|
||||
(string hint, List<string> suggestions) = GetHintAndSuggestions(error, ambiguousCandidates);
|
||||
string context = ExtractContext(lines, error.Line, error.Column);
|
||||
list.Add(new CompilationErrorDto
|
||||
{
|
||||
Line = error.Line,
|
||||
Column = error.Column,
|
||||
Message = error.Message,
|
||||
ErrorCode = error.ErrorCode,
|
||||
Hint = hint,
|
||||
Suggestions = suggestions,
|
||||
Context = context,
|
||||
PointerColumn = error.Column
|
||||
});
|
||||
}
|
||||
|
||||
return list
|
||||
.GroupBy(diagnostic => new
|
||||
{
|
||||
diagnostic.Line,
|
||||
diagnostic.Column,
|
||||
diagnostic.ErrorCode,
|
||||
diagnostic.Message
|
||||
})
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static (string hint, List<string> suggestions) GetHintAndSuggestions(
|
||||
CompilationError error,
|
||||
Dictionary<string, List<string>> ambiguousCandidates = null)
|
||||
{
|
||||
string hint = string.Empty;
|
||||
List<string> suggestions = new();
|
||||
|
||||
switch (error.ErrorCode)
|
||||
{
|
||||
case "CS0246":
|
||||
string typeName = CompilationDiagnosticMessageParser.ExtractTypeNameFromMessage(error.Message);
|
||||
if (typeName != null
|
||||
&& ambiguousCandidates != null
|
||||
&& ambiguousCandidates.TryGetValue(typeName, out List<string> candidates))
|
||||
{
|
||||
string candidateList = string.Join(", ", candidates);
|
||||
hint = $"Auto-using resolution found multiple candidates for '{typeName}': {candidateList}. Use a fully-qualified name or add the correct using directive.";
|
||||
foreach (string ns in candidates)
|
||||
{
|
||||
suggestions.Add($"Use {ns}.{typeName}");
|
||||
}
|
||||
|
||||
return (hint, suggestions);
|
||||
}
|
||||
|
||||
hint = "Auto-using resolution was attempted but could not resolve this identifier. Use a fully-qualified name (e.g., UnityEngine.Mathf) or add the correct using directive.";
|
||||
suggestions.Add("Use fully-qualified name (e.g., UnityEngine.Mathf, System.Linq.Enumerable)");
|
||||
suggestions.Add("Add the appropriate using directive at the top of the snippet");
|
||||
return (hint, suggestions);
|
||||
|
||||
case "CS0103":
|
||||
string identifierName = CompilationDiagnosticMessageParser.ExtractTypeNameFromMessage(error.Message);
|
||||
if (identifierName != null
|
||||
&& ambiguousCandidates != null
|
||||
&& ambiguousCandidates.TryGetValue(identifierName, out List<string> identifierCandidates))
|
||||
{
|
||||
string candidateList = string.Join(", ", identifierCandidates);
|
||||
hint = $"Auto-using resolution found multiple candidates for '{identifierName}': {candidateList}. Use a fully-qualified name or add the correct using directive.";
|
||||
foreach (string ns in identifierCandidates)
|
||||
{
|
||||
suggestions.Add($"Use {ns}.{identifierName}");
|
||||
}
|
||||
|
||||
return (hint, suggestions);
|
||||
}
|
||||
|
||||
hint = "Identifier does not exist in the current context. Check spelling, declaration scope, and whether this should be a type name.";
|
||||
suggestions.Add("Declare the identifier before use");
|
||||
suggestions.Add("If this is a type name, use a fully-qualified name or add the correct using directive");
|
||||
return (hint, suggestions);
|
||||
|
||||
case "CS0104":
|
||||
hint = "Identifier is ambiguous; qualify explicitly (e.g., UnityEngine.Object).";
|
||||
suggestions.Add("Qualify with full namespace (e.g., UnityEngine.Object)");
|
||||
return (hint, suggestions);
|
||||
|
||||
default:
|
||||
return (hint, suggestions);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractContext(
|
||||
string[] lines,
|
||||
int lineNumber1Based,
|
||||
int column1Based)
|
||||
{
|
||||
if (lines == null || lines.Length == 0 || lineNumber1Based <= 0 || lineNumber1Based > lines.Length)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
int start = Math.Max(1, lineNumber1Based - 3);
|
||||
int end = Math.Min(lines.Length, lineNumber1Based + 3);
|
||||
StringBuilder sb = new();
|
||||
for (int i = start; i <= end; i++)
|
||||
{
|
||||
string line = lines[i - 1].TrimEnd('\r');
|
||||
string linePrefix = $"L{i}:";
|
||||
sb.AppendLine(linePrefix + line);
|
||||
if (i == lineNumber1Based)
|
||||
{
|
||||
int caretPos = Math.Max(1, column1Based);
|
||||
sb.AppendLine(
|
||||
new string(' ', linePrefix.Length)
|
||||
+ new string(' ', Math.Max(0, caretPos - 1))
|
||||
+ "^");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string AppendReturnIfMissing(string originalCode)
|
||||
{
|
||||
string code = originalCode ?? string.Empty;
|
||||
string trimmed = code.TrimEnd();
|
||||
bool endsWithSemicolon = trimmed.EndsWith(";");
|
||||
string builder = endsWithSemicolon ? code : code + ";";
|
||||
return builder + "\nreturn null;";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 674391485f77f4fe6ad909a2ef91cf32
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Responsible for temporal cohesion of GameObject search processing
|
||||
/// Processing sequence: 1. Search criteria validation, 2. GameObject search execution, 3. Result conversion and formatting
|
||||
/// Related classes: FindGameObjectsTool, GameObjectFinderService, ComponentSerializer
|
||||
/// </summary>
|
||||
public class FindGameObjectsUseCase : AbstractUseCase<FindGameObjectsSchema, FindGameObjectsResponse>
|
||||
{
|
||||
private readonly GameObjectFinderService _finderService;
|
||||
private readonly ComponentSerializer _componentSerializer;
|
||||
|
||||
public FindGameObjectsUseCase(GameObjectFinderService finderService, ComponentSerializer componentSerializer)
|
||||
{
|
||||
_finderService = finderService ?? throw new System.ArgumentNullException(nameof(finderService));
|
||||
_componentSerializer = componentSerializer ?? throw new System.ArgumentNullException(nameof(componentSerializer));
|
||||
}
|
||||
/// <summary>
|
||||
/// Execute GameObject search processing
|
||||
/// </summary>
|
||||
/// <param name="parameters">Search parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation control token</param>
|
||||
/// <returns>Search result</returns>
|
||||
public override Task<FindGameObjectsResponse> ExecuteAsync(FindGameObjectsSchema parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
// Handle Selected mode separately
|
||||
if (parameters.SearchMode == SearchMode.Selected)
|
||||
{
|
||||
return Task.FromResult(ExecuteSelectedMode(parameters, cancellationToken));
|
||||
}
|
||||
|
||||
// 1. Search criteria validation (skip for Selected mode)
|
||||
if (string.IsNullOrEmpty(parameters.NamePattern) &&
|
||||
(parameters.RequiredComponents == null || parameters.RequiredComponents.Length == 0) &&
|
||||
string.IsNullOrEmpty(parameters.Tag) &&
|
||||
!parameters.Layer.HasValue)
|
||||
{
|
||||
return Task.FromResult(new FindGameObjectsResponse
|
||||
{
|
||||
results = new FindGameObjectResult[0],
|
||||
totalFound = 0,
|
||||
errorMessage = "At least one search criterion must be provided"
|
||||
});
|
||||
}
|
||||
|
||||
// 2. GameObject search execution
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
GameObjectSearchOptions options = new GameObjectSearchOptions
|
||||
{
|
||||
NamePattern = parameters.NamePattern,
|
||||
SearchMode = parameters.SearchMode,
|
||||
RequiredComponents = parameters.RequiredComponents,
|
||||
Tag = parameters.Tag,
|
||||
Layer = parameters.Layer,
|
||||
IncludeInactive = parameters.IncludeInactive,
|
||||
MaxResults = parameters.MaxResults
|
||||
};
|
||||
|
||||
GameObjectDetails[] foundObjects = _finderService.FindGameObjectsAdvanced(options);
|
||||
|
||||
// 3. Result conversion and formatting
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
List<FindGameObjectResult> results = new List<FindGameObjectResult>();
|
||||
|
||||
foreach (GameObjectDetails details in foundObjects)
|
||||
{
|
||||
// Check cancellation less frequently for better performance
|
||||
if (results.Count % 100 == 0)
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
FindGameObjectResult result = new FindGameObjectResult
|
||||
{
|
||||
name = details.Name,
|
||||
path = details.Path,
|
||||
isActive = details.IsActive,
|
||||
tag = details.GameObject.tag,
|
||||
layer = details.GameObject.layer,
|
||||
components = _componentSerializer.SerializeComponents(details.GameObject)
|
||||
};
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// Log error but continue processing other GameObjects
|
||||
UnityEngine.Debug.LogWarning($"Failed to process GameObject '{details.Name}': {ex.Message}");
|
||||
VibeLogger.LogWarning(
|
||||
"gameobject_processing_failed",
|
||||
$"Failed to process GameObject: {details.Name}",
|
||||
new { gameObjectName = details.Name, gameObjectPath = details.Path, error = ex.Message }
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
FindGameObjectsResponse response = new FindGameObjectsResponse
|
||||
{
|
||||
results = results.ToArray(),
|
||||
totalFound = results.Count
|
||||
};
|
||||
|
||||
// Underlying services are synchronous; wrapping in Task.FromResult for API consistency.
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// Log full exception details for debugging
|
||||
UnityEngine.Debug.LogError($"GameObject search failed: {ex}");
|
||||
VibeLogger.LogError(
|
||||
"gameobject_search_failed",
|
||||
"GameObject search execution failed",
|
||||
new { searchParameters = parameters, error = ex.Message }
|
||||
);
|
||||
|
||||
return Task.FromResult(new FindGameObjectsResponse
|
||||
{
|
||||
results = new FindGameObjectResult[0],
|
||||
totalFound = 0,
|
||||
errorMessage = "Search execution failed. Please check the logs for details."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute Selected mode: get currently selected GameObjects in Unity Editor
|
||||
/// Single selection returns JSON directly, multiple selection exports to file
|
||||
/// </summary>
|
||||
private FindGameObjectsResponse ExecuteSelectedMode(FindGameObjectsSchema parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
GameObjectDetails[] selectedObjects = _finderService.FindSelectedGameObjects(parameters.IncludeInactive);
|
||||
|
||||
// No selection
|
||||
if (selectedObjects.Length == 0)
|
||||
{
|
||||
return new FindGameObjectsResponse
|
||||
{
|
||||
results = new FindGameObjectResult[0],
|
||||
totalFound = 0,
|
||||
message = "No GameObjects are currently selected in Unity Editor."
|
||||
};
|
||||
}
|
||||
|
||||
// Convert to FindGameObjectResult array
|
||||
List<FindGameObjectResult> results = new List<FindGameObjectResult>();
|
||||
List<ProcessingError> errors = new List<ProcessingError>();
|
||||
|
||||
foreach (GameObjectDetails details in selectedObjects)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
FindGameObjectResult result = new FindGameObjectResult
|
||||
{
|
||||
name = details.Name,
|
||||
path = details.Path,
|
||||
isActive = details.IsActive,
|
||||
tag = details.GameObject.tag,
|
||||
layer = details.GameObject.layer,
|
||||
components = _componentSerializer.SerializeComponents(details.GameObject)
|
||||
};
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"Failed to process selected GameObject '{details.Name}': {ex.Message}");
|
||||
VibeLogger.LogWarning(
|
||||
"selected_gameobject_processing_failed",
|
||||
$"Failed to process selected GameObject: {details.Name}",
|
||||
new { gameObjectName = details.Name, gameObjectPath = details.Path, error = ex.Message }
|
||||
);
|
||||
errors.Add(new ProcessingError
|
||||
{
|
||||
gameObjectName = details.Name,
|
||||
gameObjectPath = details.Path,
|
||||
error = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
FindGameObjectResult[] resultArray = results.ToArray();
|
||||
ProcessingError[] errorArray = errors.Count > 0 ? errors.ToArray() : null;
|
||||
|
||||
// Single selection: return JSON directly
|
||||
if (resultArray.Length == 1)
|
||||
{
|
||||
return new FindGameObjectsResponse
|
||||
{
|
||||
results = resultArray,
|
||||
totalFound = 1,
|
||||
processingErrors = errorArray
|
||||
};
|
||||
}
|
||||
|
||||
// No successful results
|
||||
if (resultArray.Length == 0)
|
||||
{
|
||||
return new FindGameObjectsResponse
|
||||
{
|
||||
results = new FindGameObjectResult[0],
|
||||
totalFound = 0,
|
||||
processingErrors = errorArray,
|
||||
message = "All selected GameObjects failed to process."
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple selection: export to file
|
||||
string filePath = FindGameObjectsResultExporter.ExportResults(resultArray);
|
||||
|
||||
return new FindGameObjectsResponse
|
||||
{
|
||||
resultsFilePath = filePath,
|
||||
totalFound = resultArray.Length,
|
||||
message = $"Multiple objects selected ({resultArray.Length}). Results exported to file.",
|
||||
processingErrors = errorArray
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9f2bc42e02a8f4afcac362ae48853142
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Responsible for temporal cohesion of Unity Hierarchy retrieval processing
|
||||
/// Processing sequence: 1. Hierarchy information retrieval, 2. Data conversion, 3. Response size determination and file output
|
||||
/// Related classes: GetHierarchyTool, HierarchyService, HierarchySerializer, HierarchyResultExporter
|
||||
/// </summary>
|
||||
public class GetHierarchyUseCase : AbstractUseCase<GetHierarchySchema, GetHierarchyResponse>
|
||||
{
|
||||
private readonly HierarchyService _hierarchyService;
|
||||
private readonly HierarchySerializer _hierarchySerializer;
|
||||
|
||||
public GetHierarchyUseCase(HierarchyService hierarchyService, HierarchySerializer hierarchySerializer)
|
||||
{
|
||||
_hierarchyService = hierarchyService ?? throw new System.ArgumentNullException(nameof(hierarchyService));
|
||||
_hierarchySerializer = hierarchySerializer ?? throw new System.ArgumentNullException(nameof(hierarchySerializer));
|
||||
}
|
||||
/// <summary>
|
||||
/// Execute Unity Hierarchy retrieval processing
|
||||
/// </summary>
|
||||
/// <param name="parameters">Hierarchy retrieval parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation control token</param>
|
||||
/// <returns>Hierarchy retrieval result</returns>
|
||||
public override Task<GetHierarchyResponse> ExecuteAsync(GetHierarchySchema parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
if (parameters == null)
|
||||
{
|
||||
throw new System.ArgumentNullException(nameof(parameters));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Hierarchy information retrieval
|
||||
HierarchyOptions options = new HierarchyOptions
|
||||
{
|
||||
IncludeInactive = parameters.IncludeInactive,
|
||||
MaxDepth = parameters.MaxDepth,
|
||||
RootPath = parameters.RootPath,
|
||||
IncludeComponents = parameters.IncludeComponents,
|
||||
UseSelection = parameters.UseSelection
|
||||
};
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var nodes = _hierarchyService.GetHierarchyNodes(options);
|
||||
HierarchyContext context = _hierarchyService.GetCurrentContext() ?? new HierarchyContext("editor", string.Empty, 0, 0);
|
||||
|
||||
// 2. Data conversion to scene-grouped structure
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
HierarchySerializationOptions serOptions = new HierarchySerializationOptions
|
||||
{
|
||||
IncludePaths = parameters.IncludePaths,
|
||||
UseComponentsLut = parameters.UseComponentsLut
|
||||
};
|
||||
HierarchySerializationResult result = _hierarchySerializer.BuildGroups(nodes, context, serOptions);
|
||||
|
||||
// 3. Always export to JSON
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string filePath = HierarchyResultExporter.ExportHierarchyResults(result.Groups, result.Context);
|
||||
string message = "Hierarchy data saved below. Open the JSON to read 'Context' and 'Hierarchy'.";
|
||||
return Task.FromResult(new GetHierarchyResponse(filePath, message));
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
throw; // Re-throw cancellation exceptions
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// Log the error and re-throw
|
||||
VibeLogger.LogError("get_hierarchy_failed", $"Failed to get hierarchy: {ex.Message}", ex);
|
||||
throw new System.InvalidOperationException($"Failed to retrieve hierarchy: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5464b80f6990d4213854ebd34325b54e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles temporal cohesion for log retrieval processing
|
||||
/// Processing sequence: 1. Log retrieval, 2. Filtering, 3. Limiting and formatting
|
||||
/// Related classes: GetLogsTool, LogRetrievalService, LogFilteringService
|
||||
/// </summary>
|
||||
public class GetLogsUseCase : AbstractUseCase<GetLogsSchema, GetLogsResponse>
|
||||
{
|
||||
private readonly LogRetrievalService _retrievalService;
|
||||
private readonly LogFilteringService _filteringService;
|
||||
|
||||
public GetLogsUseCase(LogRetrievalService retrievalService, LogFilteringService filteringService)
|
||||
{
|
||||
_retrievalService = retrievalService ?? throw new System.ArgumentNullException(nameof(retrievalService));
|
||||
_filteringService = filteringService ?? throw new System.ArgumentNullException(nameof(filteringService));
|
||||
}
|
||||
/// <summary>
|
||||
/// Executes log retrieval processing
|
||||
/// </summary>
|
||||
/// <param name="parameters">Log retrieval parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation control token</param>
|
||||
/// <returns>Log retrieval result</returns>
|
||||
public override Task<GetLogsResponse> ExecuteAsync(GetLogsSchema parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 1. Log retrieval
|
||||
LogDisplayDto logData;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(parameters.SearchText))
|
||||
{
|
||||
logData = _retrievalService.GetLogs(parameters.LogType);
|
||||
}
|
||||
else
|
||||
{
|
||||
logData = _retrievalService.GetLogsWithSearch(
|
||||
parameters.LogType,
|
||||
parameters.SearchText,
|
||||
parameters.UseRegex,
|
||||
parameters.SearchInStackTrace);
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
throw new System.InvalidOperationException($"Failed to retrieve logs: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
// 2. Filtering and limiting
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
LogEntry[] logs = _filteringService.FilterAndLimitLogs(
|
||||
logData.LogEntries,
|
||||
parameters.MaxCount,
|
||||
parameters.IncludeStackTrace);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 3. Response creation
|
||||
GetLogsResponse response = new GetLogsResponse(
|
||||
totalCount: logData.TotalCount,
|
||||
displayedCount: logs.Length,
|
||||
logType: parameters.LogType,
|
||||
maxCount: parameters.MaxCount,
|
||||
searchText: parameters.SearchText,
|
||||
includeStackTrace: parameters.IncludeStackTrace,
|
||||
logs: logs
|
||||
);
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
// Propagate cancellations
|
||||
throw;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// Wrap or log unexpected errors
|
||||
throw new System.InvalidOperationException($"Failed to retrieve logs: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e37e18476eb9c4a7ca617aa4c23beff3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
internal interface IExecuteDynamicCodeUseCase
|
||||
{
|
||||
Task<ExecuteDynamicCodeResponse> ExecuteAsync(
|
||||
ExecuteDynamicCodeSchema parameters,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7c369f97a3ef420d941f34d47dd0f72
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
internal interface IPrewarmDynamicCodeUseCase
|
||||
{
|
||||
void Request();
|
||||
|
||||
Task RequestAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f51748dd9296d420e900265b4db3781a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,615 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
internal sealed class PrewarmDynamicCodeUseCase : IPrewarmDynamicCodeUseCase
|
||||
{
|
||||
private const int AutoPrewarmDelayFrameCount = 1;
|
||||
private const int AutoPrewarmStablePassCount = 3;
|
||||
private const int AutoPrewarmUserLikePassCount = 1;
|
||||
private const int AutoPrewarmPassCount = AutoPrewarmStablePassCount + AutoPrewarmUserLikePassCount;
|
||||
private const int AutoPrewarmMaxAttempts = 6;
|
||||
private const int AutoPrewarmBusyRetryDelayFrameCount = 1;
|
||||
// Why: startup measurements showed the first user-visible execute-dynamic-code request
|
||||
// stayed cold until the stable logging path and the default `using UnityEngine;` wrapper
|
||||
// shape had both run at least once.
|
||||
// Why not a cheaper "return null;" snippet or a dedicated prewarm class name: those warm
|
||||
// compiler registration, but they miss the default execute-dynamic-code cache key and
|
||||
// leave the first real request measurably slower.
|
||||
// Why: after a forced compile + domain reload, the first two execute-dynamic-code requests
|
||||
// still paid cold-start costs, and only the third pass reached steady-state latency in measurements.
|
||||
// Why not stop at two passes: that still leaves the first post-reload user-visible request
|
||||
// hundreds of milliseconds slower than the warmed path, which is the exact regression we need to avoid.
|
||||
// Why not warm only the stable fully-qualified snippet: that keeps startup resilient, but
|
||||
// it still leaves `using UnityEngine; Debug.Log(...)` requests paying a separate cold path.
|
||||
private const string AutoPrewarmStableCode =
|
||||
"UnityEngine.LogType previous = UnityEngine.Debug.unityLogger.filterLogType; UnityEngine.Debug.unityLogger.filterLogType = UnityEngine.LogType.Warning; try { UnityEngine.Debug.Log(\"Unity CLI Loop dynamic code prewarm\"); return \"Unity CLI Loop dynamic code prewarm\"; } finally { UnityEngine.Debug.unityLogger.filterLogType = previous; }";
|
||||
private const string AutoPrewarmUserLikeCode =
|
||||
"using UnityEngine; LogType previous = Debug.unityLogger.filterLogType; Debug.unityLogger.filterLogType = LogType.Warning; try { Debug.Log(\"Unity CLI Loop dynamic code prewarm\"); return \"Unity CLI Loop dynamic code prewarm\"; } finally { Debug.unityLogger.filterLogType = previous; }";
|
||||
private const string AutoPrewarmClassName = DynamicCodeConstants.DEFAULT_CLASS_NAME;
|
||||
private const string AutoPrewarmOperation = "dynamic_code_auto_prewarm";
|
||||
|
||||
private readonly IDynamicCodeExecutionRuntime _runtime;
|
||||
private readonly IDynamicCodeAutoPrewarmExecutor _executor;
|
||||
private readonly CancellationToken _lifecycleCancellationToken;
|
||||
private string _serverStartingLockToken;
|
||||
private readonly object _autoPrewarmLock = new();
|
||||
private Task _autoPrewarmTask;
|
||||
|
||||
public PrewarmDynamicCodeUseCase(
|
||||
IDynamicCodeExecutionRuntime runtime,
|
||||
CancellationToken lifecycleCancellationToken = default,
|
||||
IDynamicCodeAutoPrewarmExecutor executor = null,
|
||||
string serverStartingLockToken = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_executor = executor ?? new TcpDynamicCodeAutoPrewarmExecutor();
|
||||
_lifecycleCancellationToken = lifecycleCancellationToken;
|
||||
_serverStartingLockToken = serverStartingLockToken;
|
||||
}
|
||||
|
||||
internal void AttachServerStartingLockToken(string serverStartingLockToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(serverStartingLockToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_autoPrewarmLock)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_serverStartingLockToken))
|
||||
{
|
||||
_serverStartingLockToken = serverStartingLockToken;
|
||||
return;
|
||||
}
|
||||
|
||||
UnityEngine.Debug.Assert(
|
||||
string.Equals(_serverStartingLockToken, serverStartingLockToken, StringComparison.Ordinal),
|
||||
"serverStartingLockToken must not change once attached");
|
||||
}
|
||||
}
|
||||
|
||||
internal string GetServerStartingLockTokenForTests()
|
||||
{
|
||||
lock (_autoPrewarmLock)
|
||||
{
|
||||
return _serverStartingLockToken;
|
||||
}
|
||||
}
|
||||
|
||||
public void Request()
|
||||
{
|
||||
RequestAsync().Forget();
|
||||
}
|
||||
|
||||
public Task RequestAsync()
|
||||
{
|
||||
lock (_autoPrewarmLock)
|
||||
{
|
||||
if (_autoPrewarmTask != null && !_autoPrewarmTask.IsCompleted)
|
||||
{
|
||||
return _autoPrewarmTask;
|
||||
}
|
||||
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmQueued();
|
||||
_autoPrewarmTask = RunAsync();
|
||||
return _autoPrewarmTask;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_lifecycleCancellationToken.ThrowIfCancellationRequested();
|
||||
if (!_runtime.SupportsAutoPrewarm())
|
||||
{
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmSkipped("fast_path_unavailable");
|
||||
VibeLogger.LogInfo(
|
||||
AutoPrewarmOperation,
|
||||
"Skipping dynamic code auto prewarm because the fast path is unavailable",
|
||||
new { reason = "fast_path_unavailable" });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ToolSettings.IsToolEnabled(McpConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE))
|
||||
{
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmSkipped("tool_disabled");
|
||||
VibeLogger.LogInfo(
|
||||
AutoPrewarmOperation,
|
||||
"Skipping dynamic code auto prewarm because execute-dynamic-code is disabled",
|
||||
new { reason = "tool_disabled" });
|
||||
return;
|
||||
}
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
AutoPrewarmOperation,
|
||||
"Starting dynamic code auto prewarm",
|
||||
new
|
||||
{
|
||||
delay_frames = AutoPrewarmDelayFrameCount,
|
||||
class_name = AutoPrewarmClassName,
|
||||
pass_count = AutoPrewarmPassCount
|
||||
});
|
||||
|
||||
await EditorDelay.DelayFrame(AutoPrewarmDelayFrameCount, _lifecycleCancellationToken);
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmStarted();
|
||||
|
||||
int successfulPassCount = 0;
|
||||
bool sawTransientPrewarmFailure = false;
|
||||
int transientBusyRetryCount = 0;
|
||||
for (int attemptIndex = 0;
|
||||
attemptIndex < AutoPrewarmMaxAttempts && successfulPassCount < AutoPrewarmPassCount;
|
||||
attemptIndex++)
|
||||
{
|
||||
string autoPrewarmCode = GetAutoPrewarmCodeForPass(successfulPassCount);
|
||||
ExecuteDynamicCodeSchema parameters = new ExecuteDynamicCodeSchema
|
||||
{
|
||||
Code = autoPrewarmCode,
|
||||
CompileOnly = false,
|
||||
YieldToForegroundRequests = true
|
||||
};
|
||||
|
||||
// Why: the remaining domain-reload spike only disappeared when measurements warmed the
|
||||
// full execute-dynamic-code entry path, not just the runtime facade underneath it.
|
||||
// Why not keep the runtime-direct prewarm: that warmed compiler startup, but it still
|
||||
// left the first real CLI request paying extra cost in the tool/JSON-RPC layers.
|
||||
using IDisposable diagnosticSourceScope =
|
||||
DynamicCompilationHealthMonitor.UseConsoleDiagnosticSource(AutoPrewarmOperation);
|
||||
DynamicCodeAutoPrewarmResult result = await _executor.ExecuteAsync(
|
||||
parameters,
|
||||
_lifecycleCancellationToken);
|
||||
|
||||
if (IsExecutionBusy(result))
|
||||
{
|
||||
if (sawTransientPrewarmFailure)
|
||||
{
|
||||
transientBusyRetryCount++;
|
||||
if (transientBusyRetryCount >= AutoPrewarmMaxAttempts)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
AutoPrewarmOperation,
|
||||
"Dynamic code auto prewarm is waiting for an earlier transient attempt to finish",
|
||||
new
|
||||
{
|
||||
class_name = AutoPrewarmClassName,
|
||||
attempt_index = attemptIndex + 1,
|
||||
max_attempts = AutoPrewarmMaxAttempts
|
||||
});
|
||||
await EditorDelay.DelayFrame(
|
||||
AutoPrewarmBusyRetryDelayFrameCount,
|
||||
_lifecycleCancellationToken);
|
||||
attemptIndex--;
|
||||
continue;
|
||||
}
|
||||
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmSkipped("runtime_busy");
|
||||
VibeLogger.LogInfo(
|
||||
AutoPrewarmOperation,
|
||||
"Skipping dynamic code auto prewarm because execute-dynamic-code is busy",
|
||||
new
|
||||
{
|
||||
class_name = AutoPrewarmClassName,
|
||||
reason = "runtime_busy",
|
||||
pass_index = successfulPassCount + 1,
|
||||
pass_count = AutoPrewarmPassCount
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (WasCancelledByForegroundRequest(result))
|
||||
{
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmYielded("foreground_request_preempted");
|
||||
LogForegroundPreemption();
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsTransientTransportFailure(result))
|
||||
{
|
||||
sawTransientPrewarmFailure = true;
|
||||
transientBusyRetryCount = 0;
|
||||
VibeLogger.LogWarning(
|
||||
AutoPrewarmOperation,
|
||||
"Dynamic code auto prewarm hit a transient loopback failure and will retry",
|
||||
new
|
||||
{
|
||||
class_name = AutoPrewarmClassName,
|
||||
error_message = result.ErrorMessage,
|
||||
attempt_index = attemptIndex + 1,
|
||||
max_attempts = AutoPrewarmMaxAttempts
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsTransientStartupExecutionFailure(result))
|
||||
{
|
||||
sawTransientPrewarmFailure = true;
|
||||
transientBusyRetryCount = 0;
|
||||
VibeLogger.LogWarning(
|
||||
AutoPrewarmOperation,
|
||||
"Dynamic code auto prewarm hit a transient startup execution failure and will retry",
|
||||
new
|
||||
{
|
||||
class_name = AutoPrewarmClassName,
|
||||
error_message = result.ErrorMessage,
|
||||
attempt_index = attemptIndex + 1,
|
||||
max_attempts = AutoPrewarmMaxAttempts
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmFailed(result.ErrorMessage);
|
||||
VibeLogger.LogWarning(
|
||||
AutoPrewarmOperation,
|
||||
"Dynamic code auto prewarm failed",
|
||||
new
|
||||
{
|
||||
class_name = AutoPrewarmClassName,
|
||||
error_message = result.ErrorMessage,
|
||||
pass_index = successfulPassCount + 1,
|
||||
pass_count = AutoPrewarmPassCount
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sawTransientPrewarmFailure = false;
|
||||
transientBusyRetryCount = 0;
|
||||
successfulPassCount++;
|
||||
VibeLogger.LogInfo(
|
||||
AutoPrewarmOperation,
|
||||
"Dynamic code auto prewarm pass completed successfully",
|
||||
new
|
||||
{
|
||||
class_name = AutoPrewarmClassName,
|
||||
pass_index = successfulPassCount,
|
||||
pass_count = AutoPrewarmPassCount
|
||||
});
|
||||
}
|
||||
|
||||
if (successfulPassCount < AutoPrewarmPassCount)
|
||||
{
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmFailed(TcpDynamicCodeAutoPrewarmExecutor.TimeoutErrorMessage);
|
||||
VibeLogger.LogWarning(
|
||||
AutoPrewarmOperation,
|
||||
"Dynamic code auto prewarm exhausted its transient retries",
|
||||
new
|
||||
{
|
||||
class_name = AutoPrewarmClassName,
|
||||
successful_pass_count = successfulPassCount,
|
||||
pass_count = AutoPrewarmPassCount,
|
||||
max_attempts = AutoPrewarmMaxAttempts
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmCompleted();
|
||||
DynamicCodeForegroundWarmupState.MarkCompletedByBackgroundWarmup();
|
||||
VibeLogger.LogInfo(
|
||||
AutoPrewarmOperation,
|
||||
"Dynamic code auto prewarm completed successfully",
|
||||
new
|
||||
{
|
||||
class_name = AutoPrewarmClassName,
|
||||
pass_count = AutoPrewarmPassCount
|
||||
});
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifecycleCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmSkipped("lifecycle_cancelled");
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException) when (_lifecycleCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
DynamicCodeStartupTelemetry.MarkPrewarmSkipped("lifecycle_cancelled");
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ServerStartingLockService.DeleteOwnedLockFile(GetServerStartingLockTokenForTests());
|
||||
}
|
||||
}
|
||||
|
||||
private static bool WasCancelledByForegroundRequest(DynamicCodeAutoPrewarmResult result)
|
||||
{
|
||||
if (result == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !result.Success
|
||||
&& string.Equals(
|
||||
result.ErrorMessage,
|
||||
McpConstants.ERROR_MESSAGE_EXECUTION_CANCELLED,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsExecutionBusy(DynamicCodeAutoPrewarmResult result)
|
||||
{
|
||||
if (result == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !result.Success
|
||||
&& string.Equals(
|
||||
result.ErrorMessage,
|
||||
McpConstants.ERROR_MESSAGE_EXECUTION_IN_PROGRESS,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void LogForegroundPreemption()
|
||||
{
|
||||
VibeLogger.LogInfo(
|
||||
AutoPrewarmOperation,
|
||||
"Dynamic code auto prewarm yielded to a foreground execute-dynamic-code request",
|
||||
new { reason = "foreground_request_preempted" });
|
||||
}
|
||||
|
||||
private static bool IsTransientTransportFailure(DynamicCodeAutoPrewarmResult result)
|
||||
{
|
||||
if (result == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
result.ErrorMessage,
|
||||
TcpDynamicCodeAutoPrewarmExecutor.TimeoutErrorMessage,
|
||||
StringComparison.Ordinal)
|
||||
|| string.Equals(
|
||||
result.ErrorMessage,
|
||||
TcpDynamicCodeAutoPrewarmExecutor.TransportErrorMessage,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string GetAutoPrewarmCodeForPass(int successfulPassCount)
|
||||
{
|
||||
return successfulPassCount < AutoPrewarmStablePassCount
|
||||
? AutoPrewarmStableCode
|
||||
: AutoPrewarmUserLikeCode;
|
||||
}
|
||||
|
||||
private static bool IsTransientStartupExecutionFailure(DynamicCodeAutoPrewarmResult result)
|
||||
{
|
||||
if (result == null || result.Success || string.IsNullOrEmpty(result.ErrorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string errorMessage = result.ErrorMessage;
|
||||
return errorMessage.IndexOf("Internal error", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| errorMessage.IndexOf("PreUsingResolver.Resolve", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| errorMessage.IndexOf("System.NullReferenceException", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal interface IDynamicCodeAutoPrewarmExecutor
|
||||
{
|
||||
Task<DynamicCodeAutoPrewarmResult> ExecuteAsync(
|
||||
ExecuteDynamicCodeSchema parameters,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
internal sealed class DynamicCodeAutoPrewarmResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
|
||||
public string ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class TcpDynamicCodeAutoPrewarmExecutor : IDynamicCodeAutoPrewarmExecutor
|
||||
{
|
||||
private const string AutoPrewarmRequestId = "dynamic-code-auto-prewarm";
|
||||
private const int LoopbackPrewarmTimeoutMilliseconds = 10000;
|
||||
internal const string TimeoutErrorMessage = "dynamic code auto prewarm timed out";
|
||||
internal const string TransportErrorMessage = "dynamic code auto prewarm transport failed";
|
||||
private readonly Func<string, CancellationToken, Task<string>> _sendRequestAsync;
|
||||
private readonly int _timeoutMilliseconds;
|
||||
|
||||
public TcpDynamicCodeAutoPrewarmExecutor()
|
||||
: this(SendRequestOverLoopbackAsync, LoopbackPrewarmTimeoutMilliseconds)
|
||||
{
|
||||
}
|
||||
|
||||
internal TcpDynamicCodeAutoPrewarmExecutor(
|
||||
Func<string, CancellationToken, Task<string>> sendRequestAsync,
|
||||
int timeoutMilliseconds = LoopbackPrewarmTimeoutMilliseconds)
|
||||
{
|
||||
_sendRequestAsync = sendRequestAsync ?? throw new ArgumentNullException(nameof(sendRequestAsync));
|
||||
_timeoutMilliseconds = timeoutMilliseconds;
|
||||
}
|
||||
|
||||
public async Task<DynamicCodeAutoPrewarmResult> ExecuteAsync(
|
||||
ExecuteDynamicCodeSchema parameters,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
string requestJson = CreateRequestJson(parameters);
|
||||
// Why: measurements showed that even the in-process JSON-RPC path left the first
|
||||
// post-reload CLI request around 500ms, while a real loopback TCP request fully
|
||||
// warmed the remaining listener/frame-parser path.
|
||||
// Why not stop at JsonRpcProcessor.ProcessRequest: that bypasses the transport layer
|
||||
// that real Unity CLI Loop requests still have to pay on their first trip after reload.
|
||||
using CancellationTokenSource timeoutCancellationTokenSource =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCancellationTokenSource.CancelAfter(_timeoutMilliseconds);
|
||||
try
|
||||
{
|
||||
string responseJson = await _sendRequestAsync(
|
||||
requestJson,
|
||||
timeoutCancellationTokenSource.Token);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return ParseResponse(responseJson);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new DynamicCodeAutoPrewarmResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = TimeoutErrorMessage
|
||||
};
|
||||
}
|
||||
catch (IOException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new DynamicCodeAutoPrewarmResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = TransportErrorMessage
|
||||
};
|
||||
}
|
||||
catch (SocketException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new DynamicCodeAutoPrewarmResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = TransportErrorMessage
|
||||
};
|
||||
}
|
||||
catch (JsonException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new DynamicCodeAutoPrewarmResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = TransportErrorMessage
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return new DynamicCodeAutoPrewarmResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = TransportErrorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateRequestJson(ExecuteDynamicCodeSchema parameters)
|
||||
{
|
||||
JsonSerializer serializer = JsonSerializer.Create(
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
});
|
||||
JObject request = new JObject
|
||||
{
|
||||
["jsonrpc"] = McpServerConfig.JSONRPC_VERSION,
|
||||
["id"] = AutoPrewarmRequestId,
|
||||
["method"] = McpConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE,
|
||||
["params"] = JObject.FromObject(parameters, serializer),
|
||||
["x-uloop"] = new JObject
|
||||
{
|
||||
["expectedProjectRoot"] = McpEditorSettings.GetProjectRootPath(),
|
||||
["expectedServerSessionId"] = McpEditorSettings.GetServerSessionId()
|
||||
}
|
||||
};
|
||||
|
||||
return request.ToString(Formatting.None);
|
||||
}
|
||||
|
||||
private static async Task<string> SendRequestOverLoopbackAsync(
|
||||
string requestJson,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using TcpClient client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, McpServerController.ServerPort).ConfigureAwait(false);
|
||||
using NetworkStream stream = client.GetStream();
|
||||
|
||||
string framedRequest = CreateContentLengthFrame(requestJson);
|
||||
byte[] requestBytes = Encoding.UTF8.GetBytes(framedRequest);
|
||||
await stream.WriteAsync(requestBytes, 0, requestBytes.Length, ct).ConfigureAwait(false);
|
||||
await stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
return await ReadFramedResponseAsync(stream, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadFramedResponseAsync(
|
||||
NetworkStream stream,
|
||||
CancellationToken ct)
|
||||
{
|
||||
FrameParser frameParser = new FrameParser();
|
||||
byte[] readBuffer = new byte[BufferConfig.INITIAL_BUFFER_SIZE];
|
||||
using MemoryStream responseBuffer = new MemoryStream();
|
||||
|
||||
while (true)
|
||||
{
|
||||
int bytesRead = await stream.ReadAsync(readBuffer, 0, readBuffer.Length, ct).ConfigureAwait(false);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Loopback prewarm connection closed before a JSON-RPC response was received.");
|
||||
}
|
||||
|
||||
responseBuffer.Write(readBuffer, 0, bytesRead);
|
||||
byte[] bufferedBytes = responseBuffer.GetBuffer();
|
||||
int bufferedLength = checked((int)responseBuffer.Length);
|
||||
|
||||
bool parsed = frameParser.TryParseFrame(
|
||||
bufferedBytes,
|
||||
bufferedLength,
|
||||
out int contentLength,
|
||||
out int headerLength);
|
||||
if (!parsed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!frameParser.IsCompleteFrame(bufferedBytes, bufferedLength, contentLength, headerLength))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return frameParser.ExtractJsonContent(bufferedBytes, contentLength, headerLength);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateContentLengthFrame(string jsonContent)
|
||||
{
|
||||
int contentLength = Encoding.UTF8.GetByteCount(jsonContent);
|
||||
return $"Content-Length: {contentLength}\r\n\r\n{jsonContent}";
|
||||
}
|
||||
|
||||
private static DynamicCodeAutoPrewarmResult ParseResponse(string responseJson)
|
||||
{
|
||||
JObject response = JObject.Parse(responseJson);
|
||||
JToken resultToken = response["result"];
|
||||
if (resultToken != null && resultToken.Type != JTokenType.Null)
|
||||
{
|
||||
ExecuteDynamicCodeResponse executeResponse = resultToken.ToObject<ExecuteDynamicCodeResponse>();
|
||||
return new DynamicCodeAutoPrewarmResult
|
||||
{
|
||||
Success = executeResponse?.Success == true,
|
||||
ErrorMessage = executeResponse?.ErrorMessage
|
||||
};
|
||||
}
|
||||
|
||||
JToken errorToken = response["error"];
|
||||
return new DynamicCodeAutoPrewarmResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = errorToken?["message"]?.ToString() ?? "Unknown error"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b256ec16c25494ccf9a0f9904733a244
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles temporal cohesion for test execution processing
|
||||
/// Processing sequence: 1. Test filter creation, 2. Test execution, 3. Result processing
|
||||
/// Related classes: RunTestsTool, TestFilterCreationService, TestExecutionService
|
||||
/// </summary>
|
||||
public class RunTestsUseCase : AbstractUseCase<RunTestsSchema, RunTestsResponse>
|
||||
{
|
||||
private readonly TestFilterCreationService _filterService;
|
||||
private readonly TestExecutionService _executionService;
|
||||
private readonly TestExecutionStateValidationService _validationService;
|
||||
|
||||
public RunTestsUseCase()
|
||||
: this(
|
||||
new TestFilterCreationService(),
|
||||
new TestExecutionService(),
|
||||
new TestExecutionStateValidationService())
|
||||
{
|
||||
}
|
||||
|
||||
public RunTestsUseCase(
|
||||
TestFilterCreationService filterService,
|
||||
TestExecutionService executionService,
|
||||
TestExecutionStateValidationService validationService)
|
||||
{
|
||||
Debug.Assert(filterService != null, "filterService must not be null");
|
||||
Debug.Assert(executionService != null, "executionService must not be null");
|
||||
Debug.Assert(validationService != null, "validationService must not be null");
|
||||
_filterService = filterService;
|
||||
_executionService = executionService;
|
||||
_validationService = validationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes test execution processing
|
||||
/// </summary>
|
||||
/// <param name="parameters">Test execution parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation control token</param>
|
||||
/// <returns>Test execution result</returns>
|
||||
public override async Task<RunTestsResponse> ExecuteAsync(RunTestsSchema parameters, CancellationToken ct)
|
||||
{
|
||||
#if !ULOOPMCP_HAS_TEST_FRAMEWORK
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return await Task.FromResult(RunTestsResponse.CreateTestFrameworkUnavailable());
|
||||
#else
|
||||
ValidationResult validation = _validationService.Validate(parameters.TestMode, parameters.SaveBeforeRun);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return CreateFailureResponse(validation.ErrorMessage);
|
||||
}
|
||||
|
||||
// 1. Test filter creation
|
||||
TestExecutionFilter filter = null;
|
||||
if (parameters.FilterType != TestFilterType.all)
|
||||
{
|
||||
filter = _filterService.CreateFilter(parameters.FilterType, parameters.FilterValue);
|
||||
}
|
||||
|
||||
// 2. Test execution
|
||||
ct.ThrowIfCancellationRequested();
|
||||
SerializableTestResult result;
|
||||
|
||||
try
|
||||
{
|
||||
if (parameters.TestMode == RunTestMode.PlayMode)
|
||||
{
|
||||
// TODO: Add cancellationToken parameter when TestExecutionService supports it
|
||||
result = await _executionService.ExecutePlayModeTestAsync(filter);
|
||||
}
|
||||
else
|
||||
{
|
||||
// TODO: Add cancellationToken parameter when TestExecutionService supports it
|
||||
result = await _executionService.ExecuteEditModeTestAsync(filter);
|
||||
}
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
// Propagate cancellation exceptions
|
||||
throw;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
// Log full exception details for debugging
|
||||
UnityEngine.Debug.LogError($"Test execution failed: {ex}");
|
||||
VibeLogger.LogError(
|
||||
"test_execution_failed",
|
||||
"Test execution encountered an error",
|
||||
new { testMode = parameters.TestMode, filterType = parameters.FilterType, filterValue = parameters.FilterValue, error = ex.Message }
|
||||
);
|
||||
|
||||
// Create a minimal error result
|
||||
throw new System.InvalidOperationException("Test execution failed. Please check the logs for details.", ex);
|
||||
}
|
||||
|
||||
// 3. Response creation
|
||||
return new RunTestsResponse(
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
completedAt: result.completedAt,
|
||||
testCount: result.testCount,
|
||||
passedCount: result.passedCount,
|
||||
failedCount: result.failedCount,
|
||||
skippedCount: result.skippedCount,
|
||||
xmlPath: result.xmlPath
|
||||
);
|
||||
#endif
|
||||
}
|
||||
|
||||
private static RunTestsResponse CreateFailureResponse(string message)
|
||||
{
|
||||
return new RunTestsResponse(
|
||||
success: false,
|
||||
message: message,
|
||||
completedAt: DateTime.UtcNow.ToString("o"),
|
||||
testCount: 0,
|
||||
passedCount: 0,
|
||||
failedCount: 0,
|
||||
skippedCount: 0,
|
||||
xmlPath: null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f000233867ed4294aa3e1cba41a2fad
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user