Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b6905de2a6949fc40bea9d708751b0fe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,51 @@
using System;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Attribute to mark parameters of CLI command methods.
/// Provides parameter metadata for CLI validation and help generation.
/// Adapted from unity-tools [Parameter] attribute for Pipeline organization requirements.
///
/// Also valid on the fields/properties of an <see cref="IStructuredCommandInput"/> DTO, where it
/// describes a member of a nested object schema (same Name/Description/Required semantics).
/// </summary>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
public class CliArgAttribute : Attribute
{
/// <summary>
/// Name of the parameter as it appears in CLI arguments.
/// Used in "unity request command_name --param_name value" syntax.
/// </summary>
public string Name { get; }
/// <summary>
/// Human-readable description of the parameter.
/// Used in CLI help text and parameter documentation.
/// </summary>
public string Description { get; }
/// <summary>
/// Whether this parameter is required for command execution.
/// Default: false (parameter is optional).
/// </summary>
public bool Required { get; set; } = false;
/// <summary>
/// Default value for the parameter if not provided.
/// Must be compatible with the parameter type.
/// </summary>
public object DefaultValue { get; set; }
/// <summary>
/// Create a new CLI parameter attribute.
/// </summary>
/// <param name="name">Parameter name for CLI arguments</param>
/// <param name="description">Human-readable description</param>
public CliArgAttribute(string name, string description)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ac8886d9f7e92d844b4f1728ce2e2cab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,57 @@
using System;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Attribute to mark static methods as CLI-accessible commands.
/// Commands with this attribute can be executed remotely via Pipeline Server.
/// Adapted from unity-tools [Tool] attribute for Pipeline organization requirements.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class CliCommandAttribute : Attribute
{
/// <summary>
/// Unique name of the command for CLI execution.
/// Used in "unity request command_name" syntax.
/// </summary>
public string Name { get; }
/// <summary>
/// Human-readable description of what the command does.
/// Used in CLI help text and command discovery.
/// </summary>
public string Description { get; }
/// <summary>
/// Whether this command requires Unity main thread execution.
/// Default: true (most Unity APIs require main thread).
/// </summary>
public bool MainThreadRequired { get; set; } = true;
/// <summary>
/// Whether this command belongs to the runtime (Player) command surface only.
/// Runtime-only commands are hidden from an Editor server's command listing
/// (they remain executable, but are not advertised when connected to an Editor).
/// Default: false (listed on the Editor server).
/// </summary>
public bool RuntimeOnly { get; set; } = false;
/// <summary>
/// Optional hierarchical tags used to group and browse commands.
/// Path-style: a '/' separates tag from subtag (e.g. "assets", "assets/import").
/// Default: empty (untagged).
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// Create a new CLI command attribute.
/// </summary>
/// <param name="name">Unique command name for CLI execution</param>
/// <param name="description">Human-readable description</param>
public CliCommandAttribute(string name, string description)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ee4da62af1984f442af8ada30f3eee83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,234 @@
using System;
namespace Unity.Pipeline
{
/// <summary>
/// Per-server progress-reporting state. Owned one-per-<see cref="BasePipelineServer"/> (the
/// same pattern as <c>Dispatcher</c>) so a test server's execution can never cross-attribute
/// progress with the live server's. See <see cref="CliProgress"/> for the public static
/// façade that arbitrary command code calls.
/// </summary>
internal sealed class CliProgressState
{
/// <summary>Immutable copy of the current progress state (thread-safe to hand across threads).</summary>
internal readonly struct Snapshot
{
public readonly bool HasReport;
public readonly string Title;
public readonly string Info;
public readonly long? Current;
public readonly long? Total;
public readonly double? Progress01;
public Snapshot(string title, string info, long? current, long? total, double? progress01)
{
HasReport = true;
Title = title;
Info = info;
Current = current;
Total = total;
Progress01 = progress01;
}
}
readonly object m_Gate = new object();
Snapshot m_Explicit;
string m_ExplicitOwnerId;
string m_CurrentOwnerId;
Snapshot m_Ambient;
int m_ActiveCount;
/// <summary>Whether any execution is currently in flight. Read by /api/progress.</summary>
internal bool IsActive
{
get
{
lock (m_Gate)
{
return m_ActiveCount > 0;
}
}
}
/// <summary>Marks one more execution in flight (see <see cref="EndExecutionCount"/>).</summary>
internal void BeginExecutionCount()
{
lock (m_Gate)
{
m_ActiveCount++;
}
}
/// <summary>
/// Marks one execution finished, resetting the explicit-report state when this was the
/// last one in flight. The decrement and the reset-on-zero check happen under the same
/// lock as <see cref="BeginExecution"/>, so a new execution starting in the gap can never
/// have its fresh BeginExecution/Report wiped by this reset — the previous implementation
/// decremented a separate counter and reset separately, leaving a window where a finishing
/// execution could decrement to zero, get preempted, and then wipe a new execution's
/// owner id and early report once it resumed.
/// </summary>
internal void EndExecutionCount()
{
lock (m_Gate)
{
m_ActiveCount--;
if (m_ActiveCount == 0)
{
m_Explicit = default;
m_ExplicitOwnerId = null;
m_CurrentOwnerId = null;
}
}
}
/// <summary>
/// Report the current task's progress so a connected CLI can render it live.
/// Safe to call from any thread, at any frequency (the server samples on poll).
/// </summary>
/// <param name="title">Short task title, e.g. "Generating World".</param>
/// <param name="info">Detail line, e.g. "Processing 42/100".</param>
/// <param name="current">Current step, when the task has countable steps.</param>
/// <param name="total">Total steps, when the task has countable steps.</param>
/// <param name="progress">Completion in the 0–1 range; omit for indeterminate tasks
/// (when omitted and both <paramref name="current"/> and <paramref name="total"/> are
/// present, the CLI derives a percentage from them).</param>
internal void Report(string title, string info = null, long? current = null, long? total = null, double? progress = null)
{
var clamped = progress.HasValue ? Math.Max(0d, Math.Min(1d, progress.Value)) : (double?)null;
lock (m_Gate)
{
m_Explicit = new Snapshot(title, info, current, total, clamped);
m_ExplicitOwnerId = m_CurrentOwnerId;
}
}
/// <summary>Clear an explicit <see cref="Report"/> (e.g. when the reported task finishes).</summary>
internal void Clear()
{
lock (m_Gate)
{
m_Explicit = default;
m_ExplicitOwnerId = null;
}
}
/// <summary>Ambient-source mirror (UnityEditor.Progress items). Explicit reports win.</summary>
internal void ReportAmbient(string title, string info, long? current, long? total, double? progress01)
{
lock (m_Gate)
{
m_Ambient = new Snapshot(title, info, current, total, progress01);
}
}
/// <summary>Clear the ambient mirror (no running UnityEditor.Progress items remain).</summary>
internal void ClearAmbient()
{
lock (m_Gate)
{
m_Ambient = default;
}
}
/// <summary>
/// Marks the start of the execution identified by <paramref name="ownerId"/> (a job id,
/// or a synthetic id for a plain <c>/api/exec</c>). Clears any leftover explicit report
/// from whatever ran before it and becomes the only owner <see cref="Current"/> will
/// accept explicit reports from until <see cref="EndExecution"/> is called — so a queued
/// execution can never surface the previous one's progress before reporting its own.
/// </summary>
internal void BeginExecution(string ownerId)
{
lock (m_Gate)
{
m_CurrentOwnerId = ownerId;
m_Explicit = default;
m_ExplicitOwnerId = null;
}
}
/// <summary>
/// Marks the end of the execution identified by <paramref name="ownerId"/>. A no-op if
/// some other execution is already current (e.g. called after a swallowed exception),
/// so callers can call this unconditionally in a <c>finally</c>.
/// </summary>
internal void EndExecution(string ownerId)
{
lock (m_Gate)
{
if (m_CurrentOwnerId == ownerId)
{
m_CurrentOwnerId = null;
}
}
}
/// <summary>
/// The snapshot the /api/progress endpoint serves: the explicit report, but only when it
/// was made by the currently executing owner (see <see cref="BeginExecution"/>); falls
/// back to the ambient mirror otherwise.
/// </summary>
internal Snapshot Current
{
get
{
lock (m_Gate)
{
var explicitValid = m_Explicit.HasReport && m_ExplicitOwnerId == m_CurrentOwnerId;
return explicitValid ? m_Explicit : m_Ambient;
}
}
}
}
/// <summary>
/// Structured progress reporting for long-running pipeline commands — the server side of the
/// CLI's terminal progress bars (CLI-488, coordinated with CLI-335).
///
/// While a command executes over <c>/api/exec</c>, the CLI polls <c>GET /api/progress</c> and
/// renders whatever is reported here (a progress bar in a terminal, NDJSON progress frames in
/// machine formats). Command authors call <see cref="Report"/> from any thread — including a
/// main thread that is blocked inside a long synchronous command, which is exactly the
/// <c>EditorUtility.DisplayProgressBar</c> scenario this exists for. The state is read by the
/// HTTP listener thread, never the main thread, so progress stays visible even while the
/// Editor is busy.
///
/// This is a thin static façade over the executing server's own <see cref="CliProgressState"/>
/// (resolved via <see cref="BasePipelineServer.CurrentServer"/>) so command code — which has
/// no reference to "which server is running me" — can keep calling this the same way
/// regardless of which server dispatched it.
///
/// Ambient <c>UnityEditor.Progress</c> items are mirrored in by the Editor assembly
/// (<c>EditorProgressMirror</c>), targeting the live server directly; an explicit
/// <see cref="Report"/> always wins over the ambient mirror. Every execution (a plain
/// <c>/api/exec</c> or a detached job) is bracketed with BeginExecution/EndExecution on the
/// server's own <see cref="CliProgressState"/>, which tags who the current explicit report is
/// allowed to belong to — so a job queued behind one that just finished never inherits its
/// stale progress before making its own first report, no matter how long that job takes to
/// get around to reporting anything.
/// </summary>
public static class CliProgress
{
/// <summary>
/// Report the current task's progress so a connected CLI can render it live.
/// Safe to call from any thread, at any frequency (the server samples on poll). A no-op
/// when called outside a command's own execution (no server to attribute it to).
/// </summary>
/// <param name="title">Short title for the operation.</param>
/// <param name="info">Optional detail text (e.g. current step).</param>
/// <param name="current">Current progress amount, if determinate.</param>
/// <param name="total">Total amount, if determinate.</param>
/// <param name="progress">Progress in [0, 1], used instead of current/total when set.</param>
public static void Report(string title, string info = null, long? current = null, long? total = null, double? progress = null)
{
BasePipelineServer.CurrentServer?.Progress.Report(title, info, current, total, progress);
}
/// <summary>Clear an explicit <see cref="Report"/> (e.g. when the reported task finishes).</summary>
public static void Clear()
{
BasePipelineServer.CurrentServer?.Progress.Clear();
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 314bec0f00e544458e7558fde1168e82
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetVersion: 1
@@ -0,0 +1,91 @@
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
namespace Unity.Pipeline
{
/// <summary>
/// Everything known about one finished <c>/api/exec</c> interaction, handed to
/// <see cref="BasePipelineServer.OnCommandDone"/>.
///
/// The transaction half and the execution half are independent, because the two are not the
/// same set: a request can produce a transaction without executing anything (rejected for
/// size, malformed JSON, unknown command, busy host, job queue full), and a detached job
/// executes long after its own response was sent, producing no transaction at all. Consumers
/// read the half they care about and skip the ones that lack it.
/// </summary>
public readonly struct CommandExecutionInfo
{
/// <summary>
/// Raw request JSON, or null when there is no transaction to report — a detached job
/// completing after its submission response was already sent.
/// </summary>
public string RequestJson { get; }
/// <summary>
/// Raw response JSON as it went on the wire, or null alongside a null
/// <see cref="RequestJson"/>.
/// </summary>
public string ResponseJson { get; }
/// <summary>
/// The command that ran, or null when the request was rejected before any command
/// executed.
/// </summary>
public CommandInfo Command { get; }
/// <summary>
/// The parameters the command was invoked with, or null when no command ran. Lets a consumer
/// answer questions about WHAT was asked, not just which command served it — the analytics
/// reporter reads an eval body from here to decide whether a concrete command already covers
/// it. Never serialized anywhere: it is in-memory only, for the moment between the command
/// finishing and the post-command work running.
/// </summary>
public JObject Parameters { get; }
/// <summary>
/// Whether the command reported success. False when it threw AND when it returned a
/// response carrying its own failure (eval, code reload and test runs all do the latter).
/// Meaningless when <see cref="Command"/> is null.
/// </summary>
public bool Success { get; }
/// <summary>
/// Wall-clock milliseconds spent inside the command itself, excluding the wait for the
/// one-command-at-a-time execution gate. Zero when <see cref="Command"/> is null.
/// </summary>
public long DurationMs { get; }
/// <summary>Create an info describing one finished interaction.</summary>
/// <param name="requestJson">Raw request JSON, or null when there is no transaction.</param>
/// <param name="responseJson">Raw response JSON, or null alongside a null request.</param>
/// <param name="command">The command that ran, or null when none did.</param>
/// <param name="success">Whether the command reported success.</param>
/// <param name="durationMs">Milliseconds spent inside the command.</param>
/// <param name="parameters">The parameters the command was invoked with, or null.</param>
public CommandExecutionInfo(string requestJson, string responseJson, CommandInfo command,
bool success, long durationMs, JObject parameters = null)
{
RequestJson = requestJson;
ResponseJson = responseJson;
Command = command;
Success = success;
DurationMs = durationMs;
Parameters = parameters;
}
/// <summary>
/// A detached job that just finished: an execution with no transaction, since the client
/// already received its job handle.
/// </summary>
/// <param name="command">The command the job ran.</param>
/// <param name="success">Whether the command reported success.</param>
/// <param name="durationMs">Milliseconds spent inside the command.</param>
/// <param name="parameters">The parameters the job ran the command with.</param>
/// <returns>An info carrying the execution and no transaction.</returns>
public static CommandExecutionInfo ForDetachedJob(CommandInfo command, bool success, long durationMs,
JObject parameters = null)
{
return new CommandExecutionInfo(null, null, command, success, durationMs, parameters);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 36cede5ed8322f54ab8790b9b19b6647
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,101 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Reflection;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Information about a discovered CLI command.
/// Contains metadata needed for command execution and CLI help generation.
/// </summary>
public class CommandInfo
{
/// <summary>
/// Unique name of the command for CLI execution.
/// </summary>
public string Name { get; }
/// <summary>
/// Human-readable description of the command.
/// </summary>
public string Description { get; }
/// <summary>
/// Whether this command requires Unity main thread execution.
/// </summary>
public bool MainThreadRequired { get; }
/// <summary>
/// Whether this command is part of the runtime (Player) command surface only.
/// Editor servers hide runtime-only commands from their command listing.
/// </summary>
public bool RuntimeOnly { get; }
/// <summary>
/// Hierarchical tags used to group and browse commands (path-style, '/'-separated).
/// Empty for untagged commands; never null.
/// </summary>
public IReadOnlyList<string> Tags { get; }
/// <summary>
/// Name of the assembly this command originates from, used to group and filter
/// commands by contributing package/source.
/// </summary>
public string Package { get; }
/// <summary>
/// Method that implements this command.
/// </summary>
public MethodInfo Method { get; } // TODO Maybe look at generating a dynamic Delegate to increase performance of the command call.
/// <summary>
/// Parameters that this command accepts.
/// </summary>
public IReadOnlyList<CommandParameterInfo> Parameters { get; }
/// <summary>
/// Instance to invoke <see cref="Method"/> on, or null when <see cref="Method"/> is static.
/// Commands declared with a [CliCommand] attribute are always static and leave this null;
/// a dynamically registered command bound to an instance method carries its receiver here.
/// </summary>
public object Target { get; }
/// <summary>
/// Create command information from discovery.
/// </summary>
/// <param name="name">Unique name of the command for CLI execution.</param>
/// <param name="description">Human-readable description of the command.</param>
/// <param name="mainThreadRequired">Whether this command requires Unity main thread execution.</param>
/// <param name="method">Method that implements this command.</param>
/// <param name="parameters">Parameters that this command accepts.</param>
/// <param name="runtimeOnly">Whether this command is part of the runtime (Player) command surface only.</param>
/// <param name="tags">Hierarchical tags used to group and browse commands.</param>
/// <param name="package">Name of the assembly this command originates from.</param>
/// <param name="target">
/// Instance to invoke <paramref name="method"/> on, or null when it is static. Only a
/// dynamically registered command bound to an instance method supplies one.
/// </param>
public CommandInfo(string name, string description, bool mainThreadRequired,
MethodInfo method, IReadOnlyList<CommandParameterInfo> parameters, bool runtimeOnly = false,
IReadOnlyList<string> tags = null, string package = null, object target = null)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
Method = method ?? throw new ArgumentNullException(nameof(method));
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
MainThreadRequired = mainThreadRequired;
RuntimeOnly = runtimeOnly;
Tags = tags ?? Array.Empty<string>();
Package = package;
Target = target;
}
/// <summary>A short diagnostic summary of the command.</summary>
/// <returns>The summary string.</returns>
public override string ToString()
{
return $"{Name} MainThreadRequired:{MainThreadRequired} Parameters:{Parameters.Count}";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 40290d720d0d35b44a9fa5843a5ee294
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,537 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
namespace Unity.Pipeline
{
/// <summary>
/// Binds argv tokens (the tokens AFTER the command name) to a command's declared
/// parameters, producing the same <see cref="JObject"/> a client would have sent as
/// structured <c>parameters</c>.
///
/// <b>Values are stored as the structured form would carry them.</b> For a parameter with a
/// declared type that means a string JValue, never pre-converted — the single most important
/// constraint here, because it makes <c>{"commandLine":"log_editor hi"}</c> and
/// <c>{"command":"log_editor","parameters":{"message":"hi"}}</c> indistinguishable from
/// <c>ExtractCommandParameters</c> downwards, so there is exactly one execution path and
/// one place where coercion can differ. An UNTYPED parameter has no type to coerce toward, so
/// there the token's shape decides — see <c>StoreValue</c>.
///
/// Every defect is accumulated rather than bailing on the first, so a user fixing a command
/// line sees all of its problems at once. Required-parameter checking deliberately stays
/// OUT of here — <c>ValidateCommandParameters</c> owns it, so the message and error type are
/// byte-identical across both request forms.
///
/// Grammar: <c>--key value</c>, <c>--key=value</c>, bare <c>--key</c>. A follower is
/// consumed unless it starts with <c>--</c>, which is what makes <c>--path -foo</c> work.
/// <c>--</c> ends flag parsing. No short flags, no <c>-abc</c> bundling, no <c>--no-x</c>,
/// no repeated-flag-becomes-array.
/// </summary>
internal static class CommandLineBinder
{
private static readonly CommandParameterInfo[] k_NoParameters = new CommandParameterInfo[0];
/// <summary>
/// Binds <paramref name="args"/> against <paramref name="command"/>. Returns true only
/// when no problems were found; <paramref name="parameters"/> is always populated as far
/// as binding got, so a caller may inspect a partial bind.
/// </summary>
public static bool TryBind(CommandInfo command, IReadOnlyList<string> args,
out JObject parameters, out List<ArgProblem> problems)
{
if (command == null)
throw new ArgumentNullException(nameof(command));
parameters = new JObject();
problems = new List<ArgProblem>();
var declared = command.Parameters ?? (IReadOnlyList<CommandParameterInfo>)k_NoParameters;
if (args == null || args.Count == 0)
return true;
// Pass 1 - classify flags. Positionals are collected with a flag recording whether
// they appeared after "--", which exempts them from the -foo and key=value
// heuristics below.
var flagFilled = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Which parameters were set by a BARE flag. Tracked rather than inferred from the
// stored value being boolean: an untyped parameter given the literal token `true`
// also stores a boolean, and conflating the two reported a phantom missing value.
// The token as typed, per parameter, so a type error can quote the user verbatim even
// when the stored value was literal-parsed into a non-string.
var rawTokens = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var bareFlagged = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var positionals = new List<string>();
var positionalExempt = new List<bool>();
var endOfFlags = false;
for (var i = 0; i < args.Count; i++)
{
var arg = args[i] ?? string.Empty;
if (!endOfFlags && arg == "--")
{
endOfFlags = true;
continue;
}
if (endOfFlags || !arg.StartsWith("--", StringComparison.Ordinal))
{
positionals.Add(arg);
positionalExempt.Add(endOfFlags);
continue;
}
var body = arg.Substring(2);
var equals = body.IndexOf('=');
var name = equals >= 0 ? body.Substring(0, equals) : body;
var inlineValue = equals >= 0 ? body.Substring(equals + 1) : null;
if (name.Length == 0)
{
problems.Add(new ArgProblem { Kind = ArgProblemKind.EmptyName, Token = arg });
continue;
}
var parameter = FindParameter(declared, name);
if (parameter == null)
{
problems.Add(new ArgProblem
{
Kind = ArgProblemKind.UnknownName,
Name = name,
Suggestion = SuggestParameterName(name, declared),
});
// Swallow what was clearly meant as this flag's value, so it does not
// resurface as a spurious excess positional.
if (inlineValue == null && HasConsumableFollower(args, i))
i++;
continue;
}
// Checked before the empty-value bail below: a second `--key=` is BOTH a
// duplicate and an empty value, and reporting only the second would let a user
// fix what they were told about and still be duplicating the flag.
if (flagFilled.Contains(parameter.Name))
problems.Add(new ArgProblem { Kind = ArgProblemKind.Duplicate, Name = parameter.Name });
if (inlineValue != null && inlineValue.Length == 0)
{
problems.Add(new ArgProblem { Kind = ArgProblemKind.EmptyValue, Name = parameter.Name });
continue;
}
JToken value;
if (inlineValue != null)
{
value = inlineValue;
}
else if (HasConsumableFollower(args, i))
{
// Absence and emptiness are different: `--message ""` is the empty string,
// not the boolean true.
value = args[++i] ?? string.Empty;
}
else
{
value = new JValue(true);
bareFlagged.Add(parameter.Name);
}
if (value.Type == JTokenType.String)
{
var text = value.Value<string>();
parameters[parameter.Name] = StoreValue(parameter, text);
rawTokens[parameter.Name] = text;
}
else
{
parameters[parameter.Name] = value;
}
flagFilled.Add(parameter.Name);
}
// Pass 2 - positional slots: required parameters in declaration order, then
// optional in declaration order. A slot already filled by a flag is SKIPPED, not
// overwritten, so the explicit flag always wins its own slot.
var slots = new List<CommandParameterInfo>(declared.Count);
for (var i = 0; i < declared.Count; i++)
if (declared[i].Required)
slots.Add(declared[i]);
for (var i = 0; i < declared.Count; i++)
if (!declared[i].Required)
slots.Add(declared[i]);
var free = new List<CommandParameterInfo>(slots.Count);
for (var i = 0; i < slots.Count; i++)
if (!flagFilled.Contains(slots[i].Name))
free.Add(slots[i]);
var next = 0;
var firstOverflow = -1;
for (var i = 0; i < positionals.Count; i++)
{
var token = positionals[i];
if (!positionalExempt[i])
{
// Both of these are reported but STILL consume a slot: the token was
// clearly meant as a positional, and pretending otherwise would cascade
// into misleading downstream problems.
if (IsSingleDashFlag(token))
{
problems.Add(new ArgProblem { Kind = ArgProblemKind.SingleDash, Token = token });
}
else if (next < free.Count && IsBareAssignmentNamingItsOwnSlot(token, free[next]))
{
problems.Add(new ArgProblem { Kind = ArgProblemKind.BareAssignment, Token = token });
}
}
if (next < free.Count)
{
var slot = free[next++];
parameters[slot.Name] = StoreValue(slot, token);
rawTokens[slot.Name] = token;
}
else if (firstOverflow < 0)
{
firstOverflow = i;
}
}
// Pass 3 - coerce, as a DRY RUN only. We never rewrite what we stored: the executor
// re-converts from the same string, so binding and execution cannot diverge.
for (var i = 0; i < declared.Count; i++)
{
var parameter = declared[i];
var value = parameters[parameter.Name];
if (value == null)
continue;
rawTokens.TryGetValue(parameter.Name, out var rawToken);
var problem = CheckCoercible(
value, parameter, bareFlagged.Contains(parameter.Name), rawToken);
if (problem != null)
problems.Add(problem);
}
if (firstOverflow >= 0)
{
// Which of the two overflow kinds this is depends on the slot the token WOULD
// have taken, not on whether the request used a flag anywhere. Positionals fill
// `free` in order, so the token at index n would have taken slots[n]: if that
// slot exists and a flag claimed it, the token collided with that flag and the
// parameter can be named. Otherwise the command simply received more values than
// it declares parameters.
var collided = firstOverflow < slots.Count && flagFilled.Contains(slots[firstOverflow].Name)
? slots[firstOverflow]
: null;
problems.Add(collided != null
? new ArgProblem
{
Kind = ArgProblemKind.PositionalConflict,
Name = collided.Name,
Token = positionals[firstOverflow],
}
: new ArgProblem
{
Kind = ArgProblemKind.ExcessPositional,
Token = positionals[firstOverflow],
// Capacity and given must be commensurable, because they are rendered as
// one sentence: slots still FREE after the flags took theirs, against
// positionals supplied. Pairing the declared total with the positional
// count compares two different things and reads as nonsense whenever a
// flag is involved ("takes 2 but got 2").
Capacity = free.Count,
Given = positionals.Count,
});
}
return problems.Count == 0;
}
/// <summary>
/// Decides what JSON type a token is stored as.
///
/// <para>For a parameter with a DECLARED type the answer is always a string: the type
/// itself disambiguates, and <c>ConvertParameterToken</c> widens it downstream. That is
/// also the fix over the CLI's old client-side guessing, which turned <c>007</c> into the
/// number 7 even for a declared <c>string</c>.</para>
///
/// <para>An UNTYPED parameter (<c>JToken</c>/<c>JObject</c>/<c>JArray</c>/<c>object</c> —
/// 14 of them across 12 shipped commands, e.g. <c>set_serialized_field.value</c>) has
/// nothing to coerce toward, so the token's shape is the only signal there is. Those keep
/// the literal rules the CLI used, or a working <c>--value 5</c> would silently start
/// sending the string "5".</para>
/// </summary>
private static JToken StoreValue(CommandParameterInfo parameter, string token)
{
return IsUntypedTarget(parameter.ParameterType) && TryParseLiteral(token, out var literal)
? literal
: token;
}
/// <summary>A declared type that can hold any JSON value, so it constrains nothing.</summary>
private static bool IsUntypedTarget(Type type)
{
return type == typeof(object) || typeof(JToken).IsAssignableFrom(type);
}
/// <summary>
/// Whether a bare flag's implied <c>true</c> is a value this parameter could hold.
/// <c>JObject</c> and <c>JArray</c> are untyped but still cannot, so this is narrower
/// than <see cref="IsUntypedTarget"/>.
/// </summary>
private static bool CanHoldBoolean(Type type)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
return underlying == typeof(bool)
|| underlying == typeof(object)
|| underlying == typeof(JToken)
|| underlying == typeof(JValue);
}
/// <summary>
/// The CLI's own literal rules, reproduced exactly: <c>true</c>/<c>false</c>
/// case-insensitively, an all-digit integer with an optional sign (so <c>07</c> is 7 — no
/// radix handling), and a single-dot decimal. Everything else stays a string, which is why
/// <c>1.2.3</c>, a bare <c>-</c> and asset paths pass through untouched.
/// </summary>
private static bool TryParseLiteral(string token, out JToken value)
{
value = null;
if (string.IsNullOrEmpty(token))
return false;
var lowered = token.ToLowerInvariant();
if (lowered == "true") { value = new JValue(true); return true; }
if (lowered == "false") { value = new JValue(false); return true; }
var body = token[0] == '-' ? token.Substring(1) : token;
if (body.Length == 0)
return false;
var dot = body.IndexOf('.');
if (dot < 0)
{
if (!AllAsciiDigits(body))
return false;
if (long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var whole))
{
value = new JValue(whole);
return true;
}
return false;
}
// Exactly one dot, digits on both sides.
if (dot == 0 || dot == body.Length - 1
|| body.IndexOf('.', dot + 1) >= 0
|| !AllAsciiDigits(body.Substring(0, dot))
|| !AllAsciiDigits(body.Substring(dot + 1)))
{
return false;
}
if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
{
value = new JValue(real);
return true;
}
return false;
}
private static bool AllAsciiDigits(string s)
{
for (var i = 0; i < s.Length; i++)
if (s[i] < '0' || s[i] > '9')
return false;
return s.Length > 0;
}
/// <summary>
/// Dry-runs the executor's own converter against a bound value and reports the defect,
/// or null when the value is acceptable.
///
/// Three distinct failures fold into one report: the converter throwing; the converter
/// returning NULL for a non-empty token, which would otherwise make the parameter vanish
/// and let the command run with a default; and a bare flag on a non-bool parameter, which
/// <c>JValue(true).ToObject&lt;string&gt;()</c> would happily turn into the word "True".
/// </summary>
private static ArgProblem CheckCoercible(
JToken value, CommandParameterInfo parameter, bool wasBareFlag, string rawToken)
{
var targetType = parameter.ParameterType;
// A bare flag implied `true`. On a parameter that cannot hold a boolean at all the
// user simply omitted the value, which reads better as EmptyValue than as a type
// error. Untyped is not the same as bool-capable: `object`, `JToken` and `JValue`
// hold `true` happily, while `JObject` and `JArray` cannot, and letting the implied
// `true` reach the converter reported those as a type error instead.
if (wasBareFlag && !CanHoldBoolean(targetType))
{
return new ArgProblem { Kind = ArgProblemKind.EmptyValue, Name = parameter.Name };
}
// The token as the user typed it. An untyped target stores a literal-parsed JValue,
// so the stored value is not always a string and its text is not always the token
// (`007` parses to 7) — reading it back from the value dropped or altered the quote
// the diagnostic exists to show.
var token = rawToken ?? (value.Type == JTokenType.String ? value.Value<string>() : null);
// Enums are NOT pre-matched against Enum.GetNames. The converter accepts a [Flags]
// comma combination and a numeric value; a hand-rolled name match rejects both, which
// would make the raw form refuse what the structured form binds. TypeMismatch already
// unwraps Nullable<T> for the type name and the prose appends the valid values, so
// deferring costs the message nothing.
try
{
var converted = BasePipelineServer.ConvertParameterToken(value, targetType);
if (converted == null && !string.IsNullOrEmpty(token))
return TypeMismatch(parameter, token, targetType);
}
catch (Exception)
{
return TypeMismatch(parameter, token, targetType);
}
return null;
}
private static ArgProblem TypeMismatch(CommandParameterInfo parameter, string token, Type targetType)
{
return new ArgProblem
{
Kind = ArgProblemKind.TypeMismatch,
Name = parameter.Name,
Token = token,
ExpectedType = (Nullable.GetUnderlyingType(targetType) ?? targetType).Name,
};
}
/// <summary>
/// True when the token after <paramref name="index"/> should be taken as the current
/// flag's value. Anything starting with <c>--</c> is the next flag, not a value; a
/// single-dash token IS consumable, which is the escape hatch for values like
/// <c>--message -foo</c>.
/// </summary>
private static bool HasConsumableFollower(IReadOnlyList<string> args, int index)
{
if (index + 1 >= args.Count)
return false;
var follower = args[index + 1];
return follower == null || !follower.StartsWith("--", StringComparison.Ordinal);
}
private static CommandParameterInfo FindParameter(IReadOnlyList<CommandParameterInfo> declared, string name)
{
for (var i = 0; i < declared.Count; i++)
if (string.Equals(declared[i].Name, name, StringComparison.OrdinalIgnoreCase))
return declared[i];
return null;
}
/// <summary>
/// A <c>-x</c> token in positional position: a single dash followed by a LETTER.
///
/// Letter-anchored, deliberately matching the `unity` CLI's own rule byte for byte.
/// The CLI keeps this check client-side so a typo costs nothing (no Editor boot, no CI
/// license seat), which only stays safe while the local check is a strict SUBSET of this
/// one — any divergence would surface as the server rejecting what the CLI let through.
/// A looser test such as "not parseable as a number" would do exactly that for tokens
/// like <c>-5x</c>. Negative numbers and a bare <c>-</c> remain valid positionals.
/// </summary>
private static bool IsSingleDashFlag(string token)
{
return token != null && token.Length >= 2 && token[0] == '-' && IsAsciiLetter(token[1]);
}
private static bool IsAsciiLetter(char c)
{
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
}
/// <summary>
/// A bare <c>key=value</c> positional whose left side names <paramref name="slot"/> — the
/// very parameter the token is about to fill, which is what makes it a misremembered
/// <c>--key value</c> rather than a value.
///
/// <para>Testing the token against its OWN slot, rather than against every declared name,
/// is what keeps legal code clean: <c>eval "timeout=5;"</c> lands on <c>code</c>, and
/// <c>timeout</c> is a different parameter, so the token is a statement. Matching any
/// declared name rejected it while the equivalent structured request succeeded.</para>
///
/// <para>A token that assigns its own slot (<c>eval "code=1;"</c>) stays ambiguous by
/// nature and is still reported; <c>--</c> is the documented way through.</para>
/// </summary>
private static bool IsBareAssignmentNamingItsOwnSlot(string token, CommandParameterInfo slot)
{
if (string.IsNullOrEmpty(token) || token[0] == '-')
return false;
var equals = token.IndexOf('=');
if (equals <= 0)
return false;
return string.Equals(slot.Name, token.Substring(0, equals), StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Closest declared parameter name within an edit-distance budget that scales with
/// length, or null. Budget is <c>min(3, max(1, longest/3))</c>: tight enough that an
/// unrelated name gets no suggestion at all, loose enough to catch real typos.
/// </summary>
private static string SuggestParameterName(string name, IReadOnlyList<CommandParameterInfo> declared)
{
string best = null;
var bestDistance = int.MaxValue;
for (var i = 0; i < declared.Count; i++)
{
var candidate = declared[i].Name;
var budget = Math.Min(3, Math.Max(1, Math.Max(name.Length, candidate.Length) / 3));
var distance = EditDistance(name, candidate);
if (distance <= budget && distance < bestDistance)
{
bestDistance = distance;
best = candidate;
}
}
return best;
}
/// <summary>Case-insensitive Levenshtein distance.</summary>
private static int EditDistance(string a, string b)
{
a = a.ToLowerInvariant();
b = b.ToLowerInvariant();
var previous = new int[b.Length + 1];
var current = new int[b.Length + 1];
for (var j = 0; j <= b.Length; j++)
previous[j] = j;
for (var i = 1; i <= a.Length; i++)
{
current[0] = i;
for (var j = 1; j <= b.Length; j++)
{
var substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
var deletion = previous[j] + 1;
var insertion = current[j - 1] + 1;
current[j] = Math.Min(substitution, Math.Min(deletion, insertion));
}
var swap = previous;
previous = current;
current = swap;
}
return previous[b.Length];
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eb75a7888547abb419286040fb9a0a58
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,139 @@
using System.Collections.Generic;
using System.Text;
namespace Unity.Pipeline
{
/// <summary>
/// Splits a raw command line into argv tokens for <c>/api/exec</c>'s <c>commandLine</c> form.
///
/// The dialect is POSIX-ish and is the <b>pipeline's own</b> — not the caller's shell, and
/// identical on every OS, so the same string tokenizes the same way from cmd.exe, bash, an
/// MCP client or a chat box. It is documented as a versioned contract in
/// <c>Documentation~/connectivity.md</c>; <c>CommandLineTokenizerTests</c> pins it.
///
/// Rules: whitespace separates tokens; <c>"…"</c> groups with <c>\</c> escaping the next
/// character; <c>'…'</c> groups with <b>no</b> escapes at all; <c>\</c> escapes outside
/// quotes; adjacent spans concatenate (<c>a"b c"d</c> → <c>ab cd</c>); <c>""</c> yields one
/// empty token. There is <b>no expansion of any kind</b> — no <c>$VAR</c>, globbing, <c>~</c>,
/// comments or operators; <c>&amp;&amp;</c> and <c>|</c> are ordinary characters.
///
/// POSIX-ish rather than Windows <c>CommandLineToArgvW</c> because single quotes are
/// load-bearing here — <c>eval 'obj.name = "x";'</c> and <c>batch --operations '[{…}]'</c>
/// need a form that passes double quotes through untouched, and Windows semantics have no
/// single-quote concept. The usual argument for Windows rules (<c>\</c> is the path
/// separator) does not apply: every path this API accepts is a forward-slashed Unity asset
/// path.
/// </summary>
internal static class CommandLineTokenizer
{
/// <summary>
/// Tokenizes <paramref name="commandLine"/>. Returns false and sets
/// <paramref name="error"/> on an unbalanced quote or a dangling backslash.
/// A null, empty or all-whitespace input succeeds with zero tokens — a lexer has no
/// opinion about needing a command name, so the caller reports that (keeping the message
/// identical to the structured path).
/// </summary>
public static bool TryTokenize(string commandLine, out List<string> tokens, out string error)
{
tokens = new List<string>();
error = null;
if (string.IsNullOrEmpty(commandLine))
return true;
var current = new StringBuilder();
// Distinguishes `""` (one empty token) from whitespace (no token at all).
var hasToken = false;
var inSingle = false;
var inDouble = false;
for (var i = 0; i < commandLine.Length; i++)
{
var c = commandLine[i];
if (inSingle)
{
if (c == '\'')
inSingle = false;
else
current.Append(c);
continue;
}
if (inDouble)
{
if (c == '"')
{
inDouble = false;
}
else if (c == '\\')
{
if (i + 1 >= commandLine.Length)
{
error = "Dangling backslash at end of command line";
return false;
}
current.Append(commandLine[++i]);
}
else
{
current.Append(c);
}
continue;
}
if (c == '\'')
{
inSingle = true;
hasToken = true;
}
else if (c == '"')
{
inDouble = true;
hasToken = true;
}
else if (c == '\\')
{
if (i + 1 >= commandLine.Length)
{
error = "Dangling backslash at end of command line";
return false;
}
current.Append(commandLine[++i]);
hasToken = true;
}
else if (c == ' ' || c == '\t' || c == '\n' || c == '\r')
{
if (hasToken)
{
tokens.Add(current.ToString());
current.Length = 0;
hasToken = false;
}
}
else
{
current.Append(c);
hasToken = true;
}
}
if (inSingle)
{
error = "Unbalanced single quote in command line";
return false;
}
if (inDouble)
{
error = "Unbalanced double quote in command line";
return false;
}
if (hasToken)
tokens.Add(current.ToString());
return true;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 04dfc14e12dcc7f4dbd172a1e84380b2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,400 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEngine;
#if UNITY_6000_3_OR_NEWER
using UnityEngine.Assemblies;
#endif
#if UNITY_6000_5_OR_NEWER
using Unity.Scripting.LifecycleManagement;
#endif
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Registry for discovering and managing CLI commands via pluggable discovery mechanism.
/// Scans for methods marked with [CliCommand] attribute across all assemblies.
/// Based on unity-tools ToolRegistry patterns adapted for Pipeline requirements.
/// </summary>
/// <remarks>
/// [NoAutoStaticsCleanup] on purpose: m_DiscoveryLock and the injected discovery mechanism are
/// set up once per domain and must not be reset under the running server. The dynamic-command
/// table is the one static here with a shorter useful life, and PlayModeSessionEnded resets the
/// part of it that a Play session owns.
/// </remarks>
#if UNITY_6000_5_OR_NEWER
[NoAutoStaticsCleanup]
#endif
static class CommandRegistry
{
private static IReadOnlyList<CommandInfo> m_CachedCommands;
private static IReadOnlyList<CommandInfo> m_CachedAllCommands;
private static readonly Dictionary<string, CommandInfo> m_DynamicCommands =
new Dictionary<string, CommandInfo>(StringComparer.Ordinal);
// Names in m_DynamicCommands that predate the Play session currently running; null outside
// one. Lets PlayModeSessionEnded tell a registration made during the session apart from one
// that was already there. See PlayModeSessionStarted.
private static HashSet<string> m_PrePlayModeCommands;
private static ICommandDiscovery m_Discovery;
private static readonly object m_DiscoveryLock = new object();
/// <summary>
/// Set the command discovery mechanism.
/// Editor assembly provides TypeCache-based discovery, Runtime uses reflection fallback.
/// </summary>
/// <param name="discovery">The discovery mechanism to use.</param>
public static void SetDiscovery(ICommandDiscovery discovery)
{
m_Discovery = discovery;
ClearCache(); // Re-discover with new mechanism
}
/// <summary>
/// Discover every available command: the methods marked with [CliCommand], plus any
/// registered through <see cref="RegisterCommand"/>.
/// Attribute discovery uses the injected mechanism (TypeCache in Editor, reflection in
/// Runtime) and is cached until domain reload; the combined result is re-cached whenever
/// a dynamic command is registered or unregistered.
/// </summary>
/// <returns>All discovered commands.</returns>
public static IEnumerable<CommandInfo> DiscoverCommands()
{
var cached = m_CachedAllCommands;
if (cached != null)
{
return cached;
}
lock (m_DiscoveryLock)
{
if (m_CachedCommands == null)
{
m_CachedCommands = DiscoverCommandsInternal().ToList();
}
if (m_CachedAllCommands == null)
{
m_CachedAllCommands = m_DynamicCommands.Count == 0
? m_CachedCommands
: m_CachedCommands.Concat(m_DynamicCommands.Values).ToList();
}
}
return m_CachedAllCommands;
}
/// <summary>
/// Register a command at runtime, without a [CliCommand] attribute. The resulting command
/// is indistinguishable from an attribute-declared one: it is listed by <c>/api/commands</c>
/// and executable through <c>/api/exec</c> under <paramref name="name"/>.
///
/// Parameter metadata is read from the handler's own parameters exactly as it is for an
/// attribute-declared command — a [CliArg] on a parameter supplies its CLI name,
/// description, and required flag; a parameter without one falls back to its declared name
/// and default value. Note that a lambda cannot carry [CliArg], so registering a named
/// method is what yields full metadata.
///
/// Registrations live until they are unregistered or the domain reloads; they survive
/// <see cref="ClearCache"/> and <see cref="SetDiscovery"/>. One made while the Editor is in
/// Play Mode is additionally dropped when Play Mode exits — see
/// <see cref="PlayModeSessionEnded"/>.
/// </summary>
/// <param name="name">Unique command name for CLI execution.</param>
/// <param name="description">Human-readable description, shown in command listings.</param>
/// <param name="handler">
/// Method to invoke. An instance-method delegate keeps its receiver, so the command runs
/// against that instance; a static one runs with no receiver.
/// </param>
/// <param name="mainThreadRequired">Whether the command must run on the Unity main thread.</param>
/// <param name="runtimeOnly">Whether the command belongs to the Player command surface only.</param>
/// <param name="tags">Optional path-style tags used to group and browse the command.</param>
/// <exception cref="InvalidOperationException">
/// A command called <paramref name="name"/> is already registered. Attribute-declared
/// commands can never be replaced; a dynamic one must be unregistered first.
/// </exception>
public static void RegisterCommand(string name, string description, Delegate handler,
bool mainThreadRequired = true, bool runtimeOnly = false, string[] tags = null)
{
if (name == null)
throw new ArgumentNullException(nameof(name));
if (description == null)
throw new ArgumentNullException(nameof(description));
if (handler == null)
throw new ArgumentNullException(nameof(handler));
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Command name must not be empty or whitespace.", nameof(name));
var method = handler.Method;
var command = new CommandInfo(
name,
description,
mainThreadRequired,
method,
DiscoverParameters(method).ToList(),
runtimeOnly,
tags,
method.DeclaringType?.Assembly.GetName().Name,
handler.Target);
lock (m_DiscoveryLock)
{
if (m_DynamicCommands.ContainsKey(name))
{
throw new InvalidOperationException(
$"A command named '{name}' is already registered dynamically. " +
"Unregister it before registering it again.");
}
// Anything left matching here is attribute-declared: the dynamic table was just checked.
if (DiscoverCommands().Any(c => c.Name == name))
{
throw new InvalidOperationException(
$"A command named '{name}' is already declared with a [CliCommand] attribute " +
"and cannot be replaced.");
}
m_DynamicCommands[name] = command;
m_CachedAllCommands = null;
}
}
/// <summary>
/// Remove a command previously registered with <see cref="RegisterCommand"/>.
/// Attribute-declared commands are part of the code and are never removable.
/// </summary>
/// <param name="name">Name the command was registered under.</param>
/// <returns>
/// True if a dynamic command was removed; false if no command is registered under that
/// name dynamically, including when the name belongs to an attribute-declared command.
/// </returns>
public static bool UnregisterCommand(string name)
{
if (name == null)
throw new ArgumentNullException(nameof(name));
lock (m_DiscoveryLock)
{
if (!m_DynamicCommands.Remove(name))
return false;
m_CachedAllCommands = null;
return true;
}
}
/// <summary>
/// Note that a Play session is starting, so registrations made from here on can be told
/// apart from the ones that predate it. Called by the Editor on Play Mode entry, before any
/// user code runs.
/// </summary>
internal static void PlayModeSessionStarted()
{
lock (m_DiscoveryLock)
{
m_PrePlayModeCommands = new HashSet<string>(m_DynamicCommands.Keys, StringComparer.Ordinal);
}
}
/// <summary>
/// Drop every dynamic registration made during the Play session that just ended, the way a
/// domain reload would have. Registrations that predate the session are left alone: nothing
/// re-runs to restore them (an [InitializeOnLoadMethod] runs once per domain).
///
/// A script registering from Awake or OnEnable re-registers on every Play Mode entry, so
/// without this the second entry would throw "already registered dynamically" whenever no
/// domain reload happened in between — and any surviving entry would still hold a delegate
/// bound to the previous session's destroyed object. That covers Enter Play Mode with domain
/// reload disabled on any Editor version, and on 6000.5+ the class-wide
/// [NoAutoStaticsCleanup] means AutoStaticsCleanup does not step in either.
/// </summary>
internal static void PlayModeSessionEnded()
{
lock (m_DiscoveryLock)
{
// No recorded session start (e.g. a domain reload wiped it on the way in, having
// already cleared the registry): nothing is known to belong to the session, so
// removing anything would be guesswork.
if (m_PrePlayModeCommands == null)
return;
foreach (var name in m_DynamicCommands.Keys.Where(n => !m_PrePlayModeCommands.Contains(n)).ToList())
m_DynamicCommands.Remove(name);
m_PrePlayModeCommands = null;
m_CachedAllCommands = null;
}
}
/// <summary>
/// Clear the discovered-command cache, so the next discovery re-scans for [CliCommand]
/// methods. Called automatically on domain reload, and whenever the discovery mechanism
/// changes. Commands registered through <see cref="RegisterCommand"/> are not affected.
/// </summary>
public static void ClearCache()
{
lock (m_DiscoveryLock)
{
m_CachedCommands = null;
m_CachedAllCommands = null;
}
}
/// <summary>
/// Internal implementation of command discovery using injected discovery mechanism.
/// </summary>
private static IEnumerable<CommandInfo> DiscoverCommandsInternal()
{
var commands = new List<CommandInfo>();
try
{
// Use injected discovery mechanism if available, otherwise fallback to reflection
IEnumerable<MethodInfo> methods;
if (m_Discovery != null)
{
methods = m_Discovery.GetMethodsWithAttribute<CliCommandAttribute>();
}
else
{
// Fallback: Use reflection to scan loaded assemblies
methods = GetMethodsWithAttributeViaReflection<CliCommandAttribute>();
}
foreach (var method in methods)
{
try
{
var commandInfo = CreateCommandInfo(method);
if (commandInfo != null)
{
commands.Add(commandInfo);
}
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to register command from method {method.DeclaringType?.Name}.{method.Name}: {ex.Message}");
}
}
}
catch (Exception ex)
{
Debug.LogError($"Failed to discover commands: {ex.Message}");
}
return commands;
}
/// <summary>
/// Fallback command discovery using reflection when TypeCache is not available.
/// </summary>
private static IEnumerable<MethodInfo> GetMethodsWithAttributeViaReflection<T>() where T : Attribute
{
var methods = new List<MethodInfo>();
try
{
var assemblies = PipelineUtils.GetLoadedAssemblies();
foreach (var assembly in assemblies)
{
// Skip system assemblies for performance
if (IsSystemAssembly(assembly))
continue;
try
{
foreach (var type in assembly.GetTypes())
{
foreach (var method in type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
{
if (method.GetCustomAttribute<T>() != null)
{
methods.Add(method);
}
}
}
}
catch (ReflectionTypeLoadException)
{
// Skip assemblies that can't be loaded
continue;
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"Reflection-based command discovery failed: {ex.Message}");
}
return methods;
}
/// <summary>
/// Check if assembly is a system assembly that should be skipped during discovery.
/// </summary>
private static bool IsSystemAssembly(Assembly assembly)
{
var name = assembly.GetName().Name;
return name.StartsWith("System.") ||
name.StartsWith("Microsoft.") ||
name.StartsWith("mscorlib") ||
name.StartsWith("netstandard") ||
name.Equals("UnityEngine") ||
name.Equals("UnityEditor");
}
/// <summary>
/// Create CommandInfo from a method with CliCommand attribute.
/// </summary>
private static CommandInfo CreateCommandInfo(MethodInfo method)
{
var commandAttr = method.GetCustomAttribute<CliCommandAttribute>();
if (commandAttr == null)
return null;
// Validate method is static (required for CLI commands).
// Any static method may be registered regardless of accessibility
// (public, internal, or private) — invocation goes through MethodInfo.Invoke.
if (!method.IsStatic)
{
Debug.LogWarning($"Command method {method.DeclaringType?.Name}.{method.Name} must be static");
return null;
}
// Discover parameters
var parameters = DiscoverParameters(method).ToList();
return new CommandInfo(
commandAttr.Name,
commandAttr.Description,
commandAttr.MainThreadRequired,
method,
parameters,
commandAttr.RuntimeOnly,
commandAttr.Tags,
method.DeclaringType?.Assembly.GetName().Name
);
}
/// <summary>
/// Discover parameter information from method parameters.
/// </summary>
private static IEnumerable<CommandParameterInfo> DiscoverParameters(MethodInfo method)
{
foreach (var param in method.GetParameters())
{
var argAttr = param.GetCustomAttribute<CliArgAttribute>();
// Parameters without CliArg attribute get default metadata
var name = argAttr?.Name ?? param.Name;
var description = argAttr?.Description ?? $"Parameter: {param.Name}";
var required = argAttr?.Required ?? !param.HasDefaultValue;
var defaultValue = param.HasDefaultValue ? param.DefaultValue : argAttr?.DefaultValue;
yield return new CommandParameterInfo(name, description, required, param.ParameterType, defaultValue);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c53b0ba553311e14c833aadafdb44999
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Unity.Pipeline
{
/// <summary>Snapshot of one dialog shown/dismissed event, as relayed by EditorDialogEvents.</summary>
internal readonly struct DialogInfo
{
public readonly int Id;
public readonly string Source;
public readonly string Title;
public readonly string Message;
public readonly string[] Buttons;
public readonly string Level;
public readonly DateTime OpenedAtUtc;
public readonly DateTime? DismissedAtUtc;
public DialogInfo(int id, string source, string title, string message, string[] buttons, string level, DateTime openedAtUtc, DateTime? dismissedAtUtc)
{
Id = id;
Source = source;
Title = title;
Message = message;
Buttons = buttons;
Level = level;
OpenedAtUtc = openedAtUtc;
DismissedAtUtc = dismissedAtUtc;
}
public DialogInfo WithDismissed(DateTime dismissedAtUtc) =>
new DialogInfo(Id, Source, Title, Message, Buttons, Level, OpenedAtUtc, dismissedAtUtc);
}
/// <summary>
/// Per-server modal-dialog state (the same one-instance-per-<see cref="BasePipelineServer"/>
/// ownership as <c>CliProgressState</c>/<c>Progress</c>, so a test server's dialog state never
/// crosses into the live server's). Unlike progress, dialogs are not scoped to a currently
/// executing command — they can be raised by anything (a human, a background job, an unrelated
/// command) at any time, so there is no owner-id concept here.
/// </summary>
internal sealed class DialogStateTracker
{
const int MaxRecentEvents = 50;
readonly object m_Gate = new object();
readonly Dictionary<int, DialogInfo> m_Open = new Dictionary<int, DialogInfo>();
readonly LinkedList<DialogInfo> m_Recent = new LinkedList<DialogInfo>();
internal bool IsDialogOpen
{
get { lock (m_Gate) return m_Open.Count > 0; }
}
/// <summary>
/// Ordered oldest-opened-first: callers (the dialog busy-gate, /api/status) treat index 0
/// as "the first open dialog", and Dictionary&lt;,&gt;'s enumeration order isn't guaranteed
/// to match insertion order once entries have been removed and re-added.
/// </summary>
internal List<DialogInfo> CurrentlyOpen
{
get { lock (m_Gate) return m_Open.Values.OrderBy(e => e.OpenedAtUtc).ToList(); }
}
internal void OnShown(int id, string source, string title, string message, string[] buttons, string level, DateTime openedAtUtc)
{
var info = new DialogInfo(id, source, title, message, buttons, level, openedAtUtc, null);
lock (m_Gate)
{
m_Open[id] = info;
}
}
internal void OnDismissed(int id, DateTime dismissedAtUtc)
{
lock (m_Gate)
{
if (!m_Open.TryGetValue(id, out var info))
return;
m_Open.Remove(id);
m_Recent.AddLast(info.WithDismissed(dismissedAtUtc));
while (m_Recent.Count > MaxRecentEvents)
m_Recent.RemoveFirst();
}
}
/// <summary>
/// Dialogs opened at or after <paramref name="utc"/> — still-open ones and recently
/// dismissed ones. Used to attach dialogsDuringExecution to a command's exec response.
/// </summary>
internal List<DialogInfo> EventsSince(DateTime utc)
{
lock (m_Gate)
{
var result = m_Open.Values.Where(e => e.OpenedAtUtc >= utc).ToList();
result.AddRange(m_Recent.Where(e => e.OpenedAtUtc >= utc));
return result;
}
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 9700dd7e04984bd7a48f4d5f0f82d71c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetVersion: 1
@@ -0,0 +1,299 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace Unity.Pipeline.Threading
{
/// <summary>
/// Dispatches work to Unity's main thread from background threads (required for accessing Unity
/// APIs from HTTP request handlers).
///
/// Each pipeline server owns its own instance (no global singleton): it is initialized on Start
/// and pumped from the main thread — auto-pumped via EditorApplication.update in the editor, and
/// by RuntimePipelineDriver.Update in a player.
/// </summary>
public class Dispatcher
{
private readonly ConcurrentQueue<WorkItem> m_WorkQueue = new ConcurrentQueue<WorkItem>();
private volatile bool m_IsInitialized;
private int m_MainThreadId = -1;
/// <summary>True once <see cref="Initialize"/> has run and this dispatcher is pumping its queue.</summary>
public bool IsInitialized => m_IsInitialized;
/// <summary>
/// Initialize the dispatcher. Must be called from Unity's main thread.
/// </summary>
public void Initialize()
{
if (m_IsInitialized)
return;
m_MainThreadId = Thread.CurrentThread.ManagedThreadId;
m_IsInitialized = true;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update += ProcessWorkQueue;
#endif
}
/// <summary>
/// Shutdown the dispatcher and cancel any pending work.
/// </summary>
public void Shutdown()
{
if (!m_IsInitialized)
return;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update -= ProcessWorkQueue;
#endif
while (m_WorkQueue.TryDequeue(out var item))
{
try
{
item.SetException(new OperationCanceledException("Dispatcher is shutting down"));
}
catch { }
}
m_IsInitialized = false;
}
/// <summary>
/// Execute a function on the main thread and return the result (synchronous wait).
/// </summary>
/// <typeparam name="T">The function's return type.</typeparam>
/// <param name="function">Function to run on the main thread.</param>
/// <param name="timeoutMs">Time to wait before throwing <see cref="TimeoutException"/>.</param>
/// <returns>The function's result.</returns>
public T Invoke<T>(Func<T> function, int timeoutMs = 60000)
{
if (!m_IsInitialized)
throw new InvalidOperationException("Dispatcher must be initialized first");
if (IsMainThread())
return function();
var workItem = new WorkItem<T>(function);
m_WorkQueue.Enqueue(workItem);
var startTime = DateTime.UtcNow;
var task = workItem.TaskCompletionSource.Task;
while (!task.IsCompleted)
{
if ((DateTime.UtcNow - startTime).TotalMilliseconds > timeoutMs)
{
// No-op if execution already started on the main thread — only prevents a
// still-queued item from running after we've reported the timeout.
workItem.TryCancel();
throw new TimeoutException($"Main thread operation timed out after {timeoutMs}ms");
}
Thread.Sleep(1);
}
if (task.IsFaulted)
throw task.Exception?.GetBaseException() ?? new Exception("Unknown error");
if (task.IsCanceled)
throw new OperationCanceledException("Main thread operation was cancelled");
return task.Result;
}
/// <summary>
/// Execute an action on the main thread.
/// </summary>
/// <param name="action">Action to run on the main thread.</param>
/// <param name="timeoutMs">Time to wait before throwing <see cref="TimeoutException"/>.</param>
public void Invoke(Action action, int timeoutMs = 60000)
{
Invoke<object>(() =>
{
action();
return null;
}, timeoutMs);
}
/// <summary>
/// Execute a function on the main thread and return the result (async version).
/// </summary>
/// <typeparam name="T">The function's return type.</typeparam>
/// <param name="function">Function to run on the main thread.</param>
/// <param name="timeoutMs">Time to wait before throwing <see cref="TimeoutException"/>.</param>
/// <returns>The function's result.</returns>
public async Task<T> InvokeAsync<T>(Func<T> function, int timeoutMs = 60000)
{
return await Task.Run(() => Invoke(function, timeoutMs));
}
/// <summary>
/// Execute an action on the main thread (async version).
/// </summary>
/// <param name="action">Action to run on the main thread.</param>
/// <param name="timeoutMs">Time to wait before throwing <see cref="TimeoutException"/>.</param>
/// <returns>A task that completes once the action has run on the main thread.</returns>
public async Task InvokeAsync(Action action, int timeoutMs = 60000)
{
await Task.Run(() => Invoke(action, timeoutMs));
}
/// <summary>
/// Queue an action to run on the main thread and return immediately.
///
/// For work with no caller left to return to — reporting, logging, telemetry, anything
/// whose result nobody reads. <see cref="Invoke(Action, int)"/> is wrong for that: it parks
/// the calling thread until the next pump, and on a request thread that delay is paid by
/// whoever is waiting on the response. InvokeAsync only moves the same wait onto a
/// threadpool thread.
///
/// The action always runs on a later pump, never inline, even when called from the main
/// thread, so posted work runs in the order it was posted. It has nowhere to report a
/// failure, so an exception is logged and swallowed rather than propagated. Work still
/// queued when the dispatcher shuts down is dropped, and posting to a dispatcher that is
/// not running is a no-op for the same reason — losing a report is always preferable to
/// throwing at a caller that was only trying to record something.
/// </summary>
/// <param name="action">Action to run on the main thread on a later pump.</param>
public void Post(Action action)
{
if (action == null)
throw new ArgumentNullException(nameof(action));
if (!m_IsInitialized)
return;
m_WorkQueue.Enqueue(new PostedWorkItem(action));
}
/// <summary>
/// Check if we're currently on Unity's main thread.
/// </summary>
/// <returns>True if the calling thread is the main thread captured by <see cref="Initialize"/>.</returns>
public bool IsMainThread()
{
return m_MainThreadId != -1 && Thread.CurrentThread.ManagedThreadId == m_MainThreadId;
}
/// <summary>
/// Process queued work items. Called from EditorApplication.update or MonoBehaviour.Update.
/// </summary>
/// <param name="maxItemsPerFrame">Max items to process per call, to limit frame-rate impact.</param>
public void ProcessWorkQueue(int maxItemsPerFrame)
{
int processedCount = 0;
while (processedCount < maxItemsPerFrame && m_WorkQueue.TryDequeue(out var workItem))
{
try
{
workItem.Execute();
}
catch (Exception ex)
{
Debug.LogError($"Dispatcher work item failed: {ex.Message}");
workItem.SetException(ex);
}
processedCount++;
}
}
/// <summary>Process queued work items, up to a default limit of 10 per call.</summary>
public void ProcessWorkQueue()
{
ProcessWorkQueue(10);
}
private abstract class WorkItem
{
private const int Pending = 0;
private const int Started = 1;
private const int Canceled = 2;
private int m_State;
/// <summary>Marks the item canceled if it hasn't started executing yet. Returns false
/// (no-op) once <see cref="Execute"/> has already claimed it.</summary>
public bool TryCancel() => Interlocked.CompareExchange(ref m_State, Canceled, Pending) == Pending;
public void Execute()
{
if (Interlocked.CompareExchange(ref m_State, Started, Pending) != Pending)
return; // Canceled before it could start.
ExecuteCore();
}
protected abstract void ExecuteCore();
public abstract void SetException(Exception exception);
}
/// <summary>
/// A <see cref="Post(Action)"/> work item. Unlike <see cref="WorkItem{T}"/> it carries no
/// TaskCompletionSource: nobody is waiting on the result, so a fault has nowhere to go but
/// the log — and faulting a TCS no one awaits would surface later as an unobserved task
/// exception. A shutdown cancellation simply drops the work.
/// </summary>
private class PostedWorkItem : WorkItem
{
private readonly Action m_Action;
public PostedWorkItem(Action action)
{
m_Action = action;
}
protected override void ExecuteCore()
{
try
{
m_Action();
}
catch (Exception ex)
{
Debug.LogWarning($"Dispatcher posted work failed: {ex.Message}");
}
}
public override void SetException(Exception exception)
{
// Nobody is waiting on this item, so there is nothing to hand the exception to.
}
}
private class WorkItem<T> : WorkItem
{
private readonly Func<T> m_Function;
public TaskCompletionSource<T> TaskCompletionSource { get; }
public WorkItem(Func<T> function)
{
m_Function = function ?? throw new ArgumentNullException(nameof(function));
TaskCompletionSource = new TaskCompletionSource<T>();
}
protected override void ExecuteCore()
{
try
{
var result = m_Function();
TaskCompletionSource.SetResult(result);
}
catch (Exception ex)
{
TaskCompletionSource.SetException(ex);
}
}
public override void SetException(Exception exception)
{
TaskCompletionSource.SetException(exception);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df7819f4aeb94b2408fd309361f67243
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,141 @@
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Unity.Pipeline.Models;
namespace Unity.Pipeline
{
/// <summary>
/// Single source of truth for how <c>/api/exec</c> replies are serialized (AUTHAPI-21). Centralized
/// here so the server and the envelope byte-size regression test share one contract.
///
/// Every mode serializes with <see cref="Formatting.None"/> (compact): agents consume the JSON, and
/// an MCP client can pretty-print for a human if ever needed — the wire form never needs indentation.
///
/// Two independent request flags select the mode:
///
/// * <c>verbose</c> — envelope metadata. Off (default): the always-on metadata (executedAt,
/// command, executionTimeMs) is stripped from every <see cref="BaseResponse"/>-derived object
/// in the graph (the outer envelope AND responses nested as <c>result</c>, e.g. eval's), and
/// the envelope's own null keys (message/error/errorDetails when null, result on failure — all
/// redundant given <c>success</c>) are omitted, so a minimal success is just
/// <c>{"success":true,"result":...}</c>. On: full fidelity for debugging/correlation.
/// * <c>omitNulls</c> — payload null fidelity. Off (default): the command's own <c>result</c>
/// payload keeps explicit nulls — an agent must be able to distinguish "this field is null"
/// from "this field doesn't exist / I got the name wrong". On: null keys are dropped
/// everywhere, for callers that already know the result schema and want the bytes back
/// (e.g. bulk list reads where a null column repeats hundreds of times).
///
/// One invariant holds in EVERY mode: a successful reply always carries <c>result</c>, even
/// when its value is null — a null result on success is the command's actual value (e.g. a
/// format="value" read of a genuinely-null field), never a droppable envelope null.
///
/// The two axes are deliberately separate booleans: envelope metadata and payload null fidelity
/// are orthogonal concerns, and each combination is meaningful.
///
/// The gating lives in a contract resolver scoped to THIS serializer, not in ShouldSerialize*
/// hooks on the models: serializing these types anywhere else (other endpoints, logs, tests) is
/// unaffected, and Serialize never mutates the response object.
/// </summary>
internal static class ExecResponseSerializer
{
/// <summary>
/// Lean-envelope gating for every <see cref="BaseResponse"/>-derived object in the graph
/// (nested results included): strips the always-on metadata and omits the envelope's own
/// null keys — WITHOUT touching null handling on non-envelope payload objects, which keep
/// the serializer-level default. One shared instance per settings object:
/// DefaultContractResolver caches resolved contracts per resolver instance (thread-safe),
/// so the reflection cost is paid once per type, not per request.
/// </summary>
private class LeanEnvelopeContractResolver : DefaultContractResolver
{
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
var property = base.CreateProperty(member, memberSerialization);
// DeclaringType guards against unrelated same-named members (e.g.
// CommandExecutionRequest.Command must keep serializing).
if (member.DeclaringType != null && typeof(BaseResponse).IsAssignableFrom(member.DeclaringType))
{
switch (member.Name)
{
case nameof(BaseResponse.ExecutedAt):
case nameof(CommandExecutionResponse.Command):
case nameof(CommandExecutionResponse.ExecutionTimeMs):
property.ShouldSerialize = _ => false;
break;
// The ONLY keys lean mode may null-drop: genuinely redundant given
// `success` (error/errorDetails are null exactly on success, message on
// both paths when there is nothing to say). Everything else declared on
// a BaseResponse-DERIVED type is payload (TestExecutionResponse's
// summary/results/filterApplied, EvalResponse.Output, …) and keeps
// explicit nulls — a dropped payload key is indistinguishable from a
// nonexistent or misspelled one; e.g. an async run_tests poller could
// not tell "not ready yet" from "not a field". Enumerated per key
// instead of type-wide for exactly that reason.
case nameof(BaseResponse.Message):
case nameof(BaseResponse.Error):
case nameof(BaseResponse.ErrorDetails):
property.NullValueHandling = NullValueHandling.Ignore;
break;
case nameof(CommandExecutionResponse.Result):
// A null result on SUCCESS is not a redundant envelope null — it is
// the command's actual value (e.g. get_serialized_fields
// format="value" reading a genuinely-null field), so a successful
// reply always carries "result", explicitly null when null (Include
// also defeats the omitNulls serializer settings). On failure the
// result is structurally meaningless and stays omitted.
property.NullValueHandling = NullValueHandling.Include;
property.ShouldSerialize = instance =>
!(instance is CommandExecutionResponse response) || response.Success;
break;
}
}
return property;
}
}
private static readonly JsonSerializerSettings m_LeanSettings = new JsonSerializerSettings
{
Formatting = Formatting.None,
NullValueHandling = NullValueHandling.Include,
ContractResolver = new LeanEnvelopeContractResolver(),
};
private static readonly JsonSerializerSettings m_LeanOmitNullsSettings = new JsonSerializerSettings
{
Formatting = Formatting.None,
NullValueHandling = NullValueHandling.Ignore,
ContractResolver = new LeanEnvelopeContractResolver(),
};
private static readonly JsonSerializerSettings m_VerboseSettings = new JsonSerializerSettings
{
Formatting = Formatting.None,
NullValueHandling = NullValueHandling.Include,
};
private static readonly JsonSerializerSettings m_VerboseOmitNullsSettings = new JsonSerializerSettings
{
Formatting = Formatting.None,
NullValueHandling = NullValueHandling.Ignore,
};
/// <summary>
/// Serialize an exec response body with the contract selected by the request's flags (see
/// the class doc for the two axes). Pure: the body is never mutated — the mode is carried
/// entirely by the serializer settings, so serializing the same instance repeatedly (in
/// any order) always yields the same JSON per mode.
/// </summary>
public static string Serialize(BaseResponse body, bool verbose, bool omitNulls = false)
{
var settings = verbose
? (omitNulls ? m_VerboseOmitNullsSettings : m_VerboseSettings)
: (omitNulls ? m_LeanOmitNullsSettings : m_LeanSettings);
return JsonConvert.SerializeObject(body, settings);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 56ce3a87dbdb3c84bac7bccdf5c8c0fb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,110 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using UnityEngine;
using Debug = UnityEngine.Debug;
namespace Unity.Pipeline
{
/// <summary>
/// Restricts a file so that only the user who started the process can read or write it.
/// Used to protect the instance descriptor (port file), which carries the auth token.
///
/// The managed ACL APIs (System.Security.AccessControl) are not part of Unity's runtime
/// profile, so Windows is handled via icacls and Unix via libc chmod.
/// </summary>
static class FilePermissions
{
/// <summary>
/// Restrict the file at <paramref name="path"/> to the current user only.
/// Failures are logged but never thrown, so they cannot block server startup.
/// </summary>
/// <param name="path">The file to restrict.</param>
public static void RestrictToCurrentUser(string path)
{
try
{
switch (Application.platform)
{
case RuntimePlatform.WindowsEditor:
case RuntimePlatform.WindowsPlayer:
RestrictWindows(path);
break;
default:
RestrictUnix(path);
break;
}
}
catch (Exception ex)
{
Debug.LogWarning($"Pipeline: Could not restrict permissions on '{path}': {ex.Message}");
}
}
private static void RestrictWindows(string path)
{
// Grant by SID rather than account name: AzureAD/Entra-joined accounts (e.g.
// "AzureAD\\user") have no name-to-SID mapping for icacls, but the SID always resolves.
var sid = GetCurrentUserSid();
if (string.IsNullOrEmpty(sid))
{
Debug.LogWarning($"Pipeline: Could not resolve current user SID; leaving default permissions on '{path}'.");
return;
}
// /inheritance:r drops inherited ACEs; /grant:r replaces the DACL with a single
// full-control grant to the current user.
Run("icacls", $"\"{path}\" /inheritance:r /grant:r \"*{sid}:(F)\"", out _);
}
private static string GetCurrentUserSid()
{
if (!Run("whoami", "/user /fo csv /nh", out var output) || string.IsNullOrEmpty(output))
return null;
// Output looks like: "domain\user","S-1-5-21-..."
var idx = output.IndexOf("S-1-", StringComparison.Ordinal);
if (idx < 0)
return null;
var end = idx;
while (end < output.Length && (char.IsLetterOrDigit(output[end]) || output[end] == '-'))
end++;
return output.Substring(idx, end - idx);
}
private static bool Run(string fileName, string arguments, out string stdout)
{
stdout = null;
var psi = new ProcessStartInfo(fileName, arguments)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using (var process = Process.Start(psi))
{
if (process == null)
return false;
// Read before WaitForExit to avoid a full-pipe deadlock.
stdout = process.StandardOutput.ReadToEnd();
process.WaitForExit();
return process.ExitCode == 0;
}
}
[DllImport("libc", SetLastError = true)]
private static extern int chmod(string pathname, uint mode);
private static void RestrictUnix(string path)
{
// 0600 (octal) == owner read/write only.
const uint ownerReadWrite = 0x180;
chmod(path, ownerReadWrite);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e310dee2532338d4e914d3c909ea4db0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Reflection;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Interface for command discovery mechanism.
/// Allows Runtime assembly to discover commands without directly using Editor APIs.
/// </summary>
interface ICommandDiscovery
{
/// <summary>
/// Find all methods marked with CliCommand attribute.
/// Implementation uses TypeCache in Editor or reflection fallback in Runtime.
/// </summary>
/// <typeparam name="T">The attribute type to search for.</typeparam>
/// <returns>The matching methods.</returns>
IEnumerable<MethodInfo> GetMethodsWithAttribute<T>() where T : System.Attribute;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c6e1392c576ec4d4d9b389d5fc899303
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Marker for a command parameter type that should be advertised to clients as a nested JSON
/// <em>object</em> schema in <c>GET /api/commands</c>, instead of collapsing to <c>string</c>.
///
/// Convention for command authors: when a command needs a structured (multi-field) argument,
/// declare a small DTO that implements this interface and take it as a single parameter. The
/// <see cref="JsonSchemaGenerator"/> reflects over the type's public, writable fields and
/// properties to emit <c>{ "type": "object", "properties": { ... } }</c> (recursing into nested
/// <see cref="IStructuredCommandInput"/> members and arrays/lists of them). At runtime the value
/// deserializes automatically via Newtonsoft in
/// <c>BasePipelineServer.ExtractCommandParameters</c>, so no extra wiring is needed.
///
/// Member metadata:
/// <list type="bullet">
/// <item><description>Annotate members with <c>[CliArg(name, description, Required = ...)]</c> to control the
/// property name, description, and whether it appears in the schema's <c>required</c> array
/// (mirrors how command parameters are described).</description></item>
/// <item><description>Without a <c>[CliArg]</c>, the member name (or its Newtonsoft <c>[JsonProperty]</c> name)
/// is used and the member is optional.</description></item>
/// <item><description><c>[JsonIgnore]</c> members are omitted from the schema.</description></item>
/// </list>
/// </summary>
public interface IStructuredCommandInput
{
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 366b2704c3b5c044aa544fe545d462f0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,326 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Generates JSON Schema for CLI commands to enable parameter validation and help generation.
/// Produces standard JSON Schema format that CLI tools can use for validation and auto-completion.
///
/// Primitive, enum, and array parameters map directly. Parameters whose type implements
/// <see cref="IStructuredCommandInput"/> are emitted as nested object schemas (recursing into
/// structured members and arrays/lists of them) rather than collapsing to <c>string</c>, so
/// agents calling the package — directly or as MCP tools whose schemas come from
/// <c>GET /api/commands</c> — can construct structured arguments reliably.
/// </summary>
static class JsonSchemaGenerator
{
/// <summary>
/// Generate JSON Schema for a command.
/// Returns standard JSON Schema format for CLI validation.
/// </summary>
/// <param name="command">The command to generate a schema for.</param>
/// <returns>The generated JSON Schema document.</returns>
public static string GenerateCommandSchema(CommandInfo command)
{
if (command == null)
throw new ArgumentNullException(nameof(command));
var schema = new JObject
{
["$schema"] = "http://json-schema.org/draft-07/schema#",
["type"] = "object",
["title"] = command.Name,
["description"] = command.Description,
["properties"] = GenerateParameterProperties(command.Parameters),
["required"] = new JArray(command.Parameters.Where(p => p.Required).Select(p => p.Name)),
["additionalProperties"] = false
};
// Add command metadata for CLI tools
schema["x-command-metadata"] = new JObject
{
["mainThreadRequired"] = command.MainThreadRequired,
["methodName"] = $"{command.Method.DeclaringType?.FullName}.{command.Method.Name}"
};
return schema.ToString(Formatting.Indented);
}
/// <summary>
/// Generate properties section of JSON Schema from command parameters.
/// </summary>
private static JObject GenerateParameterProperties(IEnumerable<CommandParameterInfo> parameters)
{
var properties = new JObject();
foreach (var parameter in parameters)
{
properties[parameter.Name] = GenerateParameterSchema(parameter);
}
return properties;
}
/// <summary>
/// Generate JSON Schema for a single top-level command parameter.
/// </summary>
private static JObject GenerateParameterSchema(CommandParameterInfo parameter)
{
var schema = GenerateTypeSchema(parameter.ParameterType, new HashSet<Type>());
schema["description"] = parameter.Description;
// Add default value if present (skip for objects/arrays whose default is typically null)
if (parameter.DefaultValue != null)
{
try
{
schema["default"] = JToken.FromObject(parameter.DefaultValue);
}
catch
{
// Non-serializable default; omit rather than fail schema generation.
}
}
return schema;
}
/// <summary>
/// Build the schema fragment for a CLR type: scalar/enum, array/list, or — for
/// <see cref="IStructuredCommandInput"/> types — a nested object schema. <paramref name="visited"/>
/// tracks structured types currently being expanded so self-referential graphs don't recurse
/// forever.
/// </summary>
private static JObject GenerateTypeSchema(Type type, HashSet<Type> visited)
{
// Unwrap Nullable<T> so e.g. int? schemas as "integer".
var underlying = Nullable.GetUnderlyingType(type);
if (underlying != null)
type = underlying;
if (typeof(IStructuredCommandInput).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
return GenerateObjectSchema(type, visited);
if (TryGetEnumerableElementType(type, out var elementType))
{
return new JObject
{
["type"] = "array",
["items"] = GenerateTypeSchema(elementType, visited)
};
}
var schema = new JObject();
var (jsonType, format) = MapTypeToJsonSchema(type);
schema["type"] = jsonType;
if (!string.IsNullOrEmpty(format))
schema["format"] = format;
if (type.IsEnum)
schema["enum"] = new JArray(Enum.GetNames(type));
return schema;
}
/// <summary>
/// Emit a nested object schema for an <see cref="IStructuredCommandInput"/> type by reflecting
/// its public, writable fields and properties.
/// </summary>
private static JObject GenerateObjectSchema(Type type, HashSet<Type> visited)
{
var schema = new JObject { ["type"] = "object" };
// Cycle guard: if we're already expanding this type higher in the stack, stop recursing
// and emit an open object rather than looping.
if (!visited.Add(type))
{
schema["additionalProperties"] = true;
return schema;
}
try
{
var properties = new JObject();
var required = new JArray();
foreach (var member in GetSchemaMembers(type))
{
var memberSchema = GenerateTypeSchema(member.Type, visited);
if (!string.IsNullOrEmpty(member.Description))
memberSchema["description"] = member.Description;
properties[member.Name] = memberSchema;
if (member.Required)
required.Add(member.Name);
}
schema["properties"] = properties;
if (required.Count > 0)
schema["required"] = required;
schema["additionalProperties"] = false;
}
finally
{
visited.Remove(type);
}
return schema;
}
/// <summary>
/// Enumerate the schema-bearing members of a structured-input type: public instance fields and
/// read/write properties. Honors <c>[JsonIgnore]</c>, and reads name/description/required from
/// <c>[CliArg]</c> (falling back to a Newtonsoft <c>[JsonProperty]</c> name, then the member name).
/// </summary>
private static IEnumerable<SchemaMember> GetSchemaMembers(Type type)
{
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;
foreach (var field in type.GetFields(flags))
{
if (field.IsInitOnly || field.IsLiteral)
continue;
if (field.GetCustomAttribute<JsonIgnoreAttribute>() != null)
continue;
yield return ToSchemaMember(field, field.FieldType);
}
foreach (var property in type.GetProperties(flags))
{
if (!property.CanRead || !property.CanWrite)
continue;
if (property.GetIndexParameters().Length > 0)
continue;
if (property.GetCustomAttribute<JsonIgnoreAttribute>() != null)
continue;
yield return ToSchemaMember(property, property.PropertyType);
}
}
private static SchemaMember ToSchemaMember(MemberInfo member, Type memberType)
{
var cliArg = member.GetCustomAttribute<CliArgAttribute>();
var jsonProperty = member.GetCustomAttribute<JsonPropertyAttribute>();
return new SchemaMember
{
Name = cliArg?.Name ?? jsonProperty?.PropertyName ?? member.Name,
Type = memberType,
Description = cliArg?.Description,
Required = cliArg?.Required ?? false
};
}
/// <summary>
/// Detect a JSON-array-shaped type (arrays and common generic collections) and yield its
/// element type. <c>string</c> is intentionally excluded (it is <see cref="IEnumerable{T}"/>
/// of char but maps to a JSON string).
/// </summary>
private static bool TryGetEnumerableElementType(Type type, out Type elementType)
{
elementType = null;
if (type == typeof(string))
return false;
if (type.IsArray)
{
elementType = type.GetElementType();
return elementType != null;
}
if (type.IsGenericType)
{
var definition = type.GetGenericTypeDefinition();
if (definition == typeof(List<>) || definition == typeof(IList<>) ||
definition == typeof(IEnumerable<>) || definition == typeof(ICollection<>) ||
definition == typeof(IReadOnlyList<>) || definition == typeof(IReadOnlyCollection<>))
{
elementType = type.GetGenericArguments()[0];
return true;
}
}
return false;
}
/// <summary>
/// Map a scalar/enum C# type to JSON Schema type and format. Object and array shapes are
/// handled by <see cref="GenerateTypeSchema"/> before this is reached; anything else falls
/// back to <c>string</c>.
/// </summary>
private static (string type, string format) MapTypeToJsonSchema(Type csharpType)
{
// Handle nullable types
if (Nullable.GetUnderlyingType(csharpType) != null)
{
csharpType = Nullable.GetUnderlyingType(csharpType);
}
// String types
if (csharpType == typeof(string) || csharpType == typeof(char))
{
return ("string", null);
}
// Integer types
if (csharpType == typeof(int) || csharpType == typeof(long) ||
csharpType == typeof(short) || csharpType == typeof(byte) ||
csharpType == typeof(uint) || csharpType == typeof(ulong) ||
csharpType == typeof(ushort) || csharpType == typeof(sbyte))
{
return ("integer", null);
}
// Number types
if (csharpType == typeof(float) || csharpType == typeof(double) || csharpType == typeof(decimal))
{
return ("number", null);
}
// Boolean type
if (csharpType == typeof(bool))
{
return ("boolean", null);
}
// Enum types - represent as string with enum constraint (added by the caller)
if (csharpType.IsEnum)
{
return ("string", null);
}
// DateTime types
if (csharpType == typeof(DateTime) || csharpType == typeof(DateTimeOffset))
{
return ("string", "date-time");
}
// Guid type
if (csharpType == typeof(Guid))
{
return ("string", "uuid");
}
// Default to string for unknown types
return ("string", null);
}
/// <summary>A reflected member of a structured-input type, flattened for schema emission.</summary>
private struct SchemaMember
{
public string Name;
public Type Type;
public string Description;
public bool Required;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 779098faca184884f9f588e41c4c0898
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,97 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Unity.Pipeline
{
/// <summary>
/// Read-only stream wrapper that enforces a maximum number of bytes read from an inner stream.
/// Once cumulative reads exceed <c>maxBytes</c>, the next read throws
/// <see cref="RequestTooLargeException"/>. Used to cap HTTP request bodies even when the
/// Content-Length header is absent or untruthful (e.g. chunked transfer-encoding), so an
/// oversized body cannot be buffered into memory.
/// </summary>
internal sealed class MaxLengthStream : Stream
{
private readonly Stream m_Inner;
private readonly long m_MaxBytes;
private readonly bool m_LeaveOpen;
private long m_TotalRead;
/// <param name="leaveOpen">
/// When true, disposing this wrapper does not dispose <paramref name="inner"/>. The server
/// uses this so it can keep reading (draining) the raw request stream after the wrapper is
/// disposed on an over-limit read.
/// </param>
public MaxLengthStream(Stream inner, long maxBytes, bool leaveOpen = false)
{
m_Inner = inner ?? throw new ArgumentNullException(nameof(inner));
m_MaxBytes = maxBytes;
m_LeaveOpen = leaveOpen;
}
public override int Read(byte[] buffer, int offset, int count)
{
var read = m_Inner.Read(buffer, offset, count);
Account(read);
return read;
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
var read = await m_Inner.ReadAsync(buffer, offset, count, cancellationToken);
Account(read);
return read;
}
// Throw as soon as the running total exceeds the cap. Exactly maxBytes is allowed.
private void Account(int read)
{
if (read <= 0)
return;
m_TotalRead += read;
if (m_TotalRead > m_MaxBytes)
throw new RequestTooLargeException(m_MaxBytes);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => m_TotalRead;
set => throw new NotSupportedException();
}
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
// Own the inner stream's lifetime (matching the previous `using (StreamReader(InputStream))`)
// unless the caller asked to leave it open so it can keep reading from it afterwards.
if (disposing && !m_LeaveOpen)
m_Inner.Dispose();
base.Dispose(disposing);
}
}
/// <summary>
/// Thrown by <see cref="MaxLengthStream"/> when a read would exceed the configured byte cap.
/// </summary>
internal sealed class RequestTooLargeException : Exception
{
public long MaxBytes { get; }
public RequestTooLargeException(long maxBytes)
: base($"Request body exceeds the maximum allowed size of {maxBytes} bytes.")
{
MaxBytes = maxBytes;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d75fa6089aa06114c9450387a6d816ad
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,62 @@
using System;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Information about a CLI command parameter.
/// Contains metadata needed for parameter validation and CLI help generation.
/// </summary>
public class CommandParameterInfo
{
/// <summary>
/// Name of the parameter for CLI arguments.
/// </summary>
public string Name { get; }
/// <summary>
/// Human-readable description of the parameter.
/// </summary>
public string Description { get; }
/// <summary>
/// Whether this parameter is required for command execution.
/// </summary>
public bool Required { get; }
/// <summary>
/// Type of the parameter for validation and conversion.
/// </summary>
public Type ParameterType { get; }
/// <summary>
/// Default value for optional parameters.
/// </summary>
public object DefaultValue { get; }
/// <summary>
/// Create parameter information from discovery.
/// </summary>
/// <param name="name">Name of the parameter for CLI arguments.</param>
/// <param name="description">Human-readable description of the parameter.</param>
/// <param name="required">Whether this parameter is required for command execution.</param>
/// <param name="parameterType">Type of the parameter for validation and conversion.</param>
/// <param name="defaultValue">Default value for optional parameters.</param>
public CommandParameterInfo(string name, string description, bool required,
Type parameterType, object defaultValue = null)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
ParameterType = parameterType ?? throw new ArgumentNullException(nameof(parameterType));
Required = required;
DefaultValue = defaultValue;
}
/// <summary>A short diagnostic summary of the parameter.</summary>
/// <returns>The summary string.</returns>
public override string ToString()
{
return $"{Name} Required:{Required} Type:{ParameterType}";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7127497e92677024f949772c30aaf79b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,270 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
namespace Unity.Pipeline
{
/// <summary>
/// Lifecycle states of a detached command job (CLI-335).
/// Serialized to the wire as lower-case strings (see BasePipelineServer).
/// </summary>
internal enum PipelineJobState
{
Queued,
Running,
Completed,
Failed,
Canceled
}
/// <summary>One detached command execution tracked by <see cref="PipelineJobRegistry"/>.</summary>
internal sealed class PipelineJobRecord
{
public string Id;
public string Command;
public PipelineJobState State;
public object Result;
public string Error;
public string ErrorDetails;
public DateTime EnqueuedAt;
public DateTime? StartedAt;
public DateTime? CompletedAt;
public volatile bool CancellationRequested;
/// <summary>Guards State/Result/Error/timestamps; reads and writes cross threads.</summary>
public readonly object Gate = new object();
}
/// <summary>
/// Registry of detached command jobs (CLI-335): a client can submit a command as a job
/// (`/api/exec` with `"job": true`), get a job id back immediately, and later poll
/// `/api/job?id=…` to reattach and collect the result — surviving the client's own HTTP
/// timeout. Results are retained (bounded, see below) so a client that timed out or
/// disconnected can still fetch the outcome.
///
/// Owned one-per-<see cref="BasePipelineServer"/> (the same pattern as <c>Dispatcher</c>), so
/// a test server's jobs can never cross-attribute state with the live server's.
///
/// In-memory by design: a domain reload (script recompilation) discards pending jobs the
/// same way it discards every other C# static. That limitation is inherent to in-Editor
/// state and is tracked separately.
/// </summary>
internal sealed class PipelineJobRegistry
{
/// <summary>Completed/failed/canceled jobs retained beyond this count are pruned oldest-first.</summary>
private const int MaxRetainedTerminalJobs = 100;
/// <summary>
/// Jobs allowed queued or running at once. Jobs execute strictly serially through the
/// exec gate, so a deep backlog beyond this is pure queued work with no throughput
/// benefit — an authorized client loop (or a retrying script) submitting faster than jobs
/// drain would otherwise grow this without bound.
/// </summary>
private const int MaxQueuedJobs = 100;
/// <summary>Terminal jobs older than this are pruned regardless of count.</summary>
private static readonly TimeSpan m_RetentionTtl = TimeSpan.FromHours(1);
private readonly ConcurrentDictionary<string, PipelineJobRecord> m_Jobs =
new ConcurrentDictionary<string, PipelineJobRecord>();
/// <summary>Guards the queued-job cap check against a concurrent create racing past it.</summary>
private readonly object m_CreateGate = new object();
/// <summary>
/// The record currently executing. At most one job runs at a time (jobs share the
/// server's exec gate), so a single reference suffices; read by
/// <see cref="PipelineCancellation"/> from whatever thread the command runs on.
/// </summary>
private volatile PipelineJobRecord m_CurrentRunning;
internal PipelineJobRecord CurrentRunning => m_CurrentRunning;
/// <summary>
/// Create a queued job for <paramref name="command"/>, unless <see cref="MaxQueuedJobs"/>
/// queued-or-running jobs already exist (returns false; the caller should reject the
/// submission rather than grow an unbounded backlog).
/// </summary>
internal bool TryCreate(string command, out PipelineJobRecord record)
{
Prune();
lock (m_CreateGate)
{
var nonTerminal = m_Jobs.Values.Count(job =>
{
lock (job.Gate)
{
return job.State == PipelineJobState.Queued || job.State == PipelineJobState.Running;
}
});
if (nonTerminal >= MaxQueuedJobs)
{
record = null;
return false;
}
record = new PipelineJobRecord
{
Id = Guid.NewGuid().ToString("N"),
Command = command,
State = PipelineJobState.Queued,
EnqueuedAt = DateTime.UtcNow
};
m_Jobs[record.Id] = record;
}
return true;
}
internal bool TryGet(string id, out PipelineJobRecord record)
{
Prune();
return m_Jobs.TryGetValue(id ?? string.Empty, out record);
}
internal void MarkRunning(PipelineJobRecord record)
{
lock (record.Gate)
{
record.State = PipelineJobState.Running;
record.StartedAt = DateTime.UtcNow;
}
m_CurrentRunning = record;
}
internal void MarkCompleted(PipelineJobRecord record, object result)
{
lock (record.Gate)
{
record.State = PipelineJobState.Completed;
record.Result = result;
record.CompletedAt = DateTime.UtcNow;
}
ClearCurrent(record);
}
internal void MarkFailed(PipelineJobRecord record, string error, string details)
{
lock (record.Gate)
{
record.State = PipelineJobState.Failed;
record.Error = error;
record.ErrorDetails = details;
record.CompletedAt = DateTime.UtcNow;
}
ClearCurrent(record);
}
internal void MarkCanceled(PipelineJobRecord record)
{
lock (record.Gate)
{
record.State = PipelineJobState.Canceled;
record.CompletedAt = DateTime.UtcNow;
}
ClearCurrent(record);
}
/// <summary>
/// Request cancellation. A queued job is canceled before it ever starts (the runner
/// checks the flag under the exec gate); a running job gets a cooperative flag that
/// command/eval code can observe via <see cref="PipelineCancellation"/> — arbitrary
/// synchronous main-thread code cannot be aborted safely, so cancellation of a running
/// job takes effect only when the code cooperates (mirroring how test cancellation
/// requires the runner's cooperation).
/// </summary>
internal bool RequestCancel(string id, out PipelineJobRecord record)
{
if (!TryGet(id, out record))
{
return false;
}
record.CancellationRequested = true;
return true;
}
private void ClearCurrent(PipelineJobRecord record)
{
if (ReferenceEquals(m_CurrentRunning, record))
{
m_CurrentRunning = null;
}
}
private void Prune()
{
var now = DateTime.UtcNow;
var terminal = m_Jobs.Values
.Where(job =>
{
lock (job.Gate)
{
return job.State == PipelineJobState.Completed
|| job.State == PipelineJobState.Failed
|| job.State == PipelineJobState.Canceled;
}
})
.OrderBy(job => job.CompletedAt ?? job.EnqueuedAt)
.ToList();
// Oldest-first: drop everything beyond the retained count, plus anything expired.
var excess = terminal.Count - MaxRetainedTerminalJobs;
for (var i = 0; i < terminal.Count; i++)
{
var job = terminal[i];
var expired = now - (job.CompletedAt ?? job.EnqueuedAt) > m_RetentionTtl;
if (i < excess || expired)
{
m_Jobs.TryRemove(job.Id, out _);
}
}
}
/// <summary>Test hook: forget every job.</summary>
internal void Reset()
{
m_Jobs.Clear();
m_CurrentRunning = null;
}
}
/// <summary>
/// <para>Cooperative cancellation for detached jobs (CLI-335). Long-running command or
/// <c>eval</c> code checks this periodically; when a client has requested cancellation
/// (<c>POST /api/job/cancel</c>), the code can wind down early:</para>
/// <code>
/// for (int i = 0; i &lt; n; i++)
/// {
/// Unity.Pipeline.PipelineCancellation.ThrowIfCancellationRequested();
/// // … chunk of work …
/// }
/// </code>
/// <para>Arbitrary synchronous code cannot be aborted safely from outside, so a running job
/// that never checks this flag runs to completion (its state still reports canceled).</para>
/// <para>This is a thin static façade over the executing server's own
/// <see cref="PipelineJobRegistry"/> (resolved via <see cref="BasePipelineServer.CurrentServer"/>)
/// so command code — which has no reference to "which server is running me" — can keep
/// calling this the same way regardless of which server dispatched it.</para>
/// </summary>
public static class PipelineCancellation
{
/// <summary>True when the currently executing job has been asked to cancel.</summary>
public static bool IsCancellationRequested
{
get
{
var current = BasePipelineServer.CurrentServer?.JobRegistry.CurrentRunning;
return current != null && current.CancellationRequested;
}
}
/// <summary>Throw <see cref="OperationCanceledException"/> when cancellation was requested.</summary>
public static void ThrowIfCancellationRequested()
{
if (IsCancellationRequested)
{
throw new OperationCanceledException("Pipeline job cancellation was requested");
}
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 34693b29c0c343d9804a82d9b234ea3f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetVersion: 1
@@ -0,0 +1,231 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
#if UNITY_6000_3_OR_NEWER
using UnityEngine.Assemblies;
#endif
namespace Unity.Pipeline
{
/// <summary>
/// Version-stable handle to a Unity object id. In 6000.4 Unity replaced the int instance id with
/// <see cref="EntityId"/> (a ulong-backed handle), made <c>Object.GetInstanceID()</c> /
/// <c>EditorUtility.InstanceIDToObject(int)</c> obsolete-as-error, and is removing the
/// <c>EntityId</c>&lt;-&gt;<c>int</c> conversions. This struct stores the concrete id type for the
/// running editor — <see cref="EntityId"/> on 6000.4+, <c>int</c> below — and converts implicitly to
/// it so it can be passed straight to Unity APIs. All version branching for object ids is confined
/// here. It (de)serializes as a single JSON integer (the raw ulong on 6000.4+, the int below).
/// </summary>
[JsonConverter(typeof(ObjectIdConverter))]
public readonly struct ObjectId : System.IEquatable<ObjectId>
{
#if UNITY_6000_4_OR_NEWER
readonly EntityId m_Value;
/// <summary>Wrap an <see cref="EntityId"/>.</summary>
/// <param name="value">The entity id to wrap.</param>
public ObjectId(EntityId value) { m_Value = value; }
/// <summary>Unwrap to the underlying <see cref="EntityId"/>.</summary>
/// <param name="id">The id to unwrap.</param>
/// <returns>The underlying entity id.</returns>
public static implicit operator EntityId(ObjectId id) => id.m_Value;
/// <summary>Wrap an <see cref="EntityId"/>.</summary>
/// <param name="value">The entity id to wrap.</param>
/// <returns>The wrapped id.</returns>
public static implicit operator ObjectId(EntityId value) => new ObjectId(value);
/// <summary>Raw 64-bit id — the canonical wire / serialization form.</summary>
public ulong RawValue => EntityId.ToULong(m_Value);
/// <summary>Reconstruct an <see cref="ObjectId"/> from its raw wire value.</summary>
/// <param name="raw">The raw wire value.</param>
/// <returns>The reconstructed id.</returns>
public static ObjectId FromRaw(ulong raw) => new ObjectId(EntityId.FromULong(raw));
/// <summary>Value-equality on the underlying id.</summary>
/// <param name="other">The id to compare against.</param>
/// <returns>True if the underlying ids are equal.</returns>
public bool Equals(ObjectId other) => m_Value.Equals(other.m_Value);
/// <summary>Hash of the underlying id.</summary>
/// <returns>The hash code.</returns>
public override int GetHashCode() => m_Value.GetHashCode();
#else
readonly int m_Value;
/// <summary>Wrap a raw instance id.</summary>
/// <param name="value">The instance id to wrap.</param>
public ObjectId(int value) { m_Value = value; }
/// <summary>Unwrap to the underlying instance id.</summary>
/// <param name="id">The id to unwrap.</param>
/// <returns>The underlying instance id.</returns>
public static implicit operator int(ObjectId id) => id.m_Value;
/// <summary>Wrap a raw instance id.</summary>
/// <param name="value">The instance id to wrap.</param>
/// <returns>The wrapped id.</returns>
public static implicit operator ObjectId(int value) => new ObjectId(value);
/// <summary>Raw id — the canonical wire / serialization form.</summary>
public long RawValue => m_Value;
/// <summary>Reconstruct an <see cref="ObjectId"/> from its raw wire value.</summary>
/// <param name="raw">The raw wire value.</param>
/// <returns>The reconstructed id.</returns>
public static ObjectId FromRaw(long raw) => new ObjectId((int)raw);
/// <summary>Value-equality on the underlying id.</summary>
/// <param name="other">The id to compare against.</param>
/// <returns>True if the underlying ids are equal.</returns>
public bool Equals(ObjectId other) => m_Value == other.m_Value;
/// <summary>Hash of the underlying id.</summary>
/// <returns>The hash code.</returns>
public override int GetHashCode() => m_Value;
#endif
/// <summary>Value-equality on the underlying id.</summary>
/// <param name="obj">The object to compare against.</param>
/// <returns>True if <paramref name="obj"/> is an equal <see cref="ObjectId"/>.</returns>
public override bool Equals(object obj) => obj is ObjectId other && Equals(other);
/// <summary>The canonical numeric wire form (see <see cref="RawValue"/>).</summary>
/// <returns>The numeric string form.</returns>
public override string ToString() => RawValue.ToString();
/// <summary>Parse the canonical numeric form produced by <see cref="ToString"/>.</summary>
/// <param name="s">The numeric string to parse.</param>
/// <returns>The parsed id.</returns>
public static ObjectId Parse(string s)
{
#if UNITY_6000_4_OR_NEWER
return FromRaw(ulong.Parse(s));
#else
return FromRaw(long.Parse(s));
#endif
}
/// <summary>Try to parse the canonical numeric form produced by <see cref="ToString"/>.</summary>
/// <param name="s">The numeric string to parse.</param>
/// <param name="id">The parsed id, if successful.</param>
/// <returns>True if <paramref name="s"/> was a valid numeric form.</returns>
public static bool TryParse(string s, out ObjectId id)
{
#if UNITY_6000_4_OR_NEWER
if (ulong.TryParse(s, out var raw)) { id = FromRaw(raw); return true; }
#else
if (long.TryParse(s, out var raw)) { id = FromRaw(raw); return true; }
#endif
id = default;
return false;
}
}
/// <summary>JSON converter: an <see cref="ObjectId"/> is a single integer on the wire.</summary>
sealed class ObjectIdConverter : JsonConverter<ObjectId>
{
/// <summary>Write an <see cref="ObjectId"/> as its raw numeric wire value.</summary>
/// <param name="writer">The JSON writer.</param>
/// <param name="value">The id to write.</param>
/// <param name="serializer">The active serializer.</param>
public override void WriteJson(JsonWriter writer, ObjectId value, JsonSerializer serializer)
{
writer.WriteValue(value.RawValue);
}
/// <summary>Read an <see cref="ObjectId"/> from either its numeric or string wire form.</summary>
/// <param name="reader">The JSON reader.</param>
/// <param name="objectType">The declared property type.</param>
/// <param name="existingValue">The existing value, if any.</param>
/// <param name="hasExistingValue">Whether <paramref name="existingValue"/> is valid.</param>
/// <param name="serializer">The active serializer.</param>
/// <returns>The parsed id, or <c>default</c> for an unrecognized token type.</returns>
public override ObjectId ReadJson(JsonReader reader, System.Type objectType, ObjectId existingValue,
bool hasExistingValue, JsonSerializer serializer)
{
var token = JToken.Load(reader);
switch (token.Type)
{
case JTokenType.Integer:
#if UNITY_6000_4_OR_NEWER
return ObjectId.FromRaw(token.Value<ulong>());
#else
return ObjectId.FromRaw(token.Value<long>());
#endif
case JTokenType.String:
return ObjectId.Parse((string)token);
default:
return default;
}
}
}
/// <summary>Small cross-Unity-version compatibility shims used across the package.</summary>
public static class PipelineUtils
{
/// <summary>The on-disk path Unity loaded an assembly from.</summary>
/// <param name="a">The assembly to query.</param>
/// <returns>The assembly's loaded path.</returns>
public static string GetLoadedAssemblyPath(System.Reflection.Assembly a)
{
#if UNITY_6000_5_OR_NEWER
return a.GetLoadedAssemblyPath();
#else
return a.Location;
#endif
}
/// <summary>Load a compiled assembly from raw bytes (used by the code-reload/eval compilers).</summary>
/// <param name="bytes">The assembly image.</param>
/// <param name="pdb">The matching portable PDB, if any.</param>
/// <returns>The loaded assembly.</returns>
public static System.Reflection.Assembly LoadFromBytes(byte[] bytes, byte[] pdb = null)
{
#if UNITY_6000_5_OR_NEWER
return UnityEngine.Assemblies.CurrentAssemblies.LoadFromBytes(bytes, pdb);
#else
return System.Reflection.Assembly.Load(bytes, pdb);
#endif
}
/// <summary>Find every loaded object of type <typeparamref name="T"/>, including inactive ones.</summary>
/// <typeparam name="T">The object type to search for.</typeparam>
/// <returns>All matching objects.</returns>
public static T[] FindObjectsByType<T>() where T : Object
{
#if UNITY_6000_4_OR_NEWER
return GameObject.FindObjectsByType<T>();
#else
return GameObject.FindObjectsByType<T>(FindObjectsSortMode.None) ;
#endif
}
/// <summary>All assemblies currently loaded in the domain.</summary>
/// <returns>The loaded assemblies.</returns>
public static IReadOnlyList<System.Reflection.Assembly> GetLoadedAssemblies()
{
#if UNITY_6000_5_OR_NEWER
return CurrentAssemblies.GetLoadedAssemblies();
#else
return System.AppDomain.CurrentDomain.GetAssemblies();
#endif
}
/// <summary>The object's id as a version-stable <see cref="ObjectId"/> (EntityId on 6000.4+, int below).</summary>
/// <param name="obj">The object to get an id for.</param>
/// <returns>The object's version-stable id.</returns>
public static ObjectId GetObjectId(Object obj)
{
#if UNITY_6000_4_OR_NEWER
return new ObjectId(obj.GetEntityId());
#else
return new ObjectId(obj.GetInstanceID());
#endif
}
#if UNITY_EDITOR
/// <summary>Resolve a loaded/scene object from its <see cref="ObjectId"/>, or null if not found.</summary>
public static Object IdToObject(ObjectId id)
{
#if UNITY_6000_4_OR_NEWER
return UnityEditor.EditorUtility.EntityIdToObject(id);
#elif UNITY_6000_3_OR_NEWER
// EntityIdToObject is the non-obsolete resolver from 6000.3, but the ulong-backed ObjectId
// (GetRawData/FromULong) doesn't exist until 6000.4 — so ObjectId is still int-backed here and
// we route the int through the (non-obsolete on 6.3) int->EntityId conversion.
return UnityEditor.EditorUtility.EntityIdToObject((int)id);
#else
return UnityEditor.EditorUtility.InstanceIDToObject((int)id);
#endif
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e6c6a6cac05ac41438850c53ea3178b1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,31 @@
using System.IO;
namespace Unity.Pipeline
{
/// <summary>
/// The one piece of "rotate a log file" that <c>PipelineTransactionLog</c> (a whole-file JSON
/// array, rotated once per Unity session) and <c>EvalUsageTelemetry</c> (true JSONL, rotated on
/// a byte-size cap) actually share verbatim: swapping the active file for a backup. Everything
/// else — the append shape, the lock, and what triggers a rotation — differs enough between the
/// two that forcing them into one bigger "rotating append log" abstraction would cost more
/// clarity than the duplication it removes, so only this file-swap step is factored out.
/// </summary>
static class RotatingFileBackup
{
/// <summary>
/// Move <paramref name="activePath"/> to <paramref name="backupPath"/>, replacing any prior
/// backup, so the active path is free for a fresh file. No-op when there is no active file.
/// Callers are responsible for their own locking around this — it performs no synchronization
/// itself.
/// </summary>
public static void RotateToBackup(string activePath, string backupPath)
{
if (!File.Exists(activePath))
return;
if (File.Exists(backupPath))
File.Delete(backupPath);
File.Move(activePath, backupPath);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 24f2295b1c6944c89c0615c9be0ed96a