BrokkrCommandPolicy (Runtime, pure, tested outside Unity) is consulted by BrokkrEditorService before every BrokkrUnityCommandExecutor.Execute; it is the only call path into the executor. The per-project allowed-action list defaults to scene mutation plus lifecycle; createScriptableObject, createPrefabVariant and saveScene are shipped denied (parked Q4 = A), and an action the list does not name is denied. A denial gets a receipt with status denied and a reason. The window's buttons write intents like any other caller, so operator and agent share the gate. Re-landed from the donor as fresh code: refreshAssets, setEditorPlayState, setEditorPaused, captureEditorView, and the play-state fields on the host snapshot (keys 14-17). The donor's captureEditorView wrote to any rooted outputPath; it now resolves the path through one function that accepts only a plain relative .png path under <project>/.brokkr/captures and refuses drive letters, rooted or UNC paths, stream suffixes and any .. segment on every host OS, and dimensions are bounded to 8192. requestedBy joins the intent (key 17) and is echoed on the receipt (key 6). saveScene is new. The window gains an Allowed Actions field bound to a per-project preference. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
160 lines
8 KiB
C#
160 lines
8 KiB
C#
// brokkr-command: a caller. It writes one Unity command intent into the project's directory store and reads the
|
|
// receipt the editor service answers with. It never writes receipts, host snapshots or the agent-commands flag,
|
|
// and it never opens the store as a single file: the editor owns execution and its own state.
|
|
using System.Globalization;
|
|
using GameCult.Brokkr;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
|
|
try
|
|
{
|
|
return await Cli.RunAsync(args);
|
|
}
|
|
catch (Exception error) when (error is ArgumentException or TimeoutException or InvalidOperationException)
|
|
{
|
|
Console.Error.WriteLine(error.Message);
|
|
return 2;
|
|
}
|
|
|
|
internal static class Cli
|
|
{
|
|
// Every writable field of brokkr.unity.command_intent.v0 except the schema tag, the id and the action, which
|
|
// have their own options.
|
|
private static readonly Dictionary<string, Action<BrokkrUnityCommand, string>> Fields =
|
|
new(StringComparer.Ordinal)
|
|
{
|
|
["--target-object-id"] = (command, value) => command.targetObjectId = value,
|
|
["--name"] = (command, value) => command.name = value,
|
|
["--component-type"] = (command, value) => command.componentType = value,
|
|
["--property-path"] = (command, value) => command.propertyPath = value,
|
|
["--value"] = (command, value) => command.value = value,
|
|
["--asset-path"] = (command, value) => command.assetPath = value,
|
|
["--parent-object-id"] = (command, value) => command.parentObjectId = value,
|
|
["--local-position"] = (command, value) => command.localPosition = value,
|
|
["--local-euler-angles"] = (command, value) => command.localEulerAngles = value,
|
|
["--local-scale"] = (command, value) => command.localScale = value,
|
|
["--view"] = (command, value) => command.viewKind = value,
|
|
["--output"] = (command, value) => command.outputPath = value,
|
|
["--width"] = (command, value) => command.width = Integer("--width", value),
|
|
["--height"] = (command, value) => command.height = Integer("--height", value),
|
|
["--requested-by"] = (command, value) => command.requestedBy = value
|
|
};
|
|
|
|
internal static async Task<int> RunAsync(string[] args)
|
|
{
|
|
var options = Parse(args);
|
|
var cachePath = Path.GetFullPath(Required(options, "--unity-cache"));
|
|
var action = Required(options, "--action");
|
|
var readHost = string.Equals(action, "readHost", StringComparison.Ordinal);
|
|
|
|
using var cache = await CultCacheMessagePack.OpenAsync(cachePath, new CultCacheOpenOptions
|
|
{
|
|
UseDirectoryStore = true,
|
|
ReadOnly = readHost
|
|
});
|
|
return readHost ? await ReadHostAsync(cache) : await WriteIntentAsync(cache, action, options);
|
|
}
|
|
|
|
private static async Task<int> ReadHostAsync(CultCache cache)
|
|
{
|
|
await cache.PullAllBackingStoresAsync();
|
|
var host = ReadHostSnapshot(cache)
|
|
?? throw new InvalidOperationException("Brokkr host snapshot 'unity/host/current' is unavailable.");
|
|
Console.WriteLine($"project: {host.projectPath}");
|
|
Console.WriteLine($"observed: {host.observedAt}");
|
|
Console.WriteLine($"scene: {host.activeScenePath}");
|
|
Console.WriteLine($"agent-commands: {(host.agentCommandsEnabled ? "on" : "off")}");
|
|
Console.WriteLine($"state: playing={host.isPlaying} paused={host.isPaused} compiling={host.isCompiling} updating={host.isUpdating}");
|
|
Console.WriteLine($"objects: {host.sceneObjects.Length}");
|
|
foreach (var sceneObject in host.sceneObjects.OrderBy(item => item.path, StringComparer.Ordinal))
|
|
{
|
|
var components = string.Join(", ", sceneObject.components.Select(component => component.typeName));
|
|
Console.WriteLine($"- {sceneObject.path} active={sceneObject.activeSelf} layer={sceneObject.layer} components=[{components}]");
|
|
foreach (var component in sceneObject.components)
|
|
foreach (var property in component.properties.Where(property =>
|
|
!property.path.StartsWith("m_", StringComparison.Ordinal) &&
|
|
!string.IsNullOrWhiteSpace(property.value)))
|
|
Console.WriteLine($" {component.typeName}.{property.path}={property.value}");
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private static async Task<int> WriteIntentAsync(CultCache cache, string action, Dictionary<string, string> options)
|
|
{
|
|
var command = new BrokkrUnityCommand
|
|
{
|
|
// A command id is single-use, so every invocation is a new act with a new id. --command-id exists for
|
|
// callers that need to name their own; reusing an answered id is a caller defect.
|
|
commandId = options.TryGetValue("--command-id", out var commandId) && !string.IsNullOrWhiteSpace(commandId)
|
|
? commandId
|
|
: Guid.NewGuid().ToString("N"),
|
|
action = action
|
|
};
|
|
foreach (var (name, value) in options)
|
|
if (Fields.TryGetValue(name, out var assign))
|
|
assign(command, value);
|
|
|
|
await cache.AddAsync(command, new CultRecordHandle<BrokkrUnityCommand>(BrokkrCommandLedger.CommandKey(command.commandId)));
|
|
await cache.FlushAsync();
|
|
|
|
var waitMilliseconds = options.TryGetValue("--wait-ms", out var wait) ? Integer("--wait-ms", wait) : 10000;
|
|
if (waitMilliseconds <= 0)
|
|
{
|
|
Console.WriteLine(command.commandId);
|
|
return 0;
|
|
}
|
|
|
|
var receiptKey = BrokkrCommandLedger.ReceiptKey(command.commandId);
|
|
var deadline = DateTimeOffset.UtcNow.AddMilliseconds(waitMilliseconds);
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(100);
|
|
await cache.PullAllBackingStoresAsync();
|
|
if (!cache.TryGet(receiptKey, out BrokkrUnityCommandReceipt? receipt) || receipt == null)
|
|
continue;
|
|
|
|
Console.WriteLine($"{receipt.status}: {receipt.message}");
|
|
if (!string.IsNullOrWhiteSpace(receipt.objectId))
|
|
Console.WriteLine(receipt.objectId);
|
|
// The exit code is the receipt's verdict, never the write's success.
|
|
return string.Equals(receipt.status, "accepted", StringComparison.Ordinal) ? 0 : 1;
|
|
}
|
|
|
|
var host = ReadHostSnapshot(cache);
|
|
var why = host is { agentCommandsEnabled: false }
|
|
? " The editor reports agent commands off; the operator enables them in the Brokkr window."
|
|
: "";
|
|
throw new TimeoutException(
|
|
$"Timed out waiting for Brokkr receipt '{receiptKey.Value}'; the intent stays written.{why}");
|
|
}
|
|
|
|
private static BrokkrHostSnapshot? ReadHostSnapshot(CultCache cache) =>
|
|
cache.TryGet(new CultRecordKey("unity/host/current"), out BrokkrHostSnapshot? host) ? host : null;
|
|
|
|
private static Dictionary<string, string> Parse(string[] args)
|
|
{
|
|
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
for (var index = 0; index < args.Length; index += 2)
|
|
{
|
|
if (index + 1 >= args.Length || !args[index].StartsWith("--", StringComparison.Ordinal))
|
|
throw new ArgumentException("Arguments must be --name value pairs.");
|
|
var name = args[index];
|
|
if (name is not ("--unity-cache" or "--action" or "--command-id" or "--wait-ms") && !Fields.ContainsKey(name))
|
|
throw new ArgumentException($"Unknown option {name}.");
|
|
values[name] = args[index + 1];
|
|
}
|
|
|
|
return values;
|
|
}
|
|
|
|
private static string Required(Dictionary<string, string> options, string name) =>
|
|
options.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)
|
|
? value
|
|
: throw new ArgumentException($"{name} is required.");
|
|
|
|
private static int Integer(string name, string value) =>
|
|
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
|
? parsed
|
|
: throw new ArgumentException($"{name} must be an integer.");
|
|
}
|