using System; using System.Collections.Generic; using Codely.Newtonsoft.Json; using Codely.Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; using UnityTcp.Editor.Helpers; namespace UnityTcp.Editor.Tools { /// /// High-level Unity workflows (init_session, compile_and_validate, checkpoint). /// /// Important: /// - Workflows are advanced incrementally on each call to avoid blocking the editor thread. /// - Minimal state is persisted via SessionState so that the workflow can survive domain reloads. /// - Clients can poll by calling the same action again with op_id. /// public static class ManageWorkflow { private static readonly List ValidActions = new List { "init_session", "compile_and_validate", "checkpoint", }; private const string KeyPrefix = "ManageWorkflow_"; private static string CtxKey(string opId) => $"{KeyPrefix}Ctx_{opId}"; public static object HandleCommand(JObject @params) { if (@params == null) { return Response.Error("Parameters cannot be null."); } string action = @params["action"]?.ToString()?.ToLowerInvariant(); if (string.IsNullOrEmpty(action)) { return Response.Error("Action parameter is required."); } if (!ValidActions.Contains(action)) { string valid = string.Join(", ", ValidActions); return Response.Error($"Unknown action: '{action}'. Valid actions are: {valid}"); } // Optional explicit op_id for polling string requestedOpId = @params["op_id"]?.ToString(); string opId = null; JObject ctx = null; if (!string.IsNullOrEmpty(requestedOpId)) { opId = requestedOpId; ctx = LoadContext(opId); if (ctx == null) { return BuildErrorWithOpId(opId, "unknown_op_id", $"Unknown workflow op_id: {opId}"); } // Guard: prevent action/op_id mismatches from accidentally advancing the wrong workflow. string ctxAction = ctx["action"]?.ToString()?.ToLowerInvariant(); if (!string.IsNullOrEmpty(ctxAction) && !string.Equals(ctxAction, action, StringComparison.OrdinalIgnoreCase)) { return BuildErrorWithOpId( opId, "action_mismatch", $"Workflow op_id '{opId}' belongs to action '{ctxAction}', not '{action}'." ); } } if (ctx == null) { // Start a new workflow opId = Guid.NewGuid().ToString("N"); ctx = new JObject { ["op_id"] = opId, ["action"] = action, ["stage"] = "start", ["createdAtUtcTicks"] = DateTime.UtcNow.Ticks, }; // Per-action defaults (match TypeScript tool behavior) int timeoutSeconds = @params["timeoutSeconds"]?.ToObject() ?? (action == "init_session" ? 600 : 180); if (timeoutSeconds < 1) timeoutSeconds = 1; ctx["timeoutSeconds"] = timeoutSeconds; // Persist checkpoint parameters so polling doesn't require resending options if (action == "checkpoint") { bool screenshot = @params["screenshot"]?.ToObject() ?? true; ctx["screenshot"] = screenshot; string screenshotAction = @params["screenshotAction"]?.ToString(); if (string.IsNullOrEmpty(screenshotAction)) screenshotAction = "capture"; ctx["screenshotAction"] = screenshotAction; ctx["screenshotPath"] = @params["screenshotPath"]?.ToString(); ctx["screenshotFilename"] = @params["screenshotFilename"]?.ToString(); } SaveContext(opId, ctx); } // Overall workflow timeout guard if (IsTimedOut(ctx, out double elapsedSec)) { DeleteContext(opId); return BuildErrorWithOpId( opId, "timeout", $"unity_workflow '{action}' timed out after {Math.Round(elapsedSec)}s" ); } try { switch (action) { case "init_session": return AdvanceInitSession(opId, ctx); case "compile_and_validate": return AdvanceCompileAndValidate(opId, ctx); case "checkpoint": return ExecuteCheckpoint(opId, ctx); default: return Response.Error($"Unknown action: '{action}'"); } } catch (Exception e) { Debug.LogError($"[ManageWorkflow] Action '{action}' failed: {e}"); DeleteContext(opId); return BuildErrorWithOpId(opId, "exception", e.Message); } } private static object AdvanceInitSession(string opId, JObject ctx) { string stage = ctx["stage"]?.ToString() ?? "start"; var deltas = new List(); if (stage != "start" && stage != "waiting_idle") { DeleteContext(opId); return BuildErrorWithOpId( opId, "invalid_stage", $"Invalid init_session stage: '{stage}'. Please retry init_session." ); } if (stage == "start") { // 1) get_current_state var editorState = ManageEditor.HandleCommand(new JObject { ["action"] = "get_current_state", }); var editorJ = ToJObject(editorState); if (editorJ?["success"]?.ToObject() == false) { DeleteContext(opId); return editorState; } // 2) clear console var clear = ReadConsole.HandleCommand(new JObject { ["action"] = "clear", ["scope"] = "all", }); var clearJ = ToJObject(clear); if (clearJ?["success"]?.ToObject() == false) { DeleteContext(opId); return clear; } string sinceToken = clearJ?["data"]?["sinceToken"]?.ToString() ?? StateComposer.GetCurrentConsoleToken(); ctx["editor_state"] = editorJ; ctx["console_clear"] = clearJ; ctx["since_token"] = sinceToken; ctx["stage"] = "waiting_idle"; SaveContext(opId, ctx); deltas.Add(ExtractStateDelta(editorJ)); deltas.Add(ExtractStateDelta(clearJ)); } // 3) wait_for_idle (poll) int timeoutSeconds = ctx["timeoutSeconds"]?.ToObject() ?? 600; var idle = ManageEditor.HandleCommand(new JObject { ["action"] = "wait_for_idle", ["timeoutSeconds"] = timeoutSeconds, }); var idleJ = ToJObject(idle); if (idleJ?["success"]?.ToObject() == false) { DeleteContext(opId); return idle; } ctx["idle"] = idleJ; SaveContext(opId, ctx); deltas.Add(ExtractStateDelta(idleJ)); var mergedDelta = StateComposer.MergeStateDeltas(deltas.ToArray()); var stateObj = ctx["editor_state"]?["state"]; var data = new JObject { ["editor_state"] = ctx["editor_state"], ["console_clear"] = ctx["console_clear"], ["idle"] = idleJ, }; string status = idleJ?["status"]?.ToString(); if (string.Equals(status, "pending", StringComparison.OrdinalIgnoreCase)) { double poll = idleJ?["poll_interval"]?.ToObject() ?? 1.0; return BuildPending( opId, "Unity workflow init_session pending (waiting for idle)...", poll, data, stateObj, mergedDelta ); } DeleteContext(opId); return BuildComplete( opId, "Unity workflow init_session completed", data, stateObj, mergedDelta ); } private static object AdvanceCompileAndValidate(string opId, JObject ctx) { // Server-side guard: do not compile while playing/paused if (EditorApplication.isPlaying || EditorApplication.isPaused) { DeleteContext(opId); return Response.Error( "compile_blocked_in_play_mode", new { code = "compile_blocked_in_play_mode", message = "Compilation is not allowed while the editor is in Play/Paused mode. Stop Play mode (unity_editor.stop) before requesting compilation.", playMode = EditorApplication.isPlaying ? "playing" : "paused", } ); } string stage = ctx["stage"]?.ToString() ?? "start"; var deltas = new List(); if (stage != "start" && stage != "waiting_compile") { DeleteContext(opId); return BuildErrorWithOpId( opId, "invalid_stage", $"Invalid compile_and_validate stage: '{stage}'. Please retry compile_and_validate." ); } if (stage == "start") { // Start compilation pipeline: clear console → request compile → return op_id + since_token var start = ManageEditor.HandleCommand(new JObject { ["action"] = "start_compilation_pipeline", }); var startJ = ToJObject(start); if (startJ?["success"]?.ToObject() == false) { DeleteContext(opId); return start; } string compileOpId = startJ?["op_id"]?.ToString() ?? startJ?["opId"]?.ToString(); string sinceToken = startJ?["since_token"]?.ToString(); if (string.IsNullOrEmpty(compileOpId)) { DeleteContext(opId); return BuildErrorWithOpId(opId, "missing_op_id", "Compilation pipeline did not return op_id"); } ctx["start"] = startJ; ctx["compile_op_id"] = compileOpId; if (!string.IsNullOrEmpty(sinceToken)) ctx["since_token"] = sinceToken; ctx["stage"] = "waiting_compile"; SaveContext(opId, ctx); deltas.Add(ExtractStateDelta(startJ)); } string compileOpId2 = ctx["compile_op_id"]?.ToString(); string sinceToken2 = ctx["since_token"]?.ToString(); int timeoutSeconds = ctx["timeoutSeconds"]?.ToObject() ?? 180; if (string.IsNullOrEmpty(compileOpId2)) { DeleteContext(opId); return BuildErrorWithOpId( opId, "missing_op_id", "Workflow context missing compile_op_id. Please retry compile_and_validate." ); } if (string.IsNullOrEmpty(sinceToken2)) { sinceToken2 = StateComposer.GetCurrentConsoleToken(); if (!string.IsNullOrEmpty(sinceToken2)) { ctx["since_token"] = sinceToken2; SaveContext(opId, ctx); } } // Wait for compile (poll) var wait = ManageEditor.HandleCommand(new JObject { ["action"] = "wait_for_compile", ["op_id"] = compileOpId2, ["timeoutSeconds"] = timeoutSeconds, ["since_token"] = sinceToken2, }); var waitJ = ToJObject(wait); ctx["wait"] = waitJ; SaveContext(opId, ctx); deltas.Add(ExtractStateDelta(waitJ)); var status = waitJ?["status"]?.ToString(); bool waitFailed = waitJ?["success"]?.ToObject() == false; if (string.Equals(status, "pending", StringComparison.OrdinalIgnoreCase) && !waitFailed) { double poll = waitJ?["poll_interval"]?.ToObject() ?? 1.0; var pendingData = new JObject { ["stage"] = "waiting_compile", ["start"] = ctx["start"], ["wait"] = waitJ, ["op_id"] = compileOpId2, ["since_token"] = sinceToken2, }; var mergedPendingDelta = StateComposer.MergeStateDeltas(deltas.ToArray()); return BuildPending( opId, "Unity workflow compile_and_validate pending (waiting for compile)...", poll, pendingData, null, mergedPendingDelta ); } // Read console since token (even if wait failed) var consoleRead = ReadConsole.HandleCommand(new JObject { ["action"] = "get", ["since_token"] = sinceToken2, }); var consoleJ = ToJObject(consoleRead); if (consoleJ?["success"]?.ToObject() == false) { DeleteContext(opId); return consoleRead; } ctx["console"] = consoleJ; SaveContext(opId, ctx); // Compute hasErrors / hasWarnings var entriesToken = consoleJ?["data"]?["entries"]; var entriesArray = entriesToken as JArray; int errCount = 0; int warnCount = 0; if (entriesArray != null) { foreach (var entry in entriesArray) { var t = entry?["type"]?.ToString(); if (string.IsNullOrEmpty(t)) continue; var lower = t.ToLowerInvariant(); if (lower == "error" || lower == "exception") errCount++; else if (lower == "warning") warnCount++; } } bool hasErrors = errCount > 0; bool hasWarnings = warnCount > 0; bool success = !waitFailed && !hasErrors; var data = new JObject { ["start"] = ctx["start"], ["wait"] = waitJ, ["wait_failed"] = waitFailed, ["console"] = consoleJ, ["since_token"] = sinceToken2, ["op_id"] = compileOpId2, ["hasErrors"] = hasErrors, ["hasWarnings"] = hasWarnings, ["consoleErrorCount"] = errCount, ["consoleWarningCount"] = warnCount, }; var mergedDelta = StateComposer.MergeStateDeltas(deltas.ToArray()); DeleteContext(opId); if (success) { return BuildComplete( opId, "Unity workflow compile_and_validate completed", data, null, mergedDelta ); } // Treat compilation errors as tool failure, but keep the structured data string errorMessage = waitFailed ? "Unity workflow compile_and_validate failed (wait_for_compile failed)" : "Unity workflow compile_and_validate failed (console has errors)"; return new Dictionary { ["success"] = false, ["status"] = "complete", ["op_id"] = opId, ["code"] = waitFailed ? "compile_wait_failed" : "compilation_errors", ["error"] = errorMessage, ["message"] = errorMessage, ["data"] = data, ["state_delta"] = mergedDelta, }; } private static object ExecuteCheckpoint(string opId, JObject ctx) { // checkpoint should finish in one call; no multi-step polling required. bool screenshot = ctx["screenshot"]?.ToObject() ?? true; string screenshotAction = ctx["screenshotAction"]?.ToString() ?? "capture"; string screenshotPath = ctx["screenshotPath"]?.ToString(); string screenshotFilename = ctx["screenshotFilename"]?.ToString(); var deltas = new List(); var save = ManageScene.HandleCommand(new JObject { ["action"] = "ensure_scene_saved", }); var saveJ = ToJObject(save); if (saveJ?["success"]?.ToObject() == false) { DeleteContext(opId); return save; } deltas.Add(ExtractStateDelta(saveJ)); JObject screenshotJ = null; if (screenshot) { var shotParams = new JObject { ["action"] = screenshotAction, }; if (!string.IsNullOrEmpty(screenshotPath)) shotParams["path"] = screenshotPath; if (!string.IsNullOrEmpty(screenshotFilename)) shotParams["filename"] = screenshotFilename; var shot = ManageScreenshot.HandleCommand(shotParams); screenshotJ = ToJObject(shot); if (screenshotJ?["success"]?.ToObject() == false) { DeleteContext(opId); return shot; } deltas.Add(ExtractStateDelta(screenshotJ)); } var mergedDelta = StateComposer.MergeStateDeltas(deltas.ToArray()); var data = new JObject { ["scene_saved"] = saveJ, ["screenshot"] = screenshot ? screenshotJ : null, }; DeleteContext(opId); return BuildComplete( opId, "Unity workflow checkpoint completed", data, null, mergedDelta ); } private static bool IsTimedOut(JObject ctx, out double elapsedSeconds) { elapsedSeconds = 0; try { long createdTicks = ctx["createdAtUtcTicks"]?.ToObject() ?? 0; if (createdTicks <= 0) return false; int timeoutSeconds = ctx["timeoutSeconds"]?.ToObject() ?? 0; if (timeoutSeconds <= 0) return false; elapsedSeconds = (DateTime.UtcNow.Ticks - createdTicks) / (double)TimeSpan.TicksPerSecond; return elapsedSeconds > timeoutSeconds; } catch { return false; } } private static JObject LoadContext(string opId) { string json = GetSessionString(CtxKey(opId)); if (string.IsNullOrEmpty(json)) return null; try { return JObject.Parse(json); } catch { return null; } } private static void SaveContext(string opId, JObject ctx) { if (string.IsNullOrEmpty(opId) || ctx == null) return; try { SessionState.SetString(CtxKey(opId), ctx.ToString(Formatting.None)); } catch { } } private static void DeleteContext(string opId) { if (string.IsNullOrEmpty(opId)) return; try { // Use empty string instead of EraseString to maximize Unity version compatibility SessionState.SetString(CtxKey(opId), string.Empty); } catch { } } private static string GetSessionString(string key) { try { var v = SessionState.GetString(key, null); return string.IsNullOrEmpty(v) ? null : v; } catch { return null; } } private static JObject ToJObject(object obj) { if (obj == null) return null; try { if (obj is JObject j) return j; return JObject.FromObject(obj); } catch { return null; } } private static object ExtractStateDelta(JObject result) { if (result == null) return null; return result["state_delta"]; } private static object BuildPending( string opId, string message, double pollInterval, JObject data, JToken state, object stateDelta ) { var resp = new Dictionary { ["success"] = true, ["status"] = "pending", ["op_id"] = opId, ["poll_interval"] = pollInterval, ["message"] = message, ["data"] = data, }; if (state != null) resp["state"] = state; // Also surface operations delta for this workflow try { var opDelta = StateComposer.CreateOperationsDelta( new object[] { new { id = opId, type = "workflow", progress = 0.0f, message = message }, } ); resp["state_delta"] = stateDelta != null ? StateComposer.MergeStateDeltas(opDelta, stateDelta) : opDelta; } catch { if (stateDelta != null) resp["state_delta"] = stateDelta; } return resp; } private static object BuildComplete( string opId, string message, JObject data, JToken state, object stateDelta ) { var resp = new Dictionary { ["success"] = true, ["status"] = "complete", ["op_id"] = opId, ["message"] = message, ["data"] = data, }; if (state != null) resp["state"] = state; if (stateDelta != null) resp["state_delta"] = stateDelta; return resp; } private static object BuildErrorWithOpId(string opId, string code, string message) { return new Dictionary { ["success"] = false, ["status"] = "error", ["op_id"] = opId, ["code"] = code, ["error"] = message, ["message"] = message, }; } } }