chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖

This commit is contained in:
Liubing\LB
2026-09-28 18:14:59 +08:00
parent 0eb43b2b40
commit c4ae02def9
952 changed files with 70137 additions and 11 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee3e172314c274c1dae6c59aa9e4b049
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c1be0ac44f3434a16a6bd693cf4eaf41
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3a7ed1e9e34fd422e840c465c156ca08
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 409f66a22ed544772b7811a24e53ff26
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bf3ece38a78644e82931ca50dc3a9445
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8f3dabb06ec2746b19fa4703e046223c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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}";
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0d7adc07bd1f3444b9096a8c5fac399a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fc6933b2aa3e34c12850a16afff81ae8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;";
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 674391485f77f4fe6ad909a2ef91cf32
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9f2bc42e02a8f4afcac362ae48853142
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5464b80f6990d4213854ebd34325b54e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e37e18476eb9c4a7ca617aa4c23beff3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b7c369f97a3ef420d941f34d47dd0f72
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,11 @@
using System.Threading.Tasks;
namespace io.github.hatayama.uLoopMCP
{
internal interface IPrewarmDynamicCodeUseCase
{
void Request();
Task RequestAsync();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f51748dd9296d420e900265b4db3781a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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"
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b256ec16c25494ccf9a0f9904733a244
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6f000233867ed4294aa3e1cba41a2fad
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: