diff --git a/brokkr-command/Brokkr.Command.csproj b/brokkr-command/Brokkr.Command.csproj new file mode 100644 index 0000000..5e322cd --- /dev/null +++ b/brokkr-command/Brokkr.Command.csproj @@ -0,0 +1,16 @@ + + + Exe + net10.0 + enable + enable + ..\..\CultLib + + + + + + + + + diff --git a/brokkr-command/Program.cs b/brokkr-command/Program.cs new file mode 100644 index 0000000..e3f2185 --- /dev/null +++ b/brokkr-command/Program.cs @@ -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> 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 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 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 WriteIntentAsync(CultCache cache, string action, Dictionary 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(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 Parse(string[] args) + { + var values = new Dictionary(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 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."); +} diff --git a/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj b/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj index d3f6e46..816b32f 100644 --- a/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj +++ b/brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/brokkr-contracts.Tests/BrokkrCommandCliTests.cs b/brokkr-contracts.Tests/BrokkrCommandCliTests.cs new file mode 100644 index 0000000..f400d87 --- /dev/null +++ b/brokkr-contracts.Tests/BrokkrCommandCliTests.cs @@ -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().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(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()); + Assert.Equal(executed.commandId, receipt.commandId); + } +} diff --git a/brokkr-contracts.Tests/EditorProbe.cs b/brokkr-contracts.Tests/EditorProbe.cs index 133c504..6e00a78 100644 --- a/brokkr-contracts.Tests/EditorProbe.cs +++ b/brokkr-contracts.Tests/EditorProbe.cs @@ -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 Executed { get; } = new(); + internal List Executed { get; } = new(); + + // What the "editor" answers with. Real Unity answers accepted or failed per the executor. + internal Func 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); } diff --git a/brokkr-contracts.Tests/EditorScenarioTests.cs b/brokkr-contracts.Tests/EditorScenarioTests.cs index c34ef44..3294d5b 100644 --- a/brokkr-contracts.Tests/EditorScenarioTests.cs +++ b/brokkr-contracts.Tests/EditorScenarioTests.cs @@ -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().ToArray(); Assert.Equal(ids.OrderBy(id => id), receipts.Select(receipt => receipt.commandId).OrderBy(id => id)); diff --git a/docs/provider-contract.md b/docs/provider-contract.md index f1a3dd1..e8cd2ff 100644 --- a/docs/provider-contract.md +++ b/docs/provider-contract.md @@ -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. diff --git a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs index 49204bf..42a2d47 100644 --- a/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs +++ b/surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs @@ -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(); } @@ -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]