Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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<string>()</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>&&</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<,>'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 < 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><-><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
|
||||
Reference in New Issue
Block a user