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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
@@ -0,0 +1,660 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using UnityEditor;
#if UNITY_6000_5_OR_NEWER
using Unity.Scripting.LifecycleManagement;
#endif
namespace Unity.Pipeline.Editor.Commands.Batch
{
/// <summary>
/// The <c>batch</c> command (AUTHAPI-27): transactional multi-operation execution with cross-op
/// result references, atomic Undo, and dry-run.
///
/// <para>WHY it exists: a "create → add components → wire fields" job is otherwise 10–90 sequential
/// calls at ~0.8s each, and <c>eval</c> — the only alternative today — leaves partial state when it
/// throws mid-way, which is unacceptable on user-owned scenes. <c>batch</c> collapses the round
/// trips AND adds what eval structurally lacks: atomicity/rollback, dry-run validation, and
/// cross-step references.</para>
///
/// <para>Scope guard (epic AUTHAPI-24 design principle): batch is transactional object CRUD with
/// references. It deliberately has NO <c>invoke</c>/loop/conditional op types — that path reinvents
/// eval inside JSON and collapses the auditability story. Arbitrary logic belongs in
/// <c>run_script</c> (code as files on disk), not here.</para>
///
/// <para>Dispatch reuse: each sub-operation runs through
/// <see cref="Unity.Pipeline.BasePipelineServer.DispatchCommandOnMainThread"/>, the exact parameter
/// extraction / required-validation / reflection-invoke path <c>/api/exec</c> uses, so every per-op
/// result is shaped identically to running that command standalone.</para>
///
/// <para>Atomicity: the whole batch runs inside one <see cref="AuthoringUndoScope"/>, so all ops
/// (each of which registers its own Undo operations) collapse into a single Editor Undo step. When
/// <c>transactional</c> (the default) and an op fails under <c>on_error=abort</c>, every applied op
/// is reverted with <see cref="Undo.RevertAllDownToGroup(int)"/> — the scene returns to its
/// pre-batch state and there is nothing left on the Undo stack. Rollback is only real for
/// Undo-tracked mutations, which is why commands that mutate state OUTSIDE the Undo system
/// (<see cref="m_NonRevertibleCommands"/>) are rejected in a transactional batch — see that field's
/// doc for the v1 caveat.</para>
///
/// <para>Timing: the batch executes in a single main-thread turn (no frame-spanning), so the
/// Editor UI is frozen for its duration. <c>time_budget_ms</c> is the cooperative bound on that
/// freeze: the elapsed time is checked before each operation after the first, and when the budget
/// is exhausted the remaining ops are skipped and a transactional batch rolls back. Long batches
/// should be submitted as detached jobs (<c>"job": true</c>) — cancellation is checked between
/// ops, and per-op progress is reported via <see cref="Unity.Pipeline.CliProgress"/>.</para>
/// </summary>
#if UNITY_6000_5_OR_NEWER
[NoAutoStaticsCleanup]
#endif
static class BatchCommand
{
/// <summary>Hard cap on operations per batch (payload bound).</summary>
private const int MaxOperations = 200;
/// <summary>Default cooperative time budget for one batch execution (see <see cref="Batch"/>).</summary>
private const int DefaultTimeBudgetMs = 50_000;
/// <summary>Upper bound on <c>time_budget_ms</c> (1 hour).</summary>
private const int MaxTimeBudgetMs = 3_600_000;
/// <summary>
/// Per-op result size (serialized UTF-8 BYTES — what actually goes on the wire, not UTF-16
/// chars, which undercount CJK/emoji-heavy payloads 3x) above which the result is replaced
/// by a truncation marker so a large sub-result cannot bloat the batch envelope.
/// </summary>
private const int MaxPerOpResultBytes = 16 * 1024;
/// <summary>
/// Aggregate budget for ALL per-op results in one batch response (UTF-8 bytes). Bounds the
/// whole envelope: 200 ops each just under the per-op cap would otherwise produce a ~3.2 MB
/// reply. Once exhausted, later results are replaced by truncation markers (the ops still
/// execute normally — only the echoed result payload is elided).
/// </summary>
internal const int MaxBatchResultBytes = 256 * 1024;
/// <summary>
/// Operation ids must look like identifiers: they may not be purely numeric (a numeric id
/// would be ambiguous with a 0-based index in a <c>"$&lt;id-or-index&gt;"</c> reference) and
/// may not contain <c>'.'</c> (the reference path separator).
/// </summary>
private static readonly Regex m_IdPattern = new Regex("^[A-Za-z_][A-Za-z0-9_-]*$", RegexOptions.Compiled);
/// <summary>
/// Commands excluded from batch (structured <c>not_batchable</c> error). These either escape
/// the "object CRUD with references" scope (build/platform/package/play-mode/recompile), carry
/// arbitrary code that belongs in <c>run_script</c> (<c>eval</c>/<c>eval_file</c>/<c>run_script</c>),
/// can pop modal dialogs that wedge an unattended batch (<c>menu</c>), or would nest a batch
/// inside a batch. (The ticket names <c>eval</c>; <c>eval_file</c> is its file-based twin and
/// is excluded on the same grounds.)
/// </summary>
private static readonly HashSet<string> m_ExcludedCommands = new HashSet<string>(StringComparer.Ordinal)
{
"build", "switch_build_target",
"editor_play", "editor_stop", "editor_pause",
"recompile", "eval", "eval_file", "run_script", "menu", "batch",
// A synchronous wait inside a batch cannot make progress: the batch holds the main
// thread for its whole turn, so the editor state the condition watches cannot change —
// the wait just burns its full timeout inside the batch. Use an async wait_for before
// or after the batch instead. (wait_status/wait_cancel are quick reads and stay
// batchable.)
"wait_for"
};
/// <summary>Excluded command families matched by name prefix (e.g. all <c>package_*</c>).</summary>
private static readonly string[] m_ExcludedPrefixes = { "package_" };
/// <summary>
/// Commands that CLEAR the Editor Undo stack, destroying the batch's ability to revert any
/// prior operation. Always rejected inside a batch (even with <c>transactional=false</c> the
/// single-Undo-step contract would silently break).
///
/// <para>V1 CAVEAT: like <see cref="m_NonRevertibleCommands"/> below, this is a curated list —
/// any new command that clears or truncates the Undo stack must be added here, or it will
/// silently break the revert-integrity contract. The durable fix is command-site metadata
/// (<c>[CliCommand(Revertible = ...)]</c>).</para>
/// </summary>
private static readonly HashSet<string> m_UndoStackWipingCommands = new HashSet<string>(StringComparer.Ordinal)
{
"open_scene", "create_scene"
};
/// <summary>
/// Commands whose mutations live OUTSIDE the Editor Undo system — asset/file writes
/// (AssetDatabase / File IO), scene saves, Build Settings, project settings — so
/// <see cref="Undo.RevertAllDownToGroup(int)"/> cannot roll them back. In a transactional
/// batch they are rejected (<c>not_batchable_transactional</c>): allowing them would make
/// <c>reverted: true</c> a lie. With <c>transactional=false</c> they run, and their per-op
/// result reports <c>revertible: false</c>.
///
/// <para>V1 CAVEAT: this is a curated list, assembled by auditing each command's
/// implementation (AssetDatabase.SaveAssets / File writes / settings assets). The durable fix
/// is a <c>[CliCommand(Revertible = ...)]</c> metadata flag declared at the command site so
/// new commands can't silently fall through; until then, new non-Undo-tracked commands must
/// be added here.</para>
/// </summary>
private static readonly HashSet<string> m_NonRevertibleCommands = new HashSet<string>(StringComparer.Ordinal)
{
// Assets & files (AssetDatabase ops / direct file IO).
"create_asset", "import_asset", "move_asset", "copy_asset", "rename_asset", "delete_asset",
"create_folder", "set_import_settings", "write_text_file", "create_script",
// Prefab ASSET writes (scene-side prefab ops — instantiate/unpack/revert overrides — are
// Undo-tracked and stay batchable).
"create_prefab", "create_prefab_variant", "apply_prefab_overrides", "save_prefab_contents",
// Scene persistence and Build Settings scene list.
"save_scene", "save_all", "add_scene_to_build", "remove_scene_from_build", "set_build_settings",
// Project settings (their own descriptions say "Not undoable via Ctrl+Z").
"set_audio_settings", "set_graphics_settings", "set_input_settings", "set_lighting_settings",
"set_navmesh_settings", "set_physics_settings", "set_player_settings", "set_quality_settings",
"set_tags_layers", "set_time_settings", "set_authoring_root",
// Animation / Animator / Timeline commands persist their asset with AssetDatabase.SaveAssets.
"create_animation_clip", "set_animation_curve", "remove_animation_curve",
"create_animator_controller", "add_animator_layer", "add_animator_parameter",
"add_animator_state", "add_animator_transition",
"create_timeline", "add_timeline_track", "add_timeline_clip",
// Persists the .mat asset with AssetDatabase.SaveAssetIfDirty (the in-memory change is
// Undo-recorded, but the disk write is not reverted by Undo).
"set_material_properties",
// Baking writes/deletes baked data on disk (GI / NavMesh / occlusion).
"bake_lighting", "clear_baked_lighting",
"bake_navmesh", "bake_navmesh_surfaces", "clear_navmesh",
"bake_occlusion_culling", "clear_occlusion_culling"
};
[CliCommand("batch",
"Run multiple registered commands in one transactional request. Later ops can reference earlier " +
"results with \"$<id-or-index>.<jsonPath>\" (e.g. \"$0.instanceId\"; \"$$\" escapes a literal '$'). " +
"transactional=true (default) groups every op into one Undo step and reverts all applied ops if any " +
"op fails; commands that mutate outside the Undo system (asset/file/settings writes) are rejected " +
"unless transactional=false. on_error=abort|continue (continue forces transactional=false); dry_run " +
"validates without mutating; time_budget_ms (default 50000) bounds execution — when exceeded the " +
"remaining ops are skipped and a transactional batch rolls back (submit long batches with " +
"\"job\": true). Excludes build, switch_build_target, package_*, editor_play/stop/pause, recompile, " +
"eval, eval_file, run_script, menu, open_scene/create_scene, async commands (run_tests/list_tests), " +
"runtime-only commands, and nested batch.",
MainThreadRequired = true,
Tags = new[] { "batch" })]
public static BatchResult Batch(
[CliArg("operations", "Ordered operations to run; each is { id?, command, params }. Max 200.", Required = true)] List<BatchOperationInput> operations = null,
[CliArg("transactional", "Group all ops into one Undo step and revert every applied op if any op fails. Default true. Forced false when on_error=continue. Rejects commands whose mutations Undo cannot revert (asset/file/settings writes).")] bool transactional = true,
[CliArg("on_error", "abort (default): stop at the first failing op. continue: run every op, collecting per-op errors (forces transactional=false).")] string onError = "abort",
[CliArg("dry_run", "Validate command names, parameters, reference topology and excluded commands without mutating anything.")] bool dryRun = false,
[CliArg("result_fields", "Optional per-op result projection: map of op id-or-index -> array of result field paths to keep (context economy).")] JObject resultFields = null,
[CliArg("time_budget_ms", "Cooperative time budget for the whole batch in milliseconds (default 50000, max 3600000). Checked before each op after the first; when exhausted the remaining ops are skipped and a transactional batch rolls back. Use \"job\": true for long batches.")] int timeBudgetMs = DefaultTimeBudgetMs)
{
if (operations == null || operations.Count == 0)
throw new ArgumentException("batch 'operations' must contain at least one operation.");
if (operations.Count > MaxOperations)
throw new ArgumentException(
$"batch supports at most {MaxOperations} operations, but {operations.Count} were provided.");
if (timeBudgetMs < 0 || timeBudgetMs > MaxTimeBudgetMs)
throw new ArgumentException(
$"batch 'time_budget_ms' must be between 0 and {MaxTimeBudgetMs}, got {timeBudgetMs}.");
onError = string.IsNullOrWhiteSpace(onError) ? "abort" : onError.Trim().ToLowerInvariant();
if (onError != "abort" && onError != "continue")
throw new ArgumentException($"batch 'on_error' must be 'abort' or 'continue', got '{onError}'.");
// "continue" collects per-op errors and applies the independent ops, so it cannot also
// promise an all-or-nothing rollback — it forces transactional off (per the ticket).
var effectiveTransactional = transactional && onError != "continue";
var idToIndex = BuildAndValidateIdIndex(operations);
if (dryRun)
return DryRun(operations, idToIndex, effectiveTransactional, onError);
return Execute(operations, idToIndex, effectiveTransactional, onError, resultFields, timeBudgetMs);
}
/// <summary>
/// Validate operation ids (well-formed and unique when supplied) and build the id → index map
/// used to resolve <c>"$id.field"</c> references.
/// </summary>
private static Dictionary<string, int> BuildAndValidateIdIndex(List<BatchOperationInput> operations)
{
var idToIndex = new Dictionary<string, int>(StringComparer.Ordinal);
for (int i = 0; i < operations.Count; i++)
{
var op = operations[i];
if (op == null)
throw new ArgumentException($"batch operation at index {i} is null.");
if (op.Id == null)
continue;
if (!m_IdPattern.IsMatch(op.Id))
throw new ArgumentException(
$"batch operation id '{op.Id}' (index {i}) is invalid: ids must match " +
"^[A-Za-z_][A-Za-z0-9_-]*$ (start with a letter or '_'; letters, digits, '_' or '-' " +
"after). Purely numeric ids would be ambiguous with 0-based index selectors in " +
"\"$<id-or-index>\" references, and '.' collides with the reference path separator.");
if (idToIndex.ContainsKey(op.Id))
throw new ArgumentException(
$"batch operation id '{op.Id}' is used more than once; ids must be unique within a batch.");
idToIndex[op.Id] = i;
}
return idToIndex;
}
/// <summary>
/// Execute the batch: run each op (resolving its references against earlier results) inside one
/// Undo group, and on a transactional abort revert every applied op. Between ops, cooperative
/// cancellation and the time budget are checked, and per-op progress is reported.
/// </summary>
private static BatchResult Execute(List<BatchOperationInput> operations, Dictionary<string, int> idToIndex,
bool transactional, string onError, JObject resultFields, int timeBudgetMs)
{
var server = BasePipelineServer.CurrentServer;
if (server == null)
throw new InvalidOperationException(
"batch must run inside a pipeline server request (no active server on this thread).");
var result = new BatchResult { Transactional = transactional, OnError = onError };
// One name-indexed lookup per batch call instead of an O(commands) scan per op.
var commandsByName = BuildCommandIndex();
var resultsByIndex = new JToken[operations.Count];
var stoppedAt = -1; // index of the op that failed under on_error=abort
var skippedFrom = -1; // first op never attempted because of a batch-level abort (budget/cancel)
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var remainingResultBytes = MaxBatchResultBytes;
using (var undoScope = new AuthoringUndoScope("batch"))
{
var batchGroup = Undo.GetCurrentGroup();
result.UndoGroup = batchGroup;
for (int i = 0; i < operations.Count; i++)
{
// Cooperative cancellation (detached jobs: POST /api/job/cancel) — abort between
// ops and let the transactional rollback below restore the pre-batch state.
if (PipelineCancellation.IsCancellationRequested)
{
result.Error = "batch canceled: cooperative cancellation was requested; " +
(transactional ? "applied operations were reverted and " : "") +
"remaining operations were skipped.";
skippedFrom = i;
break;
}
// Cooperative time budget: the batch holds the main thread for its whole duration,
// so this is the bound on how long the Editor stays frozen. Op 0 always gets to
// run; every later op requires remaining budget.
if (i > 0 && stopwatch.ElapsedMilliseconds >= timeBudgetMs)
{
result.Error = $"batch time budget exceeded: {stopwatch.ElapsedMilliseconds}ms elapsed " +
$"after {i} of {operations.Count} operations (time_budget_ms={timeBudgetMs}). " +
(transactional ? "Applied operations were reverted and remaining operations were skipped. "
: "Remaining operations were skipped. ") +
"For long batches submit the request with \"job\": true and a larger time_budget_ms.";
skippedFrom = i;
break;
}
var op = operations[i];
CliProgress.Report("batch", $"op {i + 1}/{operations.Count}: {op.Command}",
i, operations.Count, i / (double)operations.Count);
var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command };
result.Results.Add(opResult);
try
{
var command = ResolveBatchable(op.Command, transactional, commandsByName);
opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command);
var resolvedParams = BatchReferenceResolver.Resolve(
op.Params, i, operations, idToIndex, resultsByIndex, result.Results);
// Dispatched WITHOUT re-checking the server's busy gate (GetBusyReason):
// the batch itself was admitted through it in HandleExecRequest, the settle
// gate is a one-way latch that never re-arms mid-session, and the batch
// holds the main thread for its entire turn — the Editor cannot transition
// into a busy state between two ops of the same batch.
var raw = server.DispatchCommandOnMainThread(command, resolvedParams);
// Capture the result as a token for later references. A serialization failure
// here must NOT fail an op that already ran — leave the slot null (a later
// reference into it will error clearly) and still report success.
try
{
resultsByIndex[i] = raw == null ? JValue.CreateNull() : JToken.FromObject(raw);
}
catch
{
resultsByIndex[i] = null;
}
opResult.Success = true;
opResult.Result = ProjectResult(raw, resultsByIndex[i], op, i, resultFields, opResult, ref remainingResultBytes);
result.Applied++;
}
catch (Exception ex)
{
opResult.Success = false;
opResult.Error = ex.Message;
if (onError == "abort")
{
stoppedAt = i;
break;
}
// on_error=continue: keep going with the remaining independent ops.
}
}
if ((stoppedAt >= 0 || skippedFrom >= 0) && transactional)
{
// Revert every mutation registered since the batch started — one atomic rollback
// to the pre-batch state (nothing is left on the Undo stack). Deliberately NOT
// conditioned on Applied > 0: a FIRST op that partially mutates and then throws
// has registered exactly the Undo operations this revert must unwind, and
// reverting an empty group is harmless.
Undo.RevertAllDownToGroup(batchGroup);
result.Reverted = true;
// The revert discarded batchGroup from the Undo stack; the scope must not
// collapse a group id that no longer refers to this batch's operations.
undoScope.Cancel();
}
}
// Record the operations that were never attempted because the batch aborted (op failure
// under on_error=abort, cancellation, or an exhausted time budget), so the caller can see
// exactly where it stopped.
var firstSkipped = skippedFrom >= 0 ? skippedFrom
: stoppedAt >= 0 ? stoppedAt + 1
: operations.Count;
for (int j = firstSkipped; j < operations.Count; j++)
{
result.Results.Add(new BatchOperationResult
{
Id = operations[j].Id,
Command = operations[j].Command,
Success = false,
Skipped = true
});
}
return result;
}
/// <summary>
/// Validate every operation without executing any of it: command names, that no excluded /
/// non-batchable command is used (including the transactional non-revertible rejection),
/// unknown/missing parameters, and reference topology (unknown/forward refs). Mutates nothing.
/// </summary>
private static BatchResult DryRun(List<BatchOperationInput> operations, Dictionary<string, int> idToIndex,
bool transactional, string onError)
{
var result = new BatchResult
{
DryRun = true,
Valid = true,
Transactional = transactional,
OnError = onError
};
var commandsByName = BuildCommandIndex();
for (int i = 0; i < operations.Count; i++)
{
var op = operations[i];
var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command };
result.Results.Add(opResult);
try
{
var command = ResolveBatchable(op.Command, transactional, commandsByName);
opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command);
ValidateKnownParameters(command, op.Params);
ValidateRequiredParameters(command, op.Params);
BatchReferenceResolver.Validate(op.Params, i, operations, idToIndex);
opResult.Success = true; // would run
}
catch (Exception ex)
{
opResult.Success = false;
opResult.Error = ex.Message;
result.Valid = false;
}
}
return result;
}
/// <summary>
/// Resolve a command name to its <see cref="CommandInfo"/>, rejecting non-batchable commands:
/// the excluded list (<c>not_batchable</c>), Undo-stack-wiping scene loads, runtime-only
/// commands, async (<c>Task</c>-returning) commands — which are completed by
/// <c>EditorApplication.update</c> callbacks that can never fire while the batch holds the
/// main thread, so awaiting them inline would freeze the Editor permanently — and, when
/// <paramref name="transactional"/>, commands whose mutations Undo cannot revert
/// (<c>not_batchable_transactional</c>). Unknown commands get an <c>unknown command</c> error.
/// Uses the registry directly (no server-side logging) so dry-run validation of an
/// intentionally bad name stays quiet.
/// </summary>
/// <summary>
/// Name-indexed snapshot of the registered commands, built once per batch call (the registry
/// list itself is cached; this avoids an O(commands) scan per op). NOTE: this deliberately
/// does not reuse <c>BasePipelineServer.ResolveCommand</c> — that helper serves the exec
/// request path (logging, envelope error shape); unifying the two belongs to a follow-up so
/// the server's hot path is not reshaped from this command.
/// </summary>
private static Dictionary<string, CommandInfo> BuildCommandIndex()
{
var byName = new Dictionary<string, CommandInfo>(StringComparer.Ordinal);
foreach (var c in CommandRegistry.DiscoverCommands())
if (!byName.ContainsKey(c.Name))
byName.Add(c.Name, c);
return byName;
}
private static CommandInfo ResolveBatchable(string name, bool transactional, IReadOnlyDictionary<string, CommandInfo> byName)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("operation 'command' is required.");
if (IsExcluded(name))
throw new ArgumentException(
$"Command '{name}' is not batchable (not_batchable): commands that build, switch platform, " +
"manage packages, control play mode, recompile, evaluate code, execute Editor menu items " +
"(which can open modal dialogs and wedge an unattended batch), or nest a batch are excluded.");
if (m_UndoStackWipingCommands.Contains(name))
throw new ArgumentException(
$"Command '{name}' is not batchable (not_batchable): it clears the Editor Undo stack, which " +
"would make every prior batch operation unrevertible. Call it standalone instead.");
if (!byName.TryGetValue(name, out var command))
throw new ArgumentException($"No command named '{name}' is available (unknown command).");
if (command.RuntimeOnly)
throw new ArgumentException(
$"Command '{name}' is not batchable (not_batchable): runtime-only command (Player server " +
"surface); it cannot execute on this Editor server.");
if (typeof(Task).IsAssignableFrom(command.Method.ReturnType))
throw new ArgumentException(
$"Command '{name}' is not batchable (not_batchable): async commands cannot run inside a " +
"synchronous batch. They complete via EditorApplication.update callbacks, which cannot fire " +
"while the batch holds the main thread — the batch would deadlock the Editor. Call it " +
"standalone instead.");
if (transactional && m_NonRevertibleCommands.Contains(name))
throw new ArgumentException(
$"Command '{name}' is not batchable in a transactional batch (not_batchable_transactional): " +
"it mutates state outside the Editor Undo system (asset/file/settings writes), so a rollback " +
"could not revert it. Run the batch with transactional:false, or call the command standalone.");
return command;
}
private static bool IsExcluded(string name)
{
if (m_ExcludedCommands.Contains(name))
return true;
foreach (var prefix in m_ExcludedPrefixes)
{
if (name.StartsWith(prefix, StringComparison.Ordinal))
return true;
}
return false;
}
/// <summary>Reject parameter keys that are not parameters of the command (dry-run check).</summary>
private static void ValidateKnownParameters(CommandInfo command, JObject parameters)
{
if (parameters == null)
return;
var known = new HashSet<string>(command.Parameters.Select(p => p.Name), StringComparer.Ordinal);
foreach (var prop in parameters.Properties())
{
if (!known.Contains(prop.Name))
throw new ArgumentException(
$"Command '{command.Name}' has no parameter '{prop.Name}' (unknown parameter). " +
$"Known parameters: [{string.Join(", ", known)}].");
}
}
/// <summary>
/// Ensure every required parameter is present (dry-run check). A reference string counts as
/// present; an explicit JSON null does not.
/// </summary>
private static void ValidateRequiredParameters(CommandInfo command, JObject parameters)
{
foreach (var p in command.Parameters)
{
if (!p.Required)
continue;
var token = parameters?[p.Name];
if (token == null || token.Type == JTokenType.Null)
throw new ArgumentException(
$"Command '{command.Name}' is missing required parameter '{p.Name}'.");
}
}
/// <summary>
/// Shape an op's result: apply the optional <c>result_fields</c> projection, then replace an
/// oversized result with a truncation marker. With neither in play the raw result object is
/// returned unchanged, so it serializes byte-identically to the standalone command.
/// </summary>
internal static object ProjectResult(object raw, JToken rawToken, BatchOperationInput op, int index,
JObject resultFields, BatchOperationResult opResult, ref int remainingResultBytes)
{
object projected = raw;
var fields = LookupResultFields(resultFields, op, index);
if (fields != null && rawToken != null && rawToken.Type != JTokenType.Null)
{
var projection = new JObject();
List<string> invalidPaths = null;
foreach (var path in fields)
{
if (string.IsNullOrEmpty(path))
continue;
JToken value;
try
{
value = rawToken.SelectToken(path);
}
catch (Exception)
{
// A malformed JSONPath must NEVER throw here: by this point the op has
// already run and mutated state, and the outer catch would flip its
// Success to false — triggering a rollback of a succeeded op under
// on_error=abort, and poisoning later $refs into a perfectly valid
// captured result under on_error=continue. Report the bad path in the
// projection instead.
(invalidPaths ?? (invalidPaths = new List<string>())).Add(path);
continue;
}
if (value != null)
projection[path] = value;
}
if (invalidPaths != null)
projection["invalidResultFields"] = new JArray(invalidPaths);
projected = projection;
}
string json;
try
{
// Measure with the same formatting the wire envelope uses: since the lean envelope
// (AUTHAPI-21) /api/exec serializes compact (Formatting.None) in every mode, so the
// measurement matches what actually goes on the wire.
json = JsonConvert.SerializeObject(projected, Formatting.None);
}
catch (Exception ex)
{
// A result Newtonsoft cannot serialize would fail (or bloat) the whole batch envelope
// later. Replace it with a structured marker — and still flag resultTruncated — rather
// than skipping truncation for exactly the results we know nothing about.
opResult.ResultTruncated = true;
return new JObject
{
["resultTruncated"] = true,
["error"] = $"op result could not be serialized: {ex.Message}"
};
}
var bytes = System.Text.Encoding.UTF8.GetByteCount(json);
if (bytes > MaxPerOpResultBytes)
{
opResult.ResultTruncated = true;
return new JObject
{
["resultTruncated"] = true,
["length"] = bytes,
["preview"] = json.Substring(0, Math.Min(json.Length, 512))
};
}
if (bytes > remainingResultBytes)
{
// Aggregate budget exhausted: the op executed normally, only its echoed result is
// elided so the whole batch reply stays bounded ($refs still resolve — they read
// the captured token, not this projection).
opResult.ResultTruncated = true;
return new JObject
{
["resultTruncated"] = true,
["length"] = bytes,
["error"] = $"batch aggregate result budget ({MaxBatchResultBytes} bytes) exhausted; " +
"the operation ran and later references to it still resolve, only this echoed result was elided. " +
"Use result_fields to project results down, or split the batch.",
["preview"] = json.Substring(0, Math.Min(json.Length, 512))
};
}
remainingResultBytes -= bytes;
return projected;
}
/// <summary>
/// Look up the projection field list for an op in <c>result_fields</c> (keyed by op id, then
/// by index). Returns null when no projection is configured for the op.
/// </summary>
private static string[] LookupResultFields(JObject resultFields, BatchOperationInput op, int index)
{
if (resultFields == null)
return null;
JToken entry = null;
if (!string.IsNullOrEmpty(op.Id))
entry = resultFields[op.Id];
if (entry == null)
entry = resultFields[index.ToString()];
if (entry == null)
return null;
if (entry.Type == JTokenType.Array)
return entry.ToObject<string[]>();
if (entry.Type == JTokenType.String)
return new[] { (string)entry };
return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c33c9efe866346aab0db0dc6fad780a5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,43 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
namespace Unity.Pipeline.Editor.Commands.Batch
{
/// <summary>
/// A single operation in a <c>batch</c> request (AUTHAPI-27): the name of a registered command
/// plus the parameters to invoke it with, and an optional <see cref="Id"/> so later operations
/// can reference this one's result.
///
/// String values anywhere in <see cref="Params"/> may reference an earlier operation's result
/// with <c>"$&lt;id-or-index&gt;.&lt;jsonPath&gt;"</c> (e.g. <c>"$0.instanceId"</c> or
/// <c>"$createHead.components[0].instanceId"</c>); <c>"$$"</c> escapes a literal <c>'$'</c>.
/// References are backward-only. Implements <see cref="IStructuredCommandInput"/> so the batch
/// command advertises a nested object schema (via <c>GET /api/commands</c>) rather than a string.
/// </summary>
sealed class BatchOperationInput : IStructuredCommandInput
{
/// <summary>
/// Optional operation id used to reference this operation's result from a later op
/// (<c>"$&lt;id&gt;.&lt;field&gt;"</c>). Must be unique within the batch when supplied, and
/// must match <c>^[A-Za-z_][A-Za-z0-9_-]*$</c> — purely numeric ids would be ambiguous with
/// 0-based index selectors, and <c>'.'</c> collides with the reference path separator.
/// </summary>
[CliArg("id", "Optional id for referencing this op's result from a later op (\"$<id>.<field>\"). Unique within the batch; must match ^[A-Za-z_][A-Za-z0-9_-]*$ (not purely numeric, no dots).")]
[JsonProperty("id")]
public string Id { get; set; }
/// <summary>Name of the registered command to run (any command except the excluded list).</summary>
[CliArg("command", "Name of the registered command to run.", Required = true)]
[JsonProperty("command")]
public string Command { get; set; }
/// <summary>
/// Parameters for the command, same shape as <c>/api/exec</c> <c>parameters</c>. Any string
/// value may reference a prior op's result (<c>"$&lt;id-or-index&gt;.&lt;jsonPath&gt;"</c>).
/// </summary>
[CliArg("params", "Parameters for the command. String values may reference a prior op's result: \"$<id-or-index>.<jsonPath>\".")]
[JsonProperty("params")]
public JObject Params { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8f7946d807d348c0a98e13ba7d18f714
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
namespace Unity.Pipeline.Editor.Commands.Batch
{
/// <summary>
/// Resolves cross-operation result references in a batch operation's parameters (AUTHAPI-27).
///
/// A string value that begins with a single <c>'$'</c> is a reference of the form
/// <c>"$&lt;selector&gt;"</c> or <c>"$&lt;selector&gt;.&lt;jsonPath&gt;"</c>, where
/// <c>&lt;selector&gt;</c> names an earlier operation by its <c>id</c> or its 0-based index and
/// <c>&lt;jsonPath&gt;</c> is a Newtonsoft SelectToken path into that op's result
/// (e.g. <c>"$0.instanceId"</c>, <c>"$createHead.components[0].instanceId"</c>). A leading
/// <c>"$$"</c> escapes a literal <c>'$'</c>. References are backward-only: a selector must resolve
/// to an operation strictly before the current one.
///
/// References are resolved as WHOLE values (the entire string must be the reference), and the
/// substituted JSON token preserves its type — so a numeric <c>instanceId</c> stays a number and
/// deserializes into the target parameter (e.g. an <c>ObjectRef</c>) exactly as a literal would.
/// The walk recurses through nested objects and arrays, so a reference can appear anywhere in the
/// parameter tree.
/// </summary>
internal static class BatchReferenceResolver
{
/// <summary>
/// Validate the reference topology of <paramref name="parameters"/> without substituting any
/// values (used by dry run). Throws <see cref="ArgumentException"/> on an unknown selector,
/// a forward/self reference, or a malformed reference.
/// </summary>
public static void Validate(JToken parameters, int currentIndex,
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex)
{
if (parameters == null)
return;
// No DeepClone: in validate mode (null resultsByIndex) TransformString always returns the
// original token, so Walk never reassigns a child — the walk is read-only.
Walk(parameters, currentIndex, operations, idToIndex, null, null);
}
/// <summary>
/// Return a copy of <paramref name="parameters"/> with every reference replaced by the
/// referenced value from <paramref name="resultsByIndex"/>. Throws
/// <see cref="ArgumentException"/> on an unknown selector, a forward/self reference, a
/// reference whose path does not resolve, or a reference into an operation that failed, was
/// skipped, or returned no result (per-op outcomes come from <paramref name="outcomes"/>).
/// The input token is not mutated.
/// </summary>
public static JObject Resolve(JObject parameters, int currentIndex,
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex,
JToken[] resultsByIndex, IReadOnlyList<BatchOperationResult> outcomes)
{
if (parameters == null)
return null;
var clone = (JObject)parameters.DeepClone();
return (JObject)Walk(clone, currentIndex, operations, idToIndex, resultsByIndex, outcomes);
}
/// <summary>
/// Recursively transform a token tree. <paramref name="resultsByIndex"/> is null in validate
/// mode (topology check only) and non-null in resolve mode (value substitution).
/// </summary>
private static JToken Walk(JToken node, int currentIndex,
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex,
JToken[] resultsByIndex, IReadOnlyList<BatchOperationResult> outcomes)
{
switch (node.Type)
{
case JTokenType.Object:
foreach (var prop in ((JObject)node).Properties().ToList())
{
var replaced = Walk(prop.Value, currentIndex, operations, idToIndex, resultsByIndex, outcomes);
if (!ReferenceEquals(replaced, prop.Value))
prop.Value = replaced;
}
return node;
case JTokenType.Array:
var arr = (JArray)node;
for (int i = 0; i < arr.Count; i++)
{
var child = arr[i];
var replaced = Walk(child, currentIndex, operations, idToIndex, resultsByIndex, outcomes);
if (!ReferenceEquals(replaced, child))
arr[i] = replaced;
}
return node;
case JTokenType.String:
return TransformString((string)node, node, currentIndex, operations, idToIndex, resultsByIndex, outcomes);
default:
return node;
}
}
private static JToken TransformString(string s, JToken original, int currentIndex,
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex,
JToken[] resultsByIndex, IReadOnlyList<BatchOperationResult> outcomes)
{
if (string.IsNullOrEmpty(s) || s[0] != '$')
return original; // not a reference
// "$$..." escapes a literal '$'. Only substitute when actually resolving; validation
// has nothing to check for a literal.
if (s.Length >= 2 && s[1] == '$')
return resultsByIndex == null ? original : (JToken)new JValue(s.Substring(1));
var rest = s.Substring(1);
var dot = rest.IndexOf('.');
var selector = dot < 0 ? rest : rest.Substring(0, dot);
var path = dot < 0 ? null : rest.Substring(dot + 1);
if (string.IsNullOrEmpty(selector))
throw new ArgumentException(
$"Invalid reference '{s}': missing operation selector after '$'. " +
"Use \"$<id-or-index>.<field>\", or \"$$\" for a literal '$'.");
var targetIndex = ResolveSelector(selector, operations, idToIndex);
if (targetIndex < 0)
throw new ArgumentException(
$"Reference '{s}' points to unknown operation '{selector}'. " +
"A reference must name an earlier operation by its id or 0-based index.");
if (targetIndex >= currentIndex)
throw new ArgumentException(
$"Reference '{s}' is a forward reference to operation '{selector}' (index {targetIndex}); " +
"only backward references to earlier operations are allowed.");
// Validate mode: topology is fine, and there is no result to substitute yet.
if (resultsByIndex == null)
return original;
// A failed or skipped operation has no result to reference — substituting null silently
// would feed a bogus value into this op's parameters. Error explicitly instead. In the
// real Execute() loop only Success=false outcomes are observable here (under
// on_error=continue — earlier failures don't stop the batch); Skipped entries are
// appended AFTER the loop, so the "was skipped" wording is defensive, reachable only
// when the resolver is driven directly (unit tests / future callers).
var outcome = outcomes != null && targetIndex < outcomes.Count ? outcomes[targetIndex] : null;
if (outcome != null && !outcome.Success)
{
var reason = outcome.Skipped == true ? "was skipped" : "failed";
throw new ArgumentException(
$"Reference '{s}' cannot be resolved: operation '{selector}' {reason}; " +
"its result cannot be referenced.");
}
// Distinct from failure: the op SUCCEEDED but produced nothing to reference (void/null
// result, or a result that could not be captured as a token).
var opResult = resultsByIndex[targetIndex];
if (opResult == null || opResult.Type == JTokenType.Null)
throw new ArgumentException(
$"Reference '{s}' cannot be resolved: operation '{selector}' returned no result.");
var picked = string.IsNullOrEmpty(path) ? opResult : opResult.SelectToken(path);
if (picked == null)
throw new ArgumentException(
$"Reference '{s}' did not resolve: operation '{selector}' result has no field '{path}'.");
return picked.DeepClone();
}
/// <summary>
/// Resolve a selector to an operation index: an id match takes precedence over a numeric
/// index. Returns -1 when the selector names no operation.
/// </summary>
private static int ResolveSelector(string selector,
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex)
{
if (idToIndex != null && idToIndex.TryGetValue(selector, out var byId))
return byId;
if (int.TryParse(selector, out var idx) && idx >= 0 && idx < operations.Count)
return idx;
return -1;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bf4505eeb7b343d1969a2e1b8a9ab350
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace Unity.Pipeline.Editor.Commands.Batch
{
/// <summary>
/// Result envelope for the <c>batch</c> command (AUTHAPI-27). Carries a per-operation result in
/// input order, how many ops were applied, whether a transactional rollback occurred, and the
/// Editor Undo group the whole batch collapsed into.
/// </summary>
sealed class BatchResult
{
/// <summary>Per-operation results, in the same order as the input operations.</summary>
[JsonProperty("results")]
public List<BatchOperationResult> Results { get; set; } = new List<BatchOperationResult>();
/// <summary>Number of operations that executed successfully (before any rollback).</summary>
[JsonProperty("applied")]
public int Applied { get; set; }
/// <summary>True when a transactional rollback undid every applied operation.</summary>
[JsonProperty("reverted")]
public bool Reverted { get; set; }
/// <summary>
/// Whether the batch ran transactionally (all ops in one Undo group, revert-on-failure).
/// Forced false when <c>on_error=continue</c>.
/// </summary>
[JsonProperty("transactional")]
public bool Transactional { get; set; }
/// <summary>The effective error policy: <c>abort</c> or <c>continue</c>.</summary>
[JsonProperty("onError")]
public string OnError { get; set; }
/// <summary>
/// Batch-level abort reason for stops not attributable to a single op's failure:
/// "batch time budget exceeded" (the cooperative <c>time_budget_ms</c> ran out between ops)
/// or "batch canceled" (cooperative job cancellation). Null otherwise.
/// </summary>
[JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)]
public string Error { get; set; }
/// <summary>The Editor Undo group index the whole batch collapsed into (execution only).</summary>
[JsonProperty("undoGroup", NullValueHandling = NullValueHandling.Ignore)]
public int? UndoGroup { get; set; }
/// <summary>True when this was a dry run (validation only, zero mutation).</summary>
[JsonProperty("dryRun", NullValueHandling = NullValueHandling.Ignore)]
public bool? DryRun { get; set; }
/// <summary>Dry run only: whether every operation validated without error.</summary>
[JsonProperty("valid", NullValueHandling = NullValueHandling.Ignore)]
public bool? Valid { get; set; }
}
/// <summary>Result of a single operation within a <see cref="BatchResult"/>.</summary>
sealed class BatchOperationResult
{
/// <summary>The operation's id, echoed back when supplied.</summary>
[JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)]
public string Id { get; set; }
/// <summary>The command that was (or would be) run.</summary>
[JsonProperty("command")]
public string Command { get; set; }
/// <summary>
/// Whether the operation succeeded. On a dry run this is "would run" (validation passed).
/// </summary>
[JsonProperty("success")]
public bool Success { get; set; }
/// <summary>
/// The command's result, shaped identically to running it standalone (optionally projected
/// via <c>result_fields</c>, or replaced by a truncation marker for oversized results).
/// </summary>
[JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)]
public object Result { get; set; }
/// <summary>Error message when the operation failed (or failed dry-run validation).</summary>
[JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)]
public string Error { get; set; }
/// <summary>True for operations not attempted because an earlier op aborted the batch.</summary>
[JsonProperty("skipped", NullValueHandling = NullValueHandling.Ignore)]
public bool? Skipped { get; set; }
/// <summary>True when the result was replaced by a truncation marker (payload economy).</summary>
[JsonProperty("resultTruncated", NullValueHandling = NullValueHandling.Ignore)]
public bool? ResultTruncated { get; set; }
/// <summary>
/// Whether a transactional rollback can revert this operation's mutations. False for commands
/// that mutate state outside the Editor Undo system (asset/file/settings writes) — those only
/// run with <c>transactional=false</c>. Unset when the command never resolved.
/// </summary>
[JsonProperty("revertible", NullValueHandling = NullValueHandling.Ignore)]
public bool? Revertible { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f14af31d9bca47ee9536e91221678a86
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: