diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index a513aca9a..b5227bdfd 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -68,6 +68,8 @@ internal static class EditorPrefKeys internal const string AutoStartOnLoad = "MCPForUnity.AutoStartOnLoad"; internal const string BatchExecuteMaxCommands = "MCPForUnity.BatchExecute.MaxCommands"; + internal const string GatewayJobLogging = "MCPForUnity.Gateway.JobLogging"; + internal const string GatewayJobLogPath = "MCPForUnity.Gateway.JobLogPath"; internal const string LogRecordEnabled = "MCPForUnity.LogRecordEnabled"; } } diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index 53610d965..062c755a5 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -107,6 +107,14 @@ public static string GetMcpPackageRootPath() { try { + // WSL workaround: when the package lives on a WSL UNC path and Unity + // runs on Windows, UXML/USS files cannot be parsed from the UNC path. + // UIAssetSync copies them to Assets/MCPForUnityUI/ on domain reload. + if (UIAssetSync.NeedsSync()) + { + return UIAssetSync.SyncedBasePath; + } + // Try Package Manager first (registry and local installs) var packageInfo = PackageInfo.FindForAssembly(typeof(AssetPathUtility).Assembly); if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath)) diff --git a/MCPForUnity/Editor/Helpers/UIAssetSync.cs b/MCPForUnity/Editor/Helpers/UIAssetSync.cs new file mode 100644 index 000000000..d3dba17b8 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/UIAssetSync.cs @@ -0,0 +1,121 @@ +using UnityEditor; +using UnityEngine; +using System.IO; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Automatically copies UXML and USS files from WSL package directories to a local + /// Assets/MCPForUnityUI/ folder on every domain reload, preserving directory structure. + /// + /// + /// + /// Problem: Unity's UXML/USS importer on Windows cannot properly parse files + /// when packages live on a WSL2 filesystem (UNC paths like \\wsl$\...). The + /// VisualTreeAsset loads but CloneTree produces an empty tree. + /// + /// + /// Solution: On startup, this class copies all UI asset files to + /// Assets/MCPForUnityUI/ and + /// returns this fallback path when WSL is detected. + /// + /// + [InitializeOnLoad] + static class UIAssetSync + { + /// Destination folder under the Unity project for synced UI assets. + internal const string SyncedBasePath = "Assets/MCPForUnityUI"; + + /// + /// Relative paths from package root to UXML and USS files that need syncing. + /// + private static readonly string[] k_UIAssetPaths = + { + "Editor/Windows/MCPForUnityEditorWindow.uxml", + "Editor/Windows/MCPForUnityEditorWindow.uss", + "Editor/Windows/MCPSetupWindow.uxml", + "Editor/Windows/MCPSetupWindow.uss", + "Editor/Windows/EditorPrefs/EditorPrefItem.uxml", + "Editor/Windows/EditorPrefs/EditorPrefsWindow.uxml", + "Editor/Windows/EditorPrefs/EditorPrefsWindow.uss", + "Editor/Windows/Components/Common.uss", + "Editor/Windows/Components/Connection/McpConnectionSection.uxml", + "Editor/Windows/Components/ClientConfig/McpClientConfigSection.uxml", + "Editor/Windows/Components/Validation/McpValidationSection.uxml", + "Editor/Windows/Components/Advanced/McpAdvancedSection.uxml", + "Editor/Windows/Components/Tools/McpToolsSection.uxml", + "Editor/Windows/Components/Resources/McpResourcesSection.uxml", + "Editor/Windows/Components/Queue/McpQueueSection.uxml", + "Editor/Windows/Components/Queue/McpQueueSection.uss", + }; + + static UIAssetSync() + { + if (!NeedsSync()) + return; + + string packageRoot = GetPackagePhysicalRoot(); + if (string.IsNullOrEmpty(packageRoot)) + return; + + bool anyUpdated = false; + + foreach (string relativePath in k_UIAssetPaths) + { + string sourcePath = Path.Combine(packageRoot, relativePath); + if (!File.Exists(sourcePath)) + continue; + + string sourceContent = File.ReadAllText(sourcePath); + + string destPath = Path.GetFullPath(Path.Combine(SyncedBasePath, relativePath)); + string destDir = Path.GetDirectoryName(destPath); + + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + Directory.CreateDirectory(destDir); + + if (File.Exists(destPath) && File.ReadAllText(destPath) == sourceContent) + continue; + + File.WriteAllText(destPath, sourceContent); + Debug.Log($"[UIAssetSync] Updated {relativePath}"); + anyUpdated = true; + } + + if (anyUpdated) + AssetDatabase.Refresh(); + } + + /// + /// Returns true when the MCP package lives on a WSL UNC path and Unity runs on Windows. + /// + internal static bool NeedsSync() + { + if (Application.platform != RuntimePlatform.WindowsEditor) + return false; + + string packageRoot = GetPackagePhysicalRoot(); + if (string.IsNullOrEmpty(packageRoot)) + return false; + + return packageRoot.StartsWith(@"\\wsl", System.StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets the physical (filesystem) root path of the MCP package. + /// + private static string GetPackagePhysicalRoot() + { + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssembly( + typeof(UIAssetSync).Assembly); + if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.resolvedPath)) + return packageInfo.resolvedPath; + + // Fallback: resolve the virtual asset path + if (packageInfo != null && !string.IsNullOrEmpty(packageInfo.assetPath)) + return Path.GetFullPath(packageInfo.assetPath); + + return null; + } + } +} diff --git a/MCPForUnity/Editor/Helpers/UIAssetSync.cs.meta b/MCPForUnity/Editor/Helpers/UIAssetSync.cs.meta new file mode 100644 index 000000000..382ba1f25 --- /dev/null +++ b/MCPForUnity/Editor/Helpers/UIAssetSync.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f23b4c1bd875357488d70068da564267 \ No newline at end of file diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs index c280d9559..5d3fb8fbf 100644 --- a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs +++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs @@ -1,4 +1,5 @@ using MCPForUnity.Editor.Setup; +using MCPForUnity.Editor.Tools; using MCPForUnity.Editor.Windows; using UnityEditor; using UnityEngine; @@ -26,11 +27,22 @@ public static void ShowSetupWindow() SetupWindowService.ShowSetupWindow(); } - [MenuItem("Window/MCP For Unity/Edit EditorPrefs", priority = 3)] public static void ShowEditorPrefsWindow() { EditorPrefsWindow.ShowWindow(); } + + /// + /// Emergency flush: cancels all queued/running MCP commands and clears stuck test jobs. + /// Use when the editor appears frozen due to a stuck MCP queue. + /// Shortcut: Ctrl+Shift+F5 (Cmd+Shift+F5 on Mac) + /// + [MenuItem("Window/MCP For Unity/Emergency Flush Queue %#&F5", priority = 100)] + public static void EmergencyFlushQueue() + { + CommandGatewayState.EmergencyFlush(); + Debug.LogWarning("[MCP] Emergency flush completed. Queue cleared, stuck test jobs removed."); + } } } diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs index bf2ffec4e..5e6bae05a 100644 --- a/MCPForUnity/Editor/Services/TestJobManager.cs +++ b/MCPForUnity/Editor/Services/TestJobManager.cs @@ -220,7 +220,7 @@ private static void TryRestoreFromSessionState() if (currentJob.Status == TestJobStatus.Running) { long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - long staleCutoffMs = 5 * 60 * 1000; // 5 minutes + long staleCutoffMs = 60 * 1000; // 60 seconds if (now - currentJob.LastUpdateUnixMs > staleCutoffMs) { McpLog.Warn($"[TestJobManager] Clearing stale job {_currentJobId} (last update {(now - currentJob.LastUpdateUnixMs) / 1000}s ago)"); diff --git a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs index becc3bd88..07f9b3bc2 100644 --- a/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs +++ b/MCPForUnity/Editor/Services/Transport/TransportCommandDispatcher.cs @@ -63,6 +63,12 @@ public void TrySetCanceled() } private static readonly Dictionary Pending = new(); + /// + /// Maps command JSON content hash → pending ID for deduplication. + /// When a duplicate command arrives while an identical one is still pending, + /// the duplicate shares the original's TaskCompletionSource instead of queueing again. + /// + private static readonly Dictionary ContentHashToPendingId = new(); private static readonly object PendingLock = new(); private static bool updateHooked; private static bool initialised; @@ -96,6 +102,22 @@ public static Task ExecuteCommandJsonAsync(string commandJson, Cancellat EnsureInitialised(); + // --- Deduplication: if an identical command is already pending, share its result --- + var contentHash = ComputeContentHash(commandJson); + + lock (PendingLock) + { + if (contentHash != null + && ContentHashToPendingId.TryGetValue(contentHash, out var existingId) + && Pending.TryGetValue(existingId, out var existingPending) + && !existingPending.CancellationToken.IsCancellationRequested) + { + McpLog.Info($"[Dispatcher] Dedup: identical command already pending (id={existingId}). Sharing result."); + // Return the existing task — caller gets the same result when the original completes. + return existingPending.CompletionSource.Task; + } + } + var id = Guid.NewGuid().ToString("N"); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -108,6 +130,8 @@ public static Task ExecuteCommandJsonAsync(string commandJson, Cancellat lock (PendingLock) { Pending[id] = pending; + if (contentHash != null) + ContentHashToPendingId[contentHash] = id; } // Proactively wake up the main thread execution loop. This improves responsiveness @@ -362,6 +386,54 @@ private static void ProcessCommand(string id, PendingCommand pending) } var logType = resourceMeta != null ? "resource" : toolMeta != null ? "tool" : "unknown"; + + // --- Tier-aware dispatch --- + var declaredTier = CommandRegistry.GetToolTier(command.type); + var effectiveTier = CommandClassifier.Classify(command.type, declaredTier, parameters); + + if (effectiveTier != ExecutionTier.Instant) + { + // Route Smooth/Heavy through the gateway queue for tier-aware scheduling, + // heavy exclusivity, domain-reload guards, and CancellationToken support. + var job = CommandGatewayState.Queue.SubmitSingle(command.type, parameters, "transport"); + var gatewaySw = McpLogRecord.IsEnabled ? System.Diagnostics.Stopwatch.StartNew() : null; + + async void AwaitGateway() + { + try + { + var gatewayResult = await CommandGatewayState.AwaitJob(job); + gatewaySw?.Stop(); + if (gatewayResult is IMcpResponse mcpResp && !mcpResp.Success) + { + McpLogRecord.Log(command.type, parameters, logType, "ERROR", gatewaySw?.ElapsedMilliseconds ?? 0, (gatewayResult as ErrorResponse)?.Error); + var errResponse = new { status = "error", result = gatewayResult, _queue = new { ticket = job.Ticket, tier = job.Tier.ToString().ToLowerInvariant(), queued = true } }; + pending.TrySetResult(JsonConvert.SerializeObject(errResponse)); + } + else + { + McpLogRecord.Log(command.type, parameters, logType, "SUCCESS", gatewaySw?.ElapsedMilliseconds ?? 0, null); + var okResponse = new { status = "success", result = gatewayResult, _queue = new { ticket = job.Ticket, tier = job.Tier.ToString().ToLowerInvariant(), queued = true } }; + pending.TrySetResult(JsonConvert.SerializeObject(okResponse)); + } + } + catch (Exception ex) + { + gatewaySw?.Stop(); + McpLogRecord.Log(command.type, parameters, logType, "ERROR", gatewaySw?.ElapsedMilliseconds ?? 0, ex.Message); + pending.TrySetResult(SerializeError(ex.Message, command.type, ex.StackTrace)); + } + finally + { + EditorApplication.delayCall += () => RemovePending(id, pending); + } + } + + AwaitGateway(); + return; + } + + // --- Instant tier: execute directly (existing path) --- var sw = McpLogRecord.IsEnabled ? System.Diagnostics.Stopwatch.StartNew() : null; var result = CommandRegistry.ExecuteCommand(command.type, parameters, pending.CompletionSource); @@ -412,8 +484,8 @@ private static void ProcessCommand(string id, PendingCommand pending) } McpLogRecord.Log(command.type, parameters, logType, syncLogStatus, sw?.ElapsedMilliseconds ?? 0, syncLogError); - var response = new { status = "success", result }; - pending.TrySetResult(JsonConvert.SerializeObject(response)); + var directResponse = new { status = "success", result }; + pending.TrySetResult(JsonConvert.SerializeObject(directResponse)); RemovePending(id, pending); } catch (Exception ex) @@ -431,6 +503,7 @@ private static void CancelPending(string id, CancellationToken token) { if (Pending.Remove(id, out pending)) { + CleanContentHash(id); UnhookUpdateIfIdle(); } } @@ -444,12 +517,48 @@ private static void RemovePending(string id, PendingCommand pending) lock (PendingLock) { Pending.Remove(id); + CleanContentHash(id); UnhookUpdateIfIdle(); } pending.Dispose(); } + /// + /// Remove the content hash entry that points to the given pending ID. + /// Must be called under PendingLock. + /// + private static void CleanContentHash(string pendingId) + { + string hashToRemove = null; + foreach (var kvp in ContentHashToPendingId) + { + if (kvp.Value == pendingId) + { + hashToRemove = kvp.Key; + break; + } + } + if (hashToRemove != null) + ContentHashToPendingId.Remove(hashToRemove); + } + + /// + /// Compute a stable content hash for command deduplication. + /// Returns null for non-JSON commands (e.g., "ping") which are cheap enough to not need dedup. + /// + private static string ComputeContentHash(string commandJson) + { + if (string.IsNullOrWhiteSpace(commandJson)) return null; + var trimmed = commandJson.Trim(); + if (!trimmed.StartsWith("{")) return null; // Skip non-JSON (ping, etc.) + + // Use the raw JSON string as the hash key. Retries from the same client produce + // byte-identical JSON, so this is both fast and correct for the dedup use case. + // For very large payloads, a proper hash could be used, but MCP commands are small. + return trimmed; + } + private static string SerializeError(string message, string commandType = null, string stackTrace = null) { var errorResponse = new diff --git a/MCPForUnity/Editor/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs index aca4a1994..c4e09a249 100644 --- a/MCPForUnity/Editor/Tools/BatchExecute.cs +++ b/MCPForUnity/Editor/Tools/BatchExecute.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; @@ -51,6 +52,14 @@ public static async Task HandleCommand(JObject @params) $"A maximum of {maxCommands} commands are allowed per batch (configurable in MCP Tools window, hard max {AbsoluteMaxCommandsPerBatch})."); } + // --- Async gateway path --- + bool isAsync = @params.Value("async") ?? false; + if (isAsync) + { + return HandleAsyncSubmit(@params, commandsToken); + } + + // --- Legacy synchronous path (unchanged) --- bool failFast = @params.Value("failFast") ?? false; bool parallelRequested = @params.Value("parallel") ?? false; int? maxParallel = @params.Value("maxParallelism"); @@ -231,5 +240,99 @@ private static JObject NormalizeParameterKeys(JObject source) } private static string ToCamelCase(string key) => StringCaseUtility.ToCamelCase(key); + + /// + /// Handle async batch submission. Queues commands via CommandGateway and returns + /// a ticket (for non-instant batches) or results inline (for instant batches). + /// + private static object HandleAsyncSubmit(JObject @params, JArray commandsToken) + { + bool atomic = @params.Value("atomic") ?? false; + bool failFast = @params.Value("fail_fast") ?? @params.Value("failFast") ?? false; + string agent = @params.Value("agent") ?? "anonymous"; + string label = @params.Value("label") ?? ""; + + var commands = new List(); + foreach (var token in commandsToken) + { + if (token is not JObject cmdObj) continue; + string toolName = cmdObj["tool"]?.ToString(); + if (string.IsNullOrWhiteSpace(toolName)) continue; + + var rawParams = cmdObj["params"] as JObject ?? new JObject(); + var cmdParams = NormalizeParameterKeys(rawParams); + + var toolTier = CommandRegistry.GetToolTier(toolName); + var effectiveTier = CommandClassifier.Classify(toolName, toolTier, cmdParams); + + commands.Add(new BatchCommand { Tool = toolName, Params = cmdParams, Tier = effectiveTier, CausesDomainReload = CommandClassifier.CausesDomainReload(toolName, cmdParams) }); + } + + if (commands.Count == 0) + { + return new ErrorResponse("No valid commands in async batch."); + } + + var job = CommandGatewayState.Queue.Submit(agent, label, atomic, commands); + + if (job.Tier == ExecutionTier.Instant) + { + // Execute inline, return results directly + foreach (var cmd in commands) + { + try + { + var result = CommandRegistry.InvokeCommandAsync(cmd.Tool, cmd.Params) + .ConfigureAwait(true).GetAwaiter().GetResult(); + job.Results.Add(result); + + // fail_fast: stop on first failure result + if (failFast && result is IMcpResponse resp && !resp.Success) + { + job.Status = JobStatus.Failed; + job.Error = $"Command '{cmd.Tool}' failed (fail_fast)."; + job.CompletedAt = DateTime.UtcNow; + return new ErrorResponse(job.Error, + new { ticket = job.Ticket, results = job.Results }); + } + } + catch (Exception ex) + { + job.Results.Add(new ErrorResponse(ex.Message)); + if (atomic || failFast) + { + job.Status = JobStatus.Failed; + job.Error = ex.Message; + job.CompletedAt = DateTime.UtcNow; + return new ErrorResponse($"Instant batch failed at command '{cmd.Tool}': {ex.Message}", + new { ticket = job.Ticket, results = job.Results }); + } + } + } + job.Status = JobStatus.Done; + job.CompletedAt = DateTime.UtcNow; + return new SuccessResponse("Batch completed (instant).", + new { ticket = job.Ticket, results = job.Results }); + } + + // Non-instant: return ticket for polling + var isDedup = job.Deduplicated; + return new PendingResponse( + isDedup + ? $"Duplicate batch — already queued as {job.Ticket}. Poll with poll_job." + : $"Batch queued as {job.Ticket}. Poll with poll_job.", + pollIntervalSeconds: 2.0, + data: new + { + ticket = job.Ticket, + status = job.Status.ToString().ToLowerInvariant(), + position = CommandGatewayState.Queue.GetAheadOf(job.Ticket).Count, + tier = job.Tier.ToString().ToLowerInvariant(), + agent, + label, + atomic, + deduplicated = isDedup + }); + } } } diff --git a/MCPForUnity/Editor/Tools/BatchJob.cs b/MCPForUnity/Editor/Tools/BatchJob.cs new file mode 100644 index 000000000..20abf51b2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/BatchJob.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + public enum JobStatus { Queued, Running, Done, Failed, Cancelled } + + /// + /// Represents a queued batch of MCP commands with ticket tracking. + /// + public class BatchJob + { + public string Ticket { get; set; } + public string Agent { get; set; } + public string Label { get; set; } + public bool Atomic { get; set; } + public ExecutionTier Tier { get; set; } + public bool CausesDomainReload { get; set; } + public JobStatus Status { get; set; } = JobStatus.Queued; + + public List Commands { get; set; } = new(); + public List Results { get; set; } = new(); + public int CurrentIndex { get; set; } + public string Error { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } + + public int UndoGroup { get; set; } = -1; + + /// + /// CancellationTokenSource for this job. Created when the job starts running. + /// Call Cancel() to abort the job mid-execution. + /// + [Newtonsoft.Json.JsonIgnore] + public CancellationTokenSource Cts { get; set; } + + /// + /// True if this job was returned as a dedup match instead of being newly created. + /// The caller submitted an identical command that was already queued/running. + /// + public bool Deduplicated { get; set; } + } + + public class BatchCommand + { + public string Tool { get; set; } + public JObject Params { get; set; } + public ExecutionTier Tier { get; set; } + public bool CausesDomainReload { get; set; } + } + + public class AgentStats + { + public int Active { get; set; } + public int Queued { get; set; } + public int Completed { get; set; } + } +} diff --git a/MCPForUnity/Editor/Tools/BatchJob.cs.meta b/MCPForUnity/Editor/Tools/BatchJob.cs.meta new file mode 100644 index 000000000..d7de5b88d --- /dev/null +++ b/MCPForUnity/Editor/Tools/BatchJob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 55aec8a0199deab4892946b284bc96d9 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/CommandClassifier.cs b/MCPForUnity/Editor/Tools/CommandClassifier.cs new file mode 100644 index 000000000..87cf7d981 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandClassifier.cs @@ -0,0 +1,89 @@ +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Refines a tool's ExecutionTier based on action-level parameters. + /// Tools declare a base tier via [McpForUnityTool(Tier=...)]; this classifier + /// can promote or demote based on specific action strings or param values. + /// + public static class CommandClassifier + { + /// + /// Classify a single command. Returns the effective tier after action-level overrides. + /// + public static ExecutionTier Classify(string toolName, ExecutionTier attributeTier, JObject @params) + { + if (@params == null) return attributeTier; + + string action = @params.Value("action"); + + return toolName switch + { + "manage_scene" => ClassifyManageScene(action, attributeTier), + "refresh_unity" => ClassifyRefreshUnity(@params, attributeTier), + "manage_editor" => ClassifyManageEditor(action, attributeTier), + _ => attributeTier + }; + } + + /// + /// Classify a batch of commands. Returns the highest (most restrictive) tier. + /// + public static ExecutionTier ClassifyBatch( + (string toolName, ExecutionTier attributeTier, JObject @params)[] commands) + { + var max = ExecutionTier.Instant; + foreach (var (toolName, attributeTier, @params) in commands) + { + var tier = Classify(toolName, attributeTier, @params); + if (tier > max) max = tier; + } + return max; + } + + /// + /// Returns true if the given command would trigger a domain reload (compilation or play mode entry). + /// + public static bool CausesDomainReload(string toolName, JObject @params) + { + if (@params == null) return false; + + return toolName switch + { + "refresh_unity" => @params.Value("compile") != "none", + "manage_editor" => @params.Value("action") == "play", + _ => false + }; + } + + static ExecutionTier ClassifyManageScene(string action, ExecutionTier fallback) + { + return action switch + { + "get_hierarchy" or "get_active" or "get_build_settings" or "screenshot" + => ExecutionTier.Instant, + "create" or "load" or "save" + => ExecutionTier.Heavy, + _ => fallback + }; + } + + static ExecutionTier ClassifyRefreshUnity(JObject @params, ExecutionTier fallback) + { + string compile = @params.Value("compile"); + if (compile == "none") return ExecutionTier.Smooth; + return fallback; // Heavy by default + } + + static ExecutionTier ClassifyManageEditor(string action, ExecutionTier fallback) + { + return action switch + { + "telemetry_status" or "telemetry_ping" => ExecutionTier.Instant, + "play" or "pause" or "stop" => ExecutionTier.Heavy, + _ => fallback + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/CommandClassifier.cs.meta b/MCPForUnity/Editor/Tools/CommandClassifier.cs.meta new file mode 100644 index 000000000..709500338 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandClassifier.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e8217c58008fd144e9ce35bc32acdb1c \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/CommandGatewayState.cs b/MCPForUnity/Editor/Tools/CommandGatewayState.cs new file mode 100644 index 000000000..187e949e2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandGatewayState.cs @@ -0,0 +1,106 @@ +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Singleton state for the command gateway queue. + /// Hooks into EditorApplication.update for tick processing. + /// Persists queue state across domain reloads via SessionState. + /// + [InitializeOnLoad] + public static class CommandGatewayState + { + const string SessionKey = "MCPForUnity.GatewayQueueV1"; + + public static readonly CommandQueue Queue = new(); + + static CommandGatewayState() + { + // Restore queue state from previous domain reload + string json = SessionState.GetString(SessionKey, ""); + if (!string.IsNullOrEmpty(json)) + Queue.RestoreFromJson(json); + + Queue.IsEditorBusy = () => + TestJobManager.HasRunningJob + || TestRunStatus.IsRunning + || EditorApplication.isCompiling; + + // Persist before next domain reload + AssemblyReloadEvents.beforeAssemblyReload += () => + SessionState.SetString(SessionKey, Queue.PersistToJson()); + + EditorApplication.update += OnUpdate; + } + + static void OnUpdate() + { + Queue.ProcessTick(async (tool, @params, ct) => + await CommandRegistry.InvokeCommandAsync(tool, @params, ct)); + } + + /// + /// Emergency flush: cancel all queued and running jobs, clear stuck test jobs, + /// and reset the queue. Safe to call from menu items or keyboard shortcuts. + /// + public static void EmergencyFlush() + { + int flushed = Queue.FlushAll(); + + // Also clear any stuck test job that may be holding IsEditorBusy + if (TestJobManager.HasRunningJob) + TestJobManager.ClearStuckJob(); + + McpLog.Warn($"[EmergencyFlush] Flushed {flushed} job(s), cleared stuck test state."); + } + + /// + /// Returns a Task that completes when the given BatchJob leaves Queued/Running state. + /// Uses EditorApplication.update polling — no spin-wait or sleep. + /// For single-command jobs, returns the first result. For multi-command, returns all results. + /// + public static Task AwaitJob(BatchJob job) + { + // Already finished — return immediately + switch (job.Status) + { + case JobStatus.Done: + return Task.FromResult(job.Results.Count > 0 ? job.Results[0] : null); + case JobStatus.Failed: + return Task.FromResult(new ErrorResponse(job.Error ?? "Job failed")); + case JobStatus.Cancelled: + return Task.FromResult(new ErrorResponse("Job cancelled")); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void Check() + { + if (job.Status == JobStatus.Queued || job.Status == JobStatus.Running) + return; + + EditorApplication.update -= Check; + + switch (job.Status) + { + case JobStatus.Done: + tcs.TrySetResult(job.Results.Count > 0 ? job.Results[0] : null); + break; + case JobStatus.Cancelled: + tcs.TrySetResult(new ErrorResponse("Job cancelled")); + break; + default: // Failed + tcs.TrySetResult(new ErrorResponse(job.Error ?? "Job failed")); + break; + } + } + + EditorApplication.update += Check; + return tcs.Task; + } + } +} diff --git a/MCPForUnity/Editor/Tools/CommandGatewayState.cs.meta b/MCPForUnity/Editor/Tools/CommandGatewayState.cs.meta new file mode 100644 index 000000000..da9d86109 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandGatewayState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f8e034942012d44397994c60cceea3e \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs b/MCPForUnity/Editor/Tools/CommandQueue.cs new file mode 100644 index 000000000..0eb7bcbfb --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -0,0 +1,590 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Tier-aware command queue. Processes jobs via EditorApplication.update. + /// Instant: execute inline. Smooth: execute when no heavy active. Heavy: exclusive. + /// + public class CommandQueue + { + readonly TicketStore _store = new(); + readonly Queue _heavyQueue = new(); + readonly List _smoothInFlight = new(); + string _activeHeavyTicket; + + static readonly TimeSpan TicketExpiry = TimeSpan.FromMinutes(5); + + // Watchdog: detect when IsEditorBusy blocks the queue for too long + static readonly TimeSpan BusyWatchdogTimeout = TimeSpan.FromSeconds(90); + DateTime _busyBlockedSince = DateTime.MaxValue; + bool _watchdogWarned; + + /// + /// Predicate that returns true when domain-reload operations should be deferred. + /// Default always returns false. CommandGatewayState wires this to the real checks. + /// + public Func IsEditorBusy { get; set; } = () => false; + + public bool HasActiveHeavy => _activeHeavyTicket != null; + public int QueueDepth => _store.QueueDepth; + public int SmoothInFlight => _smoothInFlight.Count; + + /// + /// Submit a batch of commands. Returns the BatchJob with ticket. + /// Deduplicates multi-command batches: if an identical batch (same tools + params in same order) + /// from the same agent is already queued/running, returns the existing job. + /// + public BatchJob Submit(string agent, string label, bool atomic, List commands) + { + // --- Dedup check for multi-command batches --- + var existing = FindDuplicateBatch(agent, commands); + if (existing != null) + { + existing.Deduplicated = true; + McpLog.Info($"[CommandQueue] Dedup: identical batch already queued/running as {existing.Ticket}. Returning existing job."); + return existing; + } + + var batchTier = CommandClassifier.ClassifyBatch( + commands.Select(c => (c.Tool, c.Tier, c.Params)).ToArray()); + + var job = _store.CreateJob(agent, label, atomic, batchTier); + job.Commands = commands; + job.CausesDomainReload = commands.Any(c => c.CausesDomainReload); + + if (batchTier == ExecutionTier.Heavy) + _heavyQueue.Enqueue(job.Ticket); + // Smooth and Instant are handled differently (see ProcessTick) + + return job; + } + + /// + /// Look for an existing queued/running job with identical commands. + /// + private BatchJob FindDuplicateBatch(string agent, List commands) + { + foreach (var existing in _store.GetAllJobs()) + { + if (existing.Status != JobStatus.Queued && existing.Status != JobStatus.Running) + continue; + if (existing.Commands == null || existing.Commands.Count != commands.Count) + continue; + + bool match = true; + for (int i = 0; i < commands.Count; i++) + { + if (existing.Commands[i].Tool != commands[i].Tool) + { + match = false; + break; + } + var existingParams = existing.Commands[i].Params?.ToString(Newtonsoft.Json.Formatting.None) ?? ""; + var newParams = commands[i].Params?.ToString(Newtonsoft.Json.Formatting.None) ?? ""; + if (existingParams != newParams) + { + match = false; + break; + } + } + + if (match) + return existing; + } + return null; + } + + /// + /// Submit a single command as a 1-command batch job. Convenience wrapper for + /// TransportCommandDispatcher gateway routing. + /// Deduplicates: if an identical command from the same agent is already queued/running, + /// returns the existing job instead of creating a duplicate. + /// + public BatchJob SubmitSingle(string tool, JObject parameters, string agent) + { + // --- Dedup check: look for an identical queued/running single-command job --- + var paramStr = parameters?.ToString(Newtonsoft.Json.Formatting.None) ?? ""; + foreach (var existing in _store.GetAllJobs()) + { + if (existing.Status != JobStatus.Queued && existing.Status != JobStatus.Running) + continue; + if (existing.Commands == null || existing.Commands.Count != 1) + continue; + if (existing.Commands[0].Tool != tool) + continue; + var existingParamStr = existing.Commands[0].Params?.ToString(Newtonsoft.Json.Formatting.None) ?? ""; + if (existingParamStr == paramStr) + { + existing.Deduplicated = true; + McpLog.Info($"[CommandQueue] Dedup: '{tool}' already queued/running as {existing.Ticket}. Returning existing job."); + return existing; + } + } + + var toolTier = CommandRegistry.GetToolTier(tool); + var effectiveTier = CommandClassifier.Classify(tool, toolTier, parameters); + + var commands = new List(1) + { + new BatchCommand + { + Tool = tool, + Params = parameters ?? new JObject(), + Tier = effectiveTier, + CausesDomainReload = CommandClassifier.CausesDomainReload(tool, parameters) + } + }; + + return Submit(agent, tool, atomic: false, commands); + } + + /// + /// Poll a job's status. + /// + public BatchJob Poll(string ticket) => _store.GetJob(ticket); + + /// + /// Cancel a queued job. Only the owning agent can cancel. + /// + public bool Cancel(string ticket, string agent) + { + var job = _store.GetJob(ticket); + if (job == null || job.Status != JobStatus.Queued) return false; + if (job.Agent != agent && agent != null) return false; + + job.Status = JobStatus.Cancelled; + job.CompletedAt = DateTime.UtcNow; + return true; + } + + /// + /// Get jobs ahead of the given ticket in the queue. + /// + public List GetAheadOf(string ticket) + { + var queued = _store.GetQueuedJobs(); + var result = new List(); + foreach (var j in queued) + { + if (j.Ticket == ticket) break; + result.Add(j); + } + // Also include the active heavy job + if (_activeHeavyTicket != null) + { + var active = _store.GetJob(_activeHeavyTicket); + if (active != null) + result.Insert(0, active); + } + return result; + } + + /// + /// Returns the reason a queued job is blocked, or null if not blocked. + /// + public string GetBlockedReason(string ticket) + { + var job = _store.GetJob(ticket); + if (job == null || job.Status != JobStatus.Queued) return null; + if (!job.CausesDomainReload) return null; + if (!IsEditorBusy()) return null; + + if (MCPForUnity.Editor.Services.TestJobManager.HasRunningJob + || MCPForUnity.Editor.Services.TestRunStatus.IsRunning) + return "tests_running"; + if (UnityEditor.EditorApplication.isCompiling) + return "compiling"; + return "editor_busy"; + } + + /// + /// Remove a completed job from the store by ticket. + /// Clears the active heavy slot if the removed job was the active heavy. + /// Returns the removed job, or null if not found. + /// + public BatchJob Remove(string ticket) + { + if (_activeHeavyTicket == ticket) + _activeHeavyTicket = null; + _smoothInFlight.Remove(ticket); + return _store.Remove(ticket); + } + + /// + /// Emergency flush: cancel ALL jobs (queued, running, smooth in-flight), + /// signal CancellationTokens on running jobs, and reset queue state. + /// Called from the emergency flush menu item to unfreeze the editor. + /// + public int FlushAll() + { + int flushed = 0; + + // Cancel the active heavy job + if (_activeHeavyTicket != null) + { + var heavy = _store.GetJob(_activeHeavyTicket); + if (heavy != null) + { + heavy.Cts?.Cancel(); + heavy.Status = JobStatus.Cancelled; + heavy.Error = "Flushed by emergency flush."; + heavy.CompletedAt = DateTime.UtcNow; + flushed++; + } + _activeHeavyTicket = null; + } + + // Cancel smooth in-flight jobs + foreach (var ticket in _smoothInFlight.ToList()) + { + var job = _store.GetJob(ticket); + if (job != null) + { + job.Cts?.Cancel(); + job.Status = JobStatus.Cancelled; + job.Error = "Flushed by emergency flush."; + job.CompletedAt = DateTime.UtcNow; + flushed++; + } + } + _smoothInFlight.Clear(); + + // Cancel all queued jobs + while (_heavyQueue.Count > 0) + { + var ticket = _heavyQueue.Dequeue(); + var job = _store.GetJob(ticket); + if (job != null && job.Status == JobStatus.Queued) + { + job.Status = JobStatus.Cancelled; + job.Error = "Flushed by emergency flush."; + job.CompletedAt = DateTime.UtcNow; + flushed++; + } + } + + // Cancel any remaining queued jobs in the store + foreach (var job in _store.GetQueuedJobs().ToList()) + { + job.Status = JobStatus.Cancelled; + job.Error = "Flushed by emergency flush."; + job.CompletedAt = DateTime.UtcNow; + flushed++; + } + + // Reset watchdog + _busyBlockedSince = DateTime.MaxValue; + _watchdogWarned = false; + + McpLog.Warn($"[CommandQueue] Emergency flush: cancelled {flushed} job(s)."); + return flushed; + } + + /// + /// Compressed summary of all jobs for batch status checks. + /// Returns short field names for token efficiency. + /// + public object GetSummary() + { + var allJobs = _store.GetAllJobs(); + var summary = new List(allJobs.Count); + + foreach (var j in allJobs) + { + var entry = new Dictionary + { + ["t"] = j.Ticket, + ["s"] = j.Status.ToString().ToLowerInvariant(), + ["a"] = j.Agent, + ["l"] = j.Label + }; + + if (j.Commands != null && j.Commands.Count > 0) + entry["p"] = $"{j.CurrentIndex + (j.Status == JobStatus.Done ? 0 : 1)}/{j.Commands.Count}"; + + if (j.Status == JobStatus.Queued && j.CausesDomainReload && IsEditorBusy()) + entry["b"] = "blocked"; + + if (j.Status == JobStatus.Failed && j.Error != null) + entry["e"] = j.Error.Length > 80 ? j.Error.Substring(0, 80) + "..." : j.Error; + + summary.Add(entry); + } + + return new + { + jobs = summary, + heavy = _activeHeavyTicket, + qd = QueueDepth, + sf = _smoothInFlight.Count + }; + } + + /// + /// Get all jobs ordered by creation time (most recent first). + /// + public List GetAllJobs() => _store.GetAllJobs(); + + /// + /// Get overall queue status. + /// + public object GetStatus() + { + var activeHeavy = _activeHeavyTicket != null ? _store.GetJob(_activeHeavyTicket) : null; + return new + { + queue_depth = QueueDepth, + active_heavy = activeHeavy != null ? new + { + ticket = activeHeavy.Ticket, + agent = activeHeavy.Agent, + label = activeHeavy.Label, + progress = $"{activeHeavy.CurrentIndex}/{activeHeavy.Commands.Count}" + } : null, + smooth_in_flight = _smoothInFlight.Count, + agents = _store.GetAgentStats() + }; + } + + /// + /// Serialize queue state for SessionState persistence. + /// + public string PersistToJson() => _store.ToJson(); + + /// + /// Restore queue state after domain reload. Re-enqueues any queued heavy jobs. + /// + public void RestoreFromJson(string json) + { + _store.FromJson(json); + + // Re-populate the heavy queue from restored queued jobs + foreach (var job in _store.GetQueuedJobs()) + { + if (job.Tier == ExecutionTier.Heavy) + _heavyQueue.Enqueue(job.Ticket); + } + } + + /// + /// Called every EditorApplication.update frame. Processes the queue. + /// + public void ProcessTick(Func> executeCommand) + { + _store.CleanExpired(TicketExpiry); + + // Watchdog: if IsEditorBusy has been blocking the heavy queue for too long, + // auto-clear stuck test jobs. This prevents orphaned test jobs (from domain + // reloads mid-test) from permanently stalling the queue. + if (IsEditorBusy()) + { + if (_busyBlockedSince == DateTime.MaxValue) + _busyBlockedSince = DateTime.UtcNow; + else if (DateTime.UtcNow - _busyBlockedSince > BusyWatchdogTimeout) + { + if (!_watchdogWarned) + { + _watchdogWarned = true; + var elapsed = DateTime.UtcNow - _busyBlockedSince; + MCPForUnity.Editor.Helpers.McpLog.Warn( + $"[CommandQueue] Watchdog: IsEditorBusy has been true for {elapsed.TotalSeconds:F0}s. " + + $"Auto-clearing stuck test job to unblock queue."); + } + + // Auto-clear the stuck test job + if (MCPForUnity.Editor.Services.TestJobManager.HasRunningJob) + MCPForUnity.Editor.Services.TestJobManager.ClearStuckJob(); + } + } + else + { + _busyBlockedSince = DateTime.MaxValue; + _watchdogWarned = false; + } + + // Clean completed smooth jobs + _smoothInFlight.RemoveAll(ticket => + { + var j = _store.GetJob(ticket); + return j == null || j.Status == JobStatus.Done || j.Status == JobStatus.Failed || j.Status == JobStatus.Cancelled; + }); + + // 1. Check if active heavy finished + if (_activeHeavyTicket != null) + { + var heavy = _store.GetJob(_activeHeavyTicket); + + // Job was removed (e.g., auto-cleanup on poll) — release the slot + if (heavy == null) + { + // Still hold if editor is busy from side-effects of the removed job + if (IsEditorBusy()) + return; + + _activeHeavyTicket = null; + return; // One-frame cooldown + } + + if (heavy.Status == JobStatus.Done || heavy.Status == JobStatus.Failed || heavy.Status == JobStatus.Cancelled) + { + // Hold the heavy slot while the editor is still busy from side-effects. + // e.g., run_tests starts tests asynchronously and returns instantly, but + // the TestRunner is still running. We keep the slot occupied so domain-reload + // jobs can't be dequeued until the async operation completes. + // Skip the hold for cancelled jobs (emergency flush should unblock immediately). + if (heavy.Status != JobStatus.Cancelled && IsEditorBusy()) + return; + + _activeHeavyTicket = null; + // One-frame cooldown: don't immediately dequeue the next heavy job. + // Gives async state one editor frame to settle before the guard check. + return; + } + + // Running job with all commands dispatched, waiting for async side-effects + // (e.g., test runner still executing). Transition to Done once editor settles. + if (heavy.Status == JobStatus.Running + && heavy.Commands != null + && heavy.CurrentIndex >= heavy.Commands.Count - 1 + && heavy.Results.Count >= heavy.Commands.Count) + { + if (!IsEditorBusy()) + { + heavy.Status = JobStatus.Done; + heavy.CompletedAt = DateTime.UtcNow; + } + return; // Either way, wait this frame + } + + return; // Heavy still running, wait + } + + // 2. If heavy queue has items and no smooth in flight, start next heavy + if (_heavyQueue.Count > 0 && _smoothInFlight.Count == 0) + { + bool editorBusy = IsEditorBusy(); + // Peek-and-skip: find the first eligible heavy job + int count = _heavyQueue.Count; + for (int i = 0; i < count; i++) + { + var ticket = _heavyQueue.Dequeue(); + var job = _store.GetJob(ticket); + if (job == null || job.Status == JobStatus.Cancelled) continue; + + // Guard: domain-reload jobs must wait when editor is busy + if (job.CausesDomainReload && editorBusy) + { + _heavyQueue.Enqueue(ticket); // put back at end + continue; + } + + _activeHeavyTicket = ticket; + _ = ExecuteJob(job, executeCommand); + return; + } + } + + // 3. No heavy active — process smooth jobs + var smoothQueued = _store.GetQueuedJobs() + .Where(j => j.Tier == ExecutionTier.Smooth) + .ToList(); + foreach (var job in smoothQueued) + { + _smoothInFlight.Add(job.Ticket); + _ = ExecuteJob(job, executeCommand); + } + } + + /// + /// Execute all commands in a job sequentially, respecting CancellationToken. + /// + async Task ExecuteJob(BatchJob job, Func> executeCommand) + { + job.Cts = new CancellationTokenSource(); + job.Status = JobStatus.Running; + var ct = job.Cts.Token; + + if (job.Atomic) + { + Undo.IncrementCurrentGroup(); + job.UndoGroup = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName($"Gateway: {job.Label}"); + } + + try + { + for (int i = 0; i < job.Commands.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + job.CurrentIndex = i; + var cmd = job.Commands[i]; + + var result = await executeCommand(cmd.Tool, cmd.Params, ct); + job.Results.Add(result); + + // Check for failure + if (result is IMcpResponse resp && !resp.Success) + { + if (job.Atomic) + { + Undo.RevertAllInCurrentGroup(); + job.Error = $"Command {i} ({cmd.Tool}) failed. Batch rolled back."; + job.Status = JobStatus.Failed; + job.CompletedAt = DateTime.UtcNow; + return; + } + } + } + + if (job.Atomic && job.UndoGroup >= 0) + Undo.CollapseUndoOperations(job.UndoGroup); + + // If the batch triggered async side-effects (e.g., run_tests starts + // the test runner which keeps going after the command returns), keep + // the job in Running so poll_job shows it as in-progress. ProcessTick + // will transition it to Done once IsEditorBusy() returns false. + if (job.Tier == ExecutionTier.Heavy && IsEditorBusy()) + { + job.Status = JobStatus.Running; + // Mark all commands as dispatched so progress shows full count + job.CurrentIndex = job.Commands.Count - 1; + } + else + { + job.Status = JobStatus.Done; + job.CompletedAt = DateTime.UtcNow; + } + } + catch (OperationCanceledException) + { + if (job.Atomic && job.UndoGroup >= 0) + Undo.RevertAllInCurrentGroup(); + job.Error = "Cancelled."; + job.Status = JobStatus.Cancelled; + job.CompletedAt = DateTime.UtcNow; + } + catch (Exception ex) + { + if (job.Atomic && job.UndoGroup >= 0) + Undo.RevertAllInCurrentGroup(); + job.Error = ex.Message; + job.Status = JobStatus.Failed; + job.CompletedAt = DateTime.UtcNow; + } + finally + { + job.Cts?.Dispose(); + job.Cts = null; + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/CommandQueue.cs.meta b/MCPForUnity/Editor/Tools/CommandQueue.cs.meta new file mode 100644 index 000000000..3608a967b --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 54e9ce4f1c68f4e4786b29af3f07e396 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/CommandRegistry.cs b/MCPForUnity/Editor/Tools/CommandRegistry.cs index ca39ea51c..f266f72bc 100644 --- a/MCPForUnity/Editor/Tools/CommandRegistry.cs +++ b/MCPForUnity/Editor/Tools/CommandRegistry.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Resources; @@ -18,14 +19,16 @@ class HandlerInfo public string CommandName { get; } public Func SyncHandler { get; } public Func> AsyncHandler { get; } + public ExecutionTier Tier { get; } public bool IsAsync => AsyncHandler != null; - public HandlerInfo(string commandName, Func syncHandler, Func> asyncHandler) + public HandlerInfo(string commandName, Func syncHandler, Func> asyncHandler, ExecutionTier tier = ExecutionTier.Smooth) { CommandName = commandName; SyncHandler = syncHandler; AsyncHandler = asyncHandler; + Tier = tier; } } @@ -102,17 +105,20 @@ private static bool RegisterCommandType(Type type, bool isResource) { string commandName; string typeLabel = isResource ? "resource" : "tool"; + ExecutionTier tier = ExecutionTier.Smooth; // default // Get command name from appropriate attribute if (isResource) { var resourceAttr = type.GetCustomAttribute(); commandName = resourceAttr.ResourceName; + tier = ExecutionTier.Instant; // Resources are read-only } else { var toolAttr = type.GetCustomAttribute(); commandName = toolAttr.CommandName; + tier = toolAttr.Tier; } // Auto-generate command name if not explicitly provided @@ -155,7 +161,7 @@ private static bool RegisterCommandType(Type type, bool isResource) if (typeof(Task).IsAssignableFrom(method.ReturnType)) { var asyncHandler = CreateAsyncHandlerDelegate(method, commandName); - handlerInfo = new HandlerInfo(commandName, null, asyncHandler); + handlerInfo = new HandlerInfo(commandName, null, asyncHandler, tier); } else { @@ -163,7 +169,7 @@ private static bool RegisterCommandType(Type type, bool isResource) typeof(Func), method ); - handlerInfo = new HandlerInfo(commandName, handler, null); + handlerInfo = new HandlerInfo(commandName, handler, null, tier); } _handlers[commandName] = handlerInfo; @@ -190,6 +196,17 @@ private static HandlerInfo GetHandlerInfo(string commandName) return handler; } + /// + /// Get the declared ExecutionTier for a registered tool. + /// Returns Smooth as default for unknown tools. + /// + public static ExecutionTier GetToolTier(string commandName) + { + if (_handlers.TryGetValue(commandName, out var handler)) + return handler.Tier; + return ExecutionTier.Smooth; + } + /// /// Get a synchronous command handler by name. /// Throws if the command is asynchronous. @@ -244,26 +261,46 @@ public static object ExecuteCommand(string commandName, JObject @params, TaskCom /// Parameters to pass to the command (optional). public static Task InvokeCommandAsync(string commandName, JObject @params) { + return InvokeCommandAsync(commandName, @params, CancellationToken.None); + } + + /// + /// Execute a command handler with cancellation support. + /// The token is checked before invocation and, for async handlers, used to + /// wrap the returned task so cancellation propagates even if the handler + /// doesn't natively support it. + /// + public static async Task InvokeCommandAsync(string commandName, JObject @params, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var handlerInfo = GetHandlerInfo(commandName); var payload = @params ?? new JObject(); if (handlerInfo.IsAsync) { if (handlerInfo.AsyncHandler == null) - { throw new InvalidOperationException($"Async handler for '{commandName}' is not configured correctly"); - } - return handlerInfo.AsyncHandler(payload); + var task = handlerInfo.AsyncHandler(payload); + + // Race the handler task against the cancellation token. + // This ensures we stop waiting even if the handler ignores cancellation. + var tcs = new TaskCompletionSource(); + using (ct.Register(() => tcs.TrySetResult(true))) + { + var completed = await Task.WhenAny(task, tcs.Task).ConfigureAwait(true); + ct.ThrowIfCancellationRequested(); + return await task.ConfigureAwait(true); + } } if (handlerInfo.SyncHandler == null) - { throw new InvalidOperationException($"Handler for '{commandName}' does not provide a synchronous implementation"); - } + ct.ThrowIfCancellationRequested(); object result = handlerInfo.SyncHandler(payload); - return Task.FromResult(result); + return result; } /// diff --git a/MCPForUnity/Editor/Tools/ExecutionTier.cs b/MCPForUnity/Editor/Tools/ExecutionTier.cs new file mode 100644 index 000000000..4b32c25ac --- /dev/null +++ b/MCPForUnity/Editor/Tools/ExecutionTier.cs @@ -0,0 +1,28 @@ +namespace MCPForUnity.Editor.Tools +{ + /// + /// Classifies a tool's execution characteristics for queue dispatch. + /// + public enum ExecutionTier + { + /// + /// Read-only, microsecond-scale. Executes synchronously, returns inline. + /// Examples: find_gameobjects, read_console, scene hierarchy queries. + /// + Instant = 0, + + /// + /// Main-thread writes that don't trigger domain reload. Non-blocking. + /// Multiple smooth operations can coexist. + /// Examples: set_property, modify transform, material changes. + /// + Smooth = 1, + + /// + /// Triggers compilation, domain reload, or long-running processes. + /// Requires exclusive access — blocks all other operations. + /// Examples: script create/delete, compile, test runs, scene load. + /// + Heavy = 2 + } +} diff --git a/MCPForUnity/Editor/Tools/ExecutionTier.cs.meta b/MCPForUnity/Editor/Tools/ExecutionTier.cs.meta new file mode 100644 index 000000000..d8b6badad --- /dev/null +++ b/MCPForUnity/Editor/Tools/ExecutionTier.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 07b50a6c5e7342f4089b4fc25598f102 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/FindGameObjects.cs b/MCPForUnity/Editor/Tools/FindGameObjects.cs index d04f09429..57662944d 100644 --- a/MCPForUnity/Editor/Tools/FindGameObjects.cs +++ b/MCPForUnity/Editor/Tools/FindGameObjects.cs @@ -13,7 +13,7 @@ namespace MCPForUnity.Editor.Tools /// This is a focused search tool that returns lightweight results (IDs only). /// For detailed GameObject data, use the unity://scene/gameobject/{id} resource. /// - [McpForUnityTool("find_gameobjects")] + [McpForUnityTool("find_gameobjects", Tier = ExecutionTier.Instant)] public static class FindGameObjects { /// diff --git a/MCPForUnity/Editor/Tools/GatewayJobLogger.cs b/MCPForUnity/Editor/Tools/GatewayJobLogger.cs new file mode 100644 index 000000000..f488ddb30 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GatewayJobLogger.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Logs completed gateway jobs to a JSON-lines file when logging is enabled. + /// Default log file: {ProjectRoot}/Logs/mcp-gateway-jobs.jsonl + /// Path is configurable via EditorPrefs. + /// + internal static class GatewayJobLogger + { + /// + /// Default log path relative to project root. + /// + public static readonly string DefaultLogPath = Path.GetFullPath( + Path.Combine(Application.dataPath, "..", "Logs", "mcp-gateway-jobs.jsonl")); + + /// + /// Returns true when gateway job logging is enabled via EditorPrefs. + /// + public static bool IsEnabled + { + get => EditorPrefs.GetBool(EditorPrefKeys.GatewayJobLogging, false); + set => EditorPrefs.SetBool(EditorPrefKeys.GatewayJobLogging, value); + } + + /// + /// The current log file path. Returns the EditorPrefs override if set, + /// otherwise the default path. + /// + public static string LogPath + { + get + { + string custom = EditorPrefs.GetString(EditorPrefKeys.GatewayJobLogPath, ""); + return string.IsNullOrWhiteSpace(custom) ? DefaultLogPath : custom; + } + set => EditorPrefs.SetString(EditorPrefKeys.GatewayJobLogPath, value ?? ""); + } + + /// + /// Write a completed job to the log file as a single JSON line. + /// + public static void Log(BatchJob job) + { + if (job == null) return; + + try + { + var entry = new JObject + { + ["ticket"] = job.Ticket, + ["agent"] = job.Agent, + ["label"] = job.Label, + ["tier"] = job.Tier.ToString().ToLowerInvariant(), + ["status"] = job.Status.ToString().ToLowerInvariant(), + ["atomic"] = job.Atomic, + ["created_at"] = job.CreatedAt.ToString("O"), + ["completed_at"] = job.CompletedAt?.ToString("O"), + ["command_count"] = job.Commands?.Count ?? 0, + ["current_index"] = job.CurrentIndex + }; + + if (!string.IsNullOrEmpty(job.Error)) + entry["error"] = job.Error; + + if (job.Commands != null && job.Commands.Count > 0) + { + var tools = new JArray(); + foreach (var cmd in job.Commands) + tools.Add(cmd.Tool); + entry["tools"] = tools; + } + + string path = LogPath; + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + File.AppendAllText(path, entry.ToString(Formatting.None) + "\n"); + } + catch (Exception ex) + { + McpLog.Debug($"[GatewayJobLogger] Failed to log job {job.Ticket}: {ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/GatewayJobLogger.cs.meta b/MCPForUnity/Editor/Tools/GatewayJobLogger.cs.meta new file mode 100644 index 000000000..c7521e48b --- /dev/null +++ b/MCPForUnity/Editor/Tools/GatewayJobLogger.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 158eb3ae7a37e834eb25af9dd1d543bc \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/GetTestJob.cs b/MCPForUnity/Editor/Tools/GetTestJob.cs index ade8811e5..ae5f03f58 100644 --- a/MCPForUnity/Editor/Tools/GetTestJob.cs +++ b/MCPForUnity/Editor/Tools/GetTestJob.cs @@ -8,7 +8,7 @@ namespace MCPForUnity.Editor.Tools /// /// Poll a previously started async test job by job_id. /// - [McpForUnityTool("get_test_job", AutoRegister = false, Group = "testing")] + [McpForUnityTool("get_test_job", AutoRegister = false, Tier = ExecutionTier.Instant, Group = "testing")] public static class GetTestJob { public static object HandleCommand(JObject @params) diff --git a/MCPForUnity/Editor/Tools/ManageSDFGraph.cs b/MCPForUnity/Editor/Tools/ManageSDFGraph.cs new file mode 100644 index 000000000..b00655acb --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageSDFGraph.cs @@ -0,0 +1,255 @@ +using System; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// First-class MCP tool for SDF graph operations. Works directly with asset paths, + /// no scene GameObject required. Uses reflection to call into the SDF package + /// (Utility.SDF.Graph / Utility.SDF.Mermaid) to avoid compile-time coupling. + /// + /// Actions: + /// export_mermaid — Serialize graph to Mermaid flowchart text + /// apply_mermaid — Build/replace graph from Mermaid text + /// get_graph — Get graph topology (node count, connections, output index) + /// compile_hlsl — Compile graph to HLSL shader code + /// + [McpForUnityTool("manage_sdf_graph", + Description = "SDF graph operations: export/apply Mermaid notation, inspect topology, compile to HLSL. " + + "Works with asset paths directly (e.g., 'Assets/SDF Graphs/MyGraph.asset').", + Tier = ExecutionTier.Smooth)] + public static class ManageSDFGraph + { + // Cached reflection types and methods + static Type _graphAssetType; + static Type _mermaidCodecType; + static Type _hlslCompilerType; + static MethodInfo _exportMethod; + static MethodInfo _parseMethod; + static MethodInfo _compileMethod; + static bool _reflectionResolved; + static string _reflectionError; + + static bool ResolveReflection() + { + if (_reflectionResolved) return _reflectionError == null; + + _reflectionResolved = true; + + _graphAssetType = FindType("Utility.SDF.Graph.SDFGraphAsset"); + if (_graphAssetType == null) + { + _reflectionError = "SDFGraphAsset type not found. Is the com.utility.sdf package installed?"; + return false; + } + + _mermaidCodecType = FindType("Utility.SDF.Mermaid.SDFMermaidCodec"); + if (_mermaidCodecType == null) + { + _reflectionError = "SDFMermaidCodec type not found. Is the com.utility.sdf.Editor assembly loaded?"; + return false; + } + + // Export has optional FlowDirection param — find by name only + var exportMethods = _mermaidCodecType.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == "Export").ToArray(); + _exportMethod = exportMethods.Length > 0 ? exportMethods[0] : null; + + _parseMethod = _mermaidCodecType.GetMethod("Parse", + BindingFlags.Public | BindingFlags.Static, + null, new[] { typeof(string), _graphAssetType }, null); + + if (_exportMethod == null || _parseMethod == null) + { + _reflectionError = "SDFMermaidCodec.Export/Parse methods not found. API may have changed."; + return false; + } + + // HLSL compiler is optional + _hlslCompilerType = FindType("Utility.SDF.Compiler.SDFGraphCompiler"); + if (_hlslCompilerType != null) + { + _compileMethod = _hlslCompilerType.GetMethod("CompileToHLSL", + BindingFlags.Public | BindingFlags.Static, + null, new[] { _graphAssetType }, null); + } + + return true; + } + + static Type FindType(string fullName) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (asm.IsDynamic) continue; + try + { + var t = asm.GetType(fullName); + if (t != null) return t; + } + catch { } + } + return null; + } + + public static object HandleCommand(JObject @params) + { + if (@params == null) + return new ErrorResponse("Parameters required."); + + string action = @params.Value("action"); + if (string.IsNullOrWhiteSpace(action)) + return new ErrorResponse("'action' is required. Options: export_mermaid, apply_mermaid, get_graph, compile_hlsl"); + + if (!ResolveReflection()) + return new ErrorResponse(_reflectionError); + + string assetPath = @params.Value("asset_path"); + if (string.IsNullOrWhiteSpace(assetPath)) + return new ErrorResponse("'asset_path' is required (e.g., 'Assets/SDF Graphs/MyGraph.asset')."); + + var asset = AssetDatabase.LoadAssetAtPath(assetPath, _graphAssetType); + if (asset == null) + return new ErrorResponse($"No SDFGraphAsset found at '{assetPath}'."); + + return action switch + { + "export_mermaid" => ExecuteExportMermaid(asset), + "apply_mermaid" => ExecuteApplyMermaid(asset, @params, assetPath), + "get_graph" => ExecuteGetGraph(asset), + "compile_hlsl" => ExecuteCompileHlsl(asset), + _ => new ErrorResponse($"Unknown action '{action}'. Options: export_mermaid, apply_mermaid, get_graph, compile_hlsl") + }; + } + + static object ExecuteExportMermaid(UnityEngine.Object asset) + { + try + { + // Export(SDFGraphAsset, FlowDirection = LR) — pass default for optional param + var exportParams = _exportMethod.GetParameters(); + var args = exportParams.Length == 1 + ? new object[] { asset } + : new object[] { asset, Type.Missing }; + var mermaid = _exportMethod.Invoke(null, args); + if (mermaid == null) + return new ErrorResponse("Export returned null."); + + return new SuccessResponse("Mermaid exported.", new { mermaid = mermaid.ToString() }); + } + catch (TargetInvocationException ex) + { + return new ErrorResponse($"Export failed: {ex.InnerException?.Message ?? ex.Message}"); + } + } + + static object ExecuteApplyMermaid(UnityEngine.Object asset, JObject @params, string assetPath) + { + string mermaidText = @params.Value("mermaid"); + if (string.IsNullOrWhiteSpace(mermaidText)) + return new ErrorResponse("'mermaid' parameter is required."); + + try + { + Undo.RecordObject(asset, "Apply Mermaid Graph"); + var parseResult = _parseMethod.Invoke(null, new object[] { mermaidText, asset }); + EditorUtility.SetDirty(asset); + AssetDatabase.SaveAssets(); + + // Extract result properties via reflection + var resultType = parseResult.GetType(); + bool success = (bool)(resultType.GetProperty("Success")?.GetValue(parseResult) ?? false); + int nodeCount = (int)(resultType.GetProperty("NodeCount")?.GetValue(parseResult) ?? 0); + int connectionCount = (int)(resultType.GetProperty("ConnectionCount")?.GetValue(parseResult) ?? 0); + + var warnings = resultType.GetProperty("Warnings")?.GetValue(parseResult); + string warningText = warnings != null ? string.Join("; ", (System.Collections.IEnumerable)warnings) : ""; + + if (!success) + return new ErrorResponse($"Parse failed: {warningText}"); + + return new SuccessResponse( + $"Applied Mermaid to {assetPath}: {nodeCount} nodes, {connectionCount} connections.", + new { nodeCount, connectionCount, warnings = warningText }); + } + catch (TargetInvocationException ex) + { + return new ErrorResponse($"Apply failed: {ex.InnerException?.Message ?? ex.Message}"); + } + } + + static object ExecuteGetGraph(UnityEngine.Object asset) + { + try + { + var assetType = asset.GetType(); + int nodeCount = (int)(assetType.GetProperty("NodeCount")?.GetValue(asset) ?? 0); + int outputIndex = (int)(assetType.GetProperty("OutputNodeIndex")?.GetValue(asset) ?? -1); + + // Get nodes via reflection + var nodesProperty = assetType.GetProperty("Nodes"); + var nodes = nodesProperty?.GetValue(asset) as System.Collections.IList; + + var nodeInfos = new JArray(); + if (nodes != null) + { + for (int i = 0; i < nodes.Count; i++) + { + var node = nodes[i]; + if (node == null) + { + nodeInfos.Add(new JObject { ["index"] = i, ["type"] = "null" }); + continue; + } + + var nodeType = node.GetType(); + var info = new JObject + { + ["index"] = i, + ["type"] = nodeType.Name + }; + + // Try to get display name + var nameField = nodeType.GetField("displayName", + BindingFlags.Public | BindingFlags.Instance); + if (nameField != null) + info["name"] = nameField.GetValue(node)?.ToString(); + + nodeInfos.Add(info); + } + } + + return new SuccessResponse($"Graph has {nodeCount} nodes.", + new { nodeCount, outputIndex, nodes = nodeInfos }); + } + catch (Exception ex) + { + return new ErrorResponse($"Get graph failed: {ex.Message}"); + } + } + + static object ExecuteCompileHlsl(UnityEngine.Object asset) + { + if (_compileMethod == null) + return new ErrorResponse("HLSL compiler not found. SDFGraphCompiler may not be available."); + + try + { + var hlsl = _compileMethod.Invoke(null, new object[] { asset }); + if (hlsl == null) + return new ErrorResponse("Compilation returned null."); + + return new SuccessResponse("Compiled to HLSL.", new { hlsl = hlsl.ToString() }); + } + catch (TargetInvocationException ex) + { + return new ErrorResponse($"Compilation failed: {ex.InnerException?.Message ?? ex.Message}"); + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageSDFGraph.cs.meta b/MCPForUnity/Editor/Tools/ManageSDFGraph.cs.meta new file mode 100644 index 000000000..404964733 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageSDFGraph.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e4b0ce75f01315e4badb79ea81c79343 \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs index 4dfb0d629..db0fe4657 100644 --- a/MCPForUnity/Editor/Tools/ManageScript.cs +++ b/MCPForUnity/Editor/Tools/ManageScript.cs @@ -51,7 +51,7 @@ namespace MCPForUnity.Editor.Tools /// Note: Without Roslyn, the system falls back to basic structural validation. /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. /// - [McpForUnityTool("manage_script", AutoRegister = false)] + [McpForUnityTool("manage_script", AutoRegister = false, Tier = ExecutionTier.Heavy)] public static class ManageScript { /// diff --git a/MCPForUnity/Editor/Tools/ManageShader.cs b/MCPForUnity/Editor/Tools/ManageShader.cs index 64cfbeb78..cf188755d 100644 --- a/MCPForUnity/Editor/Tools/ManageShader.cs +++ b/MCPForUnity/Editor/Tools/ManageShader.cs @@ -12,7 +12,7 @@ namespace MCPForUnity.Editor.Tools /// /// Handles CRUD operations for shader files within the Unity project. /// - [McpForUnityTool("manage_shader", AutoRegister = false, Group = "vfx")] + [McpForUnityTool("manage_shader", AutoRegister = false, Tier = ExecutionTier.Heavy, Group = "vfx")] public static class ManageShader { /// diff --git a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs index 0d73fbb11..c12f24a7c 100644 --- a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs +++ b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs @@ -51,6 +51,12 @@ public class McpForUnityToolAttribute : Attribute /// public string PollAction { get; set; } = "status"; + /// + /// Execution tier for queue dispatch. Default: Smooth (safe conservative default). + /// Instant = read-only, inline. Smooth = non-blocking write. Heavy = exclusive access. + /// + public ExecutionTier Tier { get; set; } = ExecutionTier.Smooth; + /// /// The command name used to route requests to this tool. /// If not specified, defaults to the PascalCase class name converted to snake_case. diff --git a/MCPForUnity/Editor/Tools/PollJob.cs b/MCPForUnity/Editor/Tools/PollJob.cs new file mode 100644 index 000000000..36bd2d3c9 --- /dev/null +++ b/MCPForUnity/Editor/Tools/PollJob.cs @@ -0,0 +1,127 @@ +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Poll the status of an async batch job by ticket ID. + /// Generalizes the get_test_job pattern to any queued batch. + /// Terminal jobs (Done, Failed, Cancelled) are auto-removed after the + /// response is built. If gateway logging is enabled, the job data is + /// written to a log file before removal. + /// + [McpForUnityTool("poll_job", Tier = ExecutionTier.Instant)] + public static class PollJob + { + public static object HandleCommand(JObject @params) + { + var p = new ToolParams(@params); + var ticketResult = p.GetRequired("ticket"); + if (!ticketResult.IsSuccess) + return new ErrorResponse(ticketResult.ErrorMessage); + + string ticket = ticketResult.Value; + var job = CommandGatewayState.Queue.Poll(ticket); + if (job == null) + return new ErrorResponse($"Ticket '{ticket}' not found or expired."); + + object response; + + switch (job.Status) + { + case JobStatus.Queued: + var ahead = CommandGatewayState.Queue.GetAheadOf(ticket); + string blockedBy = CommandGatewayState.Queue.GetBlockedReason(ticket); + response = new PendingResponse( + $"Queued at position {ahead.Count}.", + pollIntervalSeconds: 2.0, + data: new + { + ticket = job.Ticket, + status = "queued", + position = ahead.Count, + agent = job.Agent, + label = job.Label, + blocked_by = blockedBy, + ahead = ahead.ConvertAll(j => (object)new + { + ticket = j.Ticket, + agent = j.Agent, + label = j.Label, + tier = j.Tier.ToString().ToLowerInvariant(), + status = j.Status.ToString().ToLowerInvariant() + }) + }); + break; + + case JobStatus.Running: + response = new PendingResponse( + $"Running command {job.CurrentIndex + 1}/{job.Commands.Count}.", + pollIntervalSeconds: 1.0, + data: new + { + ticket = job.Ticket, + status = "running", + progress = $"{job.CurrentIndex + 1}/{job.Commands.Count}", + agent = job.Agent, + label = job.Label + }); + break; + + case JobStatus.Done: + response = new SuccessResponse( + $"Batch complete. {job.Results.Count} results.", + new + { + ticket = job.Ticket, + status = "done", + results = job.Results, + agent = job.Agent, + label = job.Label, + atomic = job.Atomic + }); + break; + + case JobStatus.Failed: + response = new ErrorResponse( + job.Error ?? "Batch failed.", + new + { + ticket = job.Ticket, + status = "failed", + results = job.Results, + error = job.Error, + failed_at_command = job.CurrentIndex, + atomic = job.Atomic, + rolled_back = job.Atomic + }); + break; + + case JobStatus.Cancelled: + response = new ErrorResponse("Job was cancelled.", new + { + ticket = job.Ticket, + status = "cancelled" + }); + break; + + default: + return new ErrorResponse($"Unknown status: {job.Status}"); + } + + // Auto-cleanup: remove terminal jobs after building the response. + // The agent has consumed the result — no need to keep it in the queue. + if (job.Status == JobStatus.Done + || job.Status == JobStatus.Failed + || job.Status == JobStatus.Cancelled) + { + if (GatewayJobLogger.IsEnabled) + GatewayJobLogger.Log(job); + + CommandGatewayState.Queue.Remove(ticket); + } + + return response; + } + } +} diff --git a/MCPForUnity/Editor/Tools/PollJob.cs.meta b/MCPForUnity/Editor/Tools/PollJob.cs.meta new file mode 100644 index 000000000..77b1d7ecb --- /dev/null +++ b/MCPForUnity/Editor/Tools/PollJob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d6a89ed4b0341b741813cd27b4e76d6f \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/QueueStatus.cs b/MCPForUnity/Editor/Tools/QueueStatus.cs new file mode 100644 index 000000000..59ac9b615 --- /dev/null +++ b/MCPForUnity/Editor/Tools/QueueStatus.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Compressed queue summary — returns all tickets and their status in one shot. + /// Instant tier: never queued, executes inline for non-blocking batch status checks. + /// Fields: t=ticket, s=status, a=agent, l=label, p=progress, b=blocked, e=error. + /// + [McpForUnityTool("queue_status", Tier = ExecutionTier.Instant)] + public static class QueueStatus + { + public static object HandleCommand(JObject @params) + { + var summary = CommandGatewayState.Queue.GetSummary(); + return new SuccessResponse("Queue snapshot.", summary); + } + } +} diff --git a/MCPForUnity/Editor/Tools/QueueStatus.cs.meta b/MCPForUnity/Editor/Tools/QueueStatus.cs.meta new file mode 100644 index 000000000..b212457f9 --- /dev/null +++ b/MCPForUnity/Editor/Tools/QueueStatus.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e4ae3fb030d56ea4d8291e2655061c3d \ No newline at end of file diff --git a/MCPForUnity/Editor/Tools/ReadConsole.cs b/MCPForUnity/Editor/Tools/ReadConsole.cs index 84bacebad..c64748d9a 100644 --- a/MCPForUnity/Editor/Tools/ReadConsole.cs +++ b/MCPForUnity/Editor/Tools/ReadConsole.cs @@ -14,7 +14,7 @@ namespace MCPForUnity.Editor.Tools /// Handles reading and clearing Unity Editor console log entries. /// Uses reflection to access internal LogEntry methods/properties. /// - [McpForUnityTool("read_console", AutoRegister = false)] + [McpForUnityTool("read_console", AutoRegister = false, Tier = ExecutionTier.Instant)] public static class ReadConsole { // (Calibration removed) diff --git a/MCPForUnity/Editor/Tools/RefreshUnity.cs b/MCPForUnity/Editor/Tools/RefreshUnity.cs index 537472ac0..1786750c7 100644 --- a/MCPForUnity/Editor/Tools/RefreshUnity.cs +++ b/MCPForUnity/Editor/Tools/RefreshUnity.cs @@ -13,7 +13,7 @@ namespace MCPForUnity.Editor.Tools /// Explicitly refreshes Unity's asset database and optionally requests a script compilation. /// This is side-effectful and should be treated as a tool. /// - [McpForUnityTool("refresh_unity", AutoRegister = false)] + [McpForUnityTool("refresh_unity", AutoRegister = false, Tier = ExecutionTier.Heavy)] public static class RefreshUnity { private const int DefaultWaitTimeoutSeconds = 60; diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs index 3c93e97d2..bbee3cd15 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs +++ b/MCPForUnity/Editor/Tools/RunTests.cs @@ -12,7 +12,7 @@ namespace MCPForUnity.Editor.Tools /// Starts a Unity Test Runner run asynchronously and returns a job id immediately. /// Use get_test_job(job_id) to poll status/results. /// - [McpForUnityTool("run_tests", AutoRegister = false, Group = "testing")] + [McpForUnityTool("run_tests", AutoRegister = false, Tier = ExecutionTier.Heavy, Group = "testing")] public static class RunTests { public static Task HandleCommand(JObject @params) diff --git a/MCPForUnity/Editor/Tools/TicketStore.cs b/MCPForUnity/Editor/Tools/TicketStore.cs new file mode 100644 index 000000000..7690f7bef --- /dev/null +++ b/MCPForUnity/Editor/Tools/TicketStore.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Store for batch job tickets. Manages job lifecycle with auto-cleanup. + /// + public class TicketStore + { + readonly Dictionary _jobs = new(); + int _nextId; + + public BatchJob CreateJob(string agent, string label, bool atomic, ExecutionTier tier) + { + var job = new BatchJob + { + Ticket = $"t-{_nextId++:D6}", + Agent = agent ?? "anonymous", + Label = label ?? "", + Atomic = atomic, + Tier = tier + }; + _jobs[job.Ticket] = job; + return job; + } + + public BatchJob GetJob(string ticket) + { + return _jobs.TryGetValue(ticket, out var job) ? job : null; + } + + public void CleanExpired(TimeSpan expiry) + { + var expired = _jobs + .Where(kvp => (kvp.Value.Status == JobStatus.Done || kvp.Value.Status == JobStatus.Failed) + && kvp.Value.CompletedAt.HasValue + && DateTime.UtcNow - kvp.Value.CompletedAt.Value > expiry) + .Select(kvp => kvp.Key) + .ToList(); + foreach (var key in expired) + _jobs.Remove(key); + } + + public List GetQueuedJobs() + { + return _jobs.Values + .Where(j => j.Status == JobStatus.Queued) + .OrderBy(j => j.CreatedAt) + .ToList(); + } + + public List GetRunningJobs() + { + return _jobs.Values.Where(j => j.Status == JobStatus.Running).ToList(); + } + + public Dictionary GetAgentStats() + { + var stats = new Dictionary(); + foreach (var job in _jobs.Values) + { + if (!stats.TryGetValue(job.Agent, out var s)) + { + s = new AgentStats(); + stats[job.Agent] = s; + } + switch (job.Status) + { + case JobStatus.Running: s.Active++; break; + case JobStatus.Queued: s.Queued++; break; + case JobStatus.Done: s.Completed++; break; + } + } + return stats; + } + + /// + /// Remove a job from the store. Returns the removed job, or null if not found. + /// + public BatchJob Remove(string ticket) + { + if (_jobs.Remove(ticket, out var job)) + return job; + return null; + } + + public int QueueDepth => _jobs.Values.Count(j => j.Status == JobStatus.Queued); + + /// + /// Get all jobs ordered by creation time (most recent first). Used for queue summary. + /// + public List GetAllJobs() + { + return _jobs.Values.OrderByDescending(j => j.CreatedAt).ToList(); + } + + /// + /// Serialize all jobs to JSON for SessionState persistence. + /// + public string ToJson() + { + var state = new JObject + { + ["next_id"] = _nextId, + ["jobs"] = new JArray(_jobs.Values.Select(j => new JObject + { + ["ticket"] = j.Ticket, + ["agent"] = j.Agent, + ["label"] = j.Label, + ["atomic"] = j.Atomic, + ["tier"] = (int)j.Tier, + ["status"] = (int)j.Status, + ["causes_domain_reload"] = j.CausesDomainReload, + ["created_at"] = j.CreatedAt.ToString("O"), + ["completed_at"] = j.CompletedAt?.ToString("O"), + ["error"] = j.Error, + ["current_index"] = j.CurrentIndex, + ["commands"] = new JArray((j.Commands ?? new List()).Select(c => new JObject + { + ["tool"] = c.Tool, + ["tier"] = (int)c.Tier, + ["causes_domain_reload"] = c.CausesDomainReload, + ["params"] = c.Params + })) + })) + }; + return state.ToString(Formatting.None); + } + + /// + /// Restore jobs from JSON. Running jobs are marked failed (interrupted by domain reload). + /// + public void FromJson(string json) + { + if (string.IsNullOrWhiteSpace(json)) return; + + try + { + var state = JObject.Parse(json); + _nextId = state.Value("next_id"); + _jobs.Clear(); + + var jobs = state["jobs"] as JArray; + if (jobs == null) return; + + foreach (var jt in jobs) + { + if (jt is not JObject jo) continue; + var ticket = jo.Value("ticket"); + if (string.IsNullOrEmpty(ticket)) continue; + + var status = (JobStatus)jo.Value("status"); + string error = jo.Value("error"); + + // Jobs that were running when domain reload hit: + // - If the job itself caused the domain reload and all commands + // were dispatched, the reload IS the success signal. + // - Otherwise, the job was interrupted unexpectedly. + if (status == JobStatus.Running) + { + bool causesDomainReload = jo.Value("causes_domain_reload"); + int currentIndex = jo.Value("current_index"); + int commandCount = jo["commands"] is JArray ca ? ca.Count : 0; + bool allDispatched = commandCount > 0 && currentIndex >= commandCount - 1; + + if (causesDomainReload && allDispatched) + { + status = JobStatus.Done; + } + else + { + status = JobStatus.Failed; + error = "Interrupted by domain reload"; + } + } + + var commands = new List(); + if (jo["commands"] is JArray cmds) + { + foreach (var ct in cmds) + { + if (ct is not JObject co) continue; + commands.Add(new BatchCommand + { + Tool = co.Value("tool"), + Tier = (ExecutionTier)co.Value("tier"), + CausesDomainReload = co.Value("causes_domain_reload"), + Params = co["params"] as JObject ?? new JObject() + }); + } + } + + var completedStr = jo.Value("completed_at"); + DateTime? completedAt = !string.IsNullOrEmpty(completedStr) && DateTime.TryParse(completedStr, out var comp) ? comp : null; + + // Domain-reload-completed or failed jobs need a CompletedAt timestamp + if (completedAt == null && (status == JobStatus.Done || status == JobStatus.Failed)) + completedAt = DateTime.UtcNow; + + _jobs[ticket] = new BatchJob + { + Ticket = ticket, + Agent = jo.Value("agent") ?? "anonymous", + Label = jo.Value("label") ?? "", + Atomic = jo.Value("atomic"), + Tier = (ExecutionTier)jo.Value("tier"), + Status = status, + CausesDomainReload = jo.Value("causes_domain_reload"), + CreatedAt = DateTime.TryParse(jo.Value("created_at"), out var ca2) ? ca2 : DateTime.UtcNow, + CompletedAt = completedAt, + Error = error, + CurrentIndex = jo.Value("current_index"), + Commands = commands, + Results = new List() + }; + } + } + catch + { + // Best-effort restore; never block editor load + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/TicketStore.cs.meta b/MCPForUnity/Editor/Tools/TicketStore.cs.meta new file mode 100644 index 000000000..fa28cd1ce --- /dev/null +++ b/MCPForUnity/Editor/Tools/TicketStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bfc8e42fd64975a41a1fa593e0b57b40 \ No newline at end of file diff --git a/MCPForUnity/Editor/Windows/Components/Queue.meta b/MCPForUnity/Editor/Windows/Components/Queue.meta new file mode 100644 index 000000000..a850df2ac --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/Queue.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f3310394111e7604ca6e8f81c2967274 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.cs b/MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.cs new file mode 100644 index 000000000..f279c9924 --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/Queue/McpQueueSection.cs @@ -0,0 +1,239 @@ +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Tools; +using UnityEditor; +using UnityEngine.UIElements; + +namespace MCPForUnity.Editor.Windows.Components.Queue +{ + /// + /// Controller for the Queue tab in the MCP For Unity editor window. + /// Displays real-time command gateway queue state and logging settings. + /// + public class McpQueueSection + { + private Label heavyTicketLabel; + private Label queuedCount; + private Label runningCount; + private Label doneCount; + private Label failedCount; + private VisualElement jobListContainer; + private VisualElement jobListHeader; + private Label emptyLabel; + private Toggle loggingToggle; + private TextField logPathField; + private Button browseLogPathBtn; + private VisualElement loggingPathRow; + + public VisualElement Root { get; private set; } + + public McpQueueSection(VisualElement root) + { + Root = root; + CacheUIElements(); + SetupLoggingControls(); + } + + private void CacheUIElements() + { + heavyTicketLabel = Root.Q