Publish runtime sessions as typed state

This commit is contained in:
Meta Vens 2026-06-14 12:31:20 +01:00
commit 5c19d515f0
10 changed files with 145 additions and 28 deletions

Binary file not shown.

View file

@ -17,6 +17,14 @@ var evePendingBefore = CountPendingEveCommands(statePath);
var now = DateTimeOffset.UtcNow.ToString("O");
await using var node = await AetheriaStateNode.OpenAsync(statePath, runtimeId);
await node.PutRuntimeSessionAsync(new AetheriaRuntimeSession
{
RuntimeId = runtimeId,
Role = "pending-drain",
StartedAtUtc = now,
LastSeenAtUtc = now,
Status = "running"
});
var report = await AetheriaRuntimeCommitLogApplier.ApplyPendingAsync(node, deleteApplied);
var eveReport = await AetheriaEveCommandBridge.ApplyPendingAsync(node, deleteApplied);
var commitStatus = new AetheriaRuntimeCommitDrainStatus
@ -49,7 +57,16 @@ var eveStatus = new AetheriaEveCommandDrainStatus
};
await node.PutRuntimeCommitDrainStatusAsync(commitStatus);
await node.PutEveCommandDrainStatusAsync(eveStatus);
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus));
var completedSession = new AetheriaRuntimeSession
{
RuntimeId = runtimeId,
Role = "pending-drain",
StartedAtUtc = now,
LastSeenAtUtc = DateTimeOffset.UtcNow.ToString("O"),
Status = "completed"
};
await node.PutRuntimeSessionAsync(completedSession);
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus, completedSession));
await node.PutProviderAdvertisementAsync(AetheriaProviderAdvertisementProjector.Build(statePath, now));
await node.FlushAsync();

View file

@ -6,7 +6,8 @@ using GameCult.Aetheria.State.Unity;
using GameCult.Eve.Surface;
var root = args.Length > 0 ? args[0] : Directory.GetCurrentDirectory();
var statePath = AetheriaStatePaths.ResolveDefaultStatePath(root);
var stateDirectory = Path.Combine(Path.GetTempPath(), "aetheria-state-smoke", Guid.NewGuid().ToString("N"));
var statePath = Path.Combine(stateDirectory, "aetheria-world.cc");
var now = DateTimeOffset.UtcNow.ToString("O");
var itemLegacyId = "smoke:aether-drive";
var factionLegacyId = "smoke:faction";
@ -106,6 +107,17 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s
await node.PutRuntimeCommitDrainStatusAsync(drainStatus);
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(drainStatus));
await node.PutProviderAdvertisementAsync(AetheriaProviderAdvertisementProjector.Build(statePath, now));
await node.PutRuntimeSessionAsync(new AetheriaRuntimeSession
{
RuntimeId = "smoke-runtime",
Role = "state-smoke",
StartedAtUtc = now,
LastSeenAtUtc = now,
Status = "running"
});
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(
drainStatus,
runtimeSession: await node.GetRuntimeSessionAsync("smoke-runtime")));
AetheriaRuntimeEveCommandLog.QueueCommand(
statePath,
@ -142,7 +154,10 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s
Status = eveCommandReport.RejectedCommands > 0 ? "rejected" : "ok"
};
await node.PutEveCommandDrainStatusAsync(eveCommandStatus);
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(drainStatus, eveCommandStatus));
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(
drainStatus,
eveCommandStatus,
await node.GetRuntimeSessionAsync("smoke-runtime")));
await node.PutLoadoutTemplateAsync(loadoutKey, new AetheriaLoadoutTemplate
{
@ -298,6 +313,7 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
var eveCommandStatus = await reopened.GetEveCommandDrainStatusAsync();
var operationsSurface = await reopened.GetOperationsSurfaceAsync();
var advertisement = await reopened.GetProviderAdvertisementAsync();
var runtimeSession = await reopened.GetRuntimeSessionAsync("smoke-runtime");
var playerSettings = await reopened.GetPlayerSettingsAsync();
var loadout = await reopened.GetLoadoutTemplateAsync(loadoutKey);
var runState = await reopened.GetRunStateAsync(runKey);
@ -342,7 +358,8 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
eveCommandStatus.AppliedCatalogRefreshes != 1 ||
!eveCommandStatus.LastRejectedReason.Contains("not advertised", StringComparison.Ordinal) ||
operationsSurface?.Surface.Id != AetheriaOperationsSurfaceProjector.SurfaceId ||
!operationsSurface.Surface.Root.Children.Any(child => child.Id == "aetheria.operations.eveCommandDrain"))
!operationsSurface.Surface.Root.Children.Any(child => child.Id == "aetheria.operations.eveCommandDrain") ||
!operationsSurface.Surface.Root.Children.Any(child => child.Id == "aetheria.operations.runtimeSession"))
{
throw new InvalidOperationException("Runtime commit/Eve command drain status or operations surface did not survive flush/reopen.");
}
@ -351,11 +368,19 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
advertisement.Surfaces.Length < 2 ||
!advertisement.Surfaces.Any(surface => surface.SurfaceId == AetheriaCatalogSurfaceProjector.SurfaceId) ||
!advertisement.Surfaces.Any(surface => surface.SurfaceId == AetheriaOperationsSurfaceProjector.SurfaceId) ||
!advertisement.Schemas.Contains("aetheria.runtime_session.v1") ||
!advertisement.Schemas.Contains(AetheriaEveCommandBridge.CommandSchema))
{
throw new InvalidOperationException("Aetheria Eve provider advertisement did not survive flush/reopen.");
}
if (runtimeSession?.RuntimeId != "smoke-runtime" ||
runtimeSession.Role != "state-smoke" ||
runtimeSession.Status != "running")
{
throw new InvalidOperationException("Runtime session document did not survive flush/reopen.");
}
if (playerSettings?.ActiveRunKey != runKey.ToString() ||
playerSettings.PlayerName != "Smoke Pilot" ||
!playerSettings.TutorialPassed ||

View file

@ -10,9 +10,13 @@ public static class AetheriaOperationsSurfaceProjector
public static EveSurfaceState Build(
AetheriaRuntimeCommitDrainStatus drainStatus,
AetheriaEveCommandDrainStatus? eveCommandStatus = null,
AetheriaRuntimeSession? runtimeSession = null,
long version = 1)
{
var updatedAtUtc = LatestTimestamp(drainStatus.LastPollAtUtc, eveCommandStatus?.LastPollAtUtc);
var updatedAtUtc = LatestTimestamp(
drainStatus.LastPollAtUtc,
eveCommandStatus?.LastPollAtUtc,
runtimeSession?.LastSeenAtUtc);
return new EveSurfaceState
{
ProviderId = "aetheria",
@ -62,7 +66,18 @@ public static class AetheriaOperationsSurfaceProjector
("lastAccepted", eveCommandStatus?.LastAcceptedAtUtc ?? ""),
("lastRejected", eveCommandStatus?.LastRejectedCommand ?? ""),
("rejectedReason", eveCommandStatus?.LastRejectedReason ?? ""),
("error", eveCommandStatus?.LastError ?? ""))))
("error", eveCommandStatus?.LastError ?? ""))),
Node(
"aetheria.operations.runtimeSession",
"card",
[("title", "Runtime Session")],
Metric("runtimeSession.status", "Status", runtimeSession?.Status ?? "missing"),
Metric("runtimeSession.role", "Role", runtimeSession?.Role ?? ""),
Row(
"runtimeSession.last",
("runtime", runtimeSession?.RuntimeId ?? ""),
("started", runtimeSession?.StartedAtUtc ?? ""),
("lastSeen", runtimeSession?.LastSeenAtUtc ?? ""))))
},
Commands =
[
@ -76,14 +91,19 @@ public static class AetheriaOperationsSurfaceProjector
};
}
private static string LatestTimestamp(string first, string? second)
private static string LatestTimestamp(params string?[] timestamps)
{
if (string.IsNullOrWhiteSpace(second))
return first;
if (string.IsNullOrWhiteSpace(first))
return second;
var latest = "";
foreach (var timestamp in timestamps)
{
if (!string.IsNullOrWhiteSpace(timestamp) &&
(string.IsNullOrWhiteSpace(latest) || string.CompareOrdinal(timestamp, latest) > 0))
{
latest = timestamp;
}
}
return string.CompareOrdinal(second, first) > 0 ? second : first;
return latest;
}
private static EveSurfaceComponent Metric(string id, string label, string value)

View file

@ -38,6 +38,7 @@ public static class AetheriaProviderAdvertisementProjector
"aetheria.run_state.v1",
"aetheria.zone_state.v1",
"aetheria.entity_snapshot.v1",
"aetheria.runtime_session.v1",
"aetheria.runtime_commit_drain_status.v1",
"aetheria.eve_command_drain_status.v1",
"gamecult.eve.surface.v1",

View file

@ -195,6 +195,17 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
new CultRecordKey(AetheriaProviderAdvertisementProjector.AdvertisementKey));
}
public Task<CultRecordHandle<AetheriaRuntimeSession>> PutRuntimeSessionAsync(AetheriaRuntimeSession session)
{
return Database.PutAsync(new CultRecordKey($"runtime:{session.RuntimeId}:aetheria.runtime_session.v1"), session);
}
public Task<AetheriaRuntimeSession?> GetRuntimeSessionAsync(string runtimeId)
{
return Database.GetAsync<AetheriaRuntimeSession>(
new CultRecordKey($"runtime:{runtimeId}:aetheria.runtime_session.v1"));
}
public Task<CultRecordHandle<AetheriaPlayerSettings>> PutPlayerSettingsAsync(AetheriaPlayerSettings settings)
{
return Database.PutAsync(new CultRecordKey("global:aetheria.player_settings.v1"), settings);

View file

@ -20,4 +20,7 @@ public sealed class AetheriaRuntimeSession
[Key(3)]
public string LastSeenAtUtc { get; set; } = "";
[Key(4)]
public string Status { get; set; } = "running";
}

View file

@ -13,11 +13,11 @@ are migration inputs only.
The typed runtime state model is split into player settings, loadout templates,
run state, zone state, entity snapshots, item slots, weapon groups, action-bar
bindings, and stat grids. Runtime checkpoints include current action-bar
bindings as stable typed targets: input control path, binding kind, item
definition reference, equipment index, behavior index, or weapon-group index.
They also include current faction relationship rows as typed corporation
references plus relationship tokens and numeric standings.
bindings, stat grids, and runtime session documents. Runtime checkpoints
include current action-bar bindings as stable typed targets: input control
path, binding kind, item definition reference, equipment index, behavior index,
or weapon-group index. They also include current faction relationship rows as
typed corporation references plus relationship tokens and numeric standings.
Player settings include player name, tutorial flag,
story-file hash cursors, gameplay formatting, graphics preferences, input
binding overrides, and action-bar inputs. Do not preserve `SavedGame`, Unity
@ -39,6 +39,11 @@ Unity settings, loadout templates, and run checkpoints now queue typed
into canonical typed state through `AetheriaRuntimeCommitLogApplier`; the
Unity-side command log is command-only and cannot become durable truth by
itself.
`AetheriaRuntimeSession` is the typed Verse signal for the running state host
or one-shot pending drain. `Economy.Server` owns the long-running
`cultmesh-state-host` heartbeat and publishes `starting`, `running`, and
`stopping` states through `Aetheria.State`; drain status remains operation
telemetry, not daemon-session ownership.
Unity Eve surfaces also emit typed `gamecult.eve.command.v1` documents under
`GameData/aetheria-world.cc.eve.pending`. `AetheriaEveCommandBridge` is the
provider-owned acceptance organ for those commands: it validates the provider,
@ -108,9 +113,10 @@ field from key 3, name-file and boss-hull legacy IDs, influence distance,
allegiance count, full allegiance edges, and music bank IDs. Corporation
documents are `aetheria.corporation.v2` records because runtime generation
needs actual allegiance weights, not only display counts. Runtime checkpoints
now persist session faction relationships into typed run state, but broader
runtime object graphs, typed behavior factory construction, and simulation
state remain legacy until dedicated typed runtime documents exist.
now persist session faction relationships into typed run state, and runtime
sessions publish typed state-host liveness, but broader runtime object graphs,
typed behavior factory construction, and simulation state remain legacy until
dedicated typed runtime documents exist.
Typed name-file documents are `aetheria.name_file.v2` records. They carry the
legacy ID, display name, count, compact sample names for surfaces, and the full
name array needed to move `Galaxy`/Markov name generation off legacy
@ -154,10 +160,12 @@ corporation records.
`Aetheria.State.Smoke` writes and reopens full typed player settings plus a
typed loadout template in addition to world, catalog, run state, zone state, and
an entity snapshot. The run smoke proves a typed run can reference a typed zone,
the zone can reference typed entity snapshots, and entity snapshots can preserve
equipment slots, weapon groups, and stat grids without reviving
`PlayerSettings.msgpack`, `.loadout`, or `.zone` serialization.
an entity snapshot. It also writes and reopens `AetheriaRuntimeSession` and
checks that the Eve provider advertises the runtime-session schema. The run
smoke proves a typed run can reference a typed zone, the zone can reference
typed entity snapshots, and entity snapshots can preserve equipment slots,
weapon groups, and stat grids without reviving `PlayerSettings.msgpack`,
`.loadout`, or `.zone` serialization.
`Aetheria.State.Verify` opens a materialized state file and checks that the
typed migration ledger matches the actual item, corporation, and name-file

View file

@ -17,9 +17,12 @@ internal static class Program
runtimeId: RuntimeId,
startServer: true).ConfigureAwait(false);
var startedAtUtc = DateTimeOffset.UtcNow.ToString("O");
await PublishRuntimeSessionAsync(node, startedAtUtc, "starting").ConfigureAwait(false);
await EnsureWorldDocumentAsync(node).ConfigureAwait(false);
await ApplyPendingRuntimeCommitsAsync(node).ConfigureAwait(false);
await ApplyPendingEveCommandsAsync(node).ConfigureAwait(false);
await PublishRuntimeSessionAsync(node, startedAtUtc, "running").ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
if (HasFlag(args, "--apply-pending-once"))
@ -29,8 +32,9 @@ internal static class Program
}
Console.WriteLine("Aetheria CultMesh state host is running. Press Ctrl+C to stop.");
await RunUntilShutdownAsync(node, PendingInterval(args)).ConfigureAwait(false);
await RunUntilShutdownAsync(node, startedAtUtc, PendingInterval(args)).ConfigureAwait(false);
await PublishRuntimeSessionAsync(node, startedAtUtc, "stopping").ConfigureAwait(false);
Console.WriteLine("Aetheria CultMesh state host stopping.");
}
@ -212,13 +216,31 @@ internal static class Program
Status = "idle"
};
var eveStatus = await node.GetEveCommandDrainStatusAsync().ConfigureAwait(false);
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus))
var runtimeSession = await node.GetRuntimeSessionAsync(RuntimeId).ConfigureAwait(false);
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus, runtimeSession))
.ConfigureAwait(false);
await node.PutProviderAdvertisementAsync(
AetheriaProviderAdvertisementProjector.Build(node.StatePath, updatedAtUtc)).ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
}
private static async Task PublishRuntimeSessionAsync(
AetheriaStateNode node,
string startedAtUtc,
string status)
{
var now = DateTimeOffset.UtcNow.ToString("O");
await node.PutRuntimeSessionAsync(new AetheriaRuntimeSession
{
RuntimeId = RuntimeId,
Role = "cultmesh-state-host",
StartedAtUtc = startedAtUtc,
LastSeenAtUtc = now,
Status = status
}).ConfigureAwait(false);
await PublishOperationsStateAsync(node, now).ConfigureAwait(false);
}
private static int CountPendingRuntimeCommits(string statePath)
{
var pendingDirectory = statePath + ".pending";
@ -235,7 +257,10 @@ internal static class Program
: 0;
}
private static async Task RunUntilShutdownAsync(AetheriaStateNode node, TimeSpan pendingInterval)
private static async Task RunUntilShutdownAsync(
AetheriaStateNode node,
string startedAtUtc,
TimeSpan pendingInterval)
{
var stopped = new TaskCompletionSource<object?>();
Console.CancelKeyPress += (_, eventArgs) =>
@ -253,6 +278,7 @@ internal static class Program
await ApplyPendingRuntimeCommitsAsync(node).ConfigureAwait(false);
await ApplyPendingEveCommandsAsync(node).ConfigureAwait(false);
await PublishRuntimeSessionAsync(node, startedAtUtc, "running").ConfigureAwait(false);
}
}

View file

@ -725,6 +725,10 @@ First Aetheria surfaces to publish:
- Done: extend run checkpoint commits and smoke coverage to carry typed
faction relationship rows into `AetheriaRunState` as corporation
references, relationship tokens, and numeric standings.
- Done: make `AetheriaRuntimeSession` a live CultMesh document with state
node ports, provider advertisement, server heartbeat publication, and
smoke coverage; daemon liveness is no longer only console output or
incidental drain status.
- Remaining: add typed documents/mappers for runtime object graphs,
typed behavior factory construction, simulation state, and any catalog
fields not covered by the stable scalar/fingerprint/payload pass.
@ -1116,7 +1120,9 @@ First Aetheria surfaces to publish:
- CultCache smoke proves write, flush, reopen, query, and typed reference
resolution, including full player settings as the `PlayerSettings.msgpack`
replacement, loadout templates as the `.loadout` replacement, and a run ->
zone -> entity snapshot graph as the `.zone` replacement.
zone -> entity snapshot graph as the `.zone` replacement. It also proves
`aetheria.runtime_session.v1` is advertised and survives reopen as the
typed daemon-session signal.
- CultMesh smoke proves node start, typed put/get, subscription, flush, and
reopen.
- Eve surface smoke proves provider-owned surface documents are generated from