using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Unity.Pipeline.Commands; using Unity.Pipeline.Compilation; using Unity.Pipeline.Editor.Authoring; using Unity.Pipeline.Models; using Unity.Pipeline.Runtime.Commands; using UnityEditor; using UnityEngine; using Debug = UnityEngine.Debug; using Object = UnityEngine.Object; namespace Unity.Pipeline.Editor.Commands.Scripts { /// /// The run_script command (AUTHAPI-26): compile a single project .cs file in memory /// via the shared and execute a NAMED static entry point — /// with NO domain reload and NO code carried through the protocol. This formalizes the /// "builder pattern": bulk construction lives in a versioned project script that is written to /// disk (ideally OUTSIDE Assets/, so writing it triggers no asset import / domain reload) /// and invoked here by name, instead of shipping escaped C# strings through eval. /// /// It composes the two halves the pipeline already had — eval_file (code from disk) and /// reload_file (single-file in-memory Roslyn compile) — into one first-class command: /// - ephemeral (default): compile to an in-memory assembly, run the entry point, discard. /// - hotpatch: delegate to reload_file's in-place [CodeReload] semantics. /// /// This is still arbitrary code execution — only the carrier changes (a reviewable file on disk /// instead of a protocol string). It is grouped under the same scripts/eval tag as /// eval so a host that disables the eval family disables this too. /// static class RunScriptCommand { /// Upper bound on a single run's timeout — 24 hours, matching the eval cap (CLI-335). private const int MaxTimeoutMs = 86_400_000; [CliCommand("run_script", "Compile a project C# file in memory (no domain reload) and execute a named static entry point. " + "The builder-pattern path for bulk construction. Still arbitrary code execution — shares eval's capability gate.", MainThreadRequired = true, Tags = new[] { "scripts/eval" })] public static async Task RunScript( [CliArg("file", "Path to a .cs file to compile. Relative paths resolve against the PROJECT ROOT (the parent of Assets/), not the process working directory. May live OUTSIDE Assets/ (e.g. AgentScripts/) so writing it never triggers an asset import or domain reload.", Required = true)] string file, [CliArg("entry", "Entry point as Namespace.Type.Method (or Type.Method, or a bare Method name). Default: the single public static method if unambiguous, else a method named Main. Static methods only in v1. Ephemeral mode only (rejected with mode=hotpatch, which never invokes an entry point).")] string entry = null, [CliArg("args", "JSON array of arguments passed to the entry point, coerced to its parameter types: primitives, enum names, string[], and a string/object handle (ObjectRef) for UnityEngine.Object parameters. Ephemeral mode only (rejected with mode=hotpatch).")] JArray args = null, [CliArg("mode", "ephemeral (default): compile to an in-memory assembly, run the entry, discard. hotpatch: apply [CodeReload] method replacements to already-loaded types (reload_file semantics).")] string mode = "ephemeral", [CliArg("references", "Extra assembly name prefixes to reference beyond the auto-resolved set. In the Editor all loaded assemblies are already referenced, so this is effectively a no-op there. Ephemeral mode only; not applied by hotpatch (which delegates to reload_file's own compile).")] string[] references = null, [CliArg("defines", "Extra scripting define symbols APPENDED to the project's active editor defines (UNITY_EDITOR etc. are already set) for the compilation's #if directives. Ephemeral mode only; not applied by hotpatch.")] string[] defines = null, [CliArg("pdb", "Emit a portable PDB mapped to the source file so breakpoints bind and exception stack traces map to file:line. Compiles unoptimized.")] bool pdb = false, [CliArg("timeout_ms", "Timeout in milliseconds. Bounds the main-thread dispatcher wait AND, for an async entry point, how long its Task is awaited (on expiry the task keeps running detached).")] int timeoutMs = 30000, [CliArg("dry_run", "Compile only: return diagnostics; the emitted assembly is NOT loaded into the domain and nothing executes. Ephemeral mode only (rejected with mode=hotpatch, which would apply live method replacements).")] bool dryRun = false) { var total = Stopwatch.StartNew(); if (string.IsNullOrWhiteSpace(file)) return RunScriptResponse.Fail("Bad Request", "The 'file' parameter is required and cannot be empty.", total.ElapsedMilliseconds); if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) return RunScriptResponse.Fail("Bad Request", "The 'file' must have a .cs extension.", total.ElapsedMilliseconds); if (timeoutMs <= 0 || timeoutMs > MaxTimeoutMs) return RunScriptResponse.Fail("Bad Request", $"'timeout_ms' must be between 1 and {MaxTimeoutMs}.", total.ElapsedMilliseconds); var normalizedMode = (mode ?? "ephemeral").Trim().ToLowerInvariant(); try { switch (normalizedMode) { case "ephemeral": return await RunEphemeral(file, entry, args, references, defines, pdb, dryRun, timeoutMs, total); case "hotpatch": { // hotpatch delegates wholesale to reload_file's [CodeReload] semantics: // nothing is invoked, so entry/args are meaningless, and there is no // compile-only path — a "dry run" would still apply live method // replacements. Reject rather than silently ignore or, worse, apply. if (dryRun) { return RunScriptResponse.Fail("Bad Request", "'dry_run' is not supported with mode=hotpatch; it would apply live method replacements. " + "Use mode=ephemeral (the default) for a compile-only check.", total.ElapsedMilliseconds); } var unsupported = new List(); if (!string.IsNullOrWhiteSpace(entry)) unsupported.Add("'entry'"); if (args != null && args.Count > 0) unsupported.Add("'args'"); if (unsupported.Count > 0) { return RunScriptResponse.Fail("Bad Request", $"{string.Join(" and ", unsupported)} {(unsupported.Count == 1 ? "is" : "are")} not supported with mode=hotpatch: " + "it applies [CodeReload] method replacements in place and never invokes an entry point. " + "Use mode=ephemeral to run an entry point.", total.ElapsedMilliseconds); } return RunHotpatch(file, pdb, timeoutMs, total); } default: return RunScriptResponse.Fail("Bad Request", $"Unknown mode '{mode}'. Expected 'ephemeral' or 'hotpatch'.", total.ElapsedMilliseconds); } } catch (Exception ex) { Debug.LogError($"Pipeline: run_script failed: {ex}"); return RunScriptResponse.Fail("Execution Failed", ex.ToString(), total.ElapsedMilliseconds); } } /// /// ephemeral mode: single-file in-memory compile → resolve the entry point → coerce args → /// invoke → serialize. No AssetDatabase.Refresh and no domain reload occur. Async so /// a Task-returning entry point can be awaited without blocking the main thread (see the /// comment at the await); bounds that wait. /// private static async Task RunEphemeral( string file, string entry, JArray args, string[] references, string[] defines, bool pdb, bool dryRun, int timeoutMs, Stopwatch total) { // Resolve the path: absolute as-is, else relative to the PROJECT ROOT (the parent of // Assets/) — NOT the process working directory, which is usually but not guaranteed to // be the project root. This deliberately accepts files outside Assets/. (hotpatch mode // intentionally keeps reload_file's own resolution, which probes under Assets/.) var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Directory.GetCurrentDirectory(); var fullPath = Path.GetFullPath(Path.IsPathRooted(file) ? file : Path.Combine(projectRoot, file)); if (!File.Exists(fullPath)) return RunScriptResponse.Fail("File Not Found", $"Source file not found: {fullPath}", total.ElapsedMilliseconds); string source; try { source = File.ReadAllText(fullPath); } catch (Exception ex) { return RunScriptResponse.Fail("Bad Request", $"Failed to read file: {ex.Message}", total.ElapsedMilliseconds); } if (string.IsNullOrWhiteSpace(source)) return RunScriptResponse.Fail("Bad Request", $"File is empty: {fullPath}", total.ElapsedMilliseconds); var assemblyName = $"PipelineRunScript_{Path.GetFileNameWithoutExtension(fullPath)}_{Guid.NewGuid():N}"; // Emit symbols mapped to the source file whenever we are going to execute, so that // exception stack traces (and, with an attached debugger, breakpoints) map back to // file:line in the original .cs. dry_run only needs symbols when explicitly requested. var emitDebug = pdb || !dryRun; // Seed the compile with the project's ACTIVE editor defines (UNITY_EDITOR, the // UNITY_ symbols, platform defines, project scripting symbols, …) so the // source's #if directives behave like project code. The user's `defines` extend that // set; they do not replace it. var activeDefines = EditorUserBuildSettings.activeScriptCompilationDefines; var effectiveDefines = defines == null || defines.Length == 0 ? activeDefines : activeDefines.Concat(defines).Distinct().ToArray(); var compileSw = Stopwatch.StartNew(); var compilation = RoslynCompilationService.Compile(new CompilationRequest { SourceCode = source, AssemblyName = assemblyName, EmitDebugInformation = emitDebug, DocumentPath = fullPath, AdditionalAssemblyPrefixes = references, PreprocessorSymbols = effectiveDefines, // dry_run never executes, so don't load the emitted assembly into the domain — // in-memory assemblies are not unloadable under Mono and would leak per call. SkipLoad = dryRun }); compileSw.Stop(); if (!compilation.Success) { return RunScriptResponse.Fail( "Compilation Failed", "The script failed to compile. See diagnostics for line/column details.", total.ElapsedMilliseconds, compilation.Diagnostics, compileSw.ElapsedMilliseconds, 0, assemblyName); } if (dryRun) { // Emit-only: no assembly was loaded, so there is no assemblyName to report. var dr = RunScriptResponse.Ok(null, null, compileSw.ElapsedMilliseconds, 0, total.ElapsedMilliseconds, compilation.Diagnostics); dr.Message = "Compiled successfully (dry run; nothing was loaded or executed)."; return dr; } // Resolve the entry point. var method = ResolveEntryPoint(compilation.Assembly, entry, Path.GetFileName(fullPath), out var entryError); if (method == null) { return RunScriptResponse.Fail("Entry Point Not Found", entryError, total.ElapsedMilliseconds, compilation.Diagnostics, compileSw.ElapsedMilliseconds, 0, assemblyName); } // Coerce JSON args to the entry point's parameter types. if (!TryCoerceArguments(method, args, out var callArgs, out var argError)) { return RunScriptResponse.Fail("Bad Request", argError, total.ElapsedMilliseconds, compilation.Diagnostics, compileSw.ElapsedMilliseconds, 0, assemblyName); } // Execute. var execSw = Stopwatch.StartNew(); object returnValue = null; try { returnValue = method.Invoke(null, callArgs); // A Task/Task-returning entry point must be awaited — otherwise the call // reports success the moment the first await yields, and any exception in the // async work is never observed. Awaited ASYNCHRONOUSLY (never a blocking // GetResult()): this command runs on the main thread, and an entry point's awaits // resume on the Unity SynchronizationContext — i.e. this same thread — so a // blocking wait here deadlocks the whole Editor permanently. Awaiting instead // returns control to the editor loop at this point (the server unwraps the // command's own Task off the main thread), letting those continuations pump. // The wait is bounded by what remains of timeout_ms; on expiry the task keeps // running detached, like an abandoned eval. Task exposes Result via reflection // (an `async Task` materializes as Task, whose Result carries no // value — treat it as null). if (returnValue is Task task) { var remainingMs = (int)Math.Max(0, timeoutMs - total.ElapsedMilliseconds); var completed = await Task.WhenAny(task, Task.Delay(remainingMs)); if (completed != task) { execSw.Stop(); return RunScriptResponse.Fail( "Timeout", $"The entry point's Task did not complete within the {timeoutMs}ms budget " + $"({remainingMs}ms remained after the synchronous part). The task keeps running detached and " + "its effects may still land; raise timeout_ms if it legitimately needs longer.", total.ElapsedMilliseconds, compilation.Diagnostics, compileSw.ElapsedMilliseconds, execSw.ElapsedMilliseconds, assemblyName); } await task; // a faulted task throws the entry point's own exception here var resultProperty = task.GetType().GetProperty("Result"); returnValue = resultProperty != null && resultProperty.PropertyType.FullName != "System.Threading.Tasks.VoidTaskResult" ? resultProperty.GetValue(task) : null; } } catch (Exception ex) when (ex is TargetInvocationException || returnValue is Task) { execSw.Stop(); // Reflection wraps a synchronous throw in a TargetInvocationException; a faulted // task rethrows the entry point's own exception from the await. Normalize both // to the entry point's exception. With the PDB emitted above and DocumentPath set // to the source file, the stack trace carries "... in :line ", so the // failure maps back to the original source. var inner = (ex as TargetInvocationException)?.InnerException ?? ex; return RunScriptResponse.Fail( "Runtime Error", $"{inner.GetType().Name}: {inner.Message}\n{inner.StackTrace}", total.ElapsedMilliseconds, compilation.Diagnostics, compileSw.ElapsedMilliseconds, execSw.ElapsedMilliseconds, assemblyName); } execSw.Stop(); return RunScriptResponse.Ok( SerializeResult(returnValue), assemblyName, compileSw.ElapsedMilliseconds, execSw.ElapsedMilliseconds, total.ElapsedMilliseconds, compilation.Diagnostics); } /// /// hotpatch mode: apply in-place [CodeReload] method replacements to already-loaded /// types, delegating to the existing reload_file command so its behavior/tests are /// preserved. The result is re-shaped into a . /// private static RunScriptResponse RunHotpatch(string file, bool pdb, int timeoutMs, Stopwatch total) { var hr = CodeReloadCommands.ReloadFile(file, timeoutMs, null, pdb); // reload_file reports a single duration for the whole reload (compile + apply); surface // it as executeMs rather than mislabeling it compile time. compileMs stays 0 here. var reloadMs = hr.ExecutionTimeMs ?? 0L; if (hr.Success) { // reload_file can succeed with advisory diagnostics (e.g. partially-skipped // overrides) — keep them, as "warning" entries in the same shape the failure // path uses, instead of dropping them on the floor. var warnings = (hr.Diagnostics ?? new List()) .Select(d => new DiagnosticInfo { Severity = "warning", Message = d }) .ToList(); var ok = RunScriptResponse.Ok(hr.Items, hr.AssemblyName, 0, reloadMs, total.ElapsedMilliseconds, warnings); ok.Message = hr.Message; return ok; } var diagnostics = (hr.Diagnostics ?? new List()) .Select(d => new DiagnosticInfo { Severity = "error", Message = d }) .ToList(); return RunScriptResponse.Fail(hr.Error ?? "Code Reload Failed", hr.ErrorDetails, total.ElapsedMilliseconds, diagnostics, 0, reloadMs, hr.AssemblyName); } /// /// Resolve the static entry method from a compiled assembly. /// - "Namespace.Type.Method" / "Type.Method": split on the last dot; find the type, then the /// static method by name (public or non-public). Nested types may be written with dots /// ("Outer.Inner.Method") — the '+' reflection form is retried automatically. /// - bare "Method": the single static method with that name across all types. /// - null/empty: the single public static method if unambiguous, else a method named "Main". /// Generic methods are never candidates (no type arguments to close them with). /// Returns null and sets when it cannot resolve unambiguously. /// private static MethodInfo ResolveEntryPoint(Assembly assembly, string entry, string fileName, out string error) { error = null; // Skip compiler-generated types (e.g. ). var types = assembly.GetTypes().Where(t => !t.Name.Contains("<") && !t.Name.Contains(">")).ToArray(); const BindingFlags AnyStatic = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly; const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly; if (!string.IsNullOrWhiteSpace(entry)) { entry = entry.Trim(); var lastDot = entry.LastIndexOf('.'); if (lastDot > 0) { var typeName = entry.Substring(0, lastDot); var methodName = entry.Substring(lastDot + 1); var type = assembly.GetType(typeName) ?? types.FirstOrDefault(t => t.FullName == typeName || t.Name == typeName); if (type == null && typeName.IndexOf('.') >= 0) { // Nested types use '+' in reflection names ("Outer+Inner"), while callers // naturally write dots ("Outer.Inner.Method"). Retry with the last dot of // the type part replaced. var nestedDot = typeName.LastIndexOf('.'); var nestedName = typeName.Substring(0, nestedDot) + "+" + typeName.Substring(nestedDot + 1); type = assembly.GetType(nestedName) ?? types.FirstOrDefault(t => t.FullName == nestedName); } if (type == null) { error = $"No type '{typeName}' in compiled file '{fileName}'. Types: [{string.Join(", ", types.Select(t => t.FullName))}]."; return null; } var named = type .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) .Where(m => m.Name == methodName && !m.IsSpecialName) .ToList(); var candidates = named.Where(m => !m.ContainsGenericParameters).ToList(); if (candidates.Count == 1) return candidates[0]; if (candidates.Count > 1) { error = $"Entry '{entry}' is overloaded ({candidates.Count} static overloads); overloaded entry points are not supported in v1."; return null; } if (named.Count > 0) { error = $"Entry '{entry}' only resolves to generic method(s); generic entry points are not supported."; return null; } var instance = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) .FirstOrDefault(m => m.Name == methodName && !m.IsSpecialName); error = instance != null ? $"Entry '{entry}' is an instance method; only static methods are supported in v1." : $"No static method '{methodName}' on type '{type.FullName}'."; return null; } // Bare method name — search every type for a static method with that name. var byNameAll = types.SelectMany(t => t.GetMethods(AnyStatic)) .Where(m => m.Name == entry && !m.IsSpecialName) .ToList(); var byName = byNameAll.Where(m => !m.ContainsGenericParameters).ToList(); if (byName.Count == 1) return byName[0]; if (byName.Count == 0) { error = byNameAll.Count > 0 ? $"Static method '{entry}' in '{fileName}' is generic; generic entry points are not supported." : $"No static method named '{entry}' found in '{fileName}'."; return null; } error = $"Multiple static methods named '{entry}' found in '{fileName}'; qualify the entry as Namespace.Type.Method."; return null; } // No entry specified — auto-detect. Generic methods can't be invoked without type // arguments, so they are never entry-point candidates. var publicStatic = types.SelectMany(t => t.GetMethods(PublicStatic)) .Where(m => !m.IsSpecialName && !m.ContainsGenericParameters) .ToList(); if (publicStatic.Count == 1) return publicStatic[0]; var mains = types.SelectMany(t => t.GetMethods(AnyStatic)) .Where(m => m.Name == "Main" && !m.IsSpecialName && !m.ContainsGenericParameters) .ToList(); if (mains.Count == 1) return mains[0]; if (publicStatic.Count == 0) { error = $"No public static entry point found in '{fileName}'. Add a public static method or specify 'entry'."; return null; } error = $"Ambiguous entry point in '{fileName}': {publicStatic.Count} public static methods and no unique 'Main'. " + $"Specify 'entry' as Namespace.Type.Method. Candidates: [{string.Join(", ", publicStatic.Select(m => (m.DeclaringType?.FullName ?? "?") + "." + m.Name))}]."; return null; } /// /// Coerce the JSON array to the entry point's parameter types using /// the shared converters: primitives / enum names / string[] via Newtonsoft, and a /// string-or-object handle ( + ) for /// UnityEngine.Object parameters. Omitted trailing args fall back to C# default values. /// An explicit JSON null is only valid for reference-type and Nullable<T> parameters — /// for a non-nullable value type it is a coercion error, never a silent default(T). /// private static bool TryCoerceArguments(MethodInfo method, JArray args, out object[] callArgs, out string error) { error = null; var parameters = method.GetParameters(); callArgs = new object[parameters.Length]; var provided = args?.Count ?? 0; if (provided > parameters.Length) { error = $"Entry point '{method.DeclaringType?.FullName}.{method.Name}' expects {parameters.Length} argument(s) but {provided} were provided."; return false; } for (int i = 0; i < parameters.Length; i++) { var p = parameters[i]; var pt = p.ParameterType; if (i >= provided) { if (p.HasDefaultValue) { callArgs[i] = p.DefaultValue; continue; } error = $"Missing required argument #{i} for parameter '{p.Name}' ({pt.Name})."; return false; } var token = args[i]; if (typeof(Object).IsAssignableFrom(pt)) { if (token == null || token.Type == JTokenType.Null) { callArgs[i] = null; continue; } ObjectRef handle; try { handle = token.ToObject(); } catch (Exception ex) { error = $"Argument #{i} ('{p.Name}'): not a valid object reference: {ex.Message}"; return false; } if (handle == null || handle.IsEmpty) { error = $"Argument #{i} ('{p.Name}'): empty object reference."; return false; } if (!ObjectResolver.TryResolve(handle, out var resolved, out var resolveError)) { error = $"Argument #{i} ('{p.Name}'): {resolveError}"; return false; } if (!pt.IsInstanceOfType(resolved)) { // Convenience: a GameObject handle can satisfy a Component parameter (and vice versa). Object coerced = null; if (resolved is GameObject go && typeof(Component).IsAssignableFrom(pt)) coerced = go.GetComponent(pt); else if (resolved is Component c && pt == typeof(GameObject)) coerced = c.gameObject; if (coerced != null && pt.IsInstanceOfType(coerced)) { resolved = coerced; } else { error = $"Argument #{i} ('{p.Name}') resolved to a {resolved.GetType().Name}, which is not assignable to {pt.Name}."; return false; } } callArgs[i] = resolved; continue; } if (token == null || token.Type == JTokenType.Null) { // null has no representation in a non-nullable value type — silently passing // default(T) would hide the caller's mistake. Nullable (and reference // types) accept null as-is. if (pt.IsValueType && Nullable.GetUnderlyingType(pt) == null) { error = $"Argument #{i} ('{p.Name}'): null cannot be converted to value type {pt.Name}; pass a value, or make the parameter nullable ({pt.Name}?)."; return false; } callArgs[i] = null; continue; } try { callArgs[i] = token.ToObject(pt); } catch (Exception ex) { error = $"Argument #{i} ('{p.Name}'): cannot convert to {pt.Name}: {ex.Message}"; return false; } } return true; } /// /// Serialize the entry point's return value for the JSON response. Mirrors /// EvalCodeCompiler.SerializeResult so run_script results look like eval results: /// primitives pass through, Unity objects go via JsonUtility, everything else via Newtonsoft, /// with a ToString() fallback. /// private static object SerializeResult(object value) { if (value == null) return null; if (value is string || value is bool || value is int || value is long || value is float || value is double) return value; if (value is Object unityObj) { try { return JsonConvert.DeserializeObject(JsonUtility.ToJson(unityObj)); } catch { return value.ToString(); } } try { return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(value)); } catch { return value.ToString(); } } } /// /// Response for run_script. Result shape: { result, diagnostics, compileMs, executeMs, /// assemblyName } on top of the standard command envelope. Deliberately carries no echo of the /// source (path + diagnostics only), so MCP results stay small and reviewable. /// [Serializable] class RunScriptResponse : CommandExecutionResponse { /// Compilation diagnostics (errors, warnings) from Roslyn, with line/column. [JsonProperty("diagnostics")] public List Diagnostics { get; set; } = new List(); /// /// Milliseconds spent compiling the file to an in-memory assembly. 0 for hotpatch, whose /// compile is inside reload_file and not reported separately (see ). /// [JsonProperty("compileMs")] public long CompileMs { get; set; } /// /// Milliseconds spent executing the entry point (0 for dry_run). For hotpatch this is the /// whole reload duration — compile + apply — since reload_file does not split the two. /// [JsonProperty("executeMs")] public long ExecuteMs { get; set; } /// Name of the generated in-memory assembly. [JsonProperty("assemblyName")] public string AssemblyName { get; set; } public static RunScriptResponse Ok(object result, string assemblyName, long compileMs, long executeMs, long executionTimeMs, List diagnostics) { return new RunScriptResponse { Success = true, Command = "run_script", Result = result, AssemblyName = assemblyName, CompileMs = compileMs, ExecuteMs = executeMs, ExecutionTimeMs = executionTimeMs, Diagnostics = diagnostics ?? new List(), ExecutedAt = DateTime.UtcNow }; } public static RunScriptResponse Fail(string error, string errorDetails, long executionTimeMs, List diagnostics = null, long compileMs = 0, long executeMs = 0, string assemblyName = null) { return new RunScriptResponse { Success = false, Command = "run_script", Error = error, ErrorDetails = errorDetails, AssemblyName = assemblyName, CompileMs = compileMs, ExecuteMs = executeMs, ExecutionTimeMs = executionTimeMs, Diagnostics = diagnostics ?? new List(), ExecutedAt = DateTime.UtcNow }; } } }