A3: brokkr-command, the caller CLI, with every intent field and a real CultLib root

Re-landed from the donor as a fresh file (read with git show, not merged).
Changes from the donor: the CultLibRoot default is ..\..\CultLib instead of a
directory that does not exist; every field of brokkr.unity.command_intent.v0
has a --kebab option (the donor mapped six of seventeen), and unknown options
are refused before anything is written; keys come from BrokkrCommandLedger;
exit is 0 accepted, 1 other receipt, 2 usage error or timeout; readHost opens
the store read-only and prints the agent-commands flag; a timeout says when
the editor reports the sink off. The intent gains the capture fields at keys
13-16, which the CLI needs and A4's executor will read.

Tests run the built CLI as a process against the editor probe: field mapping,
fresh id per invocation, exit code from the receipt, timeout leaves only the
intent, unknown option writes nothing, and every action name reaches the
editor once with one receipt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 14:26:23 +02:00
commit 1e46483fe5
8 changed files with 390 additions and 6 deletions

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CultLibRoot Condition="'$(CultLibRoot)' == ''">..\..\CultLib</CultLibRoot>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\brokkr-contracts\Brokkr.Contracts.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" />
</ItemGroup>
</Project>

158
brokkr-command/Program.cs Normal file
View file

@ -0,0 +1,158 @@
// 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)
};
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($"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.");
}

View file

@ -9,6 +9,7 @@
<ItemGroup>
<ProjectReference Include="..\brokkr-contracts\Brokkr.Contracts.csproj" />
<ProjectReference Include="..\brokkr-command\Brokkr.Command.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Mesh\GameCult.Mesh.csproj" />
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Networking\GameCult.Networking.csproj" />

View file

@ -0,0 +1,188 @@
using System.Diagnostics;
using GameCult.Brokkr;
using GameCult.Caching;
namespace Brokkr.Contracts.Tests;
// brokkr-command as a process, against the real directory store and the editor probe.
public sealed class BrokkrCommandCliTests
{
private static readonly string CliDll = Path.Combine(AppContext.BaseDirectory, "Brokkr.Command.dll");
private static async Task<(int Exit, string Out, string Error)> RunAsync(params string[] args)
{
var start = new ProcessStartInfo("dotnet")
{
RedirectStandardOutput = true,
RedirectStandardError = true
};
start.ArgumentList.Add(CliDll);
foreach (var arg in args) start.ArgumentList.Add(arg);
using var process = Process.Start(start)!;
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, await stdout, await stderr);
}
private static BrokkrUnityCommand[] StoredIntents(ScratchProject project)
{
using var audit = project.OpenCache();
return audit.AllStoredDocuments.Select(entry => entry.Document).OfType<BrokkrUnityCommand>().ToArray();
}
[Fact]
public async Task EveryIntentFieldReachesTheStore()
{
using var project = new ScratchProject();
var (exit, output, _) = await RunAsync(
"--unity-cache", project.StorePath, "--action", "setGameObjectTransform", "--command-id", "all-fields",
"--wait-ms", "0",
"--target-object-id", "obj-1", "--name", "Widget", "--component-type", "Light", "--property-path", "m_Range",
"--value", "12", "--asset-path", "Assets/x.prefab", "--parent-object-id", "parent-1",
"--local-position", "1,2,3", "--local-euler-angles", "4,5,6", "--local-scale", "7,8,9",
"--view", "scene", "--output", "artifacts/shot.png", "--width", "640", "--height", "480");
Assert.Equal(0, exit);
Assert.Equal("all-fields", output.Trim());
var intent = Assert.Single(StoredIntents(project));
Assert.Equal(
new[]
{
"all-fields", "setGameObjectTransform", "obj-1", "Widget", "Light", "m_Range", "12", "Assets/x.prefab",
"parent-1", "1,2,3", "4,5,6", "7,8,9", "scene", "artifacts/shot.png"
},
new[]
{
intent.commandId, intent.action, intent.targetObjectId, intent.name, intent.componentType,
intent.propertyPath, intent.value, intent.assetPath, intent.parentObjectId, intent.localPosition,
intent.localEulerAngles, intent.localScale, intent.viewKind, intent.outputPath
});
Assert.Equal((640, 480), (intent.width, intent.height));
}
[Fact]
public async Task EveryInvocationMintsAFreshId()
{
using var project = new ScratchProject();
var first = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--wait-ms", "0");
var second = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--wait-ms", "0");
Assert.Equal(0, first.Exit);
Assert.Equal(0, second.Exit);
Assert.NotEqual(first.Out.Trim(), second.Out.Trim());
Assert.Equal(2, StoredIntents(project).Length);
}
[Fact]
public async Task ExitCodeIsTheReceiptsVerdict()
{
using var project = new ScratchProject();
using var editor = await EditorProbe.StartAsync(project.StorePath);
editor.StatusFor = command => command.action == "createGameObject" ? "failed" : "accepted";
using var stop = new CancellationTokenSource();
var loop = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
await editor.TickAsync();
await Task.Delay(50);
}
});
var accepted = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--wait-ms", "20000");
var failed = await RunAsync("--unity-cache", project.StorePath, "--action", "createGameObject", "--wait-ms", "20000");
stop.Cancel();
await loop;
Assert.Equal(0, accepted.Exit);
Assert.StartsWith("accepted:", accepted.Out);
Assert.Equal(1, failed.Exit);
Assert.StartsWith("failed:", failed.Out);
}
[Fact]
public async Task NoEditorAnswerIsATimeoutAndLeavesOnlyTheIntent()
{
using var project = new ScratchProject();
var result = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--wait-ms", "600");
Assert.Equal(2, result.Exit);
Assert.Contains("Timed out", result.Error);
using var audit = project.OpenCache();
var documents = audit.AllStoredDocuments.ToArray();
Assert.Single(documents);
Assert.IsType<BrokkrUnityCommand>(documents[0].Document);
}
[Fact]
public async Task UnknownOptionIsRefusedBeforeAnythingIsWritten()
{
using var project = new ScratchProject();
var result = await RunAsync("--unity-cache", project.StorePath, "--action", "refreshAssets", "--bogus", "1");
Assert.Equal(2, result.Exit);
Assert.Contains("Unknown option --bogus", result.Error);
Assert.False(Directory.Exists(Path.GetDirectoryName(project.StorePath)));
}
[Fact]
public async Task ReadHostShowsWhetherTheSinkIsEnabled()
{
using var project = new ScratchProject();
using var editor = await EditorProbe.StartAsync(project.StorePath);
await editor.TickAsync();
var result = await RunAsync("--unity-cache", project.StorePath, "--action", "readHost");
Assert.Equal(0, result.Exit);
Assert.Contains("agent-commands: on", result.Out);
Assert.Contains("project: probe", result.Out);
}
[Theory]
[InlineData("createGameObject")]
[InlineData("attachComponent")]
[InlineData("setGameObjectTransform")]
[InlineData("setGameObjectActive")]
[InlineData("setGameObjectParent")]
[InlineData("setComponentProperty")]
[InlineData("instantiatePrefab")]
[InlineData("createPrefabVariant")]
[InlineData("assignMaterial")]
[InlineData("createScriptableObject")]
[InlineData("refreshAssets")]
[InlineData("setEditorPlayState")]
[InlineData("setEditorPaused")]
[InlineData("captureEditorView")]
public async Task EveryActionNameReachesTheEditorOnceWithOneReceipt(string action)
{
using var project = new ScratchProject();
using var editor = await EditorProbe.StartAsync(project.StorePath);
using var stop = new CancellationTokenSource();
var loop = Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
await editor.TickAsync();
await Task.Delay(50);
}
});
var result = await RunAsync("--unity-cache", project.StorePath, "--action", action, "--wait-ms", "20000");
await Task.Delay(200);
stop.Cancel();
await loop;
Assert.Equal(0, result.Exit);
var executed = Assert.Single(editor.Executed);
Assert.Equal(action, executed.action);
using var audit = project.OpenCache();
var receipt = Assert.Single(audit.AllStoredDocuments.Select(entry => entry.Document).OfType<BrokkrUnityCommandReceipt>());
Assert.Equal(executed.commandId, receipt.commandId);
}
}

View file

@ -9,13 +9,16 @@ namespace Brokkr.Contracts.Tests;
// The scenario harness: an editor without Unity. It opens the store exactly as BrokkrCultMeshMirror does (a CultMesh
// node with StartServer=false over a directory store and durable shard logs) and runs one service tick in the
// service order: pull, drain pending intents through the ledger, receipt, soft flush, host snapshot, soft flush.
// "Executing" an intent only records its id, so the scenarios measure delivery and once-only execution, not Unity.
// "Executing" an intent only records it, so the scenarios measure delivery and once-only execution, not Unity.
internal sealed class EditorProbe : IDisposable
{
private readonly CultMeshNode node;
private readonly BrokkrCommandLedger ledger;
internal List<string> Executed { get; } = new();
internal List<BrokkrUnityCommand> Executed { get; } = new();
// What the "editor" answers with. Real Unity answers accepted or failed per the executor.
internal Func<BrokkrUnityCommand, string> StatusFor { get; set; } = _ => "accepted";
private EditorProbe(CultMeshNode node)
{
@ -41,13 +44,13 @@ internal sealed class EditorProbe : IDisposable
await ledger.PullAsync();
while (ledger.TryNextPending(out var command))
{
Executed.Add(command.commandId);
Executed.Add(command);
await node.Database.PutAsync(
BrokkrCommandLedger.ReceiptKey(command.commandId),
new BrokkrUnityCommandReceipt
{
commandId = command.commandId,
status = "accepted",
status = StatusFor(command),
message = "probe",
observedAt = DateTime.UtcNow.ToString("O")
});
@ -56,7 +59,7 @@ internal sealed class EditorProbe : IDisposable
await node.Database.PutAsync(
new CultRecordKey("unity/host/current"),
new BrokkrHostSnapshot { observedAt = DateTime.UtcNow.ToString("O"), projectPath = "probe" });
new BrokkrHostSnapshot { observedAt = DateTime.UtcNow.ToString("O"), projectPath = "probe", agentCommandsEnabled = true });
await node.FlushAsync(soft: true);
}

View file

@ -38,7 +38,7 @@ public sealed class EditorScenarioTests
await editorLoop;
Assert.Equal(commands, editor.Executed.Count);
Assert.Equal(ids.OrderBy(id => id), editor.Executed.OrderBy(id => id));
Assert.Equal(ids.OrderBy(id => id), editor.Executed.Select(command => command.commandId).OrderBy(id => id));
using var audit = project.OpenCache();
var receipts = audit.AllStoredDocuments.Select(entry => entry.Document).OfType<BrokkrUnityCommandReceipt>().ToArray();
Assert.Equal(ids.OrderBy(id => id), receipts.Select(receipt => receipt.commandId).OrderBy(id => id));

View file

@ -72,9 +72,17 @@ Primary sync documents:
Operational commands:
- `dotnet run --project brokkr-command/Brokkr.Command.csproj -- --unity-cache .brokkr/unity-editor.ccmp --action readHost`
- `dotnet run --project brokkr-command/Brokkr.Command.csproj -- --unity-cache .brokkr/unity-editor.ccmp --action createGameObject --name Marker --local-position 0,1,0`
- `brokkr-daemon sync-once --unity-cache .brokkr/unity-editor.ccmp --blender-cache .brokkr/blender-editor.ccmp`
- `brokkr-daemon sync-loop --unity-cache .brokkr/unity-editor.ccmp --blender-cache .brokkr/blender-editor.ccmp --interval-ms 500`
`brokkr-command` is a caller. Each invocation writes one intent through the
directory store (every intent field has a `--kebab-case` option; `--view` and
`--output` fill the capture fields), waits for the receipt, and exits 0 for
`accepted`, 1 for any other status, and 2 for a usage error or a timeout. It
never writes receipts, host snapshots or the agent-commands flag.
`sync-loop` owns scheduling only. It repeatedly invokes the same sync decision
primitive as `sync-once`, writes command intents and sync receipts through the
mirrors, and prints per-pass telemetry to stdout.

View file

@ -57,6 +57,11 @@ namespace GameCult.Brokkr
[Key(10)] public string localPosition = "";
[Key(11)] public string localEulerAngles = "";
[Key(12)] public string localScale = "";
// Editor view capture (A4): viewKind is scene or game; outputPath is relative to the project root.
[Key(13)] public string viewKind = "";
[Key(14)] public string outputPath = "";
[Key(15)] public int width;
[Key(16)] public int height;
[Key(13)] public string[] materialNames = Array.Empty<string>();
}
@ -112,6 +117,11 @@ namespace GameCult.Brokkr
[Key(10)] public string localPosition = "";
[Key(11)] public string localEulerAngles = "";
[Key(12)] public string localScale = "";
// Editor view capture (A4): viewKind is scene or game; outputPath is relative to the project root.
[Key(13)] public string viewKind = "";
[Key(14)] public string outputPath = "";
[Key(15)] public int width;
[Key(16)] public int height;
}
[MessagePackObject]