- The enable commit is conditional (ExpectUnchanged) and lives in the new Runtime BrokkrCommandStore, with the pull-recompute-retry loop in BrokkrCommandDrain.EnableAsync, so the tests exercise the shipped code. An intent landing between the pull and the commit is expired, not run; eight refusals in a row fail the enable loudly with the sink off. The probe is now a thin fault-injecting wrapper over the real store, not a copy of it. - Every editor write to the command lane is one cache commit; durable shard logs are off (no server, no reader). - No receipt is pruned before ten minutes, whatever the count, and the CLI refuses a wait over five minutes. Fixed the comments that claimed otherwise. - Pending and Prunable count a document only under the key its own id names. - README and snapshot comment: the token stops stale EditorPrefs, it does not authenticate writers; a restored or sync-conflict copy of the editor's own store can rerun answered intents. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
207 lines
11 KiB
C#
207 lines
11 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;
|
|
|
|
// Exit codes: 0 the receipt was accepted (or readHost / an unwaited write succeeded); 1 the receipt says the editor
|
|
// did not accept it (failed, denied, expired, interrupted); 2 anything else: bad usage, a timeout, an unreadable store.
|
|
try
|
|
{
|
|
return await Cli.RunAsync(args);
|
|
}
|
|
catch (Exception error) when (error is ArgumentException or TimeoutException or InvalidOperationException)
|
|
{
|
|
Console.Error.WriteLine(error.Message);
|
|
return 2;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
Console.Error.WriteLine($"brokkr-command failed: {error.GetType().Name}: {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 OpenAsync(cachePath, readHost);
|
|
return readHost ? await ReadHostAsync(cache) : await WriteIntentAsync(cache, action, options);
|
|
}
|
|
|
|
// An empty file, a legacy single-file store or a damaged shard fails inside CultCache with whatever exception the
|
|
// decoder throws; the caller gets one message that names the path.
|
|
private static async Task<CultCache> OpenAsync(string cachePath, bool readOnly)
|
|
{
|
|
try
|
|
{
|
|
return await CultCacheMessagePack.OpenAsync(cachePath, new CultCacheOpenOptions
|
|
{
|
|
UseDirectoryStore = true,
|
|
ReadOnly = readOnly
|
|
});
|
|
}
|
|
catch (Exception error) when (error is not ArgumentException)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Brokkr store '{cachePath}' cannot be opened ({error.GetType().Name}: {error.Message}). An empty or legacy single-file store, or one another process holds, fails here; it must be a directory store.",
|
|
error);
|
|
}
|
|
}
|
|
|
|
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 waitMilliseconds = options.TryGetValue("--wait-ms", out var wait) ? Integer("--wait-ms", wait) : 10000;
|
|
// The editor keeps every receipt for at least BrokkrCommandDrain.ReceiptFloor, which is what makes a wait this
|
|
// long safe: the receipt a caller waits on cannot be pruned before it reads it.
|
|
if (waitMilliseconds > BrokkrCommandDrain.MaxCallerWait.TotalMilliseconds)
|
|
throw new ArgumentException($"--wait-ms may not exceed {BrokkrCommandDrain.MaxCallerWait.TotalMilliseconds:F0} (the editor keeps receipts for at least {BrokkrCommandDrain.ReceiptFloor.TotalMinutes:F0} minutes).");
|
|
// 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; one that already has a receipt is refused, because that receipt answered an
|
|
// earlier act and would be reported as the answer to this one. The check and the write are two steps, so two
|
|
// callers naming the same new id in the same instant can still collide; ids are minted, and that is a caller defect.
|
|
var named = options.TryGetValue("--command-id", out var commandId) && !string.IsNullOrWhiteSpace(commandId);
|
|
if (named)
|
|
{
|
|
await cache.PullAllBackingStoresAsync();
|
|
if (cache.TryGet(BrokkrCommandLedger.ReceiptKey(commandId!), out BrokkrUnityCommandReceipt? existing) && existing != null)
|
|
throw new ArgumentException(
|
|
$"Command id '{commandId}' already has a receipt ({existing.status}). Command ids are single-use; omit --command-id to get a fresh one.");
|
|
}
|
|
|
|
var command = new BrokkrUnityCommand
|
|
{
|
|
commandId = named ? 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();
|
|
|
|
|
|
if (waitMilliseconds <= 0)
|
|
{
|
|
Console.WriteLine(command.commandId);
|
|
return 0;
|
|
}
|
|
|
|
var receiptKey = BrokkrCommandLedger.ReceiptKey(command.commandId);
|
|
var deadline = DateTimeOffset.UtcNow.AddMilliseconds(waitMilliseconds);
|
|
var started = false;
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
await Task.Delay(100);
|
|
await cache.PullAllBackingStoresAsync();
|
|
if (!cache.TryGet(receiptKey, out BrokkrUnityCommandReceipt? receipt) || receipt == null)
|
|
continue;
|
|
|
|
// Attempted is the editor's marker that it has started; the verdict is still to come.
|
|
if (string.Equals(receipt.status, BrokkrCommandDrain.AttemptedStatus, StringComparison.Ordinal))
|
|
{
|
|
started = true;
|
|
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."
|
|
: "";
|
|
if (started)
|
|
why = " The editor started this intent and has not recorded a result; it will not run it again.";
|
|
throw new TimeoutException(
|
|
$"Timed out waiting for Brokkr receipt '{receiptKey.Value}'; the intent is still in the store: a running editor with the sink on will run it, but if the operator enables the sink after now it expires unrun.{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.");
|
|
}
|