Brokkr/brokkr-contracts.Tests/EditorProbe.cs
Meta Vens 121fa329e2 Soul pass 3: conditional enable in Runtime, receipt floor, one write path
- 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>
2026-09-29 17:52:34 +02:00

206 lines
8.4 KiB
C#

using GameCult.Brokkr;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Mesh;
using GameCult.Networking;
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, no shard log) and runs one service tick in the
// service order: pull, drain pending intents through BrokkrCommandDrain (the same class the service uses), host
// snapshot, soft flush. "Executing" an intent only records it, so the scenarios measure delivery and once-only
// execution, not Unity. It is also a thin IBrokkrCommandStore in front of the real BrokkrCommandStore, with knobs to
// make writes fail and to land a writer between the enable's pull and its commit.
internal sealed class EditorProbe : IDisposable, IBrokkrCommandStore
{
private readonly CultMeshNode node;
private readonly BrokkrCommandLedger ledger;
private readonly BrokkrCommandStore store;
private readonly BrokkrCommandPolicy policy = new("probe", BrokkrCommandPolicy.DefaultAllowedActions.Concat(new[]
{
"createPrefabVariant", "createScriptableObject"
}));
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";
// The token the operator's enable left in EditorPrefs; null while the sink is off. A probe starts with the sink
// enabled, as an editor whose operator enabled it earlier and whose store already carries the marker.
internal string? SinkToken { get; private set; } = "probe-token";
internal bool AgentCommandsEnabled => SinkToken != null;
// Runs inside the "executor", after the intent is recorded.
internal Action<BrokkrUnityCommand>? OnExecute { get; set; }
// Makes a receipt put or a flush throw, as a full disk or a lease timeout would.
internal Func<BrokkrUnityCommandReceipt, bool>? FailReceiptPut { get; set; }
internal Func<bool>? FailFlush { get; set; }
// Runs after the enable has pulled and computed, just before its conditional commit; a writer landing an intent here is the race.
internal Action<int>? BeforeEnableCommit { get; set; }
internal int EnableAttempts { get; private set; }
// Added to the drain's clock, to age receipts past the retention floor.
internal TimeSpan ClockSkew { get; set; }
// Flushes the drain asked for; each is an fsync in the real store.
internal int Flushes { get; private set; }
private EditorProbe(CultMeshNode node)
{
this.node = node;
ledger = new BrokkrCommandLedger(node.Cache);
store = new BrokkrCommandStore(node.Cache);
}
// enabledInStore false is an editor whose EditorPrefs still hold a token, opening a store that never saw that
// enable: a re-clone, or a copied .brokkr.
internal static async Task<EditorProbe> StartAsync(string cachePath, bool enabledInStore = true)
{
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions
{
StartServer = false,
CacheOptions = new CultCacheOpenOptions { UseDirectoryStore = true },
EnableDurableShardLogs = false,
DatabaseOptions = new CultNetDatabaseOptions { RuntimeId = "brokkr-unity-editor" }
});
var probe = new EditorProbe(node);
// A restart with the token persisted finds its marker; a first start is the operator's enable.
if (enabledInStore && !probe.ledger.SinkAuthorized(probe.SinkToken))
await NewDrain(probe).EnableAsync(probe.SinkToken!);
return probe;
}
private static BrokkrCommandDrain NewDrain(EditorProbe probe) =>
new(
probe.ledger,
probe.policy,
command =>
{
probe.Executed.Add(command);
probe.OnExecute?.Invoke(command);
return new BrokkrUnityCommandReceipt
{
commandId = command.commandId,
status = probe.StatusFor(command),
message = "probe",
observedAt = DateTime.UtcNow.ToString("O")
};
},
probe,
() => DateTimeOffset.UtcNow + probe.ClockSkew);
// The operator ticks the toggle off.
internal void DisableSink() => SinkToken = null;
// The operator ticks it on: a new token, every waiting intent expired, one flush. Returns how many expired.
internal async Task<int> EnableSinkAsync()
{
await ledger.PullAsync();
EnableAttempts = 0;
var token = Guid.NewGuid().ToString("N");
var expired = await NewDrain(this).EnableAsync(token);
SinkToken = token;
return expired;
}
// Returns how many intents the drain answered (0 with the sink off).
internal async Task<int> TickAsync()
{
await ledger.PullAsync();
var handled = 0;
if (SinkToken != null)
{
handled = await NewDrain(this).DrainAsync(SinkToken);
if (handled == BrokkrCommandDrain.NotAuthorized)
{
SinkToken = null;
handled = 0;
}
}
await node.Database.PutAsync(
new CultRecordKey("unity/host/current"),
new BrokkrHostSnapshot
{
observedAt = DateTime.UtcNow.ToString("O"),
projectPath = "probe",
agentCommandsEnabled = AgentCommandsEnabled
});
await node.FlushAsync(soft: true);
return handled;
}
// The real store with failure and interleaving knobs in front of it; no store logic lives here.
public Task PutReceiptAsync(BrokkrUnityCommandReceipt receipt)
{
if (FailReceiptPut?.Invoke(receipt) == true)
throw new IOException("receipt write failed");
return store.PutReceiptAsync(receipt);
}
public Task DeleteReceiptAsync(string commandId) => store.DeleteReceiptAsync(commandId);
public async Task<bool> TryCommitEnableAsync(BrokkrUnityCommandReceipt[] expired, BrokkrSinkEnabled marker)
{
EnableAttempts++;
BeforeEnableCommit?.Invoke(EnableAttempts);
return await store.TryCommitEnableAsync(expired, marker);
}
public Task DeleteAnsweredAsync(string[] commandIds) => store.DeleteAnsweredAsync(commandIds);
public Task FlushAsync()
{
if (FailFlush?.Invoke() == true)
throw new IOException("flush failed");
Flushes++;
return store.FlushAsync();
}
public void Dispose() => node.Dispose();
}
// A store on disk laid out the way a Unity project lays it out: <project>/.brokkr/unity-editor.ccmp.
internal sealed class ScratchProject : IDisposable
{
internal string Root { get; } = Path.Combine(Path.GetTempPath(), "brokkr-tests-" + Guid.NewGuid().ToString("N"));
internal string StorePath => Path.Combine(Root, ".brokkr", "unity-editor.ccmp");
// What brokkr-command does: open the same directory store, write one intent, flush.
internal async Task WriteCommandAsync(string commandId, string action = "refreshAssets")
{
using var writer = OpenCache();
await writer.AddAsync(
new BrokkrUnityCommand { commandId = commandId, action = action },
new CultRecordHandle<BrokkrUnityCommand>(BrokkrCommandLedger.CommandKey(commandId)));
await writer.FlushAsync();
}
// Many intents in one open and one flush, for the flood scenarios.
internal async Task WriteCommandsAsync(IEnumerable<string> commandIds, string action = "refreshAssets")
{
using var writer = OpenCache();
foreach (var commandId in commandIds)
await writer.AddAsync(
new BrokkrUnityCommand { commandId = commandId, action = action },
new CultRecordHandle<BrokkrUnityCommand>(BrokkrCommandLedger.CommandKey(commandId)));
await writer.FlushAsync();
}
internal CultCache OpenCache()
{
Directory.CreateDirectory(Path.GetDirectoryName(StorePath)!);
return CultCacheMessagePack.Create(StorePath, new CultCacheOpenOptions { UseDirectoryStore = true });
}
public void Dispose()
{
try { Directory.Delete(Root, recursive: true); } catch (IOException) { }
}
}