Publish runtime drain operations surface

This commit is contained in:
Meta Vens 2026-06-13 15:54:02 +01:00
commit 1f88375a7e
9 changed files with 257 additions and 11 deletions

Binary file not shown.

View file

@ -90,6 +90,20 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s
await node.PutCatalogSurfaceAsync(
AetheriaCatalogSurfaceProjector.Build(node.ReadCatalogSnapshot(), now));
var drainStatus = new AetheriaRuntimeCommitDrainStatus
{
RuntimeId = "smoke-runtime",
StatePath = statePath,
LastPollAtUtc = now,
LastAppliedAtUtc = now,
PendingBeforeApply = 1,
CommandsApplied = 1,
AppliedPlayerSettings = 1,
Status = "ok"
};
await node.PutRuntimeCommitDrainStatusAsync(drainStatus);
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(drainStatus));
await node.PutLoadoutTemplateAsync(loadoutKey, new AetheriaLoadoutTemplate
{
Name = "Smoke Aether Runner",
@ -240,6 +254,8 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
var nameFile = await reopened.GetNameFileByLegacyIdAsync(nameFileLegacyId);
var quarantine = await reopened.GetLegacyCatalogQuarantineAsync();
var catalogSurface = await reopened.GetCatalogSurfaceAsync();
var drainStatus = await reopened.GetRuntimeCommitDrainStatusAsync();
var operationsSurface = await reopened.GetOperationsSurfaceAsync();
var playerSettings = await reopened.GetPlayerSettingsAsync();
var loadout = await reopened.GetLoadoutTemplateAsync(loadoutKey);
var runState = await reopened.GetRunStateAsync(runKey);
@ -277,6 +293,13 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
throw new InvalidOperationException("Eve catalog surface did not survive flush/reopen.");
}
if (drainStatus?.RuntimeId != "smoke-runtime" ||
drainStatus.CommandsApplied != 1 ||
operationsSurface?.Surface.Id != AetheriaOperationsSurfaceProjector.SurfaceId)
{
throw new InvalidOperationException("Runtime commit drain status or operations surface did not survive flush/reopen.");
}
if (playerSettings?.ActiveRunKey != runKey.ToString() ||
playerSettings.PlayerName != "Smoke Pilot" ||
!playerSettings.TutorialPassed ||

View file

@ -37,6 +37,7 @@ public static class AetheriaDocumentRegistry
CultNetDocumentBinding.ForDocument<AetheriaRunState>(registry),
CultNetDocumentBinding.ForDocument<AetheriaZoneState>(registry),
CultNetDocumentBinding.ForDocument<AetheriaEntitySnapshot>(registry),
CultNetDocumentBinding.ForDocument<AetheriaRuntimeCommitDrainStatus>(registry),
CultNetDocumentBinding.ForDocument<EveSurfaceState>(registry)
});
}
@ -56,6 +57,7 @@ public static class AetheriaDocumentRegistry
typeof(AetheriaRunState),
typeof(AetheriaZoneState),
typeof(AetheriaEntitySnapshot),
typeof(AetheriaRuntimeCommitDrainStatus),
typeof(EveSurfaceState)
];
}

View file

@ -0,0 +1,80 @@
using Aetheria.State.Documents;
namespace Aetheria.State;
public static class AetheriaOperationsSurfaceProjector
{
public const string SurfaceKey = "eve:surface:aetheria.operations";
public const string SurfaceId = "aetheria.operations";
public static EveSurfaceState Build(AetheriaRuntimeCommitDrainStatus drainStatus, long version = 1)
{
return new EveSurfaceState
{
ProviderId = "aetheria",
ProviderKind = "game.runtime",
Title = "Aetheria Operations",
Version = version,
UpdatedAtUtc = drainStatus.LastPollAtUtc,
Surface = new EveSurface
{
Id = SurfaceId,
Root = Node(
"aetheria.operations.root",
"surface",
[],
Node(
"aetheria.operations.commitDrain",
"card",
[("title", "Runtime Commit Drain")],
Metric("commitDrain.status", "Status", drainStatus.Status),
Metric("commitDrain.pending", "Pending Before Apply", drainStatus.PendingBeforeApply.ToString()),
Metric("commitDrain.applied", "Commands Applied", drainStatus.CommandsApplied.ToString()),
Metric("commitDrain.settings", "Settings", drainStatus.AppliedPlayerSettings.ToString()),
Metric("commitDrain.loadouts", "Loadouts", drainStatus.AppliedLoadoutTemplates.ToString()),
Metric("commitDrain.runs", "Run Checkpoints", drainStatus.AppliedRunCheckpoints.ToString()),
Metric("commitDrain.failures", "Consecutive Failures", drainStatus.ConsecutiveFailures.ToString()),
Row(
"commitDrain.last",
("runtime", drainStatus.RuntimeId),
("lastPoll", drainStatus.LastPollAtUtc),
("lastApplied", drainStatus.LastAppliedAtUtc),
("error", drainStatus.LastError))))
},
Commands =
[
new EveCommandTemplate
{
Command = "aetheria.operations.refresh",
Label = "Refresh",
Transport = "cultmesh"
}
]
};
}
private static EveSurfaceComponent Metric(string id, string label, string value)
{
return Node(id, "metric", [("label", label), ("value", value)]);
}
private static EveSurfaceComponent Row(string id, params (string Key, string Value)[] props)
{
return Node(id, "row", props);
}
private static EveSurfaceComponent Node(
string id,
string kind,
(string Key, string Value)[] props,
params EveSurfaceComponent[] children)
{
return new EveSurfaceComponent
{
Id = id,
Kind = kind,
Props = props.ToDictionary(prop => prop.Key, prop => prop.Value),
Children = children
};
}
}

View file

@ -173,6 +173,16 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
return Database.GetAsync<EveSurfaceState>(new CultRecordKey(AetheriaCatalogSurfaceProjector.SurfaceKey));
}
public Task<CultRecordHandle<EveSurfaceState>> PutOperationsSurfaceAsync(EveSurfaceState surface)
{
return Database.PutAsync(new CultRecordKey(AetheriaOperationsSurfaceProjector.SurfaceKey), surface);
}
public Task<EveSurfaceState?> GetOperationsSurfaceAsync()
{
return Database.GetAsync<EveSurfaceState>(new CultRecordKey(AetheriaOperationsSurfaceProjector.SurfaceKey));
}
public Task<CultRecordHandle<AetheriaPlayerSettings>> PutPlayerSettingsAsync(AetheriaPlayerSettings settings)
{
return Database.PutAsync(new CultRecordKey("global:aetheria.player_settings.v1"), settings);
@ -227,6 +237,18 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
return Database.GetAsync<AetheriaEntitySnapshot>(key);
}
public Task<CultRecordHandle<AetheriaRuntimeCommitDrainStatus>> PutRuntimeCommitDrainStatusAsync(
AetheriaRuntimeCommitDrainStatus status)
{
return Database.PutAsync(new CultRecordKey("global:aetheria.runtime_commit_drain_status.v1"), status);
}
public Task<AetheriaRuntimeCommitDrainStatus?> GetRuntimeCommitDrainStatusAsync()
{
return Database.GetAsync<AetheriaRuntimeCommitDrainStatus>(
new CultRecordKey("global:aetheria.runtime_commit_drain_status.v1"));
}
public Task FlushAsync(bool soft = false)
{
return _node.FlushAsync(soft);

View file

@ -0,0 +1,51 @@
using GameCult.Caching;
using MessagePack;
namespace Aetheria.State.Documents;
[CultDocument("aetheria.runtime_commit_drain_status", "aetheria.runtime_commit_drain_status.v1")]
[CultGlobal]
[MessagePackObject]
public sealed class AetheriaRuntimeCommitDrainStatus
{
[Key(0)]
[CultName]
public string Name { get; set; } = "Aetheria runtime commit drain";
[Key(1)]
[CultIndex("runtimeId")]
public string RuntimeId { get; set; } = "";
[Key(2)]
public string StatePath { get; set; } = "";
[Key(3)]
public string LastPollAtUtc { get; set; } = "";
[Key(4)]
public string LastAppliedAtUtc { get; set; } = "";
[Key(5)]
public int PendingBeforeApply { get; set; }
[Key(6)]
public int CommandsApplied { get; set; }
[Key(7)]
public int AppliedPlayerSettings { get; set; }
[Key(8)]
public int AppliedLoadoutTemplates { get; set; }
[Key(9)]
public int AppliedRunCheckpoints { get; set; }
[Key(10)]
public int ConsecutiveFailures { get; set; }
[Key(11)]
public string LastError { get; set; } = "";
[Key(12)]
public string Status { get; set; } = "ok";
}

View file

@ -137,6 +137,11 @@ one-shot drain without leaving the host running:
dotnet run --project .\Economy.Server\Economy.Server.csproj -- --apply-pending-once
```
The server publishes `AetheriaRuntimeCommitDrainStatus` and the
`aetheria.operations` Eve surface after each drain attempt, so pending depth,
applied counts, failures, and timestamps are typed state rather than console-only
status.
`Aetheria.State.ApplyPending` is the smaller local operator applicator for
queued Unity runtime commits when the server host is not being used:

View file

@ -5,6 +5,8 @@ namespace Aetheria.Server;
internal static class Program
{
private const string RuntimeId = "aetheria-economy-server";
private static async Task Main(string[] args)
{
var statePath = ResolveStatePath(args);
@ -12,7 +14,7 @@ internal static class Program
await using var node = await AetheriaStateNode.OpenAsync(
statePath,
runtimeId: "aetheria-economy-server",
runtimeId: RuntimeId,
startServer: true).ConfigureAwait(false);
await EnsureWorldDocumentAsync(node).ConfigureAwait(false);
@ -68,19 +70,75 @@ internal static class Program
private static async Task ApplyPendingRuntimeCommitsAsync(AetheriaStateNode node)
{
var report = await AetheriaRuntimeCommitLogApplier.ApplyPendingAsync(node).ConfigureAwait(false);
if (report.AppliedPaths.Length == 0)
{
Console.WriteLine("No pending Aetheria runtime commits.");
return;
}
var pendingBefore = CountPendingRuntimeCommits(node.StatePath);
var now = DateTimeOffset.UtcNow.ToString("O");
Console.WriteLine(
"Applied pending Aetheria runtime commits: " +
$"settings={report.AppliedPlayerSettings}, " +
$"loadouts={report.AppliedLoadoutTemplates}, " +
$"runs={report.AppliedRunCheckpoints}, " +
$"files={report.AppliedPaths.Length}");
try
{
var report = await AetheriaRuntimeCommitLogApplier.ApplyPendingAsync(node).ConfigureAwait(false);
var status = new AetheriaRuntimeCommitDrainStatus
{
RuntimeId = RuntimeId,
StatePath = node.StatePath,
LastPollAtUtc = now,
LastAppliedAtUtc = report.AppliedPaths.Length > 0 ? now : "",
PendingBeforeApply = pendingBefore,
CommandsApplied = report.AppliedPaths.Length,
AppliedPlayerSettings = report.AppliedPlayerSettings,
AppliedLoadoutTemplates = report.AppliedLoadoutTemplates,
AppliedRunCheckpoints = report.AppliedRunCheckpoints,
ConsecutiveFailures = 0,
Status = "ok"
};
await PublishDrainStatusAsync(node, status).ConfigureAwait(false);
if (report.AppliedPaths.Length == 0)
{
Console.WriteLine("No pending Aetheria runtime commits.");
return;
}
Console.WriteLine(
"Applied pending Aetheria runtime commits: " +
$"settings={report.AppliedPlayerSettings}, " +
$"loadouts={report.AppliedLoadoutTemplates}, " +
$"runs={report.AppliedRunCheckpoints}, " +
$"files={report.AppliedPaths.Length}");
}
catch (Exception ex)
{
var existing = await node.GetRuntimeCommitDrainStatusAsync().ConfigureAwait(false);
var status = new AetheriaRuntimeCommitDrainStatus
{
RuntimeId = RuntimeId,
StatePath = node.StatePath,
LastPollAtUtc = now,
LastAppliedAtUtc = existing?.LastAppliedAtUtc ?? "",
PendingBeforeApply = pendingBefore,
ConsecutiveFailures = (existing?.ConsecutiveFailures ?? 0) + 1,
LastError = ex.ToString(),
Status = "error"
};
await PublishDrainStatusAsync(node, status).ConfigureAwait(false);
throw;
}
}
private static async Task PublishDrainStatusAsync(
AetheriaStateNode node,
AetheriaRuntimeCommitDrainStatus status)
{
await node.PutRuntimeCommitDrainStatusAsync(status).ConfigureAwait(false);
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(status)).ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
}
private static int CountPendingRuntimeCommits(string statePath)
{
var pendingDirectory = statePath + ".pending";
return Directory.Exists(pendingDirectory)
? Directory.EnumerateFiles(pendingDirectory, "*.cc").Count()
: 0;
}
private static async Task RunUntilShutdownAsync(AetheriaStateNode node, TimeSpan pendingInterval)

View file

@ -175,6 +175,9 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted.
- `Economy.Server` hosts the CultMesh state node and now owns the long-running
pending runtime commit drain loop. `--apply-pending-once` runs the same drain
path once for smoke/operator use without keeping the process alive.
- `AetheriaRuntimeCommitDrainStatus` and the `aetheria.operations` Eve surface
publish pending-drain health, pending depth, applied counts, failures, and
timestamps as typed state. Console logs are notification-only.
- `AetheriaCatalogSurfaceProjector` now emits the first provider-owned Eve
surface from typed catalog state. The importer materializes a
`gamecult.eve.surface.v1` catalog operator document at
@ -537,6 +540,8 @@ First Aetheria surfaces to publish:
queued runtime commits until the drain loop is hosted as a daemon.
- Done: move the pending runtime commit drain into `Economy.Server` startup
and daemon polling, with `--apply-pending-once` for bounded operation.
- Done: publish pending-drain health as typed CultCache state and an Eve
operations surface.
- Done: stop legacy catalog pull/read paths from writing entries back to
their source backing store.
- Done: delete legacy catalog backing-store write/realtime APIs.