diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index c5dab7c41..3cbdad172 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -66,5 +66,7 @@ internal static class EditorPrefKeys internal const string ApiKey = "MCPForUnity.ApiKey"; internal const string BatchExecuteMaxCommands = "MCPForUnity.BatchExecute.MaxCommands"; + internal const string GatewayJobLogging = "MCPForUnity.Gateway.JobLogging"; + internal const string GatewayJobLogPath = "MCPForUnity.Gateway.JobLogPath"; } } diff --git a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs index c648c19e4..03df03f17 100644 --- a/MCPForUnity/Editor/Helpers/AssetPathUtility.cs +++ b/MCPForUnity/Editor/Helpers/AssetPathUtility.cs @@ -103,6 +103,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/Tools/BatchExecute.cs b/MCPForUnity/Editor/Tools/BatchExecute.cs index aca4a1994..eed6759ba 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,84 @@ 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; + 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); + } + catch (Exception ex) + { + job.Results.Add(new ErrorResponse(ex.Message)); + if (atomic) + { + 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 + return new PendingResponse( + $"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 + }); + } } } diff --git a/MCPForUnity/Editor/Tools/BatchJob.cs b/MCPForUnity/Editor/Tools/BatchJob.cs new file mode 100644 index 000000000..a29807b4e --- /dev/null +++ b/MCPForUnity/Editor/Tools/BatchJob.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +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; + } + + 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..51fc7059f --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandGatewayState.cs @@ -0,0 +1,43 @@ +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) => + await CommandRegistry.InvokeCommandAsync(tool, @params)); + } + } +} 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..896716b37 --- /dev/null +++ b/MCPForUnity/Editor/Tools/CommandQueue.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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); + + /// + /// 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. + /// + public BatchJob Submit(string agent, string label, bool atomic, List commands) + { + 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; + } + + /// + /// 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); + } + + /// + /// 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); + + // Clean completed smooth jobs + _smoothInFlight.RemoveAll(ticket => + { + var j = _store.GetJob(ticket); + return j == null || j.Status == JobStatus.Done || j.Status == JobStatus.Failed; + }); + + // 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) + { + // 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. + if (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. + /// + async Task ExecuteJob(BatchJob job, Func> executeCommand) + { + job.Status = JobStatus.Running; + + if (job.Atomic) + { + Undo.IncrementCurrentGroup(); + job.UndoGroup = Undo.GetCurrentGroup(); + Undo.SetCurrentGroupName($"Gateway: {job.Label}"); + } + + try + { + for (int i = 0; i < job.Commands.Count; i++) + { + job.CurrentIndex = i; + var cmd = job.Commands[i]; + + var result = await executeCommand(cmd.Tool, cmd.Params); + 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 (Exception ex) + { + if (job.Atomic && job.UndoGroup >= 0) + Undo.RevertAllInCurrentGroup(); + job.Error = ex.Message; + job.Status = JobStatus.Failed; + job.CompletedAt = DateTime.UtcNow; + } + } + } +} 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..a30bfcf77 100644 --- a/MCPForUnity/Editor/Tools/CommandRegistry.cs +++ b/MCPForUnity/Editor/Tools/CommandRegistry.cs @@ -18,14 +18,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 +104,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 +160,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 +168,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 +195,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. 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 4817ab9bc..712c3ebd1 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)] + [McpForUnityTool("get_test_job", AutoRegister = false, Tier = ExecutionTier.Instant)] public static class GetTestJob { public static object HandleCommand(JObject @params) diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs index 619175125..af280672e 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 849a6f932..cd2c4cac8 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)] + [McpForUnityTool("manage_shader", AutoRegister = false, Tier = ExecutionTier.Heavy)] public static class ManageShader { /// diff --git a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs index e4db3a4c6..8b013b2b5 100644 --- a/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs +++ b/MCPForUnity/Editor/Tools/McpForUnityToolAttribute.cs @@ -42,6 +42,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 3fe95f6b7..bb6baffb5 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 abbf9225b..ed1cf39ef 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)] + [McpForUnityTool("run_tests", AutoRegister = false, Tier = ExecutionTier.Heavy)] 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