diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index 5cb32befb..ca672f540 100644 Binary files a/.voidbot/state/aetheria.cc and b/.voidbot/state/aetheria.cc differ diff --git a/Aetheria.State.ApplyPending/Program.cs b/Aetheria.State.ApplyPending/Program.cs index 0a7f9ac4d..91b38efbd 100644 --- a/Aetheria.State.ApplyPending/Program.cs +++ b/Aetheria.State.ApplyPending/Program.cs @@ -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(); diff --git a/Aetheria.State.Smoke/Program.cs b/Aetheria.State.Smoke/Program.cs index ade754924..e15851315 100644 --- a/Aetheria.State.Smoke/Program.cs +++ b/Aetheria.State.Smoke/Program.cs @@ -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 || diff --git a/Aetheria.State/AetheriaOperationsSurfaceProjector.cs b/Aetheria.State/AetheriaOperationsSurfaceProjector.cs index 96498b436..ed08ae252 100644 --- a/Aetheria.State/AetheriaOperationsSurfaceProjector.cs +++ b/Aetheria.State/AetheriaOperationsSurfaceProjector.cs @@ -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) diff --git a/Aetheria.State/AetheriaProviderAdvertisementProjector.cs b/Aetheria.State/AetheriaProviderAdvertisementProjector.cs index 9507e0197..38f43df9c 100644 --- a/Aetheria.State/AetheriaProviderAdvertisementProjector.cs +++ b/Aetheria.State/AetheriaProviderAdvertisementProjector.cs @@ -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", diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index 860baff18..b5d23e903 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -195,6 +195,17 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable new CultRecordKey(AetheriaProviderAdvertisementProjector.AdvertisementKey)); } + public Task> PutRuntimeSessionAsync(AetheriaRuntimeSession session) + { + return Database.PutAsync(new CultRecordKey($"runtime:{session.RuntimeId}:aetheria.runtime_session.v1"), session); + } + + public Task GetRuntimeSessionAsync(string runtimeId) + { + return Database.GetAsync( + new CultRecordKey($"runtime:{runtimeId}:aetheria.runtime_session.v1")); + } + public Task> PutPlayerSettingsAsync(AetheriaPlayerSettings settings) { return Database.PutAsync(new CultRecordKey("global:aetheria.player_settings.v1"), settings); diff --git a/Aetheria.State/Documents/AetheriaRuntimeSession.cs b/Aetheria.State/Documents/AetheriaRuntimeSession.cs index 8ad1a83f7..c01d51fde 100644 --- a/Aetheria.State/Documents/AetheriaRuntimeSession.cs +++ b/Aetheria.State/Documents/AetheriaRuntimeSession.cs @@ -20,4 +20,7 @@ public sealed class AetheriaRuntimeSession [Key(3)] public string LastSeenAtUtc { get; set; } = ""; + + [Key(4)] + public string Status { get; set; } = "running"; } diff --git a/Aetheria.State/README.md b/Aetheria.State/README.md index 5439051da..f785f6193 100644 --- a/Aetheria.State/README.md +++ b/Aetheria.State/README.md @@ -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 diff --git a/Economy.Server/Program.cs b/Economy.Server/Program.cs index 7d0974e06..45e444dc0 100644 --- a/Economy.Server/Program.cs +++ b/Economy.Server/Program.cs @@ -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(); 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); } } diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index ded6cc76c..7cff23f5a 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -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