From 440404abda643063793a198d7ed467c93993a576 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 8 Apr 2026 11:44:07 +0800 Subject: [PATCH 01/21] fix: refactor actor store usage and improve error handling in chat endpoints --- .../NyxIdChatActorStore.cs | 85 --------------- .../NyxIdChatEndpoints.cs | 55 ++++++++-- .../NyxIdChatServiceDefaults.cs | 2 +- .../ServiceCollectionExtensions.cs | 1 - .../Aevatar.GAgents.StreamingProxy.csproj | 1 + .../ServiceCollectionExtensions.cs | 1 - .../StreamingProxyActorStore.cs | 103 ------------------ .../StreamingProxyDefaults.cs | 1 + .../StreamingProxyEndpoints.cs | 58 +++++++--- 9 files changed, 88 insertions(+), 219 deletions(-) delete mode 100644 agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs delete mode 100644 agents/Aevatar.GAgents.StreamingProxy/StreamingProxyActorStore.cs diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs deleted file mode 100644 index 06822842..00000000 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatActorStore.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Aevatar.GAgents.NyxidChat; - -/// -/// Manages NyxIdChat conversation actors. -/// Phase 1: in-memory store. Phase 2: chrono-storage persistence. -/// -public sealed class NyxIdChatActorStore -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly ConcurrentDictionary> _store = new(StringComparer.OrdinalIgnoreCase); - - public Task> ListActorsAsync(string scopeId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var actors = _store.GetOrAdd(key, _ => []); - IReadOnlyList result; - lock (actors) - { - result = actors.ToList().AsReadOnly(); - } - return Task.FromResult(result); - } - - public Task CreateActorAsync(string scopeId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var entry = new ActorEntry - { - ActorId = NyxIdChatServiceDefaults.GenerateActorId(), - CreatedAt = DateTimeOffset.UtcNow, - }; - var actors = _store.GetOrAdd(key, _ => []); - lock (actors) - { - actors.Add(entry); - } - return Task.FromResult(entry); - } - - public Task DeleteActorAsync(string scopeId, string actorId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - if (!_store.TryGetValue(key, out var actors)) - return Task.FromResult(false); - - bool removed; - lock (actors) - { - removed = actors.RemoveAll(e => - string.Equals(e.ActorId, actorId, StringComparison.Ordinal)) > 0; - } - return Task.FromResult(removed); - } - - /// Ensure an actor entry exists for the given scope + actorId (idempotent). - public Task EnsureActorAsync(string scopeId, string actorId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var actors = _store.GetOrAdd(key, _ => []); - lock (actors) - { - if (!actors.Exists(e => string.Equals(e.ActorId, actorId, StringComparison.Ordinal))) - actors.Add(new ActorEntry { ActorId = actorId, CreatedAt = DateTimeOffset.UtcNow }); - } - return Task.CompletedTask; - } - - private static string BuildKey(string scopeId) => scopeId.Trim().ToLowerInvariant(); - - public sealed class ActorEntry - { - public string ActorId { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } - } -} diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs index 8dfe62ab..8e0ab2bf 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Aevatar.Studio.Application.Studio.Abstractions; using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.NyxidChat; @@ -76,21 +77,39 @@ public static IEndpointRouteBuilder MapNyxIdChatEndpoints(this IEndpointRouteBui private static async Task HandleCreateConversationAsync( HttpContext http, string scopeId, - [FromServices] NyxIdChatActorStore actorStore, + [FromServices] IGAgentActorStore actorStore, CancellationToken ct) { - var entry = await actorStore.CreateActorAsync(scopeId, ct); - return Results.Ok(new { actorId = entry.ActorId, createdAt = entry.CreatedAt }); + var actorId = NyxIdChatServiceDefaults.GenerateActorId(); + try + { + await actorStore.AddActorAsync(NyxIdChatServiceDefaults.GAgentTypeName, actorId, ct); + } + catch (InvalidOperationException) + { + // chrono-storage unavailable — actor still usable via runtime + } + return Results.Ok(new { actorId }); } private static async Task HandleListConversationsAsync( HttpContext http, string scopeId, - [FromServices] NyxIdChatActorStore actorStore, + [FromServices] IGAgentActorStore actorStore, CancellationToken ct) { - var actors = await actorStore.ListActorsAsync(scopeId, ct); - return Results.Ok(actors); + try + { + var groups = await actorStore.GetAsync(ct); + var group = groups.FirstOrDefault(g => + string.Equals(g.GAgentType, NyxIdChatServiceDefaults.GAgentTypeName, StringComparison.Ordinal)); + var actorIds = group?.ActorIds ?? []; + return Results.Ok(actorIds.Select(id => new { actorId = id })); + } + catch (InvalidOperationException) + { + return Results.Ok(Array.Empty()); + } } private static async Task HandleStreamMessageAsync( @@ -300,11 +319,18 @@ private static async Task HandleDeleteConversationAsync( HttpContext http, string scopeId, string actorId, - [FromServices] NyxIdChatActorStore actorStore, + [FromServices] IGAgentActorStore actorStore, CancellationToken ct) { - var removed = await actorStore.DeleteActorAsync(scopeId, actorId, ct); - return removed ? Results.Ok() : Results.NotFound(); + try + { + await actorStore.RemoveActorAsync(NyxIdChatServiceDefaults.GAgentTypeName, actorId, ct); + } + catch (InvalidOperationException) + { + // chrono-storage unavailable + } + return Results.Ok(); } /// @@ -693,7 +719,7 @@ private static async Task HandleRelayWebhookAsync( HttpContext http, [FromServices] IActorRuntime actorRuntime, [FromServices] IActorEventSubscriptionProvider subscriptionProvider, - [FromServices] NyxIdChatActorStore actorStore, + [FromServices] IGAgentActorStore actorStore, [FromServices] NyxIdRelayOptions relayOptions, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) @@ -758,7 +784,14 @@ private static async Task HandleRelayWebhookAsync( // ─── Get or create actor ─── var actor = await actorRuntime.GetAsync(actorId) ?? await actorRuntime.CreateAsync(actorId, ct); - await actorStore.EnsureActorAsync(scopeId, actorId, ct); + try + { + await actorStore.AddActorAsync(NyxIdChatServiceDefaults.GAgentTypeName, actorId, ct); + } + catch (InvalidOperationException) + { + // chrono-storage unavailable — actor still usable via runtime + } // ─── Subscribe and collect response ─── var responseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatServiceDefaults.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatServiceDefaults.cs index 517b9378..4661de04 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatServiceDefaults.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatServiceDefaults.cs @@ -4,7 +4,7 @@ public static class NyxIdChatServiceDefaults { public const string ServiceId = "nyxid-chat"; public const string DisplayName = "NyxID Chat"; - public const string GAgentTypeName = "Aevatar.GAgents.NyxidChat.NyxIdChatGAgent"; + public static readonly string GAgentTypeName = typeof(NyxIdChatGAgent).FullName!; public const string ActorIdPrefix = "nyxid-chat"; public const string ActorsFileName = "actors"; public const string ProviderName = "nyxid"; diff --git a/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs index 889fde98..0eb959f6 100644 --- a/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.NyxidChat/ServiceCollectionExtensions.cs @@ -11,7 +11,6 @@ public static IServiceCollection AddNyxIdChat(this IServiceCollection services, { RuntimeHelpers.RunClassConstructor(typeof(NyxIdChatGAgent).TypeHandle); - services.TryAddSingleton(); services.TryAddSingleton(BindRelayOptions(configuration)); return services; diff --git a/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj b/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj index 87cc9140..326c8080 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj +++ b/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj @@ -10,6 +10,7 @@ + diff --git a/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs index a334a298..b87907e1 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs @@ -7,7 +7,6 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddStreamingProxy(this IServiceCollection services) { - services.TryAddSingleton(); return services; } } diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyActorStore.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyActorStore.cs deleted file mode 100644 index b49298aa..00000000 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyActorStore.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Collections.Concurrent; - -namespace Aevatar.GAgents.StreamingProxy; - -/// -/// Manages StreamingProxy room actors. -/// Phase 1: in-memory store. Phase 2: persistent storage. -/// -public sealed class StreamingProxyActorStore -{ - private readonly ConcurrentDictionary> _store = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentDictionary> _participants = new(StringComparer.OrdinalIgnoreCase); - - public Task> ListRoomsAsync(string scopeId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var rooms = _store.GetOrAdd(key, _ => []); - IReadOnlyList result; - lock (rooms) - { - result = rooms.ToList().AsReadOnly(); - } - return Task.FromResult(result); - } - - public Task CreateRoomAsync(string scopeId, string? roomName, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - var entry = new RoomEntry - { - RoomId = StreamingProxyDefaults.GenerateRoomId(), - RoomName = roomName ?? "Group Chat", - CreatedAt = DateTimeOffset.UtcNow, - }; - var rooms = _store.GetOrAdd(key, _ => []); - lock (rooms) - { - rooms.Add(entry); - } - return Task.FromResult(entry); - } - - public Task DeleteRoomAsync(string scopeId, string roomId, CancellationToken ct = default) - { - var key = BuildKey(scopeId); - if (!_store.TryGetValue(key, out var rooms)) - return Task.FromResult(false); - - bool removed; - lock (rooms) - { - removed = rooms.RemoveAll(e => - string.Equals(e.RoomId, roomId, StringComparison.Ordinal)) > 0; - } - return Task.FromResult(removed); - } - - public void AddParticipant(string scopeId, string roomId, string agentId, string displayName) - { - var key = BuildParticipantKey(scopeId, roomId); - var participants = _participants.GetOrAdd(key, _ => []); - lock (participants) - { - participants.RemoveAll(p => string.Equals(p.AgentId, agentId, StringComparison.Ordinal)); - participants.Add(new ParticipantEntry - { - AgentId = agentId, - DisplayName = displayName, - JoinedAt = DateTimeOffset.UtcNow, - }); - } - } - - public IReadOnlyList ListParticipants(string scopeId, string roomId) - { - var key = BuildParticipantKey(scopeId, roomId); - if (!_participants.TryGetValue(key, out var participants)) - return []; - - lock (participants) - { - return participants.ToList().AsReadOnly(); - } - } - - private static string BuildKey(string scopeId) => scopeId.Trim().ToLowerInvariant(); - private static string BuildParticipantKey(string scopeId, string roomId) => - $"{scopeId.Trim().ToLowerInvariant()}:{roomId}"; - - public sealed class RoomEntry - { - public string RoomId { get; set; } = string.Empty; - public string RoomName { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } - } - - public sealed class ParticipantEntry - { - public string AgentId { get; set; } = string.Empty; - public string DisplayName { get; set; } = string.Empty; - public DateTimeOffset JoinedAt { get; set; } - } -} diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyDefaults.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyDefaults.cs index 2104852a..98f8ce31 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyDefaults.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyDefaults.cs @@ -4,6 +4,7 @@ public static class StreamingProxyDefaults { public const string ServiceId = "streaming-proxy"; public const string DisplayName = "Streaming Proxy"; + public static readonly string GAgentTypeName = typeof(StreamingProxyGAgent).FullName!; public const string ActorIdPrefix = "streaming-proxy"; public const int MaxMessages = 500; diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs index 88a030c2..61edf0cb 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Aevatar.Studio.Application.Studio.Abstractions; using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.StreamingProxy; @@ -44,7 +45,7 @@ private static async Task HandleCreateRoomAsync( HttpContext http, string scopeId, [FromBody] CreateRoomRequest? request, - [FromServices] StreamingProxyActorStore store, + [FromServices] IGAgentActorStore actorStore, [FromServices] IActorRuntime actorRuntime, CancellationToken ct) { @@ -52,10 +53,10 @@ private static async Task HandleCreateRoomAsync( if (string.IsNullOrWhiteSpace(roomName)) roomName = "Group Chat"; - var entry = await store.CreateRoomAsync(scopeId, roomName, ct); + var roomId = StreamingProxyDefaults.GenerateRoomId(); // Create the actor and initialize it - var actor = await actorRuntime.CreateAsync(entry.RoomId, ct); + var actor = await actorRuntime.CreateAsync(roomId, ct); var initEvent = new GroupChatRoomInitializedEvent { RoomName = roomName }; var envelope = new EventEnvelope @@ -67,28 +68,54 @@ private static async Task HandleCreateRoomAsync( }; await actor.HandleEventAsync(envelope, ct); - return Results.Ok(new { roomId = entry.RoomId, roomName = entry.RoomName, createdAt = entry.CreatedAt }); + try + { + await actorStore.AddActorAsync(StreamingProxyDefaults.GAgentTypeName, roomId, ct); + } + catch (InvalidOperationException) + { + // chrono-storage unavailable — actor still usable via runtime + } + + return Results.Ok(new { roomId, roomName }); } private static async Task HandleListRoomsAsync( HttpContext http, string scopeId, - [FromServices] StreamingProxyActorStore store, + [FromServices] IGAgentActorStore actorStore, CancellationToken ct) { - var rooms = await store.ListRoomsAsync(scopeId, ct); - return Results.Ok(rooms); + try + { + var groups = await actorStore.GetAsync(ct); + var group = groups.FirstOrDefault(g => + string.Equals(g.GAgentType, StreamingProxyDefaults.GAgentTypeName, StringComparison.Ordinal)); + var roomIds = group?.ActorIds ?? []; + return Results.Ok(roomIds.Select(id => new { roomId = id })); + } + catch (InvalidOperationException) + { + return Results.Ok(Array.Empty()); + } } private static async Task HandleDeleteRoomAsync( HttpContext http, string scopeId, string roomId, - [FromServices] StreamingProxyActorStore store, + [FromServices] IGAgentActorStore actorStore, CancellationToken ct) { - var removed = await store.DeleteRoomAsync(scopeId, roomId, ct); - return removed ? Results.Ok() : Results.NotFound(); + try + { + await actorStore.RemoveActorAsync(StreamingProxyDefaults.GAgentTypeName, roomId, ct); + } + catch (InvalidOperationException) + { + // chrono-storage unavailable + } + return Results.Ok(); } // ─── User Chat (trigger topic + SSE stream) ─── @@ -271,15 +298,16 @@ private static async Task HandleMessageStreamAsync( // ─── Participant management ─── + // TODO: participant query requires a readmodel (actor state holds participants via + // StreamingProxyGAgentState, but direct actor state reads violate read/write separation). + // For now return empty list; real-time participant changes are visible via the SSE stream. private static Task HandleListParticipantsAsync( HttpContext http, string scopeId, string roomId, - [FromServices] StreamingProxyActorStore store, CancellationToken ct) { - var participants = store.ListParticipants(scopeId, roomId); - return Task.FromResult(Results.Ok(participants)); + return Task.FromResult(Results.Ok(Array.Empty())); } private static async Task HandleJoinAsync( @@ -288,7 +316,6 @@ private static async Task HandleJoinAsync( string roomId, JoinRoomRequest request, [FromServices] IActorRuntime actorRuntime, - [FromServices] StreamingProxyActorStore store, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.AgentId)) @@ -316,9 +343,6 @@ private static async Task HandleJoinAsync( }; await actor.HandleEventAsync(envelope, ct); - // Track participant in the store for query endpoints - store.AddParticipant(scopeId, roomId, agentId, displayName); - return Results.Ok(new { status = "joined", agentId }); } From 66e53735c03439b7a83c68bfdf458c7e4e4bead5 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 8 Apr 2026 14:55:33 +0800 Subject: [PATCH 02/21] refactor(streaming-proxy): improve error handling and logging in endpoints - Replace generic InvalidOperationException catch blocks with specific handling for OperationCanceledException and logging for other exceptions - Add ILoggerFactory dependency injection and structured logging for actor store operations - Update TODO comment to clarify participant snapshot query requirements - Remove unused Microsoft.Extensions.DependencyInjection.Extensions namespace import --- .../ServiceCollectionExtensions.cs | 1 - .../StreamingProxyEndpoints.cs | 28 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs b/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs index b87907e1..c6e1deb7 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aevatar.GAgents.StreamingProxy; diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs index 61edf0cb..da5bf05b 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs @@ -47,8 +47,10 @@ private static async Task HandleCreateRoomAsync( [FromBody] CreateRoomRequest? request, [FromServices] IGAgentActorStore actorStore, [FromServices] IActorRuntime actorRuntime, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); var roomName = request?.RoomName?.Trim(); if (string.IsNullOrWhiteSpace(roomName)) roomName = "Group Chat"; @@ -72,9 +74,11 @@ private static async Task HandleCreateRoomAsync( { await actorStore.AddActorAsync(StreamingProxyDefaults.GAgentTypeName, roomId, ct); } - catch (InvalidOperationException) + catch (OperationCanceledException) { throw; } + catch (Exception ex) { - // chrono-storage unavailable — actor still usable via runtime + // chrono-storage unavailable (timeout/403/network) — actor still usable via runtime + logger.LogWarning(ex, "Failed to persist room {RoomId} to actor store; room is usable via runtime", roomId); } return Results.Ok(new { roomId, roomName }); @@ -84,8 +88,10 @@ private static async Task HandleListRoomsAsync( HttpContext http, string scopeId, [FromServices] IGAgentActorStore actorStore, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); try { var groups = await actorStore.GetAsync(ct); @@ -94,8 +100,10 @@ private static async Task HandleListRoomsAsync( var roomIds = group?.ActorIds ?? []; return Results.Ok(roomIds.Select(id => new { roomId = id })); } - catch (InvalidOperationException) + catch (OperationCanceledException) { throw; } + catch (Exception ex) { + logger.LogWarning(ex, "Failed to list rooms from actor store"); return Results.Ok(Array.Empty()); } } @@ -105,15 +113,18 @@ private static async Task HandleDeleteRoomAsync( string scopeId, string roomId, [FromServices] IGAgentActorStore actorStore, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); try { await actorStore.RemoveActorAsync(StreamingProxyDefaults.GAgentTypeName, roomId, ct); } - catch (InvalidOperationException) + catch (OperationCanceledException) { throw; } + catch (Exception ex) { - // chrono-storage unavailable + logger.LogWarning(ex, "Failed to remove room {RoomId} from actor store", roomId); } return Results.Ok(); } @@ -298,9 +309,10 @@ private static async Task HandleMessageStreamAsync( // ─── Participant management ─── - // TODO: participant query requires a readmodel (actor state holds participants via - // StreamingProxyGAgentState, but direct actor state reads violate read/write separation). - // For now return empty list; real-time participant changes are visible via the SSE stream. + // TODO: participant snapshot query requires an actor-scoped current-state readmodel + // projected from committed GroupChatParticipantJoined/Left events. The authoritative + // state lives in StreamingProxyGAgentState; direct actor state reads violate read/write + // separation. Real-time participant changes are visible via the SSE stream. private static Task HandleListParticipantsAsync( HttpContext http, string scopeId, From 74aa54a59a55fa856b231a55919e7cb4917dcb8c Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 8 Apr 2026 17:03:45 +0800 Subject: [PATCH 03/21] feat: add IStreamingProxyParticipantStore with chrono-storage implementation - Define IStreamingProxyParticipantStore interface in Aevatar.Studio.Application - Implement ChronoStorageStreamingProxyParticipantStore (persistent, survives restarts) - Register in DI as singleton - Endpoints not yet wired (will be connected when feature/agents-group merges) Prepares for issue #143 blocker #2: eliminates ConcurrentDictionary participant tracking when combined with feature/agents-group branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../IStreamingProxyParticipantStore.cs | 22 +++ .../ServiceCollectionExtensions.cs | 1 + ...noStorageStreamingProxyParticipantStore.cs | 128 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageStreamingProxyParticipantStore.cs diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs new file mode 100644 index 00000000..e0c6bfdc --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs @@ -0,0 +1,22 @@ +namespace Aevatar.Studio.Application.Studio.Abstractions; + +/// +/// Persistent participant index for streaming proxy rooms. +/// +public interface IStreamingProxyParticipantStore +{ + Task> ListAsync( + string roomId, CancellationToken cancellationToken = default); + + Task AddAsync( + string roomId, string agentId, string displayName, + CancellationToken cancellationToken = default); + + Task RemoveRoomAsync( + string roomId, CancellationToken cancellationToken = default); +} + +public sealed record StreamingProxyParticipant( + string AgentId, + string DisplayName, + DateTimeOffset JoinedAt); diff --git a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index d9adbac0..bd5bfce7 100644 --- a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -36,6 +36,7 @@ public static IServiceCollection AddStudioInfrastructure( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageStreamingProxyParticipantStore.cs new file mode 100644 index 00000000..3a87e5b4 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageStreamingProxyParticipantStore.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Aevatar.Studio.Application.Studio.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Aevatar.Studio.Infrastructure.Storage; + +internal sealed class ChronoStorageStreamingProxyParticipantStore : IStreamingProxyParticipantStore +{ + private const string ParticipantsFileName = "streaming-proxy-participants.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly ChronoStorageCatalogBlobClient _blobClient; + private readonly ConnectorCatalogStorageOptions _options; + private readonly ILogger _logger; + + public ChronoStorageStreamingProxyParticipantStore( + ChronoStorageCatalogBlobClient blobClient, + IOptions options, + ILogger logger) + { + _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> ListAsync( + string roomId, CancellationToken cancellationToken = default) + { + var rooms = await DownloadAsync(cancellationToken); + return rooms.TryGetValue(roomId, out var participants) + ? participants.AsReadOnly() + : []; + } + + public async Task AddAsync( + string roomId, string agentId, string displayName, + CancellationToken cancellationToken = default) + { + var remoteContext = TryResolve() + ?? throw new InvalidOperationException("Streaming proxy participant storage is not available."); + + var rooms = await DownloadAsync(cancellationToken); + + if (!rooms.TryGetValue(roomId, out var participants)) + { + participants = []; + rooms[roomId] = participants; + } + + participants.RemoveAll(p => string.Equals(p.AgentId, agentId, StringComparison.Ordinal)); + participants.Add(new StreamingProxyParticipant(agentId, displayName, DateTimeOffset.UtcNow)); + + await UploadAsync(remoteContext, rooms, cancellationToken); + } + + public async Task RemoveRoomAsync(string roomId, CancellationToken cancellationToken = default) + { + var remoteContext = TryResolve(); + if (remoteContext is null) + return; + + var rooms = await DownloadAsync(cancellationToken); + if (!rooms.Remove(roomId)) + return; + + await UploadAsync(remoteContext, rooms, cancellationToken); + } + + private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolve() + { + try + { + return _blobClient.TryResolveContext(_options.UserConfigPrefix, ParticipantsFileName); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Chrono-storage context could not be resolved for streaming proxy participant store"); + return null; + } + } + + private async Task>> DownloadAsync( + CancellationToken cancellationToken) + { + var remoteContext = TryResolve(); + if (remoteContext is null) + return new Dictionary>(StringComparer.Ordinal); + + var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); + if (payload is null) + return new Dictionary>(StringComparer.Ordinal); + + return DeserializeRooms(payload); + } + + private async Task UploadAsync( + ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, + Dictionary> rooms, + CancellationToken cancellationToken) + { + var json = JsonSerializer.SerializeToUtf8Bytes(rooms, JsonOptions); + await _blobClient.UploadAsync(remoteContext, json, "application/json", cancellationToken); + } + + private Dictionary> DeserializeRooms(byte[] payload) + { + try + { + return JsonSerializer.Deserialize>>(payload, JsonOptions) + ?? new Dictionary>(StringComparer.Ordinal); + } + catch (JsonException ex) + { + // Do NOT return empty — that would cause the next AddAsync to overwrite + // all existing rooms' participants with a blank snapshot. + _logger.LogError(ex, "Corrupt participant store payload; refusing to deserialize to prevent data loss"); + throw new InvalidOperationException("Participant store payload is corrupt", ex); + } + } +} From 9564105f55e967acb6896bac8d94d70598936b51 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 8 Apr 2026 22:20:42 +0800 Subject: [PATCH 04/21] feat(actors): migrate persistent stores to GAgent actors Replace chrono-storage implementations with event-sourced GAgent actors for all core stores (user config, chat history, connector/role catalogs, etc.). Each store now has a corresponding GAgent that publishes state snapshots after every change, enabling readmodel subscribers to stay up-to-date without reading write-model state. - Add GAgent projects with protobuf message definitions for each store - Implement actor-backed store implementations that send commands to GAgents - Update dependency injection to wire actor-backed stores - Add TODO comment about handling corrupt-data exceptions in streaming proxy participant store --- aevatar.slnx | 9 + .../Aevatar.GAgents.ChatHistory.csproj | 24 ++ .../ChatConversationGAgent.cs | 99 ++++++ .../ChatHistoryIndexGAgent.cs | 92 +++++ .../chat_history_messages.proto | 73 ++++ .../Aevatar.GAgents.ConnectorCatalog.csproj | 24 ++ .../ConnectorCatalogGAgent.cs | 102 ++++++ .../connector_catalog_messages.proto | 38 ++ .../Aevatar.GAgents.Registry.csproj | 24 ++ .../GAgentRegistryGAgent.cs | 116 ++++++ .../gagent_registry_messages.proto | 34 ++ .../Aevatar.GAgents.RoleCatalog.csproj | 24 ++ .../RoleCatalogGAgent.cs | 102 ++++++ .../role_catalog_messages.proto | 47 +++ .../Aevatar.GAgents.ScriptStorage.csproj | 24 ++ .../ScriptStorageGAgent.cs | 63 ++++ .../script_storage_messages.proto | 25 ++ .../StreamingProxyEndpoints.cs | 34 +- ...r.GAgents.StreamingProxyParticipant.csproj | 24 ++ .../StreamingProxyParticipantGAgent.cs | 111 ++++++ ...streaming_proxy_participant_messages.proto | 43 +++ .../Aevatar.GAgents.UserConfig.csproj | 24 ++ .../UserConfigGAgent.cs | 66 ++++ .../user_config_messages.proto | 33 ++ .../Aevatar.GAgents.UserMemory.csproj | 24 ++ .../UserMemoryGAgent.cs | 152 ++++++++ .../user_memory_messages.proto | 39 ++ .../Aevatar.GAgents.WorkflowStorage.csproj | 24 ++ .../WorkflowStorageGAgent.cs | 67 ++++ .../workflow_storage_messages.proto | 31 ++ .../IStreamingProxyParticipantStore.cs | 7 + .../ActorBackedChatHistoryStore.cs | 335 ++++++++++++++++++ .../ActorBackedConnectorCatalogStore.cs | 217 ++++++++++++ .../ActorBackedGAgentActorStore.cs | 183 ++++++++++ ...ActorBackedNyxIdUserLlmPreferencesStore.cs | 28 ++ .../ActorBackedRoleCatalogStore.cs | 251 +++++++++++++ .../ActorBackedScriptStoragePort.cs | 68 ++++ ...torBackedStreamingProxyParticipantStore.cs | 162 +++++++++ .../ActorBacked/ActorBackedUserConfigStore.cs | 190 ++++++++++ .../ActorBacked/ActorBackedUserMemoryStore.cs | 330 +++++++++++++++++ .../ActorBackedWorkflowStoragePort.cs | 66 ++++ .../Aevatar.Studio.Infrastructure.csproj | 9 + .../ServiceCollectionExtensions.cs | 23 +- 43 files changed, 3445 insertions(+), 16 deletions(-) create mode 100644 agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj create mode 100644 agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs create mode 100644 agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs create mode 100644 agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto create mode 100644 agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj create mode 100644 agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs create mode 100644 agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto create mode 100644 agents/Aevatar.GAgents.Registry/Aevatar.GAgents.Registry.csproj create mode 100644 agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs create mode 100644 agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto create mode 100644 agents/Aevatar.GAgents.RoleCatalog/Aevatar.GAgents.RoleCatalog.csproj create mode 100644 agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs create mode 100644 agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto create mode 100644 agents/Aevatar.GAgents.ScriptStorage/Aevatar.GAgents.ScriptStorage.csproj create mode 100644 agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs create mode 100644 agents/Aevatar.GAgents.ScriptStorage/script_storage_messages.proto create mode 100644 agents/Aevatar.GAgents.StreamingProxyParticipant/Aevatar.GAgents.StreamingProxyParticipant.csproj create mode 100644 agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs create mode 100644 agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto create mode 100644 agents/Aevatar.GAgents.UserConfig/Aevatar.GAgents.UserConfig.csproj create mode 100644 agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs create mode 100644 agents/Aevatar.GAgents.UserConfig/user_config_messages.proto create mode 100644 agents/Aevatar.GAgents.UserMemory/Aevatar.GAgents.UserMemory.csproj create mode 100644 agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs create mode 100644 agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto create mode 100644 agents/Aevatar.GAgents.WorkflowStorage/Aevatar.GAgents.WorkflowStorage.csproj create mode 100644 agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs create mode 100644 agents/Aevatar.GAgents.WorkflowStorage/workflow_storage_messages.proto create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs diff --git a/aevatar.slnx b/aevatar.slnx index 2c85469b..defcc634 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -2,7 +2,16 @@ + + + + + + + + + diff --git a/agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj b/agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj new file mode 100644 index 00000000..769c215f --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/Aevatar.GAgents.ChatHistory.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.ChatHistory + Aevatar.GAgents.ChatHistory + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs new file mode 100644 index 00000000..ed95f301 --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs @@ -0,0 +1,99 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.ChatHistory; + +/// +/// Per-conversation actor that holds all messages for a single conversation. +/// Actor ID: chat-{conversationId}. +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date snapshot. +/// +public sealed class ChatConversationGAgent : GAgentBase +{ + /// Maximum messages retained per conversation. + internal const int MaxMessages = 500; + + [EventHandler(EndpointName = "replaceMessages")] + public async Task HandleMessagesReplaced(MessagesReplacedEvent evt) + { + if (evt.Meta is null) + return; + + // Trim to MaxMessages (keep newest) + var trimmed = TrimMessages(evt); + + await PersistDomainEventAsync(trimmed); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "deleteConversation")] + public async Task HandleConversationDeleted(ConversationDeletedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.ConversationId)) + return; + + // Only delete if there is state to delete + if (State.Meta is null && State.Messages.Count == 0) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override ChatConversationState TransitionState( + ChatConversationState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyMessagesReplaced) + .On(ApplyConversationDeleted) + .OrCurrent(); + } + + private static MessagesReplacedEvent TrimMessages(MessagesReplacedEvent evt) + { + if (evt.Messages.Count <= MaxMessages) + return evt; + + var trimmed = evt.Clone(); + var excess = trimmed.Messages.Count - MaxMessages; + for (var i = 0; i < excess; i++) + trimmed.Messages.RemoveAt(0); + + if (trimmed.Meta is not null) + trimmed.Meta.MessageCount = trimmed.Messages.Count; + + return trimmed; + } + + private static ChatConversationState ApplyMessagesReplaced( + ChatConversationState state, MessagesReplacedEvent evt) + { + var next = new ChatConversationState { Meta = evt.Meta?.Clone() }; + next.Messages.AddRange(evt.Messages); + return next; + } + + private static ChatConversationState ApplyConversationDeleted( + ChatConversationState state, ConversationDeletedEvent evt) + { + return new ChatConversationState(); + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new ChatConversationStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs new file mode 100644 index 00000000..4bf1347b --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs @@ -0,0 +1,92 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.ChatHistory; + +/// +/// Per-user index actor that holds conversation list and metadata. +/// Actor ID: chat-index-{scopeId}. +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date snapshot. +/// +public sealed class ChatHistoryIndexGAgent : GAgentBase +{ + [EventHandler(EndpointName = "upsertConversation")] + public async Task HandleConversationUpserted(ConversationUpsertedEvent evt) + { + if (evt.Meta is null || string.IsNullOrWhiteSpace(evt.Meta.Id)) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "removeConversation")] + public async Task HandleConversationRemoved(ConversationRemovedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.ConversationId)) + return; + + // Idempotent: skip if not present + var existing = State.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.ConversationId, StringComparison.Ordinal)); + if (existing is null) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override ChatHistoryIndexState TransitionState( + ChatHistoryIndexState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyConversationUpserted) + .On(ApplyConversationRemoved) + .OrCurrent(); + } + + private static ChatHistoryIndexState ApplyConversationUpserted( + ChatHistoryIndexState state, ConversationUpsertedEvent evt) + { + var next = state.Clone(); + + var existing = next.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.Meta.Id, StringComparison.Ordinal)); + if (existing is not null) + next.Conversations.Remove(existing); + + next.Conversations.Add(evt.Meta.Clone()); + return next; + } + + private static ChatHistoryIndexState ApplyConversationRemoved( + ChatHistoryIndexState state, ConversationRemovedEvent evt) + { + var next = state.Clone(); + + var existing = next.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.ConversationId, StringComparison.Ordinal)); + if (existing is not null) + next.Conversations.Remove(existing); + + return next; + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new ChatHistoryIndexStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto new file mode 100644 index 00000000..98699a5a --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto @@ -0,0 +1,73 @@ +syntax = "proto3"; +package aevatar.gagents.chathistory; +option csharp_namespace = "Aevatar.GAgents.ChatHistory"; + +// ─── Shared Types ─── + +message StoredChatMessageProto { + string id = 1; + string role = 2; + string content = 3; + int64 timestamp = 4; + string status = 5; + string error = 6; + string thinking = 7; +} + +message ConversationMetaProto { + string id = 1; + string title = 2; + string service_id = 3; + string service_kind = 4; + int64 created_at_ms = 5; + int64 updated_at_ms = 6; + int32 message_count = 7; + string llm_route = 8; + string llm_model = 9; +} + +// ─── ChatConversationGAgent State ─── + +message ChatConversationState { + repeated StoredChatMessageProto messages = 1; + ConversationMetaProto meta = 2; +} + +// ─── ChatConversationGAgent Events ─── + +message MessagesReplacedEvent { + repeated StoredChatMessageProto messages = 1; + ConversationMetaProto meta = 2; +} + +message ConversationDeletedEvent { + string conversation_id = 1; +} + +// ─── ChatConversationGAgent Snapshot ─── + +message ChatConversationStateSnapshotEvent { + ChatConversationState snapshot = 1; +} + +// ─── ChatHistoryIndexGAgent State ─── + +message ChatHistoryIndexState { + repeated ConversationMetaProto conversations = 1; +} + +// ─── ChatHistoryIndexGAgent Events ─── + +message ConversationUpsertedEvent { + ConversationMetaProto meta = 1; +} + +message ConversationRemovedEvent { + string conversation_id = 1; +} + +// ─── ChatHistoryIndexGAgent Snapshot ─── + +message ChatHistoryIndexStateSnapshotEvent { + ChatHistoryIndexState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj b/agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj new file mode 100644 index 00000000..12e3dbaa --- /dev/null +++ b/agents/Aevatar.GAgents.ConnectorCatalog/Aevatar.GAgents.ConnectorCatalog.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.ConnectorCatalog + Aevatar.GAgents.ConnectorCatalog + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs new file mode 100644 index 00000000..1c839e5d --- /dev/null +++ b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs @@ -0,0 +1,102 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.ConnectorCatalog; + +/// +/// Singleton actor that persists the connector catalog and draft. +/// Replaces the chrono-storage backed ChronoStorageConnectorCatalogStore +/// for remote persistence operations. +/// +/// Actor ID: connector-catalog (cluster-scoped singleton). +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date projection without +/// reading write-model internal state. +/// +public sealed class ConnectorCatalogGAgent : GAgentBase +{ + [EventHandler(EndpointName = "saveCatalog")] + public async Task HandleCatalogSaved(ConnectorCatalogSavedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.CatalogJson)) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "saveDraft")] + public async Task HandleDraftSaved(ConnectorDraftSavedEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "deleteDraft")] + public async Task HandleDraftDeleted(ConnectorDraftDeletedEvent evt) + { + // Idempotent: skip if no draft exists + if (string.IsNullOrEmpty(State.DraftJson)) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + /// + /// On activation (after event replay), publish the current state so + /// any subscriber that activates the actor can receive the initial snapshot. + /// + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override ConnectorCatalogState TransitionState( + ConnectorCatalogState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyCatalogSaved) + .On(ApplyDraftSaved) + .On(ApplyDraftDeleted) + .OrCurrent(); + } + + private static ConnectorCatalogState ApplyCatalogSaved( + ConnectorCatalogState state, ConnectorCatalogSavedEvent evt) + { + var next = state.Clone(); + next.CatalogJson = evt.CatalogJson; + return next; + } + + private static ConnectorCatalogState ApplyDraftSaved( + ConnectorCatalogState state, ConnectorDraftSavedEvent evt) + { + var next = state.Clone(); + next.DraftJson = evt.DraftJson; + next.DraftUpdatedAtUtc = evt.UpdatedAtUtc; + return next; + } + + private static ConnectorCatalogState ApplyDraftDeleted( + ConnectorCatalogState state, ConnectorDraftDeletedEvent _) + { + var next = state.Clone(); + next.DraftJson = string.Empty; + next.DraftUpdatedAtUtc = null; + return next; + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new ConnectorCatalogStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto new file mode 100644 index 00000000..1d059ba5 --- /dev/null +++ b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; +package aevatar.gagents.connector_catalog; +option csharp_namespace = "Aevatar.GAgents.ConnectorCatalog"; + +import "google/protobuf/timestamp.proto"; + +// ─── State ─── + +// Actor state persists catalog and draft as JSON strings. +// The complex nested connector types (StoredConnectorDefinition, etc.) +// are serialized/deserialized at the ActorBackedStore boundary. +message ConnectorCatalogState { + string catalog_json = 1; + string draft_json = 2; + google.protobuf.Timestamp draft_updated_at_utc = 3; +} + +// ─── Events ─── + +message ConnectorCatalogSavedEvent { + string catalog_json = 1; +} + +message ConnectorDraftSavedEvent { + string draft_json = 1; + google.protobuf.Timestamp updated_at_utc = 2; +} + +message ConnectorDraftDeletedEvent { +} + +// ─── Readmodel ─── + +// Published by the GAgent after each state change so subscribers can +// maintain an up-to-date readmodel without reading write-model state. +message ConnectorCatalogStateSnapshotEvent { + ConnectorCatalogState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.Registry/Aevatar.GAgents.Registry.csproj b/agents/Aevatar.GAgents.Registry/Aevatar.GAgents.Registry.csproj new file mode 100644 index 00000000..39ef14d1 --- /dev/null +++ b/agents/Aevatar.GAgents.Registry/Aevatar.GAgents.Registry.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.Registry + Aevatar.GAgents.Registry + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs new file mode 100644 index 00000000..1e2e238d --- /dev/null +++ b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs @@ -0,0 +1,116 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.Registry; + +/// +/// Singleton registry actor that tracks all GAgent actor IDs grouped by type. +/// Replaces the chrono-storage backed ChronoStorageGAgentActorStore. +/// +/// Actor ID: gagent-registry (cluster-scoped singleton). +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date projection without +/// reading write-model internal state. +/// +public sealed class GAgentRegistryGAgent : GAgentBase +{ + [EventHandler(EndpointName = "registerActor")] + public async Task HandleActorRegistered(ActorRegisteredEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.GagentType) || string.IsNullOrWhiteSpace(evt.ActorId)) + return; + + // Idempotent: skip if already registered + var group = State.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + if (group is not null && group.ActorIds.Contains(evt.ActorId)) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "unregisterActor")] + public async Task HandleActorUnregistered(ActorUnregisteredEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.GagentType) || string.IsNullOrWhiteSpace(evt.ActorId)) + return; + + // Idempotent: skip if not registered + var group = State.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + if (group is null || !group.ActorIds.Contains(evt.ActorId)) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + /// + /// On activation (after event replay), publish the current state so + /// any subscriber that activates the actor can receive the initial snapshot. + /// + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override GAgentRegistryState TransitionState( + GAgentRegistryState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyRegistered) + .On(ApplyUnregistered) + .OrCurrent(); + } + + private static GAgentRegistryState ApplyRegistered( + GAgentRegistryState state, ActorRegisteredEvent evt) + { + var next = state.Clone(); + var group = next.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + + if (group is null) + { + group = new GAgentRegistryEntry { GagentType = evt.GagentType }; + next.Groups.Add(group); + } + + if (!group.ActorIds.Contains(evt.ActorId)) + group.ActorIds.Add(evt.ActorId); + + return next; + } + + private static GAgentRegistryState ApplyUnregistered( + GAgentRegistryState state, ActorUnregisteredEvent evt) + { + var next = state.Clone(); + var group = next.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + + if (group is null) + return next; + + group.ActorIds.Remove(evt.ActorId); + + if (group.ActorIds.Count == 0) + next.Groups.Remove(group); + + return next; + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new GAgentRegistryStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto new file mode 100644 index 00000000..d6aed4c8 --- /dev/null +++ b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; +package aevatar.gagents.registry; +option csharp_namespace = "Aevatar.GAgents.Registry"; + +// ─── State ─── + +message GAgentRegistryEntry { + string gagent_type = 1; + repeated string actor_ids = 2; +} + +message GAgentRegistryState { + repeated GAgentRegistryEntry groups = 1; +} + +// ─── Events ─── + +message ActorRegisteredEvent { + string gagent_type = 1; + string actor_id = 2; +} + +message ActorUnregisteredEvent { + string gagent_type = 1; + string actor_id = 2; +} + +// ─── Readmodel ─── + +// Published by the GAgent after each state change so subscribers can +// maintain an up-to-date readmodel without reading write-model state. +message GAgentRegistryStateSnapshotEvent { + GAgentRegistryState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.RoleCatalog/Aevatar.GAgents.RoleCatalog.csproj b/agents/Aevatar.GAgents.RoleCatalog/Aevatar.GAgents.RoleCatalog.csproj new file mode 100644 index 00000000..87ad2db4 --- /dev/null +++ b/agents/Aevatar.GAgents.RoleCatalog/Aevatar.GAgents.RoleCatalog.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.RoleCatalog + Aevatar.GAgents.RoleCatalog + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs new file mode 100644 index 00000000..2b67ad9a --- /dev/null +++ b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs @@ -0,0 +1,102 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.RoleCatalog; + +/// +/// Singleton actor that owns the role catalog and draft. +/// Replaces the chrono-storage backed ChronoStorageRoleCatalogStore +/// for remote persistence concerns. +/// +/// Actor ID: role-catalog (cluster-scoped singleton). +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date projection without +/// reading write-model internal state. +/// +public sealed class RoleCatalogGAgent : GAgentBase +{ + [EventHandler(EndpointName = "saveCatalog")] + public async Task HandleCatalogSaved(RoleCatalogSavedEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "saveDraft")] + public async Task HandleDraftSaved(RoleDraftSavedEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "deleteDraft")] + public async Task HandleDraftDeleted(RoleDraftDeletedEvent evt) + { + if (State.Draft is null) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + /// + /// On activation (after event replay), publish the current state so + /// any subscriber that activates the actor can receive the initial snapshot. + /// + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override RoleCatalogState TransitionState( + RoleCatalogState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyCatalogSaved) + .On(ApplyDraftSaved) + .On(ApplyDraftDeleted) + .OrCurrent(); + } + + private static RoleCatalogState ApplyCatalogSaved( + RoleCatalogState state, RoleCatalogSavedEvent evt) + { + var next = state.Clone(); + next.Roles.Clear(); + next.Roles.AddRange(evt.Roles); + return next; + } + + private static RoleCatalogState ApplyDraftSaved( + RoleCatalogState state, RoleDraftSavedEvent evt) + { + var next = state.Clone(); + next.Draft = new RoleDraftEntry + { + Draft = evt.Draft?.Clone(), + UpdatedAtUtc = evt.UpdatedAtUtc, + }; + return next; + } + + private static RoleCatalogState ApplyDraftDeleted( + RoleCatalogState state, RoleDraftDeletedEvent _) + { + var next = state.Clone(); + next.Draft = null; + return next; + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new RoleCatalogStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto b/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto new file mode 100644 index 00000000..6d0c8310 --- /dev/null +++ b/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; +package aevatar.gagents.role_catalog; +option csharp_namespace = "Aevatar.GAgents.RoleCatalog"; + +import "google/protobuf/timestamp.proto"; + +// ─── State ─── + +message RoleDefinitionEntry { + string id = 1; + string name = 2; + string system_prompt = 3; + string provider = 4; + string model = 5; + repeated string connectors = 6; +} + +message RoleDraftEntry { + RoleDefinitionEntry draft = 1; + google.protobuf.Timestamp updated_at_utc = 2; +} + +message RoleCatalogState { + repeated RoleDefinitionEntry roles = 1; + RoleDraftEntry draft = 2; // null when no draft exists +} + +// ─── Events ─── + +message RoleCatalogSavedEvent { + repeated RoleDefinitionEntry roles = 1; +} + +message RoleDraftSavedEvent { + RoleDefinitionEntry draft = 1; + google.protobuf.Timestamp updated_at_utc = 2; +} + +message RoleDraftDeletedEvent {} + +// ─── Readmodel ─── + +// Published by the GAgent after each state change so subscribers can +// maintain an up-to-date readmodel without reading write-model state. +message RoleCatalogStateSnapshotEvent { + RoleCatalogState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.ScriptStorage/Aevatar.GAgents.ScriptStorage.csproj b/agents/Aevatar.GAgents.ScriptStorage/Aevatar.GAgents.ScriptStorage.csproj new file mode 100644 index 00000000..17983f13 --- /dev/null +++ b/agents/Aevatar.GAgents.ScriptStorage/Aevatar.GAgents.ScriptStorage.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.ScriptStorage + Aevatar.GAgents.ScriptStorage + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs b/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs new file mode 100644 index 00000000..ec7d3e8a --- /dev/null +++ b/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs @@ -0,0 +1,63 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.ScriptStorage; + +/// +/// Singleton actor that stores uploaded script source artifacts. +/// Replaces the chrono-storage backed ChronoStorageScriptStoragePort. +/// +/// Actor ID: script-storage (cluster-scoped singleton). +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date projection without +/// reading write-model internal state. +/// +public sealed class ScriptStorageGAgent : GAgentBase +{ + [EventHandler(EndpointName = "uploadScript")] + public async Task HandleScriptUploaded(ScriptUploadedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.ScriptId) || string.IsNullOrWhiteSpace(evt.SourceText)) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + /// + /// On activation (after event replay), publish the current state so + /// any subscriber that activates the actor can receive the initial snapshot. + /// + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override ScriptStorageState TransitionState( + ScriptStorageState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyScriptUploaded) + .OrCurrent(); + } + + private static ScriptStorageState ApplyScriptUploaded( + ScriptStorageState state, ScriptUploadedEvent evt) + { + var next = state.Clone(); + next.Scripts[evt.ScriptId] = evt.SourceText; + return next; + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new ScriptStorageStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.ScriptStorage/script_storage_messages.proto b/agents/Aevatar.GAgents.ScriptStorage/script_storage_messages.proto new file mode 100644 index 00000000..3b5416c8 --- /dev/null +++ b/agents/Aevatar.GAgents.ScriptStorage/script_storage_messages.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; +package aevatar.gagents.script_storage; +option csharp_namespace = "Aevatar.GAgents.ScriptStorage"; + +// ─── State ─── + +message ScriptStorageState { + // scriptId → sourceText + map scripts = 1; +} + +// ─── Events ─── + +message ScriptUploadedEvent { + string script_id = 1; + string source_text = 2; +} + +// ─── Readmodel ─── + +// Published by the GAgent after each state change so subscribers can +// maintain an up-to-date readmodel without reading write-model state. +message ScriptStorageStateSnapshotEvent { + ScriptStorageState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs index da5bf05b..886f1096 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs @@ -309,17 +309,26 @@ private static async Task HandleMessageStreamAsync( // ─── Participant management ─── - // TODO: participant snapshot query requires an actor-scoped current-state readmodel - // projected from committed GroupChatParticipantJoined/Left events. The authoritative - // state lives in StreamingProxyGAgentState; direct actor state reads violate read/write - // separation. Real-time participant changes are visible via the SSE stream. - private static Task HandleListParticipantsAsync( + private static async Task HandleListParticipantsAsync( HttpContext http, string scopeId, string roomId, + [FromServices] IStreamingProxyParticipantStore participantStore, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { - return Task.FromResult(Results.Ok(Array.Empty())); + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); + try + { + var participants = await participantStore.ListAsync(roomId, ct); + return Results.Ok(participants); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to list participants for room {RoomId}", roomId); + return Results.Ok(Array.Empty()); + } } private static async Task HandleJoinAsync( @@ -328,6 +337,8 @@ private static async Task HandleJoinAsync( string roomId, JoinRoomRequest request, [FromServices] IActorRuntime actorRuntime, + [FromServices] IStreamingProxyParticipantStore participantStore, + [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.AgentId)) @@ -355,6 +366,17 @@ private static async Task HandleJoinAsync( }; await actor.HandleEventAsync(envelope, ct); + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); + try + { + await participantStore.AddAsync(roomId, agentId, displayName, ct); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to persist participant {AgentId} in room {RoomId}", agentId, roomId); + } + return Results.Ok(new { status = "joined", agentId }); } diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/Aevatar.GAgents.StreamingProxyParticipant.csproj b/agents/Aevatar.GAgents.StreamingProxyParticipant/Aevatar.GAgents.StreamingProxyParticipant.csproj new file mode 100644 index 00000000..0a8c0688 --- /dev/null +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/Aevatar.GAgents.StreamingProxyParticipant.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.StreamingProxyParticipant + Aevatar.GAgents.StreamingProxyParticipant + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs new file mode 100644 index 00000000..a63218d0 --- /dev/null +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs @@ -0,0 +1,111 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.GAgents.StreamingProxyParticipant; + +/// +/// Singleton actor that tracks streaming proxy room participants. +/// Replaces the chrono-storage backed ChronoStorageStreamingProxyParticipantStore. +/// +/// Actor ID: streaming-proxy-participants (cluster-scoped singleton). +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date projection without +/// reading write-model internal state. +/// +public sealed class StreamingProxyParticipantGAgent + : GAgentBase +{ + [EventHandler(EndpointName = "addParticipant")] + public async Task HandleParticipantAdded(ParticipantAddedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.RoomId) || string.IsNullOrWhiteSpace(evt.AgentId)) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "removeRoomParticipants")] + public async Task HandleRoomParticipantsRemoved(RoomParticipantsRemovedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.RoomId)) + return; + + // Idempotent: skip if room does not exist + if (!State.Rooms.ContainsKey(evt.RoomId)) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + /// + /// On activation (after event replay), publish the current state so + /// any subscriber that activates the actor can receive the initial snapshot. + /// + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override StreamingProxyParticipantGAgentState TransitionState( + StreamingProxyParticipantGAgentState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyParticipantAdded) + .On(ApplyRoomRemoved) + .OrCurrent(); + } + + private static StreamingProxyParticipantGAgentState ApplyParticipantAdded( + StreamingProxyParticipantGAgentState state, ParticipantAddedEvent evt) + { + var next = state.Clone(); + + if (!next.Rooms.TryGetValue(evt.RoomId, out var list)) + { + list = new ParticipantList(); + next.Rooms[evt.RoomId] = list; + } + + // Remove existing entry for the same agent (upsert semantics) + var existing = list.Participants.FirstOrDefault(p => + string.Equals(p.AgentId, evt.AgentId, StringComparison.Ordinal)); + if (existing is not null) + list.Participants.Remove(existing); + + list.Participants.Add(new ParticipantEntry + { + AgentId = evt.AgentId, + DisplayName = evt.DisplayName, + JoinedAt = evt.JoinedAt, + }); + + return next; + } + + private static StreamingProxyParticipantGAgentState ApplyRoomRemoved( + StreamingProxyParticipantGAgentState state, RoomParticipantsRemovedEvent evt) + { + var next = state.Clone(); + next.Rooms.Remove(evt.RoomId); + return next; + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new StreamingProxyParticipantStateSnapshotEvent + { + Snapshot = State.Clone(), + }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto b/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto new file mode 100644 index 00000000..f599cce2 --- /dev/null +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; +package aevatar.gagents.streaming_proxy_participant; +option csharp_namespace = "Aevatar.GAgents.StreamingProxyParticipant"; + +import "google/protobuf/timestamp.proto"; + +// ─── State ─── + +message ParticipantEntry { + string agent_id = 1; + string display_name = 2; + google.protobuf.Timestamp joined_at = 3; +} + +message ParticipantList { + repeated ParticipantEntry participants = 1; +} + +message StreamingProxyParticipantGAgentState { + // roomId → participants + map rooms = 1; +} + +// ─── Events ─── + +message ParticipantAddedEvent { + string room_id = 1; + string agent_id = 2; + string display_name = 3; + google.protobuf.Timestamp joined_at = 4; +} + +message RoomParticipantsRemovedEvent { + string room_id = 1; +} + +// ─── Readmodel ─── + +// Published by the GAgent after each state change so subscribers can +// maintain an up-to-date readmodel without reading write-model state. +message StreamingProxyParticipantStateSnapshotEvent { + StreamingProxyParticipantGAgentState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.UserConfig/Aevatar.GAgents.UserConfig.csproj b/agents/Aevatar.GAgents.UserConfig/Aevatar.GAgents.UserConfig.csproj new file mode 100644 index 00000000..9661f708 --- /dev/null +++ b/agents/Aevatar.GAgents.UserConfig/Aevatar.GAgents.UserConfig.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.UserConfig + Aevatar.GAgents.UserConfig + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs new file mode 100644 index 00000000..978723d8 --- /dev/null +++ b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs @@ -0,0 +1,66 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.UserConfig; + +/// +/// User-scoped actor that owns the user configuration state. +/// Replaces the chrono-storage backed ChronoStorageUserConfigStore. +/// +/// Actor ID: user-config (user-scoped singleton). +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date projection without +/// reading write-model internal state. +/// +public sealed class UserConfigGAgent : GAgentBase +{ + [EventHandler(EndpointName = "updateConfig")] + public async Task HandleConfigUpdated(UserConfigUpdatedEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + /// + /// On activation (after event replay), publish the current state so + /// any subscriber that activates the actor can receive the initial snapshot. + /// + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override UserConfigGAgentState TransitionState( + UserConfigGAgentState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyConfigUpdated) + .OrCurrent(); + } + + private static UserConfigGAgentState ApplyConfigUpdated( + UserConfigGAgentState state, UserConfigUpdatedEvent evt) + { + return new UserConfigGAgentState + { + DefaultModel = evt.DefaultModel, + PreferredLlmRoute = evt.PreferredLlmRoute, + RuntimeMode = evt.RuntimeMode, + LocalRuntimeBaseUrl = evt.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl = evt.RemoteRuntimeBaseUrl, + MaxToolRounds = evt.MaxToolRounds, + }; + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new UserConfigStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto b/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto new file mode 100644 index 00000000..78440f1f --- /dev/null +++ b/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; +package aevatar.gagents.user_config; +option csharp_namespace = "Aevatar.GAgents.UserConfig"; + +// ─── State ─── + +message UserConfigGAgentState { + string default_model = 1; + string preferred_llm_route = 2; + string runtime_mode = 3; + string local_runtime_base_url = 4; + string remote_runtime_base_url = 5; + int32 max_tool_rounds = 6; +} + +// ─── Events ─── + +message UserConfigUpdatedEvent { + string default_model = 1; + string preferred_llm_route = 2; + string runtime_mode = 3; + string local_runtime_base_url = 4; + string remote_runtime_base_url = 5; + int32 max_tool_rounds = 6; +} + +// ─── Readmodel ─── + +// Published by the GAgent after each state change so subscribers can +// maintain an up-to-date readmodel without reading write-model state. +message UserConfigStateSnapshotEvent { + UserConfigGAgentState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.UserMemory/Aevatar.GAgents.UserMemory.csproj b/agents/Aevatar.GAgents.UserMemory/Aevatar.GAgents.UserMemory.csproj new file mode 100644 index 00000000..759383e3 --- /dev/null +++ b/agents/Aevatar.GAgents.UserMemory/Aevatar.GAgents.UserMemory.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.UserMemory + Aevatar.GAgents.UserMemory + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs new file mode 100644 index 00000000..beba9a3f --- /dev/null +++ b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs @@ -0,0 +1,152 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.UserMemory; + +/// +/// Per-user memory actor that maintains a capped set of memory entries. +/// +/// Actor ID: user-memory-{userId} (user-scoped). +/// +/// Eviction policy (runs inside ): +/// 1. When adding an entry that would exceed , +/// evict the oldest entry in the same category first. +/// 2. If no same-category entry remains, evict the globally oldest entry. +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date projection without +/// reading write-model internal state. +/// +public sealed class UserMemoryGAgent : GAgentBase +{ + internal const int MaxEntries = 50; + + [EventHandler(EndpointName = "addMemoryEntry")] + public async Task HandleMemoryEntryAdded(MemoryEntryAddedEvent evt) + { + if (evt.Entry is null + || string.IsNullOrWhiteSpace(evt.Entry.Id) + || string.IsNullOrWhiteSpace(evt.Entry.Content)) + return; + + // Idempotent: skip if an entry with this ID already exists + if (State.Entries.Any(e => string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal))) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "removeMemoryEntry")] + public async Task HandleMemoryEntryRemoved(MemoryEntryRemovedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.EntryId)) + return; + + // Idempotent: skip if not present + if (!State.Entries.Any(e => string.Equals(e.Id, evt.EntryId, StringComparison.Ordinal))) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + [EventHandler(EndpointName = "clearMemoryEntries")] + public async Task HandleMemoryEntriesCleared(MemoryEntriesClearedEvent evt) + { + if (State.Entries.Count == 0) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + /// + /// On activation (after event replay), publish the current state so + /// any subscriber that activates the actor can receive the initial snapshot. + /// + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override UserMemoryState TransitionState( + UserMemoryState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyAdded) + .On(ApplyRemoved) + .On(ApplyCleared) + .OrCurrent(); + } + + private static UserMemoryState ApplyAdded( + UserMemoryState state, MemoryEntryAddedEvent evt) + { + var next = state.Clone(); + next.Entries.Add(evt.Entry.Clone()); + + // Eviction: enforce global cap. + // Priority: evict oldest in same category first, then globally oldest. + while (next.Entries.Count > MaxEntries) + { + var category = evt.Entry.Category; + var oldestSameCategory = next.Entries + .Where(e => string.Equals(e.Category, category, StringComparison.Ordinal) + && !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal)) + .OrderBy(e => e.CreatedAt) + .FirstOrDefault(); + + if (oldestSameCategory is not null) + { + next.Entries.Remove(oldestSameCategory); + } + else + { + var globallyOldest = next.Entries + .Where(e => !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal)) + .OrderBy(e => e.CreatedAt) + .FirstOrDefault(); + + if (globallyOldest is not null) + next.Entries.Remove(globallyOldest); + else + break; + } + } + + return next; + } + + private static UserMemoryState ApplyRemoved( + UserMemoryState state, MemoryEntryRemovedEvent evt) + { + var next = state.Clone(); + var entry = next.Entries.FirstOrDefault(e => + string.Equals(e.Id, evt.EntryId, StringComparison.Ordinal)); + + if (entry is not null) + next.Entries.Remove(entry); + + return next; + } + + private static UserMemoryState ApplyCleared( + UserMemoryState state, MemoryEntriesClearedEvent _) + { + var next = state.Clone(); + next.Entries.Clear(); + return next; + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new UserMemoryStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto new file mode 100644 index 00000000..1b9a218f --- /dev/null +++ b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; +package aevatar.gagents.user_memory; +option csharp_namespace = "Aevatar.GAgents.UserMemory"; + +// ─── State ─── + +message UserMemoryEntryProto { + string id = 1; + string category = 2; + string content = 3; + string source = 4; + int64 created_at = 5; + int64 updated_at = 6; +} + +message UserMemoryState { + repeated UserMemoryEntryProto entries = 1; +} + +// ─── Events ─── + +message MemoryEntryAddedEvent { + UserMemoryEntryProto entry = 1; +} + +message MemoryEntryRemovedEvent { + string entry_id = 1; +} + +message MemoryEntriesClearedEvent { +} + +// ─── Readmodel ─── + +// Published by the GAgent after each state change so subscribers can +// maintain an up-to-date readmodel without reading write-model state. +message UserMemoryStateSnapshotEvent { + UserMemoryState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.WorkflowStorage/Aevatar.GAgents.WorkflowStorage.csproj b/agents/Aevatar.GAgents.WorkflowStorage/Aevatar.GAgents.WorkflowStorage.csproj new file mode 100644 index 00000000..88358f81 --- /dev/null +++ b/agents/Aevatar.GAgents.WorkflowStorage/Aevatar.GAgents.WorkflowStorage.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.WorkflowStorage + Aevatar.GAgents.WorkflowStorage + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs b/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs new file mode 100644 index 00000000..32a8fb9b --- /dev/null +++ b/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs @@ -0,0 +1,67 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.WorkflowStorage; + +/// +/// Singleton actor that stores workflow YAML artifacts keyed by workflow ID. +/// Replaces the chrono-storage backed ChronoStorageWorkflowStoragePort. +/// +/// Actor ID: workflow-storage (cluster-scoped singleton). +/// +/// After each state change, publishes +/// so readmodel subscribers can maintain an up-to-date projection without +/// reading write-model internal state. +/// +public sealed class WorkflowStorageGAgent : GAgentBase +{ + [EventHandler(EndpointName = "uploadWorkflowYaml")] + public async Task HandleWorkflowYamlUploaded(WorkflowYamlUploadedEvent evt) + { + if (string.IsNullOrWhiteSpace(evt.WorkflowId) || string.IsNullOrWhiteSpace(evt.Yaml)) + return; + + await PersistDomainEventAsync(evt); + await PublishStateSnapshotAsync(); + } + + /// + /// On activation (after event replay), publish the current state so + /// any subscriber that activates the actor can receive the initial snapshot. + /// + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishStateSnapshotAsync(); + } + + protected override WorkflowStorageState TransitionState( + WorkflowStorageState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyYamlUploaded) + .OrCurrent(); + } + + private static WorkflowStorageState ApplyYamlUploaded( + WorkflowStorageState state, WorkflowYamlUploadedEvent evt) + { + var next = state.Clone(); + next.Workflows[evt.WorkflowId] = new WorkflowEntry + { + WorkflowName = evt.WorkflowName, + Yaml = evt.Yaml, + }; + return next; + } + + private async Task PublishStateSnapshotAsync() + { + var snapshot = new WorkflowStorageStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.WorkflowStorage/workflow_storage_messages.proto b/agents/Aevatar.GAgents.WorkflowStorage/workflow_storage_messages.proto new file mode 100644 index 00000000..9101249d --- /dev/null +++ b/agents/Aevatar.GAgents.WorkflowStorage/workflow_storage_messages.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; +package aevatar.gagents.workflow_storage; +option csharp_namespace = "Aevatar.GAgents.WorkflowStorage"; + +// ─── State ─── + +message WorkflowEntry { + string workflow_name = 1; + string yaml = 2; +} + +message WorkflowStorageState { + // workflowId → (name, yaml) + map workflows = 1; +} + +// ─── Events ─── + +message WorkflowYamlUploadedEvent { + string workflow_id = 1; + string workflow_name = 2; + string yaml = 3; +} + +// ─── Readmodel ─── + +// Published by the GAgent after each state change so subscribers can +// maintain an up-to-date readmodel without reading write-model state. +message WorkflowStorageStateSnapshotEvent { + WorkflowStorageState snapshot = 1; +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs index e0c6bfdc..795ab006 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs @@ -3,6 +3,13 @@ namespace Aevatar.Studio.Application.Studio.Abstractions; /// /// Persistent participant index for streaming proxy rooms. /// +/// +/// TODO: When wiring to endpoints, the caller must handle corrupt-data exceptions +/// from the underlying store (e.g. from +/// deserialization failures). Swallowing errors and returning an empty list would +/// silently discard existing participants. The chrono-storage implementation +/// intentionally throws on corruption to prevent data loss. +/// public interface IStreamingProxyParticipantStore { Task> ListAsync( diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs new file mode 100644 index 00000000..eacf4731 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs @@ -0,0 +1,335 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.GAgents.ChatHistory; +using Aevatar.Studio.Application.Studio.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Uses a dual-actor architecture: +/// +/// : per-conversation actor (actorId = chat-{conversationId}) +/// : per-user index actor (actorId = chat-index-{scopeId}) +/// +/// Writes go through actor event handlers. Reads come from snapshots via event subscription. +/// +internal sealed class ActorBackedChatHistoryStore : IChatHistoryStore, IAsyncDisposable +{ + private static readonly ChatHistoryIndex EmptyIndex = new([]); + + private readonly IActorRuntime _runtime; + private readonly IActorEventSubscriptionProvider _subscriptions; + private readonly ILogger _logger; + + // Per-index-actor subscription tracking + private readonly SemaphoreSlim _indexLock = new(1, 1); + private readonly Dictionary _indexSubscriptions = new(StringComparer.Ordinal); + + // Per-conversation subscription tracking + private readonly SemaphoreSlim _conversationLock = new(1, 1); + private readonly Dictionary _conversationSubscriptions = new(StringComparer.Ordinal); + + public ActorBackedChatHistoryStore( + IActorRuntime runtime, + IActorEventSubscriptionProvider subscriptions, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetIndexAsync(string scopeId, CancellationToken ct = default) + { + var sub = await EnsureIndexSubscriptionAsync(scopeId, ct); + var state = sub.Snapshot; + if (state is null) + return EmptyIndex; + + return new ChatHistoryIndex(state.Conversations + .Select(ToConversationMeta) + .OrderByDescending(static c => c.UpdatedAt) + .ThenBy(static c => c.Id, StringComparer.Ordinal) + .ToList() + .AsReadOnly()); + } + + public async Task> GetMessagesAsync( + string scopeId, string conversationId, CancellationToken ct = default) + { + var sub = await EnsureConversationSubscriptionAsync(scopeId, conversationId, ct); + var state = sub.Snapshot; + if (state is null || state.Messages.Count == 0) + return []; + + return state.Messages + .Select(ToStoredChatMessage) + .ToList() + .AsReadOnly(); + } + + public async Task SaveMessagesAsync( + string scopeId, string conversationId, ConversationMeta meta, + IReadOnlyList messages, CancellationToken ct = default) + { + // 1. Send messages to the conversation actor + var conversationActor = await EnsureConversationActorAsync(scopeId, conversationId, ct); + var metaProto = ToConversationMetaProto(conversationId, meta); + var replaceEvt = new MessagesReplacedEvent { Meta = metaProto }; + foreach (var msg in messages) + replaceEvt.Messages.Add(ToStoredChatMessageProto(msg)); + + await SendCommandAsync(conversationActor, replaceEvt, ct); + + // 2. Update the index actor + var indexActor = await EnsureIndexActorAsync(scopeId, ct); + // Update message count from actual messages + var indexMeta = metaProto.Clone(); + indexMeta.MessageCount = messages.Count; + var upsertEvt = new ConversationUpsertedEvent { Meta = indexMeta }; + await SendCommandAsync(indexActor, upsertEvt, ct); + } + + public async Task DeleteConversationAsync( + string scopeId, string conversationId, CancellationToken ct = default) + { + // 1. Mark conversation deleted + var conversationActor = await EnsureConversationActorAsync(scopeId, conversationId, ct); + var deleteEvt = new ConversationDeletedEvent { ConversationId = conversationId }; + await SendCommandAsync(conversationActor, deleteEvt, ct); + + // 2. Remove from index + var indexActor = await EnsureIndexActorAsync(scopeId, ct); + var removeEvt = new ConversationRemovedEvent { ConversationId = conversationId }; + await SendCommandAsync(indexActor, removeEvt, ct); + } + + public async ValueTask DisposeAsync() + { + foreach (var sub in _indexSubscriptions.Values) + { + if (sub.Subscription is not null) + await sub.Subscription.DisposeAsync(); + } + _indexSubscriptions.Clear(); + + foreach (var sub in _conversationSubscriptions.Values) + { + if (sub.Subscription is not null) + await sub.Subscription.DisposeAsync(); + } + _conversationSubscriptions.Clear(); + } + + // ── Index actor helpers ───────────────────────────────────── + + private async Task EnsureIndexSubscriptionAsync(string scopeId, CancellationToken ct) + { + var actorId = IndexActorId(scopeId); + + if (_indexSubscriptions.TryGetValue(actorId, out var existing) && existing.Initialized) + return existing; + + await _indexLock.WaitAsync(ct); + try + { + if (_indexSubscriptions.TryGetValue(actorId, out existing) && existing.Initialized) + return existing; + + var sub = new IndexSubscription(); + sub.Subscription = await _subscriptions.SubscribeAsync( + actorId, + envelope => HandleIndexEventAsync(actorId, envelope), + ct); + + await EnsureIndexActorAsync(scopeId, ct); + sub.Initialized = true; + _indexSubscriptions[actorId] = sub; + return sub; + } + finally + { + _indexLock.Release(); + } + } + + private Task HandleIndexEventAsync(string actorId, EventEnvelope envelope) + { + if (envelope.Payload is null) + return Task.CompletedTask; + + if (envelope.Payload.Is(ChatHistoryIndexStateSnapshotEvent.Descriptor)) + { + var snapshot = envelope.Payload.Unpack(); + if (_indexSubscriptions.TryGetValue(actorId, out var sub)) + { + sub.Snapshot = snapshot.Snapshot; + _logger.LogDebug("Chat history index updated for {ActorId}: {Count} conversations", + actorId, snapshot.Snapshot?.Conversations.Count ?? 0); + } + } + + return Task.CompletedTask; + } + + private async Task EnsureIndexActorAsync(string scopeId, CancellationToken ct) + { + var actorId = IndexActorId(scopeId); + var actor = await _runtime.GetAsync(actorId); + return actor ?? await _runtime.CreateAsync(actorId, ct); + } + + // ── Conversation actor helpers ────────────────────────────── + + private async Task EnsureConversationSubscriptionAsync( + string scopeId, string conversationId, CancellationToken ct) + { + var actorId = ConversationActorId(scopeId, conversationId); + + if (_conversationSubscriptions.TryGetValue(actorId, out var existing) && existing.Initialized) + return existing; + + await _conversationLock.WaitAsync(ct); + try + { + if (_conversationSubscriptions.TryGetValue(actorId, out existing) && existing.Initialized) + return existing; + + var sub = new ConversationSubscription(); + sub.Subscription = await _subscriptions.SubscribeAsync( + actorId, + envelope => HandleConversationEventAsync(actorId, envelope), + ct); + + await EnsureConversationActorAsync(scopeId, conversationId, ct); + sub.Initialized = true; + _conversationSubscriptions[actorId] = sub; + return sub; + } + finally + { + _conversationLock.Release(); + } + } + + private Task HandleConversationEventAsync(string actorId, EventEnvelope envelope) + { + if (envelope.Payload is null) + return Task.CompletedTask; + + if (envelope.Payload.Is(ChatConversationStateSnapshotEvent.Descriptor)) + { + var snapshot = envelope.Payload.Unpack(); + if (_conversationSubscriptions.TryGetValue(actorId, out var sub)) + { + sub.Snapshot = snapshot.Snapshot; + _logger.LogDebug("Chat conversation updated for {ActorId}: {Count} messages", + actorId, snapshot.Snapshot?.Messages.Count ?? 0); + } + } + + return Task.CompletedTask; + } + + private async Task EnsureConversationActorAsync(string scopeId, string conversationId, CancellationToken ct) + { + var actorId = ConversationActorId(scopeId, conversationId); + var actor = await _runtime.GetAsync(actorId); + return actor ?? await _runtime.CreateAsync(actorId, ct); + } + + // ── Actor ID conventions ──────────────────────────────────── + + private static string IndexActorId(string scopeId) => $"chat-index-{scopeId}"; + private static string ConversationActorId(string scopeId, string conversationId) => $"chat-{scopeId}-{conversationId}"; + + // ── Command dispatch ──────────────────────────────────────── + + private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } + + // ── Mapping helpers ───────────────────────────────────────── + + private static ConversationMeta ToConversationMeta(ConversationMetaProto proto) => + new( + Id: proto.Id, + Title: proto.Title, + ServiceId: proto.ServiceId, + ServiceKind: proto.ServiceKind, + CreatedAt: FromUnixMs(proto.CreatedAtMs), + UpdatedAt: FromUnixMs(proto.UpdatedAtMs), + MessageCount: proto.MessageCount, + LlmRoute: string.IsNullOrEmpty(proto.LlmRoute) ? null : proto.LlmRoute, + LlmModel: string.IsNullOrEmpty(proto.LlmModel) ? null : proto.LlmModel); + + private static ConversationMetaProto ToConversationMetaProto(string conversationId, ConversationMeta meta) => + new() + { + Id = conversationId, + Title = meta.Title ?? string.Empty, + ServiceId = meta.ServiceId ?? string.Empty, + ServiceKind = meta.ServiceKind ?? string.Empty, + CreatedAtMs = meta.CreatedAt.ToUnixTimeMilliseconds(), + UpdatedAtMs = meta.UpdatedAt.ToUnixTimeMilliseconds(), + MessageCount = meta.MessageCount, + LlmRoute = meta.LlmRoute ?? string.Empty, + LlmModel = meta.LlmModel ?? string.Empty, + }; + + private static StoredChatMessage ToStoredChatMessage(StoredChatMessageProto proto) => + new( + Id: proto.Id, + Role: proto.Role, + Content: proto.Content, + Timestamp: proto.Timestamp, + Status: proto.Status, + Error: string.IsNullOrEmpty(proto.Error) ? null : proto.Error, + Thinking: string.IsNullOrEmpty(proto.Thinking) ? null : proto.Thinking); + + private static StoredChatMessageProto ToStoredChatMessageProto(StoredChatMessage msg) => + new() + { + Id = msg.Id ?? string.Empty, + Role = msg.Role ?? string.Empty, + Content = msg.Content ?? string.Empty, + Timestamp = msg.Timestamp, + Status = msg.Status ?? string.Empty, + Error = msg.Error ?? string.Empty, + Thinking = msg.Thinking ?? string.Empty, + }; + + private static DateTimeOffset FromUnixMs(long ms) => + ms > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(ms) : DateTimeOffset.UnixEpoch; + + // ── Subscription tracking ─────────────────────────────────── + + private sealed class IndexSubscription + { + public volatile ChatHistoryIndexState? Snapshot; + public IAsyncDisposable? Subscription; + public bool Initialized; + } + + private sealed class ConversationSubscription + { + public volatile ChatConversationState? Snapshot; + public IAsyncDisposable? Subscription; + public bool Initialized; + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs new file mode 100644 index 00000000..aa3a6d02 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -0,0 +1,217 @@ +using System.Text; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.GAgents.ConnectorCatalog; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.Storage; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Remote catalog persistence goes through . +/// Local workspace operations (import, draft) delegate to . +/// +internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogStore, IAsyncDisposable +{ + private const string CatalogActorId = "connector-catalog"; + + private readonly IActorRuntime _runtime; + private readonly IActorEventSubscriptionProvider _subscriptions; + private readonly IStudioWorkspaceStore _workspaceStore; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _initLock = new(1, 1); + private volatile ConnectorCatalogState? _snapshot; + private IAsyncDisposable? _subscription; + private bool _initialized; + + public ActorBackedConnectorCatalogStore( + IActorRuntime runtime, + IActorEventSubscriptionProvider subscriptions, + IStudioWorkspaceStore workspaceStore, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); + _workspaceStore = workspaceStore ?? throw new ArgumentNullException(nameof(workspaceStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetConnectorCatalogAsync( + CancellationToken cancellationToken = default) + { + await EnsureInitializedAsync(cancellationToken); + + var state = _snapshot; + if (state is null || string.IsNullOrEmpty(state.CatalogJson)) + { + return new StoredConnectorCatalog( + HomeDirectory: string.Empty, + FilePath: string.Empty, + FileExists: false, + Connectors: []); + } + + var connectors = await DeserializeCatalogJsonAsync(state.CatalogJson, cancellationToken); + return new StoredConnectorCatalog( + HomeDirectory: string.Empty, + FilePath: string.Empty, + FileExists: true, + Connectors: connectors); + } + + public async Task SaveConnectorCatalogAsync( + StoredConnectorCatalog catalog, + CancellationToken cancellationToken = default) + { + var catalogJson = await SerializeCatalogJsonAsync(catalog.Connectors, cancellationToken); + var actor = await EnsureActorAsync(cancellationToken); + var evt = new ConnectorCatalogSavedEvent { CatalogJson = catalogJson }; + await SendCommandAsync(actor, evt, cancellationToken); + + return new StoredConnectorCatalog( + HomeDirectory: string.Empty, + FilePath: string.Empty, + FileExists: true, + Connectors: catalog.Connectors); + } + + public async Task ImportLocalCatalogAsync( + CancellationToken cancellationToken = default) + { + var localCatalog = await _workspaceStore.GetConnectorCatalogAsync(cancellationToken); + if (!localCatalog.FileExists) + { + throw new InvalidOperationException( + $"Local connector catalog not found at '{localCatalog.FilePath}'."); + } + + // Persist the local catalog into the actor + var saved = await SaveConnectorCatalogAsync( + new StoredConnectorCatalog( + HomeDirectory: string.Empty, + FilePath: string.Empty, + FileExists: true, + Connectors: localCatalog.Connectors), + cancellationToken); + + return new ImportedConnectorCatalog(localCatalog.FilePath, true, saved); + } + + public Task GetConnectorDraftAsync( + CancellationToken cancellationToken = default) + { + return _workspaceStore.GetConnectorDraftAsync(cancellationToken); + } + + public Task SaveConnectorDraftAsync( + StoredConnectorDraft draft, + CancellationToken cancellationToken = default) + { + return _workspaceStore.SaveConnectorDraftAsync(draft, cancellationToken); + } + + public Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) + { + return _workspaceStore.DeleteConnectorDraftAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + if (_subscription is not null) + await _subscription.DisposeAsync(); + } + + private async Task EnsureInitializedAsync(CancellationToken ct) + { + if (_initialized) + return; + + await _initLock.WaitAsync(ct); + try + { + if (_initialized) + return; + + // Subscribe to the catalog actor's events to receive state snapshots + _subscription = await _subscriptions.SubscribeAsync( + CatalogActorId, + HandleCatalogEventAsync, + ct); + + // Activate the actor — triggers event replay + OnActivateAsync + // which publishes the initial state snapshot + await EnsureActorAsync(ct); + + _initialized = true; + } + finally + { + _initLock.Release(); + } + } + + private Task HandleCatalogEventAsync(EventEnvelope envelope) + { + if (envelope.Payload is null) + return Task.CompletedTask; + + if (envelope.Payload.Is(ConnectorCatalogStateSnapshotEvent.Descriptor)) + { + var snapshot = envelope.Payload.Unpack(); + _snapshot = snapshot.Snapshot; + _logger.LogDebug( + "Connector catalog readmodel updated: catalog has {HasCatalog}, draft has {HasDraft}", + !string.IsNullOrEmpty(snapshot.Snapshot?.CatalogJson), + !string.IsNullOrEmpty(snapshot.Snapshot?.DraftJson)); + } + + return Task.CompletedTask; + } + + private async Task EnsureActorAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(CatalogActorId); + if (actor is not null) + return actor; + + return await _runtime.CreateAsync(CatalogActorId, ct); + } + + private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } + + private static async Task SerializeCatalogJsonAsync( + IReadOnlyList connectors, + CancellationToken ct) + { + await using var stream = new MemoryStream(); + await ConnectorCatalogJsonSerializer.WriteCatalogAsync(stream, connectors, ct); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static async Task> DeserializeCatalogJsonAsync( + string json, + CancellationToken ct) + { + var bytes = Encoding.UTF8.GetBytes(json); + await using var stream = new MemoryStream(bytes, writable: false); + return await ConnectorCatalogJsonSerializer.ReadCatalogAsync(stream, ct); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs new file mode 100644 index 00000000..7d8d50a4 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs @@ -0,0 +1,183 @@ +using System.Collections.Concurrent; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.GAgents.Registry; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Per-scope isolation: each scope gets its own gagent-registry-{scopeId} actor. +/// +internal sealed class ActorBackedGAgentActorStore : IGAgentActorStore, IAsyncDisposable +{ + private const string ActorIdPrefix = "gagent-registry-"; + + private readonly IActorRuntime _runtime; + private readonly IActorEventSubscriptionProvider _subscriptions; + private readonly IAppScopeResolver _scopeResolver; + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _scopes = new(StringComparer.Ordinal); + + public ActorBackedGAgentActorStore( + IActorRuntime runtime, + IActorEventSubscriptionProvider subscriptions, + IAppScopeResolver scopeResolver, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> GetAsync( + CancellationToken cancellationToken = default) + { + var scopeState = await EnsureScopeAsync(cancellationToken); + + var state = scopeState.Snapshot; + if (state is null) + return []; + + return state.Groups + .Select(g => new GAgentActorGroup( + g.GagentType, + g.ActorIds.ToList().AsReadOnly())) + .ToList() + .AsReadOnly(); + } + + public async Task AddActorAsync( + string gagentType, string actorId, + CancellationToken cancellationToken = default) + { + var scopeState = await EnsureScopeAsync(cancellationToken); + var actor = await EnsureActorAsync(scopeState.ActorId, cancellationToken); + var evt = new ActorRegisteredEvent + { + GagentType = gagentType, + ActorId = actorId, + }; + await SendCommandAsync(actor, evt, cancellationToken); + } + + public async Task RemoveActorAsync( + string gagentType, string actorId, + CancellationToken cancellationToken = default) + { + var scopeState = await EnsureScopeAsync(cancellationToken); + var actor = await EnsureActorAsync(scopeState.ActorId, cancellationToken); + var evt = new ActorUnregisteredEvent + { + GagentType = gagentType, + ActorId = actorId, + }; + await SendCommandAsync(actor, evt, cancellationToken); + } + + public async ValueTask DisposeAsync() + { + foreach (var scope in _scopes.Values) + { + if (scope.Subscription is not null) + await scope.Subscription.DisposeAsync(); + } + } + + private string ResolveScopeId() + { + var scope = _scopeResolver.Resolve(); + return scope?.ScopeId ?? "default"; + } + + private async Task EnsureScopeAsync(CancellationToken ct) + { + var scopeId = ResolveScopeId(); + var actorId = ActorIdPrefix + scopeId; + + var scopeState = _scopes.GetOrAdd(actorId, _ => new ScopeState(actorId)); + + if (scopeState.Initialized) + return scopeState; + + await scopeState.InitLock.WaitAsync(ct); + try + { + if (scopeState.Initialized) + return scopeState; + + scopeState.Subscription = await _subscriptions.SubscribeAsync( + actorId, + envelope => HandleRegistryEventAsync(actorId, envelope), + ct); + + await EnsureActorAsync(actorId, ct); + scopeState.Initialized = true; + } + finally + { + scopeState.InitLock.Release(); + } + + return scopeState; + } + + private Task HandleRegistryEventAsync(string actorId, EventEnvelope envelope) + { + if (envelope.Payload is null) + return Task.CompletedTask; + + if (envelope.Payload.Is(GAgentRegistryStateSnapshotEvent.Descriptor)) + { + var snapshot = envelope.Payload.Unpack(); + if (_scopes.TryGetValue(actorId, out var scopeState)) + { + scopeState.Snapshot = snapshot.Snapshot; + _logger.LogDebug("Registry readmodel updated for {ActorId}: {GroupCount} groups", + actorId, snapshot.Snapshot?.Groups.Count ?? 0); + } + } + + return Task.CompletedTask; + } + + private async Task EnsureActorAsync(string actorId, CancellationToken ct) + { + var actor = await _runtime.GetAsync(actorId); + if (actor is not null) + return actor; + + return await _runtime.CreateAsync(actorId, ct); + } + + private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } + + private sealed class ScopeState(string actorId) + { + public string ActorId { get; } = actorId; + public SemaphoreSlim InitLock { get; } = new(1, 1); + public volatile bool Initialized; + public volatile GAgentRegistryState? Snapshot; + public IAsyncDisposable? Subscription; + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs new file mode 100644 index 00000000..6fe694aa --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs @@ -0,0 +1,28 @@ +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.Studio.Application.Studio.Abstractions; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Read-only view that extracts LLM preferences from the same +/// backed by UserConfigGAgent. +/// +internal sealed class ActorBackedNyxIdUserLlmPreferencesStore : INyxIdUserLlmPreferencesStore +{ + private readonly IUserConfigStore _userConfigStore; + + public ActorBackedNyxIdUserLlmPreferencesStore(IUserConfigStore userConfigStore) + { + _userConfigStore = userConfigStore ?? throw new ArgumentNullException(nameof(userConfigStore)); + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + var config = await _userConfigStore.GetAsync(cancellationToken); + return new NyxIdUserLlmPreferences( + config.DefaultModel, + UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), + config.MaxToolRounds); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs new file mode 100644 index 00000000..88c834cd --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs @@ -0,0 +1,251 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.GAgents.RoleCatalog; +using Aevatar.Studio.Application.Studio.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Writes go through event handlers. +/// Reads come from a readmodel snapshot maintained via event subscription. +/// Local workspace operations (ImportLocalCatalogAsync) delegate to +/// . +/// +internal sealed class ActorBackedRoleCatalogStore : IRoleCatalogStore, IAsyncDisposable +{ + private const string CatalogActorId = "role-catalog"; + private const string ActorHomeDirectory = "actor://role-catalog"; + private const string ActorFilePath = "actor://role-catalog/roles"; + + private readonly IActorRuntime _runtime; + private readonly IActorEventSubscriptionProvider _subscriptions; + private readonly IStudioWorkspaceStore _localWorkspaceStore; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _initLock = new(1, 1); + private volatile RoleCatalogState? _snapshot; + private IAsyncDisposable? _subscription; + private bool _initialized; + + public ActorBackedRoleCatalogStore( + IActorRuntime runtime, + IActorEventSubscriptionProvider subscriptions, + IStudioWorkspaceStore localWorkspaceStore, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); + _localWorkspaceStore = localWorkspaceStore ?? throw new ArgumentNullException(nameof(localWorkspaceStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) + { + await EnsureInitializedAsync(cancellationToken); + + var state = _snapshot; + var roles = state?.Roles + .Select(ToStoredRoleDefinition) + .ToList() + .AsReadOnly() + ?? (IReadOnlyList)[]; + + return new StoredRoleCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: roles.Count > 0, + Roles: roles); + } + + public async Task SaveRoleCatalogAsync( + StoredRoleCatalog catalog, + CancellationToken cancellationToken = default) + { + var actor = await EnsureActorAsync(cancellationToken); + var evt = new RoleCatalogSavedEvent(); + evt.Roles.AddRange(catalog.Roles.Select(ToProtoRoleDefinition)); + await SendCommandAsync(actor, evt, cancellationToken); + + return new StoredRoleCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: true, + Roles: catalog.Roles); + } + + public async Task ImportLocalCatalogAsync(CancellationToken cancellationToken = default) + { + var localCatalog = await _localWorkspaceStore.GetRoleCatalogAsync(cancellationToken); + if (!localCatalog.FileExists) + { + throw new InvalidOperationException($"Local role catalog not found at '{localCatalog.FilePath}'."); + } + + var actor = await EnsureActorAsync(cancellationToken); + var evt = new RoleCatalogSavedEvent(); + evt.Roles.AddRange(localCatalog.Roles.Select(ToProtoRoleDefinition)); + await SendCommandAsync(actor, evt, cancellationToken); + + var importedCatalog = new StoredRoleCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: true, + Roles: localCatalog.Roles); + + return new ImportedRoleCatalog(localCatalog.FilePath, true, importedCatalog); + } + + public async Task GetRoleDraftAsync(CancellationToken cancellationToken = default) + { + await EnsureInitializedAsync(cancellationToken); + + var state = _snapshot; + var draftEntry = state?.Draft; + if (draftEntry is null) + { + return new StoredRoleDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: false, + UpdatedAtUtc: null, + Draft: null); + } + + return new StoredRoleDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: true, + UpdatedAtUtc: draftEntry.UpdatedAtUtc?.ToDateTimeOffset(), + Draft: draftEntry.Draft is not null ? ToStoredRoleDefinition(draftEntry.Draft) : null); + } + + public async Task SaveRoleDraftAsync( + StoredRoleDraft draft, + CancellationToken cancellationToken = default) + { + var actor = await EnsureActorAsync(cancellationToken); + var updatedAtUtc = draft.UpdatedAtUtc ?? DateTimeOffset.UtcNow; + var evt = new RoleDraftSavedEvent + { + Draft = draft.Draft is not null ? ToProtoRoleDefinition(draft.Draft) : null, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(updatedAtUtc), + }; + await SendCommandAsync(actor, evt, cancellationToken); + + return new StoredRoleDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: true, + UpdatedAtUtc: updatedAtUtc, + Draft: draft.Draft); + } + + public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) + { + var actor = await EnsureActorAsync(cancellationToken); + await SendCommandAsync(actor, new RoleDraftDeletedEvent(), cancellationToken); + } + + public async ValueTask DisposeAsync() + { + if (_subscription is not null) + await _subscription.DisposeAsync(); + } + + private async Task EnsureInitializedAsync(CancellationToken ct) + { + if (_initialized) + return; + + await _initLock.WaitAsync(ct); + try + { + if (_initialized) + return; + + // Subscribe to the catalog actor's events to receive state snapshots + _subscription = await _subscriptions.SubscribeAsync( + CatalogActorId, + HandleCatalogEventAsync, + ct); + + // Activate the actor — this triggers event replay + OnActivateAsync + // which publishes the initial state snapshot + await EnsureActorAsync(ct); + + _initialized = true; + } + finally + { + _initLock.Release(); + } + } + + private Task HandleCatalogEventAsync(EventEnvelope envelope) + { + if (envelope.Payload is null) + return Task.CompletedTask; + + if (envelope.Payload.Is(RoleCatalogStateSnapshotEvent.Descriptor)) + { + var snapshot = envelope.Payload.Unpack(); + _snapshot = snapshot.Snapshot; + _logger.LogDebug("Role catalog readmodel updated: {RoleCount} roles, draft={HasDraft}", + snapshot.Snapshot?.Roles.Count ?? 0, + snapshot.Snapshot?.Draft is not null); + } + + return Task.CompletedTask; + } + + private async Task EnsureActorAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(CatalogActorId); + if (actor is not null) + return actor; + + return await _runtime.CreateAsync(CatalogActorId, ct); + } + + private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } + + private static StoredRoleDefinition ToStoredRoleDefinition(RoleDefinitionEntry entry) => + new( + Id: entry.Id, + Name: entry.Name, + SystemPrompt: entry.SystemPrompt, + Provider: entry.Provider, + Model: entry.Model, + Connectors: entry.Connectors.ToList().AsReadOnly()); + + private static RoleDefinitionEntry ToProtoRoleDefinition(StoredRoleDefinition def) + { + var entry = new RoleDefinitionEntry + { + Id = def.Id, + Name = def.Name, + SystemPrompt = def.SystemPrompt, + Provider = def.Provider, + Model = def.Model, + }; + entry.Connectors.AddRange(def.Connectors); + return entry; + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs new file mode 100644 index 00000000..bf8227b4 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs @@ -0,0 +1,68 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.ScriptStorage; +using Aevatar.Studio.Application.Studio.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Writes go through event handlers. +/// +/// This port is write-only — no readmodel subscription is needed since +/// the interface only exposes . +/// +internal sealed class ActorBackedScriptStoragePort : IScriptStoragePort +{ + private const string ScriptStorageActorId = "script-storage"; + + private readonly IActorRuntime _runtime; + private readonly ILogger _logger; + + public ActorBackedScriptStoragePort( + IActorRuntime runtime, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task UploadScriptAsync(string scriptId, string sourceText, CancellationToken ct) + { + var actor = await EnsureActorAsync(ct); + var evt = new ScriptUploadedEvent + { + ScriptId = scriptId, + SourceText = sourceText, + }; + await SendCommandAsync(actor, evt, ct); + + _logger.LogDebug("Script {ScriptId} uploaded to actor-backed storage", scriptId); + } + + private async Task EnsureActorAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(ScriptStorageActorId); + if (actor is not null) + return actor; + + return await _runtime.CreateAsync(ScriptStorageActorId, ct); + } + + private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs new file mode 100644 index 00000000..70a594cd --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs @@ -0,0 +1,162 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.GAgents.StreamingProxyParticipant; +using Aevatar.Studio.Application.Studio.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Writes go through event handlers. +/// Reads come from a readmodel snapshot maintained via event subscription. +/// +internal sealed class ActorBackedStreamingProxyParticipantStore + : IStreamingProxyParticipantStore, IAsyncDisposable +{ + private const string ParticipantActorId = "streaming-proxy-participants"; + + private readonly IActorRuntime _runtime; + private readonly IActorEventSubscriptionProvider _subscriptions; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _initLock = new(1, 1); + private volatile StreamingProxyParticipantGAgentState? _snapshot; + private IAsyncDisposable? _subscription; + private bool _initialized; + + public ActorBackedStreamingProxyParticipantStore( + IActorRuntime runtime, + IActorEventSubscriptionProvider subscriptions, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> ListAsync( + string roomId, CancellationToken cancellationToken = default) + { + await EnsureInitializedAsync(cancellationToken); + + var state = _snapshot; + if (state is null) + return []; + + if (!state.Rooms.TryGetValue(roomId, out var list)) + return []; + + return list.Participants + .Select(p => new StreamingProxyParticipant( + p.AgentId, + p.DisplayName, + p.JoinedAt.ToDateTimeOffset())) + .ToList() + .AsReadOnly(); + } + + public async Task AddAsync( + string roomId, string agentId, string displayName, + CancellationToken cancellationToken = default) + { + var actor = await EnsureActorAsync(cancellationToken); + var evt = new ParticipantAddedEvent + { + RoomId = roomId, + AgentId = agentId, + DisplayName = displayName, + JoinedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + await SendCommandAsync(actor, evt, cancellationToken); + } + + public async Task RemoveRoomAsync( + string roomId, CancellationToken cancellationToken = default) + { + var actor = await EnsureActorAsync(cancellationToken); + var evt = new RoomParticipantsRemovedEvent + { + RoomId = roomId, + }; + await SendCommandAsync(actor, evt, cancellationToken); + } + + public async ValueTask DisposeAsync() + { + if (_subscription is not null) + await _subscription.DisposeAsync(); + } + + private async Task EnsureInitializedAsync(CancellationToken ct) + { + if (_initialized) + return; + + await _initLock.WaitAsync(ct); + try + { + if (_initialized) + return; + + // Subscribe to the participant actor's events to receive state snapshots + _subscription = await _subscriptions.SubscribeAsync( + ParticipantActorId, + HandleParticipantEventAsync, + ct); + + // Activate the actor — this triggers event replay + OnActivateAsync + // which publishes the initial state snapshot + await EnsureActorAsync(ct); + + _initialized = true; + } + finally + { + _initLock.Release(); + } + } + + private Task HandleParticipantEventAsync(EventEnvelope envelope) + { + if (envelope.Payload is null) + return Task.CompletedTask; + + if (envelope.Payload.Is(StreamingProxyParticipantStateSnapshotEvent.Descriptor)) + { + var snapshot = envelope.Payload.Unpack(); + _snapshot = snapshot.Snapshot; + _logger.LogDebug( + "Participant readmodel updated: {RoomCount} rooms", + snapshot.Snapshot?.Rooms.Count ?? 0); + } + + return Task.CompletedTask; + } + + private async Task EnsureActorAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(ParticipantActorId); + if (actor is not null) + return actor; + + return await _runtime.CreateAsync(ParticipantActorId, ct); + } + + private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs new file mode 100644 index 00000000..63781dc1 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs @@ -0,0 +1,190 @@ +using System.Collections.Concurrent; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.GAgents.UserConfig; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Per-scope isolation: each scope gets its own user-config-{scopeId} actor. +/// +internal sealed class ActorBackedUserConfigStore : IUserConfigStore, IAsyncDisposable +{ + private const string ActorIdPrefix = "user-config-"; + + private readonly IActorRuntime _runtime; + private readonly IActorEventSubscriptionProvider _subscriptions; + private readonly IAppScopeResolver _scopeResolver; + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _scopes = new(StringComparer.Ordinal); + + public ActorBackedUserConfigStore( + IActorRuntime runtime, + IActorEventSubscriptionProvider subscriptions, + IAppScopeResolver scopeResolver, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + var scopeState = await EnsureScopeAsync(cancellationToken); + + var state = scopeState.Snapshot; + if (state is null) + return CreateDefaultConfig(); + + return new UserConfig( + DefaultModel: state.DefaultModel, + PreferredLlmRoute: string.IsNullOrEmpty(state.PreferredLlmRoute) + ? UserConfigLlmRouteDefaults.Gateway + : state.PreferredLlmRoute, + RuntimeMode: string.IsNullOrEmpty(state.RuntimeMode) + ? UserConfigRuntimeDefaults.LocalMode + : state.RuntimeMode, + LocalRuntimeBaseUrl: string.IsNullOrEmpty(state.LocalRuntimeBaseUrl) + ? UserConfigRuntimeDefaults.LocalRuntimeBaseUrl + : state.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl: string.IsNullOrEmpty(state.RemoteRuntimeBaseUrl) + ? UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl + : state.RemoteRuntimeBaseUrl, + MaxToolRounds: state.MaxToolRounds); + } + + public async Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) + { + var scopeState = await EnsureScopeAsync(cancellationToken); + var actor = await EnsureActorAsync(scopeState.ActorId, cancellationToken); + var evt = new UserConfigUpdatedEvent + { + DefaultModel = config.DefaultModel, + PreferredLlmRoute = UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), + RuntimeMode = UserConfigRuntime.NormalizeMode(config.RuntimeMode), + LocalRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( + config.LocalRuntimeBaseUrl, + UserConfigRuntimeDefaults.LocalRuntimeBaseUrl), + RemoteRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( + config.RemoteRuntimeBaseUrl, + UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl), + MaxToolRounds = config.MaxToolRounds, + }; + await SendCommandAsync(actor, evt, cancellationToken); + } + + public async ValueTask DisposeAsync() + { + foreach (var scope in _scopes.Values) + { + if (scope.Subscription is not null) + await scope.Subscription.DisposeAsync(); + } + } + + private string ResolveScopeId() + { + var scope = _scopeResolver.Resolve(); + return scope?.ScopeId ?? "default"; + } + + private async Task EnsureScopeAsync(CancellationToken ct) + { + var scopeId = ResolveScopeId(); + var actorId = ActorIdPrefix + scopeId; + + var scopeState = _scopes.GetOrAdd(actorId, _ => new ScopeState(actorId)); + + if (scopeState.Initialized) + return scopeState; + + await scopeState.InitLock.WaitAsync(ct); + try + { + if (scopeState.Initialized) + return scopeState; + + scopeState.Subscription = await _subscriptions.SubscribeAsync( + actorId, + envelope => HandleConfigEventAsync(actorId, envelope), + ct); + + await EnsureActorAsync(actorId, ct); + scopeState.Initialized = true; + } + finally + { + scopeState.InitLock.Release(); + } + + return scopeState; + } + + private Task HandleConfigEventAsync(string actorId, EventEnvelope envelope) + { + if (envelope.Payload is null) + return Task.CompletedTask; + + if (envelope.Payload.Is(UserConfigStateSnapshotEvent.Descriptor)) + { + var snapshot = envelope.Payload.Unpack(); + if (_scopes.TryGetValue(actorId, out var scopeState)) + { + scopeState.Snapshot = snapshot.Snapshot; + _logger.LogDebug("User config readmodel updated for {ActorId}", actorId); + } + } + + return Task.CompletedTask; + } + + private async Task EnsureActorAsync(string actorId, CancellationToken ct) + { + var actor = await _runtime.GetAsync(actorId); + if (actor is not null) + return actor; + + return await _runtime.CreateAsync(actorId, ct); + } + + private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } + + private static UserConfig CreateDefaultConfig() => + new( + DefaultModel: string.Empty, + PreferredLlmRoute: UserConfigLlmRouteDefaults.Gateway, + RuntimeMode: UserConfigRuntimeDefaults.LocalMode, + LocalRuntimeBaseUrl: UserConfigRuntimeDefaults.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl: UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl); + + private sealed class ScopeState(string actorId) + { + public string ActorId { get; } = actorId; + public SemaphoreSlim InitLock { get; } = new(1, 1); + public volatile bool Initialized; + public volatile UserConfigGAgentState? Snapshot; + public IAsyncDisposable? Subscription; + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs new file mode 100644 index 00000000..f06bdc93 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs @@ -0,0 +1,330 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.GAgents.UserMemory; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Per-scope isolation: each scope gets its own user-memory-{scopeId} actor. +/// +internal sealed class ActorBackedUserMemoryStore : IUserMemoryStore, IAsyncDisposable +{ + private const string ActorIdPrefix = "user-memory-"; + + private readonly IActorRuntime _runtime; + private readonly IActorEventSubscriptionProvider _subscriptions; + private readonly IAppScopeResolver _scopeResolver; + private readonly ILogger _logger; + + private readonly ConcurrentDictionary _scopes = new(StringComparer.Ordinal); + + public ActorBackedUserMemoryStore( + IActorRuntime runtime, + IActorEventSubscriptionProvider subscriptions, + IAppScopeResolver scopeResolver, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetAsync(CancellationToken ct = default) + { + var scopeState = await EnsureScopeAsync(ct); + + var state = scopeState.Snapshot; + if (state is null) + return UserMemoryDocument.Empty; + + var entries = state.Entries + .Where(e => !string.IsNullOrWhiteSpace(e.Id) && !string.IsNullOrWhiteSpace(e.Content)) + .Select(e => new UserMemoryEntry( + Id: e.Id, + Category: e.Category, + Content: e.Content, + Source: e.Source, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt)) + .ToList(); + + return new UserMemoryDocument(1, entries); + } + + public async Task SaveAsync(UserMemoryDocument document, CancellationToken ct = default) + { + // Actor-backed store does not support bulk save. + // The canonical path is AddEntryAsync / RemoveEntryAsync. + // SaveAsync is kept for interface compatibility; it reconciles + // the actor state by adding missing entries and removing stale ones. + var current = await GetAsync(ct); + + var currentIds = new HashSet(current.Entries.Select(e => e.Id), StringComparer.Ordinal); + var targetIds = new HashSet(document.Entries.Select(e => e.Id), StringComparer.Ordinal); + + // Remove entries not in the target document + foreach (var id in currentIds.Except(targetIds)) + { + await RemoveEntryAsync(id, ct); + } + + // Add entries not in the current state + foreach (var entry in document.Entries.Where(e => !currentIds.Contains(e.Id))) + { + var actor = await EnsureActorAsync(ct); + var evt = new MemoryEntryAddedEvent + { + Entry = new UserMemoryEntryProto + { + Id = entry.Id, + Category = entry.Category, + Content = entry.Content, + Source = entry.Source, + CreatedAt = entry.CreatedAt, + UpdatedAt = entry.UpdatedAt, + }, + }; + await SendCommandAsync(actor, evt, ct); + } + } + + public async Task AddEntryAsync( + string category, string content, string source, CancellationToken ct = default) + { + var actor = await EnsureActorAsync(ct); + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var entry = new UserMemoryEntryProto + { + Id = GenerateId(), + Category = NormalizeCategory(category), + Content = content.Trim(), + Source = NormalizeSource(source), + CreatedAt = now, + UpdatedAt = now, + }; + + var evt = new MemoryEntryAddedEvent { Entry = entry }; + await SendCommandAsync(actor, evt, ct); + + return new UserMemoryEntry( + Id: entry.Id, + Category: entry.Category, + Content: entry.Content, + Source: entry.Source, + CreatedAt: entry.CreatedAt, + UpdatedAt: entry.UpdatedAt); + } + + public async Task RemoveEntryAsync(string id, CancellationToken ct = default) + { + var scopeState = await EnsureScopeAsync(ct); + + var state = scopeState.Snapshot; + if (state is null || !state.Entries.Any(e => string.Equals(e.Id, id, StringComparison.Ordinal))) + return false; + + var actor = await EnsureActorAsync(ct); + var evt = new MemoryEntryRemovedEvent { EntryId = id }; + await SendCommandAsync(actor, evt, ct); + return true; + } + + public async Task BuildPromptSectionAsync(int maxChars = 2000, CancellationToken ct = default) + { + UserMemoryDocument doc; + try + { + doc = await GetAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to load user memory for prompt injection"); + return string.Empty; + } + + if (doc.Entries.Count == 0) + return string.Empty; + + var sb = new StringBuilder(); + sb.AppendLine(""); + + var categoryOrder = new[] + { + UserMemoryCategories.Preference, + UserMemoryCategories.Instruction, + UserMemoryCategories.Context, + }; + + var grouped = doc.Entries + .GroupBy(e => e.Category) + .OrderBy(g => Array.IndexOf(categoryOrder, g.Key) is var i && i >= 0 ? i : int.MaxValue); + + foreach (var group in grouped) + { + var header = group.Key switch + { + UserMemoryCategories.Preference => "## Preferences", + UserMemoryCategories.Instruction => "## Instructions", + UserMemoryCategories.Context => "## Context", + _ => $"## {Capitalize(group.Key)}", + }; + sb.AppendLine(header); + foreach (var entry in group.OrderByDescending(e => e.UpdatedAt)) + sb.AppendLine($"- {entry.Content}"); + sb.AppendLine(); + } + + sb.Append(""); + + var result = sb.ToString(); + if (result.Length <= maxChars) + return result; + + // Truncate to maxChars at a newline boundary. + var truncated = result[..maxChars]; + var lastNewline = truncated.LastIndexOf('\n'); + return lastNewline > 0 + ? truncated[..lastNewline] + "\n" + : truncated; + } + + public async ValueTask DisposeAsync() + { + foreach (var scope in _scopes.Values) + { + if (scope.Subscription is not null) + await scope.Subscription.DisposeAsync(); + } + } + + private string ResolveActorId() + { + var scope = _scopeResolver.Resolve(); + if (scope is null) + throw new InvalidOperationException( + "User memory store requires an authenticated user scope. No scope could be resolved."); + return ActorIdPrefix + scope.ScopeId; + } + + private async Task EnsureScopeAsync(CancellationToken ct) + { + var actorId = ResolveActorId(); + var scopeState = _scopes.GetOrAdd(actorId, _ => new ScopeState(actorId)); + + if (scopeState.Initialized) + return scopeState; + + await scopeState.InitLock.WaitAsync(ct); + try + { + if (scopeState.Initialized) + return scopeState; + + scopeState.Subscription = await _subscriptions.SubscribeAsync( + actorId, + envelope => HandleSnapshotEventAsync(actorId, envelope), + ct); + + await EnsureActorAsync(actorId, ct); + scopeState.Initialized = true; + } + finally + { + scopeState.InitLock.Release(); + } + + return scopeState; + } + + private Task HandleSnapshotEventAsync(string actorId, EventEnvelope envelope) + { + if (envelope.Payload is null) + return Task.CompletedTask; + + if (envelope.Payload.Is(UserMemoryStateSnapshotEvent.Descriptor)) + { + var snapshot = envelope.Payload.Unpack(); + if (_scopes.TryGetValue(actorId, out var scopeState)) + { + scopeState.Snapshot = snapshot.Snapshot; + _logger.LogDebug("User memory readmodel updated for {ActorId}: {EntryCount} entries", + actorId, snapshot.Snapshot?.Entries.Count ?? 0); + } + } + + return Task.CompletedTask; + } + + private async Task EnsureActorAsync(string actorId, CancellationToken ct) + { + var actor = await _runtime.GetAsync(actorId); + if (actor is not null) + return actor; + + return await _runtime.CreateAsync(actorId, ct); + } + + private async Task EnsureActorAsync(CancellationToken ct) + { + var actorId = ResolveActorId(); + return await EnsureActorAsync(actorId, ct); + } + + private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } + + private static string GenerateId() + { + var bytes = RandomNumberGenerator.GetBytes(6); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static string NormalizeCategory(string? value) => + value?.Trim().ToLowerInvariant() switch + { + UserMemoryCategories.Preference => UserMemoryCategories.Preference, + UserMemoryCategories.Instruction => UserMemoryCategories.Instruction, + UserMemoryCategories.Context => UserMemoryCategories.Context, + null or "" => UserMemoryCategories.Context, + var v => v, + }; + + private static string NormalizeSource(string? value) => + string.Equals(value?.Trim(), UserMemorySources.Explicit, StringComparison.OrdinalIgnoreCase) + ? UserMemorySources.Explicit + : UserMemorySources.Inferred; + + private static string Capitalize(string s) => + s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; + + private sealed class ScopeState(string actorId) + { + public string ActorId { get; } = actorId; + public SemaphoreSlim InitLock { get; } = new(1, 1); + public volatile bool Initialized; + public volatile UserMemoryState? Snapshot; + public IAsyncDisposable? Subscription; + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs new file mode 100644 index 00000000..a233c99a --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs @@ -0,0 +1,66 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.WorkflowStorage; +using Aevatar.Studio.Application.Studio.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Actor-backed implementation of . +/// Writes go through event handlers. +/// +internal sealed class ActorBackedWorkflowStoragePort : IWorkflowStoragePort +{ + private const string StorageActorId = "workflow-storage"; + + private readonly IActorRuntime _runtime; + private readonly ILogger _logger; + + public ActorBackedWorkflowStoragePort( + IActorRuntime runtime, + ILogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task UploadWorkflowYamlAsync( + string workflowId, string workflowName, string yaml, CancellationToken ct) + { + var actor = await EnsureActorAsync(ct); + var evt = new WorkflowYamlUploadedEvent + { + WorkflowId = workflowId, + WorkflowName = workflowName, + Yaml = yaml, + }; + await SendCommandAsync(actor, evt, ct); + _logger.LogDebug("Workflow YAML uploaded via actor: {WorkflowId}", workflowId); + } + + private async Task EnsureActorAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(StorageActorId); + if (actor is not null) + return actor; + + return await _runtime.CreateAsync(StorageActorId, ct); + } + + private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj index 01c3204e..4c8a9237 100644 --- a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj +++ b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj @@ -11,6 +11,15 @@ + + + + + + + + + diff --git a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index bd5bfce7..cdba0979 100644 --- a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Domain.Studio.Compatibility; using Aevatar.Studio.Domain.Studio.Services; +using Aevatar.Studio.Infrastructure.ActorBacked; using Aevatar.Studio.Infrastructure.Middleware; using Aevatar.Studio.Infrastructure.Serialization; using Aevatar.Studio.Infrastructure.Storage; @@ -27,19 +28,21 @@ public static IServiceCollection AddStudioInfrastructure( services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + // chrono-storage blob client retained for media file uploads (ExplorerEndpoints) services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + // ── Actor-backed stores (replacing ChronoStorage* implementations) ── + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); return services; } From bb5a4c129440a80191e7b334015d62d7c4610531 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Thu, 9 Apr 2026 12:17:40 +0800 Subject: [PATCH 05/21] feat: introduce persistent readmodel actors for GAgent state projection - Add new readmodel GAgents (UserMemoryReadModelGAgent, RoleCatalogReadModelGAgent, etc.) that receive state updates via SendToAsync - Modify write GAgents to push state snapshots to readmodel counterparts instead of publishing snapshots - Remove chrono-storage implementations (ChronoStorageScriptStoragePort, ChronoStorageGAgentActorStore, etc.) - Update proto messages to include ReadModelUpdateEvent definitions and clarify snapshot semantics - Refactor ActorBackedStore implementations to use per-request readmodel subscriptions - Convert singleton GAgents to per-scope where appropriate (UserConfigGAgent, GAgentRegistryGAgent) - Simplify write-only GAgents (ScriptStorageGAgent, WorkflowStorageGAgent) by removing snapshot publishing --- .../ChatConversationGAgent.cs | 44 +- .../ChatConversationReadModelGAgent.cs | 55 + .../ChatHistoryIndexGAgent.cs | 17 +- .../ChatHistoryIndexReadModelGAgent.cs | 55 + .../chat_history_messages.proto | 14 + .../ConnectorCatalogGAgent.cs | 46 +- .../ConnectorCatalogReadModelGAgent.cs | 55 + .../connector_catalog_messages.proto | 77 +- .../GAgentRegistryGAgent.cs | 26 +- .../GAgentRegistryReadModelGAgent.cs | 55 + .../gagent_registry_messages.proto | 8 +- .../RoleCatalogGAgent.cs | 24 +- .../RoleCatalogReadModelGAgent.cs | 55 + .../role_catalog_messages.proto | 5 + .../ScriptStorageGAgent.cs | 22 +- .../StreamingProxyParticipantGAgent.cs | 25 +- ...treamingProxyParticipantReadModelGAgent.cs | 56 + ...streaming_proxy_participant_messages.proto | 8 +- .../UserConfigGAgent.cs | 22 +- .../UserConfigReadModelGAgent.cs | 55 + .../user_config_messages.proto | 8 +- .../UserMemoryGAgent.cs | 24 +- .../UserMemoryReadModelGAgent.cs | 55 + .../user_memory_messages.proto | 5 + .../WorkflowStorageGAgent.cs | 22 +- .../ActorBackedChatHistoryStore.cs | 251 ++-- .../ActorBackedConnectorCatalogStore.cs | 352 ++++-- .../ActorBackedGAgentActorStore.cs | 138 +-- .../ActorBackedRoleCatalogStore.cs | 112 +- ...torBackedStreamingProxyParticipantStore.cs | 104 +- .../ActorBacked/ActorBackedUserConfigStore.cs | 138 +-- .../ActorBacked/ActorBackedUserMemoryStore.cs | 138 +-- .../Storage/ChronoStorageChatHistoryStore.cs | 527 --------- .../ChronoStorageConnectorCatalogStore.cs | 171 --- .../Storage/ChronoStorageGAgentActorStore.cs | 155 --- ...ronoStorageNyxIdUserLlmPreferencesStore.cs | 23 - .../Storage/ChronoStorageRoleCatalogStore.cs | 171 --- .../Storage/ChronoStorageScriptStoragePort.cs | 24 - ...noStorageStreamingProxyParticipantStore.cs | 128 -- .../Storage/ChronoStorageUserConfigStore.cs | 155 --- .../Storage/ChronoStorageUserMemoryStore.cs | 258 ---- .../ChronoStorageWorkflowStoragePort.cs | 24 - .../ActorBackedGAgentStateTransitionTests.cs | 1046 +++++++++++++++++ .../ActorBackedStoreAdapterTests.cs | 672 +++++++++++ .../ChronoStorageChatHistoryStoreTests.cs | 372 ------ ...ChronoStorageConnectorCatalogStoreTests.cs | 600 ---------- .../ChronoStorageRoleCatalogStoreTests.cs | 502 -------- .../ChronoStorageUserConfigStoreTests.cs | 231 ---- .../ChronoStorageUserMemoryStoreTests.cs | 351 ------ 49 files changed, 2990 insertions(+), 4491 deletions(-) create mode 100644 agents/Aevatar.GAgents.ChatHistory/ChatConversationReadModelGAgent.cs create mode 100644 agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexReadModelGAgent.cs create mode 100644 agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogReadModelGAgent.cs create mode 100644 agents/Aevatar.GAgents.Registry/GAgentRegistryReadModelGAgent.cs create mode 100644 agents/Aevatar.GAgents.RoleCatalog/RoleCatalogReadModelGAgent.cs create mode 100644 agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantReadModelGAgent.cs create mode 100644 agents/Aevatar.GAgents.UserConfig/UserConfigReadModelGAgent.cs create mode 100644 agents/Aevatar.GAgents.UserMemory/UserMemoryReadModelGAgent.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageChatHistoryStore.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageConnectorCatalogStore.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageGAgentActorStore.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageNyxIdUserLlmPreferencesStore.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageRoleCatalogStore.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageScriptStoragePort.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageStreamingProxyParticipantStore.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserConfigStore.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserMemoryStore.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs create mode 100644 test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs create mode 100644 test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs delete mode 100644 test/Aevatar.Tools.Cli.Tests/ChronoStorageChatHistoryStoreTests.cs delete mode 100644 test/Aevatar.Tools.Cli.Tests/ChronoStorageConnectorCatalogStoreTests.cs delete mode 100644 test/Aevatar.Tools.Cli.Tests/ChronoStorageRoleCatalogStoreTests.cs delete mode 100644 test/Aevatar.Tools.Cli.Tests/ChronoStorageUserConfigStoreTests.cs delete mode 100644 test/Aevatar.Tools.Cli.Tests/ChronoStorageUserMemoryStoreTests.cs diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs index ed95f301..86ebea13 100644 --- a/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs +++ b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs @@ -8,10 +8,14 @@ namespace Aevatar.GAgents.ChatHistory; /// /// Per-conversation actor that holds all messages for a single conversation. -/// Actor ID: chat-{conversationId}. +/// Actor ID: chat-{scopeId}-{conversationId}. /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date snapshot. +/// After each state change, pushes the current state to the paired +/// via SendToAsync. +/// +/// When messages are replaced or the conversation is deleted, also forwards +/// the change to the via SendToAsync, +/// ensuring transactional consistency between conversation and index actors. /// public sealed class ChatConversationGAgent : GAgentBase { @@ -28,7 +32,19 @@ public async Task HandleMessagesReplaced(MessagesReplacedEvent evt) var trimmed = TrimMessages(evt); await PersistDomainEventAsync(trimmed); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); + + // Forward index upsert to the index actor + if (!string.IsNullOrWhiteSpace(evt.ScopeId)) + { + var indexActorId = IndexActorId(evt.ScopeId); + var indexMeta = State.Meta?.Clone(); + if (indexMeta is not null) + { + indexMeta.MessageCount = State.Messages.Count; + await SendToAsync(indexActorId, new ConversationUpsertedEvent { Meta = indexMeta }); + } + } } [EventHandler(EndpointName = "deleteConversation")] @@ -42,13 +58,20 @@ public async Task HandleConversationDeleted(ConversationDeletedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); + + // Forward index removal to the index actor + if (!string.IsNullOrWhiteSpace(evt.ScopeId)) + { + var indexActorId = IndexActorId(evt.ScopeId); + await SendToAsync(indexActorId, new ConversationRemovedEvent { ConversationId = evt.ConversationId }); + } } protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } protected override ChatConversationState TransitionState( @@ -91,9 +114,12 @@ private static ChatConversationState ApplyConversationDeleted( return new ChatConversationState(); } - private async Task PublishStateSnapshotAsync() + private async Task PushToReadModelAsync() { - var snapshot = new ChatConversationStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); + var readModelActorId = Id + "-readmodel"; + var update = new ChatConversationReadModelUpdateEvent { Snapshot = State.Clone() }; + await SendToAsync(readModelActorId, update); } + + private static string IndexActorId(string scopeId) => $"chat-index-{scopeId}"; } diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatConversationReadModelGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatConversationReadModelGAgent.cs new file mode 100644 index 00000000..535317a7 --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/ChatConversationReadModelGAgent.cs @@ -0,0 +1,55 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.ChatHistory; + +/// +/// Persistent readmodel actor for a single conversation. +/// Receives state snapshots from via +/// (SendToAsync) and persists them. +/// +/// Actor ID convention: {writeActorId}-readmodel. +/// +/// On activation and after each update, publishes +/// so per-request subscribers +/// (ActorBackedStore) can receive the current projected state. +/// +public sealed class ChatConversationReadModelGAgent : GAgentBase +{ + [EventHandler(EndpointName = "updateReadModel")] + public async Task HandleReadModelUpdate(ChatConversationReadModelUpdateEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishSnapshotAsync(); + } + + protected override ChatConversationState TransitionState( + ChatConversationState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyUpdate) + .OrCurrent(); + } + + private static ChatConversationState ApplyUpdate( + ChatConversationState _, ChatConversationReadModelUpdateEvent evt) + { + return evt.Snapshot?.Clone() ?? new ChatConversationState(); + } + + private async Task PublishSnapshotAsync() + { + var snapshot = new ChatConversationStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs index 4bf1347b..06ae1b4d 100644 --- a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs +++ b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs @@ -10,8 +10,8 @@ namespace Aevatar.GAgents.ChatHistory; /// Per-user index actor that holds conversation list and metadata. /// Actor ID: chat-index-{scopeId}. /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date snapshot. +/// After each state change, pushes the current state to the paired +/// via SendToAsync. /// public sealed class ChatHistoryIndexGAgent : GAgentBase { @@ -22,7 +22,7 @@ public async Task HandleConversationUpserted(ConversationUpsertedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } [EventHandler(EndpointName = "removeConversation")] @@ -38,13 +38,13 @@ public async Task HandleConversationRemoved(ConversationRemovedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } protected override ChatHistoryIndexState TransitionState( @@ -84,9 +84,10 @@ private static ChatHistoryIndexState ApplyConversationRemoved( return next; } - private async Task PublishStateSnapshotAsync() + private async Task PushToReadModelAsync() { - var snapshot = new ChatHistoryIndexStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); + var readModelActorId = Id + "-readmodel"; + var update = new ChatHistoryIndexReadModelUpdateEvent { Snapshot = State.Clone() }; + await SendToAsync(readModelActorId, update); } } diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexReadModelGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexReadModelGAgent.cs new file mode 100644 index 00000000..17df1972 --- /dev/null +++ b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexReadModelGAgent.cs @@ -0,0 +1,55 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.ChatHistory; + +/// +/// Persistent readmodel actor for the chat history index. +/// Receives state snapshots from via +/// (SendToAsync) and persists them. +/// +/// Actor ID convention: {writeActorId}-readmodel. +/// +/// On activation and after each update, publishes +/// so per-request subscribers +/// (ActorBackedStore) can receive the current projected state. +/// +public sealed class ChatHistoryIndexReadModelGAgent : GAgentBase +{ + [EventHandler(EndpointName = "updateReadModel")] + public async Task HandleReadModelUpdate(ChatHistoryIndexReadModelUpdateEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishSnapshotAsync(); + } + + protected override ChatHistoryIndexState TransitionState( + ChatHistoryIndexState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyUpdate) + .OrCurrent(); + } + + private static ChatHistoryIndexState ApplyUpdate( + ChatHistoryIndexState _, ChatHistoryIndexReadModelUpdateEvent evt) + { + return evt.Snapshot?.Clone() ?? new ChatHistoryIndexState(); + } + + private async Task PublishSnapshotAsync() + { + var snapshot = new ChatHistoryIndexStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto index 98699a5a..3f7cbe2c 100644 --- a/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto +++ b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto @@ -38,10 +38,12 @@ message ChatConversationState { message MessagesReplacedEvent { repeated StoredChatMessageProto messages = 1; ConversationMetaProto meta = 2; + string scope_id = 3; } message ConversationDeletedEvent { string conversation_id = 1; + string scope_id = 2; } // ─── ChatConversationGAgent Snapshot ─── @@ -71,3 +73,15 @@ message ConversationRemovedEvent { message ChatHistoryIndexStateSnapshotEvent { ChatHistoryIndexState snapshot = 1; } + +// ─── ReadModel Update Events (sent via SendToAsync from write actors) ─── + +// Sent from ChatConversationGAgent to ChatConversationReadModelGAgent. +message ChatConversationReadModelUpdateEvent { + ChatConversationState snapshot = 1; +} + +// Sent from ChatHistoryIndexGAgent to ChatHistoryIndexReadModelGAgent. +message ChatHistoryIndexReadModelUpdateEvent { + ChatHistoryIndexState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs index 1c839e5d..bf807bf9 100644 --- a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs +++ b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs @@ -7,54 +7,46 @@ namespace Aevatar.GAgents.ConnectorCatalog; /// -/// Singleton actor that persists the connector catalog and draft. +/// Singleton actor that owns the connector catalog and draft. /// Replaces the chrono-storage backed ChronoStorageConnectorCatalogStore -/// for remote persistence operations. +/// for remote persistence concerns. /// /// Actor ID: connector-catalog (cluster-scoped singleton). /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date projection without -/// reading write-model internal state. +/// After each state change, pushes the current state to the paired +/// via SendToAsync. /// public sealed class ConnectorCatalogGAgent : GAgentBase { [EventHandler(EndpointName = "saveCatalog")] public async Task HandleCatalogSaved(ConnectorCatalogSavedEvent evt) { - if (string.IsNullOrWhiteSpace(evt.CatalogJson)) - return; - await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } [EventHandler(EndpointName = "saveDraft")] public async Task HandleDraftSaved(ConnectorDraftSavedEvent evt) { await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } [EventHandler(EndpointName = "deleteDraft")] public async Task HandleDraftDeleted(ConnectorDraftDeletedEvent evt) { // Idempotent: skip if no draft exists - if (string.IsNullOrEmpty(State.DraftJson)) + if (State.Draft is null) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } - /// - /// On activation (after event replay), publish the current state so - /// any subscriber that activates the actor can receive the initial snapshot. - /// protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } protected override ConnectorCatalogState TransitionState( @@ -72,7 +64,8 @@ private static ConnectorCatalogState ApplyCatalogSaved( ConnectorCatalogState state, ConnectorCatalogSavedEvent evt) { var next = state.Clone(); - next.CatalogJson = evt.CatalogJson; + next.Connectors.Clear(); + next.Connectors.AddRange(evt.Connectors); return next; } @@ -80,8 +73,11 @@ private static ConnectorCatalogState ApplyDraftSaved( ConnectorCatalogState state, ConnectorDraftSavedEvent evt) { var next = state.Clone(); - next.DraftJson = evt.DraftJson; - next.DraftUpdatedAtUtc = evt.UpdatedAtUtc; + next.Draft = new ConnectorDraftEntry + { + Draft = evt.Draft?.Clone(), + UpdatedAtUtc = evt.UpdatedAtUtc, + }; return next; } @@ -89,14 +85,14 @@ private static ConnectorCatalogState ApplyDraftDeleted( ConnectorCatalogState state, ConnectorDraftDeletedEvent _) { var next = state.Clone(); - next.DraftJson = string.Empty; - next.DraftUpdatedAtUtc = null; + next.Draft = null; return next; } - private async Task PublishStateSnapshotAsync() + private async Task PushToReadModelAsync() { - var snapshot = new ConnectorCatalogStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); + var readModelActorId = Id + "-readmodel"; + var update = new ConnectorCatalogReadModelUpdateEvent { Snapshot = State.Clone() }; + await SendToAsync(readModelActorId, update); } } diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogReadModelGAgent.cs b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogReadModelGAgent.cs new file mode 100644 index 00000000..333311db --- /dev/null +++ b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogReadModelGAgent.cs @@ -0,0 +1,55 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.ConnectorCatalog; + +/// +/// Persistent readmodel actor for the connector catalog. +/// Receives state snapshots from via +/// (SendToAsync) and persists them. +/// +/// Actor ID convention: {writeActorId}-readmodel. +/// +/// On activation and after each update, publishes +/// so per-request subscribers +/// (ActorBackedStore) can receive the current projected state. +/// +public sealed class ConnectorCatalogReadModelGAgent : GAgentBase +{ + [EventHandler(EndpointName = "updateReadModel")] + public async Task HandleReadModelUpdate(ConnectorCatalogReadModelUpdateEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishSnapshotAsync(); + } + + protected override ConnectorCatalogState TransitionState( + ConnectorCatalogState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyUpdate) + .OrCurrent(); + } + + private static ConnectorCatalogState ApplyUpdate( + ConnectorCatalogState _, ConnectorCatalogReadModelUpdateEvent evt) + { + return evt.Snapshot?.Clone() ?? new ConnectorCatalogState(); + } + + private async Task PublishSnapshotAsync() + { + var snapshot = new ConnectorCatalogStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto index 1d059ba5..7a8f6772 100644 --- a/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto +++ b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto @@ -4,25 +4,78 @@ option csharp_namespace = "Aevatar.GAgents.ConnectorCatalog"; import "google/protobuf/timestamp.proto"; +// ─── Sub-messages ─── + +message ConnectorAuthEntry { + string type = 1; + string token_url = 2; + string client_id = 3; + string client_secret = 4; + string scope = 5; +} + +message HttpConnectorConfigEntry { + string base_url = 1; + repeated string allowed_methods = 2; + repeated string allowed_paths = 3; + repeated string allowed_input_keys = 4; + map default_headers = 5; + ConnectorAuthEntry auth = 6; +} + +message CliConnectorConfigEntry { + string command = 1; + repeated string fixed_arguments = 2; + repeated string allowed_operations = 3; + repeated string allowed_input_keys = 4; + string working_directory = 5; + map environment = 6; +} + +message McpConnectorConfigEntry { + string server_name = 1; + string command = 2; + string url = 3; + repeated string arguments = 4; + map environment = 5; + map additional_headers = 6; + ConnectorAuthEntry auth = 7; + string default_tool = 8; + repeated string allowed_tools = 9; + repeated string allowed_input_keys = 10; +} + // ─── State ─── -// Actor state persists catalog and draft as JSON strings. -// The complex nested connector types (StoredConnectorDefinition, etc.) -// are serialized/deserialized at the ActorBackedStore boundary. +message ConnectorDefinitionEntry { + string name = 1; + string type = 2; + bool enabled = 3; + int32 timeout_ms = 4; + int32 retry = 5; + HttpConnectorConfigEntry http = 6; + CliConnectorConfigEntry cli = 7; + McpConnectorConfigEntry mcp = 8; +} + +message ConnectorDraftEntry { + ConnectorDefinitionEntry draft = 1; + google.protobuf.Timestamp updated_at_utc = 2; +} + message ConnectorCatalogState { - string catalog_json = 1; - string draft_json = 2; - google.protobuf.Timestamp draft_updated_at_utc = 3; + repeated ConnectorDefinitionEntry connectors = 1; + ConnectorDraftEntry draft = 2; // null when no draft exists } // ─── Events ─── message ConnectorCatalogSavedEvent { - string catalog_json = 1; + repeated ConnectorDefinitionEntry connectors = 1; } message ConnectorDraftSavedEvent { - string draft_json = 1; + ConnectorDefinitionEntry draft = 1; google.protobuf.Timestamp updated_at_utc = 2; } @@ -31,8 +84,12 @@ message ConnectorDraftDeletedEvent { // ─── Readmodel ─── -// Published by the GAgent after each state change so subscribers can -// maintain an up-to-date readmodel without reading write-model state. +// Published by ReadModel GAgent so subscribers can observe the current projected state. message ConnectorCatalogStateSnapshotEvent { ConnectorCatalogState snapshot = 1; } + +// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. +message ConnectorCatalogReadModelUpdateEvent { + ConnectorCatalogState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs index 1e2e238d..791caa3b 100644 --- a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs +++ b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs @@ -8,14 +8,13 @@ namespace Aevatar.GAgents.Registry; /// -/// Singleton registry actor that tracks all GAgent actor IDs grouped by type. +/// Per-scope registry actor that tracks all GAgent actor IDs grouped by type. /// Replaces the chrono-storage backed ChronoStorageGAgentActorStore. /// -/// Actor ID: gagent-registry (cluster-scoped singleton). +/// Actor ID: gagent-registry-{scopeId} (per-scope). /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date projection without -/// reading write-model internal state. +/// After each state change, pushes the current state to the paired +/// via SendToAsync. /// public sealed class GAgentRegistryGAgent : GAgentBase { @@ -32,7 +31,7 @@ public async Task HandleActorRegistered(ActorRegisteredEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } [EventHandler(EndpointName = "unregisterActor")] @@ -48,17 +47,13 @@ public async Task HandleActorUnregistered(ActorUnregisteredEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } - /// - /// On activation (after event replay), publish the current state so - /// any subscriber that activates the actor can receive the initial snapshot. - /// protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } protected override GAgentRegistryState TransitionState( @@ -108,9 +103,10 @@ private static GAgentRegistryState ApplyUnregistered( return next; } - private async Task PublishStateSnapshotAsync() + private async Task PushToReadModelAsync() { - var snapshot = new GAgentRegistryStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); + var readModelActorId = Id + "-readmodel"; + var update = new GAgentRegistryReadModelUpdateEvent { Snapshot = State.Clone() }; + await SendToAsync(readModelActorId, update); } } diff --git a/agents/Aevatar.GAgents.Registry/GAgentRegistryReadModelGAgent.cs b/agents/Aevatar.GAgents.Registry/GAgentRegistryReadModelGAgent.cs new file mode 100644 index 00000000..a3e6a591 --- /dev/null +++ b/agents/Aevatar.GAgents.Registry/GAgentRegistryReadModelGAgent.cs @@ -0,0 +1,55 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.Registry; + +/// +/// Persistent readmodel actor for the GAgent registry. +/// Receives state snapshots from via +/// (SendToAsync) and persists them. +/// +/// Actor ID convention: {writeActorId}-readmodel. +/// +/// On activation and after each update, publishes +/// so per-request subscribers +/// (ActorBackedStore) can receive the current projected state. +/// +public sealed class GAgentRegistryReadModelGAgent : GAgentBase +{ + [EventHandler(EndpointName = "updateReadModel")] + public async Task HandleReadModelUpdate(GAgentRegistryReadModelUpdateEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishSnapshotAsync(); + } + + protected override GAgentRegistryState TransitionState( + GAgentRegistryState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyUpdate) + .OrCurrent(); + } + + private static GAgentRegistryState ApplyUpdate( + GAgentRegistryState _, GAgentRegistryReadModelUpdateEvent evt) + { + return evt.Snapshot?.Clone() ?? new GAgentRegistryState(); + } + + private async Task PublishSnapshotAsync() + { + var snapshot = new GAgentRegistryStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto index d6aed4c8..28381e77 100644 --- a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto +++ b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto @@ -27,8 +27,12 @@ message ActorUnregisteredEvent { // ─── Readmodel ─── -// Published by the GAgent after each state change so subscribers can -// maintain an up-to-date readmodel without reading write-model state. +// Published by ReadModel GAgent so subscribers can observe the current projected state. message GAgentRegistryStateSnapshotEvent { GAgentRegistryState snapshot = 1; } + +// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. +message GAgentRegistryReadModelUpdateEvent { + GAgentRegistryState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs index 2b67ad9a..7de2ca63 100644 --- a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs +++ b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs @@ -14,9 +14,8 @@ namespace Aevatar.GAgents.RoleCatalog; /// /// Actor ID: role-catalog (cluster-scoped singleton). /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date projection without -/// reading write-model internal state. +/// After each state change, pushes the current state to the paired +/// via SendToAsync. /// public sealed class RoleCatalogGAgent : GAgentBase { @@ -24,14 +23,14 @@ public sealed class RoleCatalogGAgent : GAgentBase public async Task HandleCatalogSaved(RoleCatalogSavedEvent evt) { await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } [EventHandler(EndpointName = "saveDraft")] public async Task HandleDraftSaved(RoleDraftSavedEvent evt) { await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } [EventHandler(EndpointName = "deleteDraft")] @@ -41,17 +40,13 @@ public async Task HandleDraftDeleted(RoleDraftDeletedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } - /// - /// On activation (after event replay), publish the current state so - /// any subscriber that activates the actor can receive the initial snapshot. - /// protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } protected override RoleCatalogState TransitionState( @@ -94,9 +89,10 @@ private static RoleCatalogState ApplyDraftDeleted( return next; } - private async Task PublishStateSnapshotAsync() + private async Task PushToReadModelAsync() { - var snapshot = new RoleCatalogStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); + var readModelActorId = Id + "-readmodel"; + var update = new RoleCatalogReadModelUpdateEvent { Snapshot = State.Clone() }; + await SendToAsync(readModelActorId, update); } } diff --git a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogReadModelGAgent.cs b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogReadModelGAgent.cs new file mode 100644 index 00000000..d1ebc48d --- /dev/null +++ b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogReadModelGAgent.cs @@ -0,0 +1,55 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.RoleCatalog; + +/// +/// Persistent readmodel actor for the role catalog. +/// Receives state snapshots from via +/// (SendToAsync) and persists them. +/// +/// Actor ID convention: {writeActorId}-readmodel. +/// +/// On activation and after each update, publishes +/// so per-request subscribers +/// (ActorBackedStore) can receive the current projected state. +/// +public sealed class RoleCatalogReadModelGAgent : GAgentBase +{ + [EventHandler(EndpointName = "updateReadModel")] + public async Task HandleReadModelUpdate(RoleCatalogReadModelUpdateEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishSnapshotAsync(); + } + + protected override RoleCatalogState TransitionState( + RoleCatalogState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyUpdate) + .OrCurrent(); + } + + private static RoleCatalogState ApplyUpdate( + RoleCatalogState _, RoleCatalogReadModelUpdateEvent evt) + { + return evt.Snapshot?.Clone() ?? new RoleCatalogState(); + } + + private async Task PublishSnapshotAsync() + { + var snapshot = new RoleCatalogStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto b/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto index 6d0c8310..5cdb7d58 100644 --- a/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto +++ b/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto @@ -45,3 +45,8 @@ message RoleDraftDeletedEvent {} message RoleCatalogStateSnapshotEvent { RoleCatalogState snapshot = 1; } + +// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. +message RoleCatalogReadModelUpdateEvent { + RoleCatalogState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs b/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs index ec7d3e8a..87814dc3 100644 --- a/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs +++ b/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs @@ -12,9 +12,8 @@ namespace Aevatar.GAgents.ScriptStorage; /// /// Actor ID: script-storage (cluster-scoped singleton). /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date projection without -/// reading write-model internal state. +/// Write-only: no readmodel needed since the only port method is +/// UploadScriptAsync. /// public sealed class ScriptStorageGAgent : GAgentBase { @@ -25,17 +24,6 @@ public async Task HandleScriptUploaded(ScriptUploadedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); - } - - /// - /// On activation (after event replay), publish the current state so - /// any subscriber that activates the actor can receive the initial snapshot. - /// - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); } protected override ScriptStorageState TransitionState( @@ -54,10 +42,4 @@ private static ScriptStorageState ApplyScriptUploaded( next.Scripts[evt.ScriptId] = evt.SourceText; return next; } - - private async Task PublishStateSnapshotAsync() - { - var snapshot = new ScriptStorageStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } } diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs index a63218d0..b58d5b39 100644 --- a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs @@ -14,9 +14,8 @@ namespace Aevatar.GAgents.StreamingProxyParticipant; /// /// Actor ID: streaming-proxy-participants (cluster-scoped singleton). /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date projection without -/// reading write-model internal state. +/// After each state change, pushes the current state to the paired +/// via SendToAsync. /// public sealed class StreamingProxyParticipantGAgent : GAgentBase @@ -28,7 +27,7 @@ public async Task HandleParticipantAdded(ParticipantAddedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } [EventHandler(EndpointName = "removeRoomParticipants")] @@ -42,17 +41,13 @@ public async Task HandleRoomParticipantsRemoved(RoomParticipantsRemovedEvent evt return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } - /// - /// On activation (after event replay), publish the current state so - /// any subscriber that activates the actor can receive the initial snapshot. - /// protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } protected override StreamingProxyParticipantGAgentState TransitionState( @@ -100,12 +95,10 @@ private static StreamingProxyParticipantGAgentState ApplyRoomRemoved( return next; } - private async Task PublishStateSnapshotAsync() + private async Task PushToReadModelAsync() { - var snapshot = new StreamingProxyParticipantStateSnapshotEvent - { - Snapshot = State.Clone(), - }; - await PublishAsync(snapshot, TopologyAudience.Parent); + var readModelActorId = Id + "-readmodel"; + var update = new StreamingProxyParticipantReadModelUpdateEvent { Snapshot = State.Clone() }; + await SendToAsync(readModelActorId, update); } } diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantReadModelGAgent.cs b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantReadModelGAgent.cs new file mode 100644 index 00000000..075d52aa --- /dev/null +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantReadModelGAgent.cs @@ -0,0 +1,56 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.StreamingProxyParticipant; + +/// +/// Persistent readmodel actor for streaming proxy participants. +/// Receives state snapshots from via +/// (SendToAsync) and persists them. +/// +/// Actor ID convention: {writeActorId}-readmodel. +/// +/// On activation and after each update, publishes +/// so per-request subscribers +/// (ActorBackedStore) can receive the current projected state. +/// +public sealed class StreamingProxyParticipantReadModelGAgent + : GAgentBase +{ + [EventHandler(EndpointName = "updateReadModel")] + public async Task HandleReadModelUpdate(StreamingProxyParticipantReadModelUpdateEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishSnapshotAsync(); + } + + protected override StreamingProxyParticipantGAgentState TransitionState( + StreamingProxyParticipantGAgentState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyUpdate) + .OrCurrent(); + } + + private static StreamingProxyParticipantGAgentState ApplyUpdate( + StreamingProxyParticipantGAgentState _, StreamingProxyParticipantReadModelUpdateEvent evt) + { + return evt.Snapshot?.Clone() ?? new StreamingProxyParticipantGAgentState(); + } + + private async Task PublishSnapshotAsync() + { + var snapshot = new StreamingProxyParticipantStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto b/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto index f599cce2..ff8d0bfa 100644 --- a/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto @@ -36,8 +36,12 @@ message RoomParticipantsRemovedEvent { // ─── Readmodel ─── -// Published by the GAgent after each state change so subscribers can -// maintain an up-to-date readmodel without reading write-model state. +// Published by ReadModel GAgent so subscribers can observe the current projected state. message StreamingProxyParticipantStateSnapshotEvent { StreamingProxyParticipantGAgentState snapshot = 1; } + +// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. +message StreamingProxyParticipantReadModelUpdateEvent { + StreamingProxyParticipantGAgentState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs index 978723d8..087a542c 100644 --- a/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs +++ b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs @@ -10,11 +10,10 @@ namespace Aevatar.GAgents.UserConfig; /// User-scoped actor that owns the user configuration state. /// Replaces the chrono-storage backed ChronoStorageUserConfigStore. /// -/// Actor ID: user-config (user-scoped singleton). +/// Actor ID: user-config-{scopeId} (per-scope). /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date projection without -/// reading write-model internal state. +/// After each state change, pushes the current state to the paired +/// via SendToAsync. /// public sealed class UserConfigGAgent : GAgentBase { @@ -22,17 +21,13 @@ public sealed class UserConfigGAgent : GAgentBase public async Task HandleConfigUpdated(UserConfigUpdatedEvent evt) { await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } - /// - /// On activation (after event replay), publish the current state so - /// any subscriber that activates the actor can receive the initial snapshot. - /// protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } protected override UserConfigGAgentState TransitionState( @@ -58,9 +53,10 @@ private static UserConfigGAgentState ApplyConfigUpdated( }; } - private async Task PublishStateSnapshotAsync() + private async Task PushToReadModelAsync() { - var snapshot = new UserConfigStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); + var readModelActorId = Id + "-readmodel"; + var update = new UserConfigReadModelUpdateEvent { Snapshot = State.Clone() }; + await SendToAsync(readModelActorId, update); } } diff --git a/agents/Aevatar.GAgents.UserConfig/UserConfigReadModelGAgent.cs b/agents/Aevatar.GAgents.UserConfig/UserConfigReadModelGAgent.cs new file mode 100644 index 00000000..1a6f1ee6 --- /dev/null +++ b/agents/Aevatar.GAgents.UserConfig/UserConfigReadModelGAgent.cs @@ -0,0 +1,55 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.UserConfig; + +/// +/// Persistent readmodel actor for user configuration. +/// Receives state snapshots from via +/// (SendToAsync) and persists them. +/// +/// Actor ID convention: {writeActorId}-readmodel. +/// +/// On activation and after each update, publishes +/// so per-request subscribers +/// (ActorBackedStore) can receive the current projected state. +/// +public sealed class UserConfigReadModelGAgent : GAgentBase +{ + [EventHandler(EndpointName = "updateReadModel")] + public async Task HandleReadModelUpdate(UserConfigReadModelUpdateEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishSnapshotAsync(); + } + + protected override UserConfigGAgentState TransitionState( + UserConfigGAgentState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyUpdate) + .OrCurrent(); + } + + private static UserConfigGAgentState ApplyUpdate( + UserConfigGAgentState _, UserConfigReadModelUpdateEvent evt) + { + return evt.Snapshot?.Clone() ?? new UserConfigGAgentState(); + } + + private async Task PublishSnapshotAsync() + { + var snapshot = new UserConfigStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto b/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto index 78440f1f..89bd6812 100644 --- a/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto +++ b/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto @@ -26,8 +26,12 @@ message UserConfigUpdatedEvent { // ─── Readmodel ─── -// Published by the GAgent after each state change so subscribers can -// maintain an up-to-date readmodel without reading write-model state. +// Published by ReadModel GAgent so subscribers can observe the current projected state. message UserConfigStateSnapshotEvent { UserConfigGAgentState snapshot = 1; } + +// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. +message UserConfigReadModelUpdateEvent { + UserConfigGAgentState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs index beba9a3f..9bfc1789 100644 --- a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs +++ b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs @@ -16,9 +16,8 @@ namespace Aevatar.GAgents.UserMemory; /// evict the oldest entry in the same category first. /// 2. If no same-category entry remains, evict the globally oldest entry. /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date projection without -/// reading write-model internal state. +/// After each state change, pushes the current state to the paired +/// via SendToAsync. /// public sealed class UserMemoryGAgent : GAgentBase { @@ -37,7 +36,7 @@ public async Task HandleMemoryEntryAdded(MemoryEntryAddedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } [EventHandler(EndpointName = "removeMemoryEntry")] @@ -51,7 +50,7 @@ public async Task HandleMemoryEntryRemoved(MemoryEntryRemovedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } [EventHandler(EndpointName = "clearMemoryEntries")] @@ -61,17 +60,13 @@ public async Task HandleMemoryEntriesCleared(MemoryEntriesClearedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } - /// - /// On activation (after event replay), publish the current state so - /// any subscriber that activates the actor can receive the initial snapshot. - /// protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); + await PushToReadModelAsync(); } protected override UserMemoryState TransitionState( @@ -144,9 +139,10 @@ private static UserMemoryState ApplyCleared( return next; } - private async Task PublishStateSnapshotAsync() + private async Task PushToReadModelAsync() { - var snapshot = new UserMemoryStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); + var readModelActorId = Id + "-readmodel"; + var update = new UserMemoryReadModelUpdateEvent { Snapshot = State.Clone() }; + await SendToAsync(readModelActorId, update); } } diff --git a/agents/Aevatar.GAgents.UserMemory/UserMemoryReadModelGAgent.cs b/agents/Aevatar.GAgents.UserMemory/UserMemoryReadModelGAgent.cs new file mode 100644 index 00000000..24d6197e --- /dev/null +++ b/agents/Aevatar.GAgents.UserMemory/UserMemoryReadModelGAgent.cs @@ -0,0 +1,55 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.GAgents.UserMemory; + +/// +/// Persistent readmodel actor for user memory. +/// Receives state snapshots from via +/// (SendToAsync) and persists them. +/// +/// Actor ID convention: {writeActorId}-readmodel. +/// +/// On activation and after each update, publishes +/// so per-request subscribers +/// (ActorBackedStore) can receive the current projected state. +/// +public sealed class UserMemoryReadModelGAgent : GAgentBase +{ + [EventHandler(EndpointName = "updateReadModel")] + public async Task HandleReadModelUpdate(UserMemoryReadModelUpdateEvent evt) + { + await PersistDomainEventAsync(evt); + await PublishSnapshotAsync(); + } + + protected override async Task OnActivateAsync(CancellationToken ct) + { + await base.OnActivateAsync(ct); + await PublishSnapshotAsync(); + } + + protected override UserMemoryState TransitionState( + UserMemoryState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyUpdate) + .OrCurrent(); + } + + private static UserMemoryState ApplyUpdate( + UserMemoryState _, UserMemoryReadModelUpdateEvent evt) + { + return evt.Snapshot?.Clone() ?? new UserMemoryState(); + } + + private async Task PublishSnapshotAsync() + { + var snapshot = new UserMemoryStateSnapshotEvent { Snapshot = State.Clone() }; + await PublishAsync(snapshot, TopologyAudience.Parent); + } +} diff --git a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto index 1b9a218f..2b03d820 100644 --- a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto +++ b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto @@ -37,3 +37,8 @@ message MemoryEntriesClearedEvent { message UserMemoryStateSnapshotEvent { UserMemoryState snapshot = 1; } + +// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. +message UserMemoryReadModelUpdateEvent { + UserMemoryState snapshot = 1; +} diff --git a/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs b/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs index 32a8fb9b..c5934253 100644 --- a/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs +++ b/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs @@ -12,9 +12,8 @@ namespace Aevatar.GAgents.WorkflowStorage; /// /// Actor ID: workflow-storage (cluster-scoped singleton). /// -/// After each state change, publishes -/// so readmodel subscribers can maintain an up-to-date projection without -/// reading write-model internal state. +/// Write-only: no readmodel needed since the only port method is +/// UploadWorkflowYamlAsync. /// public sealed class WorkflowStorageGAgent : GAgentBase { @@ -25,17 +24,6 @@ public async Task HandleWorkflowYamlUploaded(WorkflowYamlUploadedEvent evt) return; await PersistDomainEventAsync(evt); - await PublishStateSnapshotAsync(); - } - - /// - /// On activation (after event replay), publish the current state so - /// any subscriber that activates the actor can receive the initial snapshot. - /// - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishStateSnapshotAsync(); } protected override WorkflowStorageState TransitionState( @@ -58,10 +46,4 @@ private static WorkflowStorageState ApplyYamlUploaded( }; return next; } - - private async Task PublishStateSnapshotAsync() - { - var snapshot = new WorkflowStorageStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs index eacf4731..290261e8 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs @@ -10,29 +10,17 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Uses a dual-actor architecture: -/// -/// : per-conversation actor (actorId = chat-{conversationId}) -/// : per-user index actor (actorId = chat-index-{scopeId}) -/// -/// Writes go through actor event handlers. Reads come from snapshots via event subscription. +/// Completely stateless: no fields hold snapshot or subscription state. +/// Reads use per-request temporary subscription to the ReadModel GAgents. +/// Writes send commands only to +/// (index updates are handled internally by the conversation actor). /// -internal sealed class ActorBackedChatHistoryStore : IChatHistoryStore, IAsyncDisposable +internal sealed class ActorBackedChatHistoryStore : IChatHistoryStore { - private static readonly ChatHistoryIndex EmptyIndex = new([]); - private readonly IActorRuntime _runtime; private readonly IActorEventSubscriptionProvider _subscriptions; private readonly ILogger _logger; - // Per-index-actor subscription tracking - private readonly SemaphoreSlim _indexLock = new(1, 1); - private readonly Dictionary _indexSubscriptions = new(StringComparer.Ordinal); - - // Per-conversation subscription tracking - private readonly SemaphoreSlim _conversationLock = new(1, 1); - private readonly Dictionary _conversationSubscriptions = new(StringComparer.Ordinal); - public ActorBackedChatHistoryStore( IActorRuntime runtime, IActorEventSubscriptionProvider subscriptions, @@ -45,10 +33,9 @@ public ActorBackedChatHistoryStore( public async Task GetIndexAsync(string scopeId, CancellationToken ct = default) { - var sub = await EnsureIndexSubscriptionAsync(scopeId, ct); - var state = sub.Snapshot; + var state = await ReadIndexFromReadModelAsync(scopeId, ct); if (state is null) - return EmptyIndex; + return new ChatHistoryIndex([]); return new ChatHistoryIndex(state.Conversations .Select(ToConversationMeta) @@ -61,8 +48,7 @@ public async Task GetIndexAsync(string scopeId, CancellationTo public async Task> GetMessagesAsync( string scopeId, string conversationId, CancellationToken ct = default) { - var sub = await EnsureConversationSubscriptionAsync(scopeId, conversationId, ct); - var state = sub.Snapshot; + var state = await ReadConversationFromReadModelAsync(scopeId, conversationId, ct); if (state is null || state.Messages.Count == 0) return []; @@ -76,178 +62,135 @@ public async Task SaveMessagesAsync( string scopeId, string conversationId, ConversationMeta meta, IReadOnlyList messages, CancellationToken ct = default) { - // 1. Send messages to the conversation actor + // Only send to conversation actor; it forwards to index actor internally var conversationActor = await EnsureConversationActorAsync(scopeId, conversationId, ct); var metaProto = ToConversationMetaProto(conversationId, meta); - var replaceEvt = new MessagesReplacedEvent { Meta = metaProto }; + var replaceEvt = new MessagesReplacedEvent { Meta = metaProto, ScopeId = scopeId }; foreach (var msg in messages) replaceEvt.Messages.Add(ToStoredChatMessageProto(msg)); await SendCommandAsync(conversationActor, replaceEvt, ct); - - // 2. Update the index actor - var indexActor = await EnsureIndexActorAsync(scopeId, ct); - // Update message count from actual messages - var indexMeta = metaProto.Clone(); - indexMeta.MessageCount = messages.Count; - var upsertEvt = new ConversationUpsertedEvent { Meta = indexMeta }; - await SendCommandAsync(indexActor, upsertEvt, ct); } public async Task DeleteConversationAsync( string scopeId, string conversationId, CancellationToken ct = default) { - // 1. Mark conversation deleted + // Only send to conversation actor; it forwards to index actor internally var conversationActor = await EnsureConversationActorAsync(scopeId, conversationId, ct); - var deleteEvt = new ConversationDeletedEvent { ConversationId = conversationId }; - await SendCommandAsync(conversationActor, deleteEvt, ct); - - // 2. Remove from index - var indexActor = await EnsureIndexActorAsync(scopeId, ct); - var removeEvt = new ConversationRemovedEvent { ConversationId = conversationId }; - await SendCommandAsync(indexActor, removeEvt, ct); - } - - public async ValueTask DisposeAsync() - { - foreach (var sub in _indexSubscriptions.Values) + var deleteEvt = new ConversationDeletedEvent { - if (sub.Subscription is not null) - await sub.Subscription.DisposeAsync(); - } - _indexSubscriptions.Clear(); - - foreach (var sub in _conversationSubscriptions.Values) - { - if (sub.Subscription is not null) - await sub.Subscription.DisposeAsync(); - } - _conversationSubscriptions.Clear(); + ConversationId = conversationId, + ScopeId = scopeId, + }; + await SendCommandAsync(conversationActor, deleteEvt, ct); } - // ── Index actor helpers ───────────────────────────────────── + // ── Per-request readmodel reads (no service-level state) ─── - private async Task EnsureIndexSubscriptionAsync(string scopeId, CancellationToken ct) + private async Task ReadIndexFromReadModelAsync( + string scopeId, CancellationToken ct) { - var actorId = IndexActorId(scopeId); + var readModelActorId = IndexActorId(scopeId) + "-readmodel"; + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await _subscriptions.SubscribeAsync( + readModelActorId, + envelope => + { + if (envelope.Payload?.Is(ChatHistoryIndexStateSnapshotEvent.Descriptor) == true) + { + var snapshot = envelope.Payload.Unpack(); + tcs.TrySetResult(snapshot.Snapshot); + } + return Task.CompletedTask; + }, + ct); - if (_indexSubscriptions.TryGetValue(actorId, out var existing) && existing.Initialized) - return existing; + // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) + await EnsureIndexReadModelActorAsync(readModelActorId, ct); - await _indexLock.WaitAsync(ct); + // Wait for snapshot with timeout + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); try { - if (_indexSubscriptions.TryGetValue(actorId, out existing) && existing.Initialized) - return existing; - - var sub = new IndexSubscription(); - sub.Subscription = await _subscriptions.SubscribeAsync( - actorId, - envelope => HandleIndexEventAsync(actorId, envelope), - ct); - - await EnsureIndexActorAsync(scopeId, ct); - sub.Initialized = true; - _indexSubscriptions[actorId] = sub; - return sub; + return await tcs.Task.WaitAsync(cts.Token); } - finally + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - _indexLock.Release(); + _logger.LogWarning("Timeout waiting for index readmodel snapshot from {ActorId}", readModelActorId); + return null; } } - private Task HandleIndexEventAsync(string actorId, EventEnvelope envelope) + private async Task ReadConversationFromReadModelAsync( + string scopeId, string conversationId, CancellationToken ct) { - if (envelope.Payload is null) - return Task.CompletedTask; + var readModelActorId = ConversationActorId(scopeId, conversationId) + "-readmodel"; + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); - if (envelope.Payload.Is(ChatHistoryIndexStateSnapshotEvent.Descriptor)) - { - var snapshot = envelope.Payload.Unpack(); - if (_indexSubscriptions.TryGetValue(actorId, out var sub)) + await using var sub = await _subscriptions.SubscribeAsync( + readModelActorId, + envelope => { - sub.Snapshot = snapshot.Snapshot; - _logger.LogDebug("Chat history index updated for {ActorId}: {Count} conversations", - actorId, snapshot.Snapshot?.Conversations.Count ?? 0); - } - } - - return Task.CompletedTask; - } - - private async Task EnsureIndexActorAsync(string scopeId, CancellationToken ct) - { - var actorId = IndexActorId(scopeId); - var actor = await _runtime.GetAsync(actorId); - return actor ?? await _runtime.CreateAsync(actorId, ct); - } - - // ── Conversation actor helpers ────────────────────────────── - - private async Task EnsureConversationSubscriptionAsync( - string scopeId, string conversationId, CancellationToken ct) - { - var actorId = ConversationActorId(scopeId, conversationId); + if (envelope.Payload?.Is(ChatConversationStateSnapshotEvent.Descriptor) == true) + { + var snapshot = envelope.Payload.Unpack(); + tcs.TrySetResult(snapshot.Snapshot); + } + return Task.CompletedTask; + }, + ct); - if (_conversationSubscriptions.TryGetValue(actorId, out var existing) && existing.Initialized) - return existing; + // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) + await EnsureConversationReadModelActorAsync(readModelActorId, ct); - await _conversationLock.WaitAsync(ct); + // Wait for snapshot with timeout + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); try { - if (_conversationSubscriptions.TryGetValue(actorId, out existing) && existing.Initialized) - return existing; - - var sub = new ConversationSubscription(); - sub.Subscription = await _subscriptions.SubscribeAsync( - actorId, - envelope => HandleConversationEventAsync(actorId, envelope), - ct); - - await EnsureConversationActorAsync(scopeId, conversationId, ct); - sub.Initialized = true; - _conversationSubscriptions[actorId] = sub; - return sub; + return await tcs.Task.WaitAsync(cts.Token); } - finally + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - _conversationLock.Release(); + _logger.LogWarning("Timeout waiting for conversation readmodel snapshot from {ActorId}", readModelActorId); + return null; } } - private Task HandleConversationEventAsync(string actorId, EventEnvelope envelope) - { - if (envelope.Payload is null) - return Task.CompletedTask; - - if (envelope.Payload.Is(ChatConversationStateSnapshotEvent.Descriptor)) - { - var snapshot = envelope.Payload.Unpack(); - if (_conversationSubscriptions.TryGetValue(actorId, out var sub)) - { - sub.Snapshot = snapshot.Snapshot; - _logger.LogDebug("Chat conversation updated for {ActorId}: {Count} messages", - actorId, snapshot.Snapshot?.Messages.Count ?? 0); - } - } + // ── Actor resolution ─────────────────────────────────────── - return Task.CompletedTask; - } - - private async Task EnsureConversationActorAsync(string scopeId, string conversationId, CancellationToken ct) + private async Task EnsureConversationActorAsync( + string scopeId, string conversationId, CancellationToken ct) { var actorId = ConversationActorId(scopeId, conversationId); var actor = await _runtime.GetAsync(actorId); return actor ?? await _runtime.CreateAsync(actorId, ct); } - // ── Actor ID conventions ──────────────────────────────────── + private async Task EnsureIndexReadModelActorAsync(string readModelActorId, CancellationToken ct) + { + var actor = await _runtime.GetAsync(readModelActorId); + if (actor is null) + await _runtime.CreateAsync(readModelActorId, ct); + } + + private async Task EnsureConversationReadModelActorAsync(string readModelActorId, CancellationToken ct) + { + var actor = await _runtime.GetAsync(readModelActorId); + if (actor is null) + await _runtime.CreateAsync(readModelActorId, ct); + } + + // ── Actor ID conventions ─────────────────────────────────── private static string IndexActorId(string scopeId) => $"chat-index-{scopeId}"; private static string ConversationActorId(string scopeId, string conversationId) => $"chat-{scopeId}-{conversationId}"; - // ── Command dispatch ──────────────────────────────────────── + // ── Command dispatch ─────────────────────────────────────── private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) { @@ -264,7 +207,7 @@ private static async Task SendCommandAsync(IActor actor, IMessage command, Cance await actor.HandleEventAsync(envelope, ct); } - // ── Mapping helpers ───────────────────────────────────────── + // ── Mapping helpers ──────────────────────────────────────── private static ConversationMeta ToConversationMeta(ConversationMetaProto proto) => new( @@ -316,20 +259,4 @@ private static StoredChatMessageProto ToStoredChatMessageProto(StoredChatMessage private static DateTimeOffset FromUnixMs(long ms) => ms > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(ms) : DateTimeOffset.UnixEpoch; - - // ── Subscription tracking ─────────────────────────────────── - - private sealed class IndexSubscription - { - public volatile ChatHistoryIndexState? Snapshot; - public IAsyncDisposable? Subscription; - public bool Initialized; - } - - private sealed class ConversationSubscription - { - public volatile ChatConversationState? Snapshot; - public IAsyncDisposable? Subscription; - public bool Initialized; - } } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs index aa3a6d02..dfd8f502 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -1,9 +1,7 @@ -using System.Text; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.ConnectorCatalog; using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.Storage; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -12,23 +10,22 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Remote catalog persistence goes through . -/// Local workspace operations (import, draft) delegate to . +/// Completely stateless: no fields hold snapshot or subscription state. +/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Writes send commands to the Write GAgent. +/// Local workspace operations (import, draft backup) delegate to . /// -internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogStore, IAsyncDisposable +internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogStore { - private const string CatalogActorId = "connector-catalog"; + private const string WriteActorId = "connector-catalog"; + private const string ActorHomeDirectory = "actor://connector-catalog"; + private const string ActorFilePath = "actor://connector-catalog/connectors"; private readonly IActorRuntime _runtime; private readonly IActorEventSubscriptionProvider _subscriptions; private readonly IStudioWorkspaceStore _workspaceStore; private readonly ILogger _logger; - private readonly SemaphoreSlim _initLock = new(1, 1); - private volatile ConnectorCatalogState? _snapshot; - private IAsyncDisposable? _subscription; - private bool _initialized; - public ActorBackedConnectorCatalogStore( IActorRuntime runtime, IActorEventSubscriptionProvider subscriptions, @@ -44,23 +41,25 @@ public ActorBackedConnectorCatalogStore( public async Task GetConnectorCatalogAsync( CancellationToken cancellationToken = default) { - await EnsureInitializedAsync(cancellationToken); - - var state = _snapshot; - if (state is null || string.IsNullOrEmpty(state.CatalogJson)) + var state = await ReadFromReadModelAsync(cancellationToken); + if (state is null) { return new StoredConnectorCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, FileExists: false, Connectors: []); } - var connectors = await DeserializeCatalogJsonAsync(state.CatalogJson, cancellationToken); + var connectors = state.Connectors + .Select(ToStoredConnectorDefinition) + .ToList() + .AsReadOnly(); + return new StoredConnectorCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: true, + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: connectors.Count > 0, Connectors: connectors); } @@ -68,14 +67,14 @@ public async Task SaveConnectorCatalogAsync( StoredConnectorCatalog catalog, CancellationToken cancellationToken = default) { - var catalogJson = await SerializeCatalogJsonAsync(catalog.Connectors, cancellationToken); - var actor = await EnsureActorAsync(cancellationToken); - var evt = new ConnectorCatalogSavedEvent { CatalogJson = catalogJson }; + var actor = await EnsureWriteActorAsync(cancellationToken); + var evt = new ConnectorCatalogSavedEvent(); + evt.Connectors.AddRange(catalog.Connectors.Select(ToProtoConnectorDefinition)); await SendCommandAsync(actor, evt, cancellationToken); return new StoredConnectorCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, FileExists: true, Connectors: catalog.Connectors); } @@ -90,96 +89,126 @@ public async Task ImportLocalCatalogAsync( $"Local connector catalog not found at '{localCatalog.FilePath}'."); } - // Persist the local catalog into the actor - var saved = await SaveConnectorCatalogAsync( - new StoredConnectorCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: true, - Connectors: localCatalog.Connectors), - cancellationToken); + var actor = await EnsureWriteActorAsync(cancellationToken); + var evt = new ConnectorCatalogSavedEvent(); + evt.Connectors.AddRange(localCatalog.Connectors.Select(ToProtoConnectorDefinition)); + await SendCommandAsync(actor, evt, cancellationToken); + + var importedCatalog = new StoredConnectorCatalog( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath, + FileExists: true, + Connectors: localCatalog.Connectors); - return new ImportedConnectorCatalog(localCatalog.FilePath, true, saved); + return new ImportedConnectorCatalog(localCatalog.FilePath, true, importedCatalog); } - public Task GetConnectorDraftAsync( + public async Task GetConnectorDraftAsync( CancellationToken cancellationToken = default) { - return _workspaceStore.GetConnectorDraftAsync(cancellationToken); + var state = await ReadFromReadModelAsync(cancellationToken); + var draftEntry = state?.Draft; + if (draftEntry is null) + { + return new StoredConnectorDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: false, + UpdatedAtUtc: null, + Draft: null); + } + + return new StoredConnectorDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: true, + UpdatedAtUtc: draftEntry.UpdatedAtUtc?.ToDateTimeOffset(), + Draft: draftEntry.Draft is not null ? ToStoredConnectorDefinition(draftEntry.Draft) : null); } - public Task SaveConnectorDraftAsync( + public async Task SaveConnectorDraftAsync( StoredConnectorDraft draft, CancellationToken cancellationToken = default) { - return _workspaceStore.SaveConnectorDraftAsync(draft, cancellationToken); - } + var actor = await EnsureWriteActorAsync(cancellationToken); + var updatedAtUtc = draft.UpdatedAtUtc ?? DateTimeOffset.UtcNow; + var evt = new ConnectorDraftSavedEvent + { + Draft = draft.Draft is not null ? ToProtoConnectorDefinition(draft.Draft) : null, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(updatedAtUtc), + }; + await SendCommandAsync(actor, evt, cancellationToken); - public Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) - { - return _workspaceStore.DeleteConnectorDraftAsync(cancellationToken); + // Also persist to local workspace for offline access + await _workspaceStore.SaveConnectorDraftAsync(draft, cancellationToken); + + return new StoredConnectorDraft( + HomeDirectory: ActorHomeDirectory, + FilePath: ActorFilePath + "/draft", + FileExists: true, + UpdatedAtUtc: updatedAtUtc, + Draft: draft.Draft); } - public async ValueTask DisposeAsync() + public async Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) { - if (_subscription is not null) - await _subscription.DisposeAsync(); + var actor = await EnsureWriteActorAsync(cancellationToken); + await SendCommandAsync(actor, new ConnectorDraftDeletedEvent(), cancellationToken); + + await _workspaceStore.DeleteConnectorDraftAsync(cancellationToken); } - private async Task EnsureInitializedAsync(CancellationToken ct) - { - if (_initialized) - return; + // ── Per-request readmodel read (no service-level state) ── - await _initLock.WaitAsync(ct); - try - { - if (_initialized) - return; + private async Task ReadFromReadModelAsync(CancellationToken ct) + { + var readModelActorId = WriteActorId + "-readmodel"; + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); - // Subscribe to the catalog actor's events to receive state snapshots - _subscription = await _subscriptions.SubscribeAsync( - CatalogActorId, - HandleCatalogEventAsync, - ct); + await using var sub = await _subscriptions.SubscribeAsync( + readModelActorId, + envelope => + { + if (envelope.Payload?.Is(ConnectorCatalogStateSnapshotEvent.Descriptor) == true) + { + var snapshot = envelope.Payload.Unpack(); + tcs.TrySetResult(snapshot.Snapshot); + } + return Task.CompletedTask; + }, + ct); - // Activate the actor — triggers event replay + OnActivateAsync - // which publishes the initial state snapshot - await EnsureActorAsync(ct); + // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) + await EnsureReadModelActorAsync(readModelActorId, ct); - _initialized = true; + // Wait for snapshot with timeout + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + return await tcs.Task.WaitAsync(cts.Token); } - finally + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - _initLock.Release(); + _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); + return null; } } - private Task HandleCatalogEventAsync(EventEnvelope envelope) - { - if (envelope.Payload is null) - return Task.CompletedTask; + // ── Actor resolution ── - if (envelope.Payload.Is(ConnectorCatalogStateSnapshotEvent.Descriptor)) - { - var snapshot = envelope.Payload.Unpack(); - _snapshot = snapshot.Snapshot; - _logger.LogDebug( - "Connector catalog readmodel updated: catalog has {HasCatalog}, draft has {HasDraft}", - !string.IsNullOrEmpty(snapshot.Snapshot?.CatalogJson), - !string.IsNullOrEmpty(snapshot.Snapshot?.DraftJson)); - } - - return Task.CompletedTask; + private async Task EnsureWriteActorAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(WriteActorId); + return actor ?? await _runtime.CreateAsync(WriteActorId, ct); } - private async Task EnsureActorAsync(CancellationToken ct) + private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) { - var actor = await _runtime.GetAsync(CatalogActorId); - if (actor is not null) - return actor; - - return await _runtime.CreateAsync(CatalogActorId, ct); + var actor = await _runtime.GetAsync(readModelActorId); + if (actor is null) + await _runtime.CreateAsync(readModelActorId, ct); } private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) @@ -197,21 +226,146 @@ private static async Task SendCommandAsync(IActor actor, IMessage command, Cance await actor.HandleEventAsync(envelope, ct); } - private static async Task SerializeCatalogJsonAsync( - IReadOnlyList connectors, - CancellationToken ct) + // ── Proto <-> Domain mapping ── + + private static StoredConnectorDefinition ToStoredConnectorDefinition(ConnectorDefinitionEntry entry) => + new( + Name: entry.Name, + Type: entry.Type, + Enabled: entry.Enabled, + TimeoutMs: entry.TimeoutMs, + Retry: entry.Retry, + Http: entry.Http is not null ? ToStoredHttpConfig(entry.Http) : EmptyHttpConfig(), + Cli: entry.Cli is not null ? ToStoredCliConfig(entry.Cli) : EmptyCliConfig(), + Mcp: entry.Mcp is not null ? ToStoredMcpConfig(entry.Mcp) : EmptyMcpConfig()); + + private static StoredHttpConnectorConfig ToStoredHttpConfig(HttpConnectorConfigEntry entry) => + new( + BaseUrl: entry.BaseUrl, + AllowedMethods: entry.AllowedMethods.ToList().AsReadOnly(), + AllowedPaths: entry.AllowedPaths.ToList().AsReadOnly(), + AllowedInputKeys: entry.AllowedInputKeys.ToList().AsReadOnly(), + DefaultHeaders: new Dictionary(entry.DefaultHeaders, StringComparer.OrdinalIgnoreCase), + Auth: entry.Auth is not null ? ToStoredAuthConfig(entry.Auth) : EmptyAuthConfig()); + + private static StoredCliConnectorConfig ToStoredCliConfig(CliConnectorConfigEntry entry) => + new( + Command: entry.Command, + FixedArguments: entry.FixedArguments.ToList().AsReadOnly(), + AllowedOperations: entry.AllowedOperations.ToList().AsReadOnly(), + AllowedInputKeys: entry.AllowedInputKeys.ToList().AsReadOnly(), + WorkingDirectory: entry.WorkingDirectory, + Environment: new Dictionary(entry.Environment, StringComparer.OrdinalIgnoreCase)); + + private static StoredMcpConnectorConfig ToStoredMcpConfig(McpConnectorConfigEntry entry) => + new( + ServerName: entry.ServerName, + Command: entry.Command, + Url: entry.Url, + Arguments: entry.Arguments.ToList().AsReadOnly(), + Environment: new Dictionary(entry.Environment, StringComparer.OrdinalIgnoreCase), + AdditionalHeaders: new Dictionary(entry.AdditionalHeaders, StringComparer.OrdinalIgnoreCase), + Auth: entry.Auth is not null ? ToStoredAuthConfig(entry.Auth) : EmptyAuthConfig(), + DefaultTool: entry.DefaultTool, + AllowedTools: entry.AllowedTools.ToList().AsReadOnly(), + AllowedInputKeys: entry.AllowedInputKeys.ToList().AsReadOnly()); + + private static StoredConnectorAuthConfig ToStoredAuthConfig(ConnectorAuthEntry entry) => + new( + Type: entry.Type, + TokenUrl: entry.TokenUrl, + ClientId: entry.ClientId, + ClientSecret: entry.ClientSecret, + Scope: entry.Scope); + + private static ConnectorDefinitionEntry ToProtoConnectorDefinition(StoredConnectorDefinition def) + { + var entry = new ConnectorDefinitionEntry + { + Name = def.Name, + Type = def.Type, + Enabled = def.Enabled, + TimeoutMs = def.TimeoutMs, + Retry = def.Retry, + Http = ToProtoHttpConfig(def.Http), + Cli = ToProtoCliConfig(def.Cli), + Mcp = ToProtoMcpConfig(def.Mcp), + }; + return entry; + } + + private static HttpConnectorConfigEntry ToProtoHttpConfig(StoredHttpConnectorConfig config) + { + var entry = new HttpConnectorConfigEntry + { + BaseUrl = config.BaseUrl, + Auth = ToProtoAuthConfig(config.Auth), + }; + entry.AllowedMethods.AddRange(config.AllowedMethods); + entry.AllowedPaths.AddRange(config.AllowedPaths); + entry.AllowedInputKeys.AddRange(config.AllowedInputKeys); + foreach (var kvp in config.DefaultHeaders) + entry.DefaultHeaders[kvp.Key] = kvp.Value; + return entry; + } + + private static CliConnectorConfigEntry ToProtoCliConfig(StoredCliConnectorConfig config) { - await using var stream = new MemoryStream(); - await ConnectorCatalogJsonSerializer.WriteCatalogAsync(stream, connectors, ct); - return Encoding.UTF8.GetString(stream.ToArray()); + var entry = new CliConnectorConfigEntry + { + Command = config.Command, + WorkingDirectory = config.WorkingDirectory, + }; + entry.FixedArguments.AddRange(config.FixedArguments); + entry.AllowedOperations.AddRange(config.AllowedOperations); + entry.AllowedInputKeys.AddRange(config.AllowedInputKeys); + foreach (var kvp in config.Environment) + entry.Environment[kvp.Key] = kvp.Value; + return entry; } - private static async Task> DeserializeCatalogJsonAsync( - string json, - CancellationToken ct) + private static McpConnectorConfigEntry ToProtoMcpConfig(StoredMcpConnectorConfig config) { - var bytes = Encoding.UTF8.GetBytes(json); - await using var stream = new MemoryStream(bytes, writable: false); - return await ConnectorCatalogJsonSerializer.ReadCatalogAsync(stream, ct); + var entry = new McpConnectorConfigEntry + { + ServerName = config.ServerName, + Command = config.Command, + Url = config.Url, + Auth = ToProtoAuthConfig(config.Auth), + DefaultTool = config.DefaultTool, + }; + entry.Arguments.AddRange(config.Arguments); + entry.AllowedTools.AddRange(config.AllowedTools); + entry.AllowedInputKeys.AddRange(config.AllowedInputKeys); + foreach (var kvp in config.Environment) + entry.Environment[kvp.Key] = kvp.Value; + foreach (var kvp in config.AdditionalHeaders) + entry.AdditionalHeaders[kvp.Key] = kvp.Value; + return entry; } + + private static ConnectorAuthEntry ToProtoAuthConfig(StoredConnectorAuthConfig config) => + new() + { + Type = config.Type, + TokenUrl = config.TokenUrl, + ClientId = config.ClientId, + ClientSecret = config.ClientSecret, + Scope = config.Scope, + }; + + private static StoredHttpConnectorConfig EmptyHttpConfig() => + new(string.Empty, [], [], [], new Dictionary(StringComparer.OrdinalIgnoreCase), EmptyAuthConfig()); + + private static StoredCliConnectorConfig EmptyCliConfig() => + new(string.Empty, [], [], [], string.Empty, new Dictionary(StringComparer.OrdinalIgnoreCase)); + + private static StoredMcpConnectorConfig EmptyMcpConfig() => + new(string.Empty, string.Empty, string.Empty, [], + new Dictionary(StringComparer.OrdinalIgnoreCase), + new Dictionary(StringComparer.OrdinalIgnoreCase), + EmptyAuthConfig(), string.Empty, [], []); + + private static StoredConnectorAuthConfig EmptyAuthConfig() => + new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs index 7d8d50a4..27ce5928 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.Registry; @@ -12,19 +11,19 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Per-scope isolation: each scope gets its own gagent-registry-{scopeId} actor. +/// Completely stateless: no fields hold snapshot or subscription state. +/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Writes send commands to the Write GAgent. /// -internal sealed class ActorBackedGAgentActorStore : IGAgentActorStore, IAsyncDisposable +internal sealed class ActorBackedGAgentActorStore : IGAgentActorStore { - private const string ActorIdPrefix = "gagent-registry-"; + private const string WriteActorIdPrefix = "gagent-registry-"; private readonly IActorRuntime _runtime; private readonly IActorEventSubscriptionProvider _subscriptions; private readonly IAppScopeResolver _scopeResolver; private readonly ILogger _logger; - private readonly ConcurrentDictionary _scopes = new(StringComparer.Ordinal); - public ActorBackedGAgentActorStore( IActorRuntime runtime, IActorEventSubscriptionProvider subscriptions, @@ -40,9 +39,7 @@ public ActorBackedGAgentActorStore( public async Task> GetAsync( CancellationToken cancellationToken = default) { - var scopeState = await EnsureScopeAsync(cancellationToken); - - var state = scopeState.Snapshot; + var state = await ReadFromReadModelAsync(cancellationToken); if (state is null) return []; @@ -58,103 +55,87 @@ public async Task AddActorAsync( string gagentType, string actorId, CancellationToken cancellationToken = default) { - var scopeState = await EnsureScopeAsync(cancellationToken); - var actor = await EnsureActorAsync(scopeState.ActorId, cancellationToken); - var evt = new ActorRegisteredEvent + var actor = await EnsureWriteActorAsync(cancellationToken); + await SendCommandAsync(actor, new ActorRegisteredEvent { GagentType = gagentType, ActorId = actorId, - }; - await SendCommandAsync(actor, evt, cancellationToken); + }, cancellationToken); } public async Task RemoveActorAsync( string gagentType, string actorId, CancellationToken cancellationToken = default) { - var scopeState = await EnsureScopeAsync(cancellationToken); - var actor = await EnsureActorAsync(scopeState.ActorId, cancellationToken); - var evt = new ActorUnregisteredEvent + var actor = await EnsureWriteActorAsync(cancellationToken); + await SendCommandAsync(actor, new ActorUnregisteredEvent { GagentType = gagentType, ActorId = actorId, - }; - await SendCommandAsync(actor, evt, cancellationToken); + }, cancellationToken); } - public async ValueTask DisposeAsync() - { - foreach (var scope in _scopes.Values) - { - if (scope.Subscription is not null) - await scope.Subscription.DisposeAsync(); - } - } + // ── Per-request readmodel read (no service-level state) ── - private string ResolveScopeId() + private async Task ReadFromReadModelAsync(CancellationToken ct) { - var scope = _scopeResolver.Resolve(); - return scope?.ScopeId ?? "default"; - } - - private async Task EnsureScopeAsync(CancellationToken ct) - { - var scopeId = ResolveScopeId(); - var actorId = ActorIdPrefix + scopeId; + var readModelActorId = ResolveReadModelActorId(); + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); - var scopeState = _scopes.GetOrAdd(actorId, _ => new ScopeState(actorId)); + await using var sub = await _subscriptions.SubscribeAsync( + readModelActorId, + envelope => + { + if (envelope.Payload?.Is(GAgentRegistryStateSnapshotEvent.Descriptor) == true) + { + var snapshot = envelope.Payload.Unpack(); + tcs.TrySetResult(snapshot.Snapshot); + } + return Task.CompletedTask; + }, + ct); - if (scopeState.Initialized) - return scopeState; + // Activate readmodel actor (triggers OnActivateAsync → PublishAsync snapshot) + await EnsureReadModelActorAsync(readModelActorId, ct); - await scopeState.InitLock.WaitAsync(ct); + // Wait for snapshot with timeout + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); try { - if (scopeState.Initialized) - return scopeState; - - scopeState.Subscription = await _subscriptions.SubscribeAsync( - actorId, - envelope => HandleRegistryEventAsync(actorId, envelope), - ct); - - await EnsureActorAsync(actorId, ct); - scopeState.Initialized = true; + return await tcs.Task.WaitAsync(cts.Token); } - finally + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - scopeState.InitLock.Release(); + _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); + return null; } - - return scopeState; } - private Task HandleRegistryEventAsync(string actorId, EventEnvelope envelope) - { - if (envelope.Payload is null) - return Task.CompletedTask; + // ── Actor resolution ── - if (envelope.Payload.Is(GAgentRegistryStateSnapshotEvent.Descriptor)) - { - var snapshot = envelope.Payload.Unpack(); - if (_scopes.TryGetValue(actorId, out var scopeState)) - { - scopeState.Snapshot = snapshot.Snapshot; - _logger.LogDebug("Registry readmodel updated for {ActorId}: {GroupCount} groups", - actorId, snapshot.Snapshot?.Groups.Count ?? 0); - } - } - - return Task.CompletedTask; + private string ResolveScopeId() + { + var scope = _scopeResolver.Resolve(); + return scope?.ScopeId ?? "default"; } - private async Task EnsureActorAsync(string actorId, CancellationToken ct) + private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; + + private async Task EnsureWriteActorAsync(CancellationToken ct) { + var actorId = ResolveWriteActorId(); var actor = await _runtime.GetAsync(actorId); - if (actor is not null) - return actor; + return actor ?? await _runtime.CreateAsync(actorId, ct); + } - return await _runtime.CreateAsync(actorId, ct); + private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) + { + var actor = await _runtime.GetAsync(readModelActorId); + if (actor is null) + await _runtime.CreateAsync(readModelActorId, ct); } private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) @@ -171,13 +152,4 @@ private static async Task SendCommandAsync(IActor actor, IMessage command, Cance }; await actor.HandleEventAsync(envelope, ct); } - - private sealed class ScopeState(string actorId) - { - public string ActorId { get; } = actorId; - public SemaphoreSlim InitLock { get; } = new(1, 1); - public volatile bool Initialized; - public volatile GAgentRegistryState? Snapshot; - public IAsyncDisposable? Subscription; - } } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs index 88c834cd..801c45da 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs @@ -10,14 +10,16 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Writes go through event handlers. -/// Reads come from a readmodel snapshot maintained via event subscription. +/// Completely stateless: no fields hold snapshot or subscription state. +/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Writes send commands to the Write GAgent. /// Local workspace operations (ImportLocalCatalogAsync) delegate to /// . /// -internal sealed class ActorBackedRoleCatalogStore : IRoleCatalogStore, IAsyncDisposable +internal sealed class ActorBackedRoleCatalogStore : IRoleCatalogStore { - private const string CatalogActorId = "role-catalog"; + private const string WriteActorId = "role-catalog"; + private const string ReadModelActorId = "role-catalog-readmodel"; private const string ActorHomeDirectory = "actor://role-catalog"; private const string ActorFilePath = "actor://role-catalog/roles"; @@ -26,11 +28,6 @@ internal sealed class ActorBackedRoleCatalogStore : IRoleCatalogStore, IAsyncDis private readonly IStudioWorkspaceStore _localWorkspaceStore; private readonly ILogger _logger; - private readonly SemaphoreSlim _initLock = new(1, 1); - private volatile RoleCatalogState? _snapshot; - private IAsyncDisposable? _subscription; - private bool _initialized; - public ActorBackedRoleCatalogStore( IActorRuntime runtime, IActorEventSubscriptionProvider subscriptions, @@ -45,9 +42,7 @@ public ActorBackedRoleCatalogStore( public async Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) { - await EnsureInitializedAsync(cancellationToken); - - var state = _snapshot; + var state = await ReadFromReadModelAsync(cancellationToken); var roles = state?.Roles .Select(ToStoredRoleDefinition) .ToList() @@ -65,7 +60,7 @@ public async Task SaveRoleCatalogAsync( StoredRoleCatalog catalog, CancellationToken cancellationToken = default) { - var actor = await EnsureActorAsync(cancellationToken); + var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new RoleCatalogSavedEvent(); evt.Roles.AddRange(catalog.Roles.Select(ToProtoRoleDefinition)); await SendCommandAsync(actor, evt, cancellationToken); @@ -85,7 +80,7 @@ public async Task ImportLocalCatalogAsync(CancellationToken throw new InvalidOperationException($"Local role catalog not found at '{localCatalog.FilePath}'."); } - var actor = await EnsureActorAsync(cancellationToken); + var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new RoleCatalogSavedEvent(); evt.Roles.AddRange(localCatalog.Roles.Select(ToProtoRoleDefinition)); await SendCommandAsync(actor, evt, cancellationToken); @@ -101,9 +96,7 @@ public async Task ImportLocalCatalogAsync(CancellationToken public async Task GetRoleDraftAsync(CancellationToken cancellationToken = default) { - await EnsureInitializedAsync(cancellationToken); - - var state = _snapshot; + var state = await ReadFromReadModelAsync(cancellationToken); var draftEntry = state?.Draft; if (draftEntry is null) { @@ -127,7 +120,7 @@ public async Task SaveRoleDraftAsync( StoredRoleDraft draft, CancellationToken cancellationToken = default) { - var actor = await EnsureActorAsync(cancellationToken); + var actor = await EnsureWriteActorAsync(cancellationToken); var updatedAtUtc = draft.UpdatedAtUtc ?? DateTimeOffset.UtcNow; var evt = new RoleDraftSavedEvent { @@ -146,69 +139,60 @@ public async Task SaveRoleDraftAsync( public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) { - var actor = await EnsureActorAsync(cancellationToken); + var actor = await EnsureWriteActorAsync(cancellationToken); await SendCommandAsync(actor, new RoleDraftDeletedEvent(), cancellationToken); } - public async ValueTask DisposeAsync() - { - if (_subscription is not null) - await _subscription.DisposeAsync(); - } + // ── Per-request readmodel read (no service-level state) ── - private async Task EnsureInitializedAsync(CancellationToken ct) + private async Task ReadFromReadModelAsync(CancellationToken ct) { - if (_initialized) - return; + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); - await _initLock.WaitAsync(ct); - try - { - if (_initialized) - return; - - // Subscribe to the catalog actor's events to receive state snapshots - _subscription = await _subscriptions.SubscribeAsync( - CatalogActorId, - HandleCatalogEventAsync, - ct); + await using var sub = await _subscriptions.SubscribeAsync( + ReadModelActorId, + envelope => + { + if (envelope.Payload?.Is(RoleCatalogStateSnapshotEvent.Descriptor) == true) + { + var snapshot = envelope.Payload.Unpack(); + tcs.TrySetResult(snapshot.Snapshot); + } + return Task.CompletedTask; + }, + ct); - // Activate the actor — this triggers event replay + OnActivateAsync - // which publishes the initial state snapshot - await EnsureActorAsync(ct); + // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) + await EnsureReadModelActorAsync(ct); - _initialized = true; + // Wait for snapshot with timeout + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + return await tcs.Task.WaitAsync(cts.Token); } - finally + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - _initLock.Release(); + _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", ReadModelActorId); + return null; } } - private Task HandleCatalogEventAsync(EventEnvelope envelope) - { - if (envelope.Payload is null) - return Task.CompletedTask; + // ── Actor resolution ── - if (envelope.Payload.Is(RoleCatalogStateSnapshotEvent.Descriptor)) - { - var snapshot = envelope.Payload.Unpack(); - _snapshot = snapshot.Snapshot; - _logger.LogDebug("Role catalog readmodel updated: {RoleCount} roles, draft={HasDraft}", - snapshot.Snapshot?.Roles.Count ?? 0, - snapshot.Snapshot?.Draft is not null); - } - - return Task.CompletedTask; + private async Task EnsureWriteActorAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(WriteActorId); + return actor ?? await _runtime.CreateAsync(WriteActorId, ct); } - private async Task EnsureActorAsync(CancellationToken ct) + private async Task EnsureReadModelActorAsync(CancellationToken ct) { - var actor = await _runtime.GetAsync(CatalogActorId); - if (actor is not null) - return actor; - - return await _runtime.CreateAsync(CatalogActorId, ct); + var actor = await _runtime.GetAsync(ReadModelActorId); + if (actor is null) + await _runtime.CreateAsync(ReadModelActorId, ct); } private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs index 70a594cd..1d5a0c0a 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs @@ -10,23 +10,19 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Writes go through event handlers. -/// Reads come from a readmodel snapshot maintained via event subscription. +/// Completely stateless: no fields hold snapshot or subscription state. +/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Writes send commands to the Write GAgent. /// internal sealed class ActorBackedStreamingProxyParticipantStore - : IStreamingProxyParticipantStore, IAsyncDisposable + : IStreamingProxyParticipantStore { - private const string ParticipantActorId = "streaming-proxy-participants"; + private const string WriteActorId = "streaming-proxy-participants"; private readonly IActorRuntime _runtime; private readonly IActorEventSubscriptionProvider _subscriptions; private readonly ILogger _logger; - private readonly SemaphoreSlim _initLock = new(1, 1); - private volatile StreamingProxyParticipantGAgentState? _snapshot; - private IAsyncDisposable? _subscription; - private bool _initialized; - public ActorBackedStreamingProxyParticipantStore( IActorRuntime runtime, IActorEventSubscriptionProvider subscriptions, @@ -40,9 +36,7 @@ public ActorBackedStreamingProxyParticipantStore( public async Task> ListAsync( string roomId, CancellationToken cancellationToken = default) { - await EnsureInitializedAsync(cancellationToken); - - var state = _snapshot; + var state = await ReadFromReadModelAsync(cancellationToken); if (state is null) return []; @@ -62,7 +56,7 @@ public async Task AddAsync( string roomId, string agentId, string displayName, CancellationToken cancellationToken = default) { - var actor = await EnsureActorAsync(cancellationToken); + var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new ParticipantAddedEvent { RoomId = roomId, @@ -76,7 +70,7 @@ public async Task AddAsync( public async Task RemoveRoomAsync( string roomId, CancellationToken cancellationToken = default) { - var actor = await EnsureActorAsync(cancellationToken); + var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new RoomParticipantsRemovedEvent { RoomId = roomId, @@ -84,65 +78,57 @@ public async Task RemoveRoomAsync( await SendCommandAsync(actor, evt, cancellationToken); } - public async ValueTask DisposeAsync() - { - if (_subscription is not null) - await _subscription.DisposeAsync(); - } + // ── Per-request readmodel read (no service-level state) ── - private async Task EnsureInitializedAsync(CancellationToken ct) + private async Task ReadFromReadModelAsync(CancellationToken ct) { - if (_initialized) - return; + var readModelActorId = WriteActorId + "-readmodel"; + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); - await _initLock.WaitAsync(ct); - try - { - if (_initialized) - return; - - // Subscribe to the participant actor's events to receive state snapshots - _subscription = await _subscriptions.SubscribeAsync( - ParticipantActorId, - HandleParticipantEventAsync, - ct); + await using var sub = await _subscriptions.SubscribeAsync( + readModelActorId, + envelope => + { + if (envelope.Payload?.Is(StreamingProxyParticipantStateSnapshotEvent.Descriptor) == true) + { + var snapshot = envelope.Payload.Unpack(); + tcs.TrySetResult(snapshot.Snapshot); + } + return Task.CompletedTask; + }, + ct); - // Activate the actor — this triggers event replay + OnActivateAsync - // which publishes the initial state snapshot - await EnsureActorAsync(ct); + // Activate readmodel actor (triggers OnActivateAsync → PublishAsync snapshot) + await EnsureReadModelActorAsync(readModelActorId, ct); - _initialized = true; + // Wait for snapshot with timeout + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + return await tcs.Task.WaitAsync(cts.Token); } - finally + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - _initLock.Release(); + _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); + return null; } } - private Task HandleParticipantEventAsync(EventEnvelope envelope) - { - if (envelope.Payload is null) - return Task.CompletedTask; - - if (envelope.Payload.Is(StreamingProxyParticipantStateSnapshotEvent.Descriptor)) - { - var snapshot = envelope.Payload.Unpack(); - _snapshot = snapshot.Snapshot; - _logger.LogDebug( - "Participant readmodel updated: {RoomCount} rooms", - snapshot.Snapshot?.Rooms.Count ?? 0); - } + // ── Actor resolution ── - return Task.CompletedTask; + private async Task EnsureWriteActorAsync(CancellationToken ct) + { + var actor = await _runtime.GetAsync(WriteActorId); + return actor ?? await _runtime.CreateAsync(WriteActorId, ct); } - private async Task EnsureActorAsync(CancellationToken ct) + private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) { - var actor = await _runtime.GetAsync(ParticipantActorId); - if (actor is not null) - return actor; - - return await _runtime.CreateAsync(ParticipantActorId, ct); + var actor = await _runtime.GetAsync(readModelActorId); + if (actor is null) + await _runtime.CreateAsync(readModelActorId, ct); } private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs index 63781dc1..82305cfb 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs @@ -1,47 +1,50 @@ -using System.Collections.Concurrent; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.UserConfig; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Infrastructure.Storage; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . +/// Completely stateless: no fields hold snapshot or subscription state. +/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Writes send commands to the Write GAgent. /// Per-scope isolation: each scope gets its own user-config-{scopeId} actor. /// -internal sealed class ActorBackedUserConfigStore : IUserConfigStore, IAsyncDisposable +internal sealed class ActorBackedUserConfigStore : IUserConfigStore { - private const string ActorIdPrefix = "user-config-"; + private const string WriteActorIdPrefix = "user-config-"; private readonly IActorRuntime _runtime; private readonly IActorEventSubscriptionProvider _subscriptions; private readonly IAppScopeResolver _scopeResolver; + private readonly StudioStorageOptions _storageOptions; private readonly ILogger _logger; - private readonly ConcurrentDictionary _scopes = new(StringComparer.Ordinal); - public ActorBackedUserConfigStore( IActorRuntime runtime, IActorEventSubscriptionProvider subscriptions, IAppScopeResolver scopeResolver, + IOptions storageOptions, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _storageOptions = storageOptions?.Value ?? new StudioStorageOptions(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetAsync(CancellationToken cancellationToken = default) { - var scopeState = await EnsureScopeAsync(cancellationToken); - - var state = scopeState.Snapshot; + var state = await ReadFromReadModelAsync(cancellationToken); if (state is null) return CreateDefaultConfig(); @@ -54,18 +57,17 @@ public async Task GetAsync(CancellationToken cancellationToken = def ? UserConfigRuntimeDefaults.LocalMode : state.RuntimeMode, LocalRuntimeBaseUrl: string.IsNullOrEmpty(state.LocalRuntimeBaseUrl) - ? UserConfigRuntimeDefaults.LocalRuntimeBaseUrl + ? _storageOptions.ResolveDefaultLocalRuntimeBaseUrl() : state.LocalRuntimeBaseUrl, RemoteRuntimeBaseUrl: string.IsNullOrEmpty(state.RemoteRuntimeBaseUrl) - ? UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl + ? _storageOptions.ResolveDefaultRemoteRuntimeBaseUrl() : state.RemoteRuntimeBaseUrl, MaxToolRounds: state.MaxToolRounds); } public async Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) { - var scopeState = await EnsureScopeAsync(cancellationToken); - var actor = await EnsureActorAsync(scopeState.ActorId, cancellationToken); + var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new UserConfigUpdatedEvent { DefaultModel = config.DefaultModel, @@ -82,78 +84,67 @@ public async Task SaveAsync(UserConfig config, CancellationToken cancellationTok await SendCommandAsync(actor, evt, cancellationToken); } - public async ValueTask DisposeAsync() - { - foreach (var scope in _scopes.Values) - { - if (scope.Subscription is not null) - await scope.Subscription.DisposeAsync(); - } - } - - private string ResolveScopeId() - { - var scope = _scopeResolver.Resolve(); - return scope?.ScopeId ?? "default"; - } + // ── Per-request readmodel read (no service-level state) ── - private async Task EnsureScopeAsync(CancellationToken ct) + private async Task ReadFromReadModelAsync(CancellationToken ct) { - var scopeId = ResolveScopeId(); - var actorId = ActorIdPrefix + scopeId; + var readModelActorId = ResolveReadModelActorId(); + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); - var scopeState = _scopes.GetOrAdd(actorId, _ => new ScopeState(actorId)); + await using var sub = await _subscriptions.SubscribeAsync( + readModelActorId, + envelope => + { + if (envelope.Payload?.Is(UserConfigStateSnapshotEvent.Descriptor) == true) + { + var snapshot = envelope.Payload.Unpack(); + tcs.TrySetResult(snapshot.Snapshot); + } + return Task.CompletedTask; + }, + ct); - if (scopeState.Initialized) - return scopeState; + // Activate readmodel actor (triggers OnActivateAsync → PublishAsync snapshot) + await EnsureReadModelActorAsync(readModelActorId, ct); - await scopeState.InitLock.WaitAsync(ct); + // Wait for snapshot with timeout + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); try { - if (scopeState.Initialized) - return scopeState; - - scopeState.Subscription = await _subscriptions.SubscribeAsync( - actorId, - envelope => HandleConfigEventAsync(actorId, envelope), - ct); - - await EnsureActorAsync(actorId, ct); - scopeState.Initialized = true; + return await tcs.Task.WaitAsync(cts.Token); } - finally + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - scopeState.InitLock.Release(); + _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); + return null; } - - return scopeState; } - private Task HandleConfigEventAsync(string actorId, EventEnvelope envelope) - { - if (envelope.Payload is null) - return Task.CompletedTask; - - if (envelope.Payload.Is(UserConfigStateSnapshotEvent.Descriptor)) - { - var snapshot = envelope.Payload.Unpack(); - if (_scopes.TryGetValue(actorId, out var scopeState)) - { - scopeState.Snapshot = snapshot.Snapshot; - _logger.LogDebug("User config readmodel updated for {ActorId}", actorId); - } - } + // ── Actor resolution ── - return Task.CompletedTask; + private string ResolveScopeId() + { + var scope = _scopeResolver.Resolve(); + return scope?.ScopeId ?? "default"; } - private async Task EnsureActorAsync(string actorId, CancellationToken ct) + private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; + + private async Task EnsureWriteActorAsync(CancellationToken ct) { + var actorId = ResolveWriteActorId(); var actor = await _runtime.GetAsync(actorId); - if (actor is not null) - return actor; + return actor ?? await _runtime.CreateAsync(actorId, ct); + } - return await _runtime.CreateAsync(actorId, ct); + private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) + { + var actor = await _runtime.GetAsync(readModelActorId); + if (actor is null) + await _runtime.CreateAsync(readModelActorId, ct); } private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) @@ -171,20 +162,11 @@ private static async Task SendCommandAsync(IActor actor, IMessage command, Cance await actor.HandleEventAsync(envelope, ct); } - private static UserConfig CreateDefaultConfig() => + private UserConfig CreateDefaultConfig() => new( DefaultModel: string.Empty, PreferredLlmRoute: UserConfigLlmRouteDefaults.Gateway, RuntimeMode: UserConfigRuntimeDefaults.LocalMode, - LocalRuntimeBaseUrl: UserConfigRuntimeDefaults.LocalRuntimeBaseUrl, - RemoteRuntimeBaseUrl: UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl); - - private sealed class ScopeState(string actorId) - { - public string ActorId { get; } = actorId; - public SemaphoreSlim InitLock { get; } = new(1, 1); - public volatile bool Initialized; - public volatile UserConfigGAgentState? Snapshot; - public IAsyncDisposable? Subscription; - } + LocalRuntimeBaseUrl: _storageOptions.ResolveDefaultLocalRuntimeBaseUrl(), + RemoteRuntimeBaseUrl: _storageOptions.ResolveDefaultRemoteRuntimeBaseUrl()); } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs index f06bdc93..4ccef425 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using Aevatar.AI.Abstractions.LLMProviders; @@ -14,19 +13,19 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Per-scope isolation: each scope gets its own user-memory-{scopeId} actor. +/// Completely stateless: no fields hold snapshot or subscription state. +/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Writes send commands to the Write GAgent. /// -internal sealed class ActorBackedUserMemoryStore : IUserMemoryStore, IAsyncDisposable +internal sealed class ActorBackedUserMemoryStore : IUserMemoryStore { - private const string ActorIdPrefix = "user-memory-"; + private const string WriteActorIdPrefix = "user-memory-"; private readonly IActorRuntime _runtime; private readonly IActorEventSubscriptionProvider _subscriptions; private readonly IAppScopeResolver _scopeResolver; private readonly ILogger _logger; - private readonly ConcurrentDictionary _scopes = new(StringComparer.Ordinal); - public ActorBackedUserMemoryStore( IActorRuntime runtime, IActorEventSubscriptionProvider subscriptions, @@ -41,9 +40,7 @@ public ActorBackedUserMemoryStore( public async Task GetAsync(CancellationToken ct = default) { - var scopeState = await EnsureScopeAsync(ct); - - var state = scopeState.Snapshot; + var state = await ReadFromReadModelAsync(ct); if (state is null) return UserMemoryDocument.Empty; @@ -81,7 +78,7 @@ public async Task SaveAsync(UserMemoryDocument document, CancellationToken ct = // Add entries not in the current state foreach (var entry in document.Entries.Where(e => !currentIds.Contains(e.Id))) { - var actor = await EnsureActorAsync(ct); + var actor = await EnsureWriteActorAsync(ct); var evt = new MemoryEntryAddedEvent { Entry = new UserMemoryEntryProto @@ -101,7 +98,7 @@ public async Task SaveAsync(UserMemoryDocument document, CancellationToken ct = public async Task AddEntryAsync( string category, string content, string source, CancellationToken ct = default) { - var actor = await EnsureActorAsync(ct); + var actor = await EnsureWriteActorAsync(ct); var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var entry = new UserMemoryEntryProto { @@ -127,13 +124,11 @@ public async Task AddEntryAsync( public async Task RemoveEntryAsync(string id, CancellationToken ct = default) { - var scopeState = await EnsureScopeAsync(ct); - - var state = scopeState.Snapshot; + var state = await ReadFromReadModelAsync(ct); if (state is null || !state.Entries.Any(e => string.Equals(e.Id, id, StringComparison.Ordinal))) return false; - var actor = await EnsureActorAsync(ct); + var actor = await EnsureWriteActorAsync(ct); var evt = new MemoryEntryRemovedEvent { EntryId = id }; await SendCommandAsync(actor, evt, ct); return true; @@ -198,86 +193,70 @@ public async Task BuildPromptSectionAsync(int maxChars = 2000, Cancellat : truncated; } - public async ValueTask DisposeAsync() - { - foreach (var scope in _scopes.Values) - { - if (scope.Subscription is not null) - await scope.Subscription.DisposeAsync(); - } - } + // ── Per-request readmodel read (no service-level state) ── - private string ResolveActorId() + private async Task ReadFromReadModelAsync(CancellationToken ct) { - var scope = _scopeResolver.Resolve(); - if (scope is null) - throw new InvalidOperationException( - "User memory store requires an authenticated user scope. No scope could be resolved."); - return ActorIdPrefix + scope.ScopeId; - } + var readModelActorId = ResolveReadModelActorId(); + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); - private async Task EnsureScopeAsync(CancellationToken ct) - { - var actorId = ResolveActorId(); - var scopeState = _scopes.GetOrAdd(actorId, _ => new ScopeState(actorId)); + await using var sub = await _subscriptions.SubscribeAsync( + readModelActorId, + envelope => + { + if (envelope.Payload?.Is(UserMemoryStateSnapshotEvent.Descriptor) == true) + { + var snapshot = envelope.Payload.Unpack(); + tcs.TrySetResult(snapshot.Snapshot); + } + return Task.CompletedTask; + }, + ct); - if (scopeState.Initialized) - return scopeState; + // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) + await EnsureReadModelActorAsync(readModelActorId, ct); - await scopeState.InitLock.WaitAsync(ct); + // Wait for snapshot with timeout + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(5)); try { - if (scopeState.Initialized) - return scopeState; - - scopeState.Subscription = await _subscriptions.SubscribeAsync( - actorId, - envelope => HandleSnapshotEventAsync(actorId, envelope), - ct); - - await EnsureActorAsync(actorId, ct); - scopeState.Initialized = true; + return await tcs.Task.WaitAsync(cts.Token); } - finally + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - scopeState.InitLock.Release(); + _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); + return null; } - - return scopeState; } - private Task HandleSnapshotEventAsync(string actorId, EventEnvelope envelope) - { - if (envelope.Payload is null) - return Task.CompletedTask; + // ── Actor resolution ── - if (envelope.Payload.Is(UserMemoryStateSnapshotEvent.Descriptor)) - { - var snapshot = envelope.Payload.Unpack(); - if (_scopes.TryGetValue(actorId, out var scopeState)) - { - scopeState.Snapshot = snapshot.Snapshot; - _logger.LogDebug("User memory readmodel updated for {ActorId}: {EntryCount} entries", - actorId, snapshot.Snapshot?.Entries.Count ?? 0); - } - } - - return Task.CompletedTask; + private string ResolveScopeId() + { + var scope = _scopeResolver.Resolve(); + if (scope is null) + throw new InvalidOperationException( + "User memory store requires an authenticated user scope. No scope could be resolved."); + return scope.ScopeId; } - private async Task EnsureActorAsync(string actorId, CancellationToken ct) + private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; + + private async Task EnsureWriteActorAsync(CancellationToken ct) { + var actorId = ResolveWriteActorId(); var actor = await _runtime.GetAsync(actorId); - if (actor is not null) - return actor; - - return await _runtime.CreateAsync(actorId, ct); + return actor ?? await _runtime.CreateAsync(actorId, ct); } - private async Task EnsureActorAsync(CancellationToken ct) + private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) { - var actorId = ResolveActorId(); - return await EnsureActorAsync(actorId, ct); + var actor = await _runtime.GetAsync(readModelActorId); + if (actor is null) + await _runtime.CreateAsync(readModelActorId, ct); } private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) @@ -318,13 +297,4 @@ private static string NormalizeSource(string? value) => private static string Capitalize(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; - - private sealed class ScopeState(string actorId) - { - public string ActorId { get; } = actorId; - public SemaphoreSlim InitLock { get; } = new(1, 1); - public volatile bool Initialized; - public volatile UserMemoryState? Snapshot; - public IAsyncDisposable? Subscription; - } } diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageChatHistoryStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageChatHistoryStore.cs deleted file mode 100644 index 16d845df..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageChatHistoryStore.cs +++ /dev/null @@ -1,527 +0,0 @@ -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageChatHistoryStore : IChatHistoryStore -{ - private const string ChatHistoriesDir = "chat-histories"; - private const string MetaDir = $"{ChatHistoriesDir}/_meta"; - private const string LegacyIndexKey = $"{ChatHistoriesDir}/index.json"; - private const string NyxIdChatActorPrefix = "nyxid-chat-"; - private const string LegacyConversationPrefix = "conv-"; - - private static readonly ChatHistoryIndex EmptyIndex = new([]); - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - private readonly ILogger _logger; - - public ChronoStorageChatHistoryStore( - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - ILogger logger) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task GetIndexAsync(string scopeId, CancellationToken ct = default) - { - try - { - var context = TryResolveChatHistoryScope(scopeId); - if (context is null) - return EmptyIndex; - - // Single list call for the entire chat-histories/ prefix. - var objects = await _blobClient.ListObjectsAsync(context, ChatHistoriesDir, ct); - if (objects.Objects.Count == 0) - return EmptyIndex; - - // Partition into .jsonl conversation files and _meta/ sidecar files. - var jsonlByConvId = new Dictionary(StringComparer.Ordinal); - var metaByConvId = new Dictionary(StringComparer.Ordinal); - - foreach (var obj in objects.Objects) - { - if (IsMetaFile(obj.Key)) - { - var convId = TryExtractMetaConversationId(obj.Key); - if (convId is not null) - metaByConvId[convId] = obj; - } - else if (IsConversationFile(obj.Key)) - { - var convId = TryExtractConversationId(obj.Key); - if (convId is not null) - jsonlByConvId[convId] = obj; - } - } - - if (jsonlByConvId.Count == 0) - return EmptyIndex; - - // For each conversation, prefer sidecar if fresh; fallback to full download. - var tasks = new List>(jsonlByConvId.Count); - foreach (var (convId, jsonlObj) in jsonlByConvId) - { - if (metaByConvId.TryGetValue(convId, out var metaObj) && !IsSidecarStale(metaObj, jsonlObj)) - tasks.Add(TryReadSidecarMetaAsync(scopeId, convId, ct)); - else - tasks.Add(TryBuildAndBackfillMetaAsync(scopeId, convId, jsonlObj, ct)); - } - - var conversations = await Task.WhenAll(tasks); - - return new ChatHistoryIndex(conversations - .Where(static c => c is not null) - .Select(static c => c!) - .OrderByDescending(static c => c.UpdatedAt) - .ThenBy(static c => c.Id, StringComparer.Ordinal) - .ToList()); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to read chat history index for scope {ScopeId}", scopeId); - return EmptyIndex; - } - } - - public async Task> GetMessagesAsync( - string scopeId, string conversationId, CancellationToken ct = default) - { - try - { - var context = TryResolveMessages(scopeId, conversationId); - if (context is null) - return []; - - var payload = await _blobClient.TryDownloadAsync(context, ct); - if (payload is null) - return []; - - return DeserializeJsonl(payload); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to read messages for conversation {ConversationId}", conversationId); - return []; - } - } - - public async Task SaveMessagesAsync( - string scopeId, string conversationId, ConversationMeta meta, IReadOnlyList messages, CancellationToken ct = default) - { - var context = TryResolveMessages(scopeId, conversationId) - ?? throw new InvalidOperationException("Chat history storage is not available."); - - var jsonl = SerializeJsonl(messages); - await _blobClient.UploadAsync(context, jsonl, "application/x-ndjson", ct); - - // Best-effort sidecar write after .jsonl succeeds. - try - { - await WriteSidecarAsync(scopeId, conversationId, meta, messages, ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to write sidecar for conversation {ConversationId}", conversationId); - } - - await DeleteLegacyIndexIfExistsAsync(scopeId, ct); - } - - public async Task DeleteConversationAsync(string scopeId, string conversationId, CancellationToken ct = default) - { - // Delete sidecar first, then .jsonl — avoids orphan sidecar pointing to deleted conversation. - var metaContext = TryResolveSidecar(scopeId, conversationId); - if (metaContext is not null) - { - try { await _blobClient.DeleteIfExistsAsync(metaContext, ct); } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to delete sidecar for conversation {ConversationId}", conversationId); - } - } - - var messagesContext = TryResolveMessages(scopeId, conversationId); - if (messagesContext is not null) - { - await _blobClient.DeleteIfExistsAsync(messagesContext, ct); - } - await DeleteLegacyIndexIfExistsAsync(scopeId, ct); - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolveChatHistoryScope(string scopeId) - { - try - { - return _blobClient.TryResolveContext( - _options.UserConfigPrefix, - $"{ChatHistoriesDir}/.probe"); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for chat history scope"); - return null; - } - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolveMessages(string scopeId, string conversationId) - { - try - { - return _blobClient.TryResolveContext( - _options.UserConfigPrefix, - $"{ChatHistoriesDir}/{conversationId}.jsonl"); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for conversation {ConversationId}", conversationId); - return null; - } - } - - // ── Sidecar read/write helpers ────────────────────────────────────── - - private sealed record SidecarPayload( - string Title, - string ServiceId, - string ServiceKind, - long CreatedAtMs, - long UpdatedAtMs, - int MessageCount, - string? LlmRoute, - string? LlmModel); - - private async Task TryReadSidecarMetaAsync( - string scopeId, string conversationId, CancellationToken ct) - { - var context = TryResolveSidecar(scopeId, conversationId); - if (context is null) - return null; - - var payload = await _blobClient.TryDownloadAsync(context, ct); - if (payload is null) - return null; - - try - { - var sidecar = JsonSerializer.Deserialize(payload, JsonOptions); - if (sidecar is null) - return null; - - return new ConversationMeta( - Id: conversationId, - Title: sidecar.Title, - ServiceId: sidecar.ServiceId, - ServiceKind: sidecar.ServiceKind, - CreatedAt: FromUnixTimestampMilliseconds(sidecar.CreatedAtMs), - UpdatedAt: FromUnixTimestampMilliseconds(sidecar.UpdatedAtMs), - MessageCount: sidecar.MessageCount, - LlmRoute: sidecar.LlmRoute, - LlmModel: sidecar.LlmModel); - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Malformed sidecar for conversation {ConversationId}", conversationId); - return null; - } - } - - /// - /// Fallback: download full .jsonl, build meta, and best-effort write sidecar for next time. - /// - private async Task TryBuildAndBackfillMetaAsync( - string scopeId, - string conversationId, - ChronoStorageCatalogBlobClient.StorageObject jsonlObj, - CancellationToken ct) - { - var meta = await TryBuildConversationMetaFromJsonl(scopeId, conversationId, jsonlObj, ct); - if (meta is null) - return null; - - // Best-effort backfill — don't block the response on this. - _ = Task.Run(async () => - { - try - { - var messagesContext = TryResolveMessages(scopeId, conversationId); - if (messagesContext is null) return; - var bytes = await _blobClient.TryDownloadAsync(messagesContext, ct); - if (bytes is null) return; - var messages = DeserializeJsonl(bytes); - await WriteSidecarAsync(scopeId, conversationId, meta, messages, ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Best-effort sidecar backfill failed for {ConversationId}", conversationId); - } - }, ct); - - return meta; - } - - private async Task TryBuildConversationMetaFromJsonl( - string scopeId, - string conversationId, - ChronoStorageCatalogBlobClient.StorageObject storageObject, - CancellationToken ct) - { - var context = TryResolveMessages(scopeId, conversationId); - if (context is null) - return null; - - var payload = await _blobClient.TryDownloadAsync(context, ct); - if (payload is null) - return null; - - var messages = DeserializeJsonl(payload); - return BuildMetaFromMessages(scopeId, conversationId, messages, storageObject); - } - - private static ConversationMeta BuildMetaFromMessages( - string scopeId, - string conversationId, - IReadOnlyList messages, - ChronoStorageCatalogBlobClient.StorageObject? storageObject) - { - if (messages.Count == 0) - { - var fallbackTimestamp = (storageObject is not null ? TryParseStorageTimestamp(storageObject.LastModified) : null) - ?? DateTimeOffset.UtcNow; - var (fsi, fsk) = InferConversationService(scopeId, conversationId); - return new ConversationMeta(conversationId, conversationId, fsi, fsk, fallbackTimestamp, fallbackTimestamp, 0); - } - - var title = BuildConversationTitle(messages, conversationId); - var defaultTs = (storageObject is not null ? TryParseStorageTimestamp(storageObject.LastModified) : null) - ?? DateTimeOffset.UtcNow; - var createdAt = messages.Select(static m => FromUnixTimestampMilliseconds(m.Timestamp)).DefaultIfEmpty(defaultTs).Min(); - var updatedAt = messages.Select(static m => FromUnixTimestampMilliseconds(m.Timestamp)).DefaultIfEmpty(createdAt).Max(); - var (serviceId, serviceKind) = InferConversationService(scopeId, conversationId); - return new ConversationMeta(conversationId, title, serviceId, serviceKind, createdAt, updatedAt, messages.Count); - } - - private async Task WriteSidecarAsync( - string scopeId, string conversationId, - ConversationMeta meta, - IReadOnlyList messages, CancellationToken ct) - { - var context = TryResolveSidecar(scopeId, conversationId); - if (context is null) return; - - var inferredMeta = BuildMetaFromMessages(scopeId, conversationId, messages, storageObject: null); - var normalizedMeta = NormalizeMeta(conversationId, meta, inferredMeta); - var sidecar = new SidecarPayload( - normalizedMeta.Title, - normalizedMeta.ServiceId, - normalizedMeta.ServiceKind, - normalizedMeta.CreatedAt.ToUnixTimeMilliseconds(), - normalizedMeta.UpdatedAt.ToUnixTimeMilliseconds(), - normalizedMeta.MessageCount, - normalizedMeta.LlmRoute, - normalizedMeta.LlmModel); - var bytes = JsonSerializer.SerializeToUtf8Bytes(sidecar, JsonOptions); - await _blobClient.UploadAsync(context, bytes, "application/json", ct); - } - - private static ConversationMeta NormalizeMeta( - string conversationId, - ConversationMeta meta, - ConversationMeta fallback) - { - var title = string.IsNullOrWhiteSpace(meta.Title) - ? fallback.Title - : meta.Title.Trim(); - var serviceId = string.IsNullOrWhiteSpace(meta.ServiceId) - ? fallback.ServiceId - : meta.ServiceId.Trim(); - var serviceKind = string.IsNullOrWhiteSpace(meta.ServiceKind) - ? fallback.ServiceKind - : meta.ServiceKind.Trim(); - var createdAt = meta.CreatedAt == default ? fallback.CreatedAt : meta.CreatedAt; - var updatedAt = meta.UpdatedAt == default ? fallback.UpdatedAt : meta.UpdatedAt; - var messageCount = meta.MessageCount > 0 || fallback.MessageCount == 0 - ? meta.MessageCount - : fallback.MessageCount; - var llmRoute = meta.LlmRoute is null ? null : meta.LlmRoute.Trim(); - var llmModel = string.IsNullOrWhiteSpace(meta.LlmModel) ? null : meta.LlmModel.Trim(); - - return new ConversationMeta( - Id: conversationId, - Title: title, - ServiceId: serviceId, - ServiceKind: serviceKind, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - MessageCount: messageCount, - LlmRoute: llmRoute, - LlmModel: llmModel); - } - - private static bool IsSidecarStale( - ChronoStorageCatalogBlobClient.StorageObject metaObj, - ChronoStorageCatalogBlobClient.StorageObject jsonlObj) - { - var metaTs = TryParseStorageTimestamp(metaObj.LastModified); - var jsonlTs = TryParseStorageTimestamp(jsonlObj.LastModified); - if (metaTs is null || jsonlTs is null) - return true; // Can't compare — treat as stale. - return metaTs.Value < jsonlTs.Value; - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolveSidecar(string scopeId, string conversationId) - { - try - { - return _blobClient.TryResolveContext( - _options.UserConfigPrefix, - $"{MetaDir}/{conversationId}.json"); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for sidecar {ConversationId}", conversationId); - return null; - } - } - - private static bool IsMetaFile(string key) => - key.StartsWith($"{MetaDir}/", StringComparison.Ordinal) && - key.EndsWith(".json", StringComparison.OrdinalIgnoreCase); - - private static IReadOnlyList DeserializeJsonl(byte[] payload) - { - var text = Encoding.UTF8.GetString(payload); - var messages = new List(); - - foreach (var line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - var msg = JsonSerializer.Deserialize(line, JsonOptions); - if (msg is not null) - messages.Add(msg); - } - - return messages; - } - - private static byte[] SerializeJsonl(IReadOnlyList messages) - { - var sb = new StringBuilder(); - foreach (var msg in messages) - { - sb.AppendLine(JsonSerializer.Serialize(msg, JsonOptions)); - } - - return Encoding.UTF8.GetBytes(sb.ToString()); - } - - private async Task DeleteLegacyIndexIfExistsAsync(string scopeId, CancellationToken ct) - { - var context = TryResolveLegacyIndex(scopeId); - if (context is null) - return; - - try - { - await _blobClient.DeleteIfExistsAsync(context, ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to delete legacy chat history index for scope {ScopeId}", scopeId); - } - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolveLegacyIndex(string scopeId) - { - try - { - return _blobClient.TryResolveContext(_options.UserConfigPrefix, LegacyIndexKey); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for legacy chat history index"); - return null; - } - } - - private static bool IsConversationFile(string key) => - key.StartsWith($"{ChatHistoriesDir}/", StringComparison.Ordinal) && - key.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase); - - private static string? TryExtractConversationId(string key) - { - if (!IsConversationFile(key)) - return null; - - var fileName = key[(ChatHistoriesDir.Length + 1)..]; - return fileName.EndsWith(".jsonl", StringComparison.OrdinalIgnoreCase) - ? fileName[..^".jsonl".Length] - : null; - } - - private static string? TryExtractMetaConversationId(string key) - { - if (!IsMetaFile(key)) - return null; - - var fileName = key[(MetaDir.Length + 1)..]; - return fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) - ? fileName[..^".json".Length] - : null; - } - - private static string BuildConversationTitle(IReadOnlyList messages, string fallback) - { - var source = messages - .FirstOrDefault(static message => string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrWhiteSpace(message.Content)) - ?.Content - ?? messages.FirstOrDefault(static message => !string.IsNullOrWhiteSpace(message.Content))?.Content - ?? fallback; - var trimmed = source.Trim(); - return trimmed.Length <= 60 ? trimmed : trimmed[..60]; - } - - private static (string ServiceId, string ServiceKind) InferConversationService(string scopeId, string conversationId) - { - if (conversationId.StartsWith("NyxIdChat:", StringComparison.OrdinalIgnoreCase) || - conversationId.StartsWith(NyxIdChatActorPrefix, StringComparison.OrdinalIgnoreCase) || - conversationId.StartsWith(LegacyConversationPrefix, StringComparison.OrdinalIgnoreCase)) - { - return ("nyxid-chat", "nyxid-chat"); - } - - var separatorIndex = conversationId.IndexOf(':'); - if (separatorIndex > 0) - { - var serviceId = conversationId[..separatorIndex]; - var isNyxIdChat = string.Equals(serviceId, "nyxid-chat", StringComparison.OrdinalIgnoreCase) || - string.Equals(conversationId, $"NyxIdChat:{scopeId}", StringComparison.OrdinalIgnoreCase); - return (serviceId, isNyxIdChat ? "nyxid-chat" : "service"); - } - - return ("nyxid-chat", "nyxid-chat"); - } - - private static DateTimeOffset FromUnixTimestampMilliseconds(long timestamp) => - timestamp > 0 - ? DateTimeOffset.FromUnixTimeMilliseconds(timestamp) - : DateTimeOffset.UnixEpoch; - - private static DateTimeOffset? TryParseStorageTimestamp(string? value) => - DateTimeOffset.TryParse(value, out var timestamp) ? timestamp : null; -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageConnectorCatalogStore.cs deleted file mode 100644 index a0413c88..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageConnectorCatalogStore.cs +++ /dev/null @@ -1,171 +0,0 @@ -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageConnectorCatalogStore : IConnectorCatalogStore -{ - private const string CatalogFileName = "connectors.json"; - private const string DraftFileName = "connectors.draft.json"; - - private readonly IStudioWorkspaceStore _localWorkspaceStore; - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - - public ChronoStorageConnectorCatalogStore( - IStudioWorkspaceStore localWorkspaceStore, - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - IOptions studioStorageOptions) - { - _localWorkspaceStore = localWorkspaceStore ?? throw new ArgumentNullException(nameof(localWorkspaceStore)); - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _ = studioStorageOptions?.Value.ResolveRootDirectory() - ?? throw new ArgumentNullException(nameof(studioStorageOptions)); - } - - public async Task GetConnectorCatalogAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, CatalogFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.GetConnectorCatalogAsync(cancellationToken); - } - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - { - return CreateRemoteCatalog(remoteContext, fileExists: false, []); - } - - await using var stream = new MemoryStream(payload, writable: false); - var connectors = await ConnectorCatalogJsonSerializer.ReadCatalogAsync(stream, cancellationToken); - return CreateRemoteCatalog(remoteContext, fileExists: true, connectors); - } - - public async Task SaveConnectorCatalogAsync( - StoredConnectorCatalog catalog, - CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, CatalogFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.SaveConnectorCatalogAsync(catalog, cancellationToken); - } - - await UploadCatalogAsync(remoteContext, catalog.Connectors, cancellationToken); - return CreateRemoteCatalog(remoteContext, fileExists: true, catalog.Connectors); - } - - public async Task ImportLocalCatalogAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, CatalogFileName) - ?? throw new InvalidOperationException("Chrono-storage connector catalog import requires remote storage to be enabled."); - var localCatalog = await _localWorkspaceStore.GetConnectorCatalogAsync(cancellationToken); - if (!localCatalog.FileExists) - { - throw new InvalidOperationException($"Local connector catalog not found at '{localCatalog.FilePath}'."); - } - - await UploadCatalogAsync(remoteContext, localCatalog.Connectors, cancellationToken); - var importedCatalog = CreateRemoteCatalog(remoteContext, fileExists: true, localCatalog.Connectors); - return new ImportedConnectorCatalog(localCatalog.FilePath, true, importedCatalog); - } - - public async Task GetConnectorDraftAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, DraftFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.GetConnectorDraftAsync(cancellationToken); - } - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - { - return new StoredConnectorDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: false, - UpdatedAtUtc: null, - Draft: null); - } - - await using var stream = new MemoryStream(payload, writable: false); - var parsed = await ConnectorCatalogJsonSerializer.ReadDraftAsync( - stream, - fallbackUpdatedAtUtc: DateTimeOffset.UtcNow, - cancellationToken); - - return new StoredConnectorDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: true, - UpdatedAtUtc: parsed.UpdatedAtUtc, - Draft: parsed.Draft); - } - - public async Task SaveConnectorDraftAsync( - StoredConnectorDraft draft, - CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, DraftFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.SaveConnectorDraftAsync(draft, cancellationToken); - } - - var updatedAtUtc = draft.UpdatedAtUtc ?? DateTimeOffset.UtcNow; - await UploadDraftAsync(remoteContext, draft.Draft, updatedAtUtc, cancellationToken); - - return new StoredConnectorDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: true, - UpdatedAtUtc: updatedAtUtc, - Draft: draft.Draft); - } - - public Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.Prefix, DraftFileName); - if (remoteContext is null) - { - return _localWorkspaceStore.DeleteConnectorDraftAsync(cancellationToken); - } - - return _blobClient.DeleteIfExistsAsync(remoteContext, cancellationToken); - } - - private async Task UploadCatalogAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - IReadOnlyList connectors, - CancellationToken cancellationToken) - { - await using var stream = new MemoryStream(); - await ConnectorCatalogJsonSerializer.WriteCatalogAsync(stream, connectors, cancellationToken); - await _blobClient.UploadAsync(remoteContext, stream.ToArray(), "application/json", cancellationToken); - } - - private async Task UploadDraftAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - StoredConnectorDefinition? draft, - DateTimeOffset updatedAtUtc, - CancellationToken cancellationToken) - { - await using var stream = new MemoryStream(); - await ConnectorCatalogJsonSerializer.WriteDraftAsync(stream, draft, updatedAtUtc, cancellationToken); - await _blobClient.UploadAsync(remoteContext, stream.ToArray(), "application/json", cancellationToken); - } - - private StoredConnectorCatalog CreateRemoteCatalog( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - bool fileExists, - IReadOnlyList connectors) => - new( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: fileExists, - Connectors: connectors); -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageGAgentActorStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageGAgentActorStore.cs deleted file mode 100644 index c5685008..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageGAgentActorStore.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageGAgentActorStore : IGAgentActorStore -{ - private const string ActorsFileName = "actors.json"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - private readonly ILogger _logger; - - public ChronoStorageGAgentActorStore( - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - ILogger logger) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task> GetAsync(CancellationToken cancellationToken = default) - { - var remoteContext = TryResolve(); - if (remoteContext is null) - return []; - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - return []; - - return DeserializeGroups(payload); - } - - public async Task AddActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) - { - var remoteContext = TryResolve() - ?? throw new InvalidOperationException("GAgent actor storage is not available."); - - var existing = await DownloadGroupsAsync(remoteContext, cancellationToken); - var group = existing.Find(g => string.Equals(g.GAgentType, gagentType, StringComparison.Ordinal)); - - if (group is not null) - { - if (group.ActorIds.Contains(actorId)) - return; - - var updatedIds = group.ActorIds.Append(actorId).ToList().AsReadOnly(); - var idx = existing.IndexOf(group); - existing[idx] = new GAgentActorGroup(gagentType, updatedIds); - } - else - { - existing.Add(new GAgentActorGroup(gagentType, new[] { actorId })); - } - - await UploadGroupsAsync(remoteContext, existing, cancellationToken); - } - - public async Task RemoveActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) - { - var remoteContext = TryResolve() - ?? throw new InvalidOperationException("GAgent actor storage is not available."); - - var existing = await DownloadGroupsAsync(remoteContext, cancellationToken); - var group = existing.Find(g => string.Equals(g.GAgentType, gagentType, StringComparison.Ordinal)); - - if (group is null) - return; - - var updatedIds = group.ActorIds.Where(id => !string.Equals(id, actorId, StringComparison.Ordinal)).ToList().AsReadOnly(); - var idx = existing.IndexOf(group); - - if (updatedIds.Count == 0) - existing.RemoveAt(idx); - else - existing[idx] = new GAgentActorGroup(gagentType, updatedIds); - - await UploadGroupsAsync(remoteContext, existing, cancellationToken); - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolve() - { - try - { - return _blobClient.TryResolveContext(_options.UserConfigPrefix, ActorsFileName); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for GAgent actor store"); - return null; - } - } - - private async Task> DownloadGroupsAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - CancellationToken cancellationToken) - { - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - return payload is null ? [] : DeserializeGroups(payload); - } - - private async Task UploadGroupsAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - List groups, - CancellationToken cancellationToken) - { - var json = JsonSerializer.SerializeToUtf8Bytes(groups, JsonOptions); - await _blobClient.UploadAsync(remoteContext, json, "application/json", cancellationToken); - } - - private static List DeserializeGroups(byte[] payload) - { - var doc = JsonDocument.Parse(payload); - var result = new List(); - - if (doc.RootElement.ValueKind != JsonValueKind.Array) - return result; - - foreach (var element in doc.RootElement.EnumerateArray()) - { - var gagentType = element.TryGetProperty("gagentType", out var typeProp) - ? typeProp.GetString() ?? string.Empty - : string.Empty; - - var actorIds = new List(); - if (element.TryGetProperty("actorIds", out var idsProp) && idsProp.ValueKind == JsonValueKind.Array) - { - foreach (var id in idsProp.EnumerateArray()) - { - var val = id.GetString(); - if (!string.IsNullOrWhiteSpace(val)) - actorIds.Add(val); - } - } - - if (!string.IsNullOrWhiteSpace(gagentType)) - result.Add(new GAgentActorGroup(gagentType, actorIds.AsReadOnly())); - } - - return result; - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageNyxIdUserLlmPreferencesStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageNyxIdUserLlmPreferencesStore.cs deleted file mode 100644 index 61b63fb3..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageNyxIdUserLlmPreferencesStore.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.Studio.Application.Studio.Abstractions; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageNyxIdUserLlmPreferencesStore : INyxIdUserLlmPreferencesStore -{ - private readonly IUserConfigStore _userConfigStore; - - public ChronoStorageNyxIdUserLlmPreferencesStore(IUserConfigStore userConfigStore) - { - _userConfigStore = userConfigStore ?? throw new ArgumentNullException(nameof(userConfigStore)); - } - - public async Task GetAsync(CancellationToken cancellationToken = default) - { - var config = await _userConfigStore.GetAsync(cancellationToken); - return new NyxIdUserLlmPreferences( - config.DefaultModel, - UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), - config.MaxToolRounds); - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageRoleCatalogStore.cs deleted file mode 100644 index 1450c60f..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageRoleCatalogStore.cs +++ /dev/null @@ -1,171 +0,0 @@ -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageRoleCatalogStore : IRoleCatalogStore -{ - private const string CatalogFileName = "roles.json"; - private const string DraftFileName = "roles.draft.json"; - - private readonly IStudioWorkspaceStore _localWorkspaceStore; - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - - public ChronoStorageRoleCatalogStore( - IStudioWorkspaceStore localWorkspaceStore, - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - IOptions studioStorageOptions) - { - _localWorkspaceStore = localWorkspaceStore ?? throw new ArgumentNullException(nameof(localWorkspaceStore)); - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _ = studioStorageOptions?.Value.ResolveRootDirectory() - ?? throw new ArgumentNullException(nameof(studioStorageOptions)); - } - - public async Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, CatalogFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.GetRoleCatalogAsync(cancellationToken); - } - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - { - return CreateRemoteCatalog(remoteContext, fileExists: false, []); - } - - await using var stream = new MemoryStream(payload, writable: false); - var roles = await RoleCatalogJsonSerializer.ReadCatalogAsync(stream, cancellationToken); - return CreateRemoteCatalog(remoteContext, fileExists: true, roles); - } - - public async Task SaveRoleCatalogAsync( - StoredRoleCatalog catalog, - CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, CatalogFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.SaveRoleCatalogAsync(catalog, cancellationToken); - } - - await UploadCatalogAsync(remoteContext, catalog.Roles, cancellationToken); - return CreateRemoteCatalog(remoteContext, fileExists: true, catalog.Roles); - } - - public async Task ImportLocalCatalogAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, CatalogFileName) - ?? throw new InvalidOperationException("Chrono-storage role catalog import requires remote storage to be enabled."); - var localCatalog = await _localWorkspaceStore.GetRoleCatalogAsync(cancellationToken); - if (!localCatalog.FileExists) - { - throw new InvalidOperationException($"Local role catalog not found at '{localCatalog.FilePath}'."); - } - - await UploadCatalogAsync(remoteContext, localCatalog.Roles, cancellationToken); - var importedCatalog = CreateRemoteCatalog(remoteContext, fileExists: true, localCatalog.Roles); - return new ImportedRoleCatalog(localCatalog.FilePath, true, importedCatalog); - } - - public async Task GetRoleDraftAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, DraftFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.GetRoleDraftAsync(cancellationToken); - } - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - { - return new StoredRoleDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: false, - UpdatedAtUtc: null, - Draft: null); - } - - await using var stream = new MemoryStream(payload, writable: false); - var parsed = await RoleCatalogJsonSerializer.ReadDraftAsync( - stream, - fallbackUpdatedAtUtc: DateTimeOffset.UtcNow, - cancellationToken); - - return new StoredRoleDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: true, - UpdatedAtUtc: parsed.UpdatedAtUtc, - Draft: parsed.Draft); - } - - public async Task SaveRoleDraftAsync( - StoredRoleDraft draft, - CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, DraftFileName); - if (remoteContext is null) - { - return await _localWorkspaceStore.SaveRoleDraftAsync(draft, cancellationToken); - } - - var updatedAtUtc = draft.UpdatedAtUtc ?? DateTimeOffset.UtcNow; - await UploadDraftAsync(remoteContext, draft.Draft, updatedAtUtc, cancellationToken); - - return new StoredRoleDraft( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: true, - UpdatedAtUtc: updatedAtUtc, - Draft: draft.Draft); - } - - public Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.RolesPrefix, DraftFileName); - if (remoteContext is null) - { - return _localWorkspaceStore.DeleteRoleDraftAsync(cancellationToken); - } - - return _blobClient.DeleteIfExistsAsync(remoteContext, cancellationToken); - } - - private async Task UploadCatalogAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - IReadOnlyList roles, - CancellationToken cancellationToken) - { - await using var stream = new MemoryStream(); - await RoleCatalogJsonSerializer.WriteCatalogAsync(stream, roles, cancellationToken); - await _blobClient.UploadAsync(remoteContext, stream.ToArray(), "application/json", cancellationToken); - } - - private async Task UploadDraftAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - StoredRoleDefinition? draft, - DateTimeOffset updatedAtUtc, - CancellationToken cancellationToken) - { - await using var stream = new MemoryStream(); - await RoleCatalogJsonSerializer.WriteDraftAsync(stream, draft, updatedAtUtc, cancellationToken); - await _blobClient.UploadAsync(remoteContext, stream.ToArray(), "application/json", cancellationToken); - } - - private StoredRoleCatalog CreateRemoteCatalog( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - bool fileExists, - IReadOnlyList roles) => - new( - HomeDirectory: _blobClient.CreateRemoteHomeDirectory(remoteContext), - FilePath: _blobClient.CreateRemoteFilePath(remoteContext), - FileExists: fileExists, - Roles: roles); -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageScriptStoragePort.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageScriptStoragePort.cs deleted file mode 100644 index c7566a07..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageScriptStoragePort.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Text; -using Aevatar.Studio.Application.Studio.Abstractions; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageScriptStoragePort : IScriptStoragePort -{ - private readonly ChronoStorageCatalogBlobClient _blobClient; - - public ChronoStorageScriptStoragePort(ChronoStorageCatalogBlobClient blobClient) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - } - - public async Task UploadScriptAsync(string scriptId, string sourceText, CancellationToken ct) - { - var bytes = Encoding.UTF8.GetBytes(sourceText); - var key = $"scripts/{scriptId}.cs"; - var context = _blobClient.TryResolveContext(string.Empty, key); - if (context == null) return; - - await _blobClient.UploadAsync(context, bytes, "text/plain", ct); - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageStreamingProxyParticipantStore.cs deleted file mode 100644 index 3a87e5b4..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageStreamingProxyParticipantStore.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageStreamingProxyParticipantStore : IStreamingProxyParticipantStore -{ - private const string ParticipantsFileName = "streaming-proxy-participants.json"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - private readonly ILogger _logger; - - public ChronoStorageStreamingProxyParticipantStore( - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - ILogger logger) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task> ListAsync( - string roomId, CancellationToken cancellationToken = default) - { - var rooms = await DownloadAsync(cancellationToken); - return rooms.TryGetValue(roomId, out var participants) - ? participants.AsReadOnly() - : []; - } - - public async Task AddAsync( - string roomId, string agentId, string displayName, - CancellationToken cancellationToken = default) - { - var remoteContext = TryResolve() - ?? throw new InvalidOperationException("Streaming proxy participant storage is not available."); - - var rooms = await DownloadAsync(cancellationToken); - - if (!rooms.TryGetValue(roomId, out var participants)) - { - participants = []; - rooms[roomId] = participants; - } - - participants.RemoveAll(p => string.Equals(p.AgentId, agentId, StringComparison.Ordinal)); - participants.Add(new StreamingProxyParticipant(agentId, displayName, DateTimeOffset.UtcNow)); - - await UploadAsync(remoteContext, rooms, cancellationToken); - } - - public async Task RemoveRoomAsync(string roomId, CancellationToken cancellationToken = default) - { - var remoteContext = TryResolve(); - if (remoteContext is null) - return; - - var rooms = await DownloadAsync(cancellationToken); - if (!rooms.Remove(roomId)) - return; - - await UploadAsync(remoteContext, rooms, cancellationToken); - } - - private ChronoStorageCatalogBlobClient.RemoteScopeContext? TryResolve() - { - try - { - return _blobClient.TryResolveContext(_options.UserConfigPrefix, ParticipantsFileName); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Chrono-storage context could not be resolved for streaming proxy participant store"); - return null; - } - } - - private async Task>> DownloadAsync( - CancellationToken cancellationToken) - { - var remoteContext = TryResolve(); - if (remoteContext is null) - return new Dictionary>(StringComparer.Ordinal); - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - return new Dictionary>(StringComparer.Ordinal); - - return DeserializeRooms(payload); - } - - private async Task UploadAsync( - ChronoStorageCatalogBlobClient.RemoteScopeContext remoteContext, - Dictionary> rooms, - CancellationToken cancellationToken) - { - var json = JsonSerializer.SerializeToUtf8Bytes(rooms, JsonOptions); - await _blobClient.UploadAsync(remoteContext, json, "application/json", cancellationToken); - } - - private Dictionary> DeserializeRooms(byte[] payload) - { - try - { - return JsonSerializer.Deserialize>>(payload, JsonOptions) - ?? new Dictionary>(StringComparer.Ordinal); - } - catch (JsonException ex) - { - // Do NOT return empty — that would cause the next AddAsync to overwrite - // all existing rooms' participants with a blank snapshot. - _logger.LogError(ex, "Corrupt participant store payload; refusing to deserialize to prevent data loss"); - throw new InvalidOperationException("Participant store payload is corrupt", ex); - } - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserConfigStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserConfigStore.cs deleted file mode 100644 index 55f303b7..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserConfigStore.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Text.Json; -using Aevatar.Studio.Application.Studio.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageUserConfigStore : IUserConfigStore -{ - private const string ConfigFileName = "config.json"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - }; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - private readonly string _defaultLocalRuntimeBaseUrl; - private readonly string _defaultRemoteRuntimeBaseUrl; - private readonly ILogger _logger; - - public ChronoStorageUserConfigStore( - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - IOptions studioStorageOptions, - ILogger logger) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - var resolvedStudioStorageOptions = studioStorageOptions?.Value.ResolveRootDirectory() - ?? throw new ArgumentNullException(nameof(studioStorageOptions)); - _defaultLocalRuntimeBaseUrl = resolvedStudioStorageOptions.ResolveDefaultLocalRuntimeBaseUrl(); - _defaultRemoteRuntimeBaseUrl = resolvedStudioStorageOptions.ResolveDefaultRemoteRuntimeBaseUrl(); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task GetAsync(CancellationToken cancellationToken = default) - { - try - { - var remoteContext = _blobClient.TryResolveContext(_options.UserConfigPrefix, ConfigFileName); - if (remoteContext is null) - return CreateDefaultConfig(); - - var payload = await _blobClient.TryDownloadAsync(remoteContext, cancellationToken); - if (payload is null) - return CreateDefaultConfig(); - - var doc = JsonDocument.Parse(payload); - var defaultModel = doc.RootElement.TryGetProperty("defaultModel", out var modelElement) - ? modelElement.GetString() ?? string.Empty - : string.Empty; - var preferredLlmRoute = doc.RootElement.TryGetProperty("preferredLlmRoute", out var routeElement) - ? UserConfigLlmRoute.Normalize(routeElement.GetString()) - : UserConfigLlmRouteDefaults.Gateway; - - var maxToolRounds = doc.RootElement.TryGetProperty("maxToolRounds", out var maxToolRoundsElement) - && maxToolRoundsElement.ValueKind == JsonValueKind.Number - ? maxToolRoundsElement.GetInt32() - : 0; - - var hasRuntimeMode = doc.RootElement.TryGetProperty("runtimeMode", out var runtimeModeElement); - var hasLocalRuntimeBaseUrl = doc.RootElement.TryGetProperty("localRuntimeBaseUrl", out var localRuntimeElement); - var hasRemoteRuntimeBaseUrl = doc.RootElement.TryGetProperty("remoteRuntimeBaseUrl", out var remoteRuntimeElement); - - if (hasRuntimeMode || hasLocalRuntimeBaseUrl || hasRemoteRuntimeBaseUrl) - { - return new UserConfig( - DefaultModel: defaultModel, - PreferredLlmRoute: preferredLlmRoute, - RuntimeMode: UserConfigRuntime.NormalizeMode(hasRuntimeMode ? runtimeModeElement.GetString() : null), - LocalRuntimeBaseUrl: UserConfigRuntime.NormalizeBaseUrl( - hasLocalRuntimeBaseUrl ? localRuntimeElement.GetString() : null, - _defaultLocalRuntimeBaseUrl), - RemoteRuntimeBaseUrl: UserConfigRuntime.NormalizeBaseUrl( - hasRemoteRuntimeBaseUrl ? remoteRuntimeElement.GetString() : null, - _defaultRemoteRuntimeBaseUrl), - MaxToolRounds: maxToolRounds); - } - - var legacyRuntimeBaseUrl = doc.RootElement.TryGetProperty("runtimeBaseUrl", out var runtimeElement) - ? runtimeElement.GetString() ?? string.Empty - : string.Empty; - - if (string.IsNullOrWhiteSpace(legacyRuntimeBaseUrl)) - { - return CreateDefaultConfig() with - { - DefaultModel = defaultModel, - PreferredLlmRoute = preferredLlmRoute, - MaxToolRounds = maxToolRounds, - }; - } - - var normalizedLegacyRuntimeBaseUrl = legacyRuntimeBaseUrl.Trim().TrimEnd('/'); - var runtimeMode = UserConfigRuntime.IsLoopbackRuntime(normalizedLegacyRuntimeBaseUrl) - ? UserConfigRuntimeDefaults.LocalMode - : UserConfigRuntimeDefaults.RemoteMode; - - return runtimeMode == UserConfigRuntimeDefaults.RemoteMode - ? new UserConfig( - DefaultModel: defaultModel, - PreferredLlmRoute: preferredLlmRoute, - RuntimeMode: runtimeMode, - LocalRuntimeBaseUrl: _defaultLocalRuntimeBaseUrl, - RemoteRuntimeBaseUrl: normalizedLegacyRuntimeBaseUrl) - : new UserConfig( - DefaultModel: defaultModel, - PreferredLlmRoute: preferredLlmRoute, - RuntimeMode: runtimeMode, - LocalRuntimeBaseUrl: normalizedLegacyRuntimeBaseUrl, - RemoteRuntimeBaseUrl: _defaultRemoteRuntimeBaseUrl); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to read user config from chrono-storage; returning default config"); - return CreateDefaultConfig(); - } - } - - public async Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) - { - var remoteContext = _blobClient.TryResolveContext(_options.UserConfigPrefix, ConfigFileName); - if (remoteContext is null) - throw new InvalidOperationException( - "User config storage is not available. Chrono-storage is disabled or the remote context could not be resolved."); - - var payload = new Dictionary - { - ["defaultModel"] = config.DefaultModel, - ["preferredLlmRoute"] = UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), - ["runtimeMode"] = UserConfigRuntime.NormalizeMode(config.RuntimeMode), - ["localRuntimeBaseUrl"] = UserConfigRuntime.NormalizeBaseUrl( - config.LocalRuntimeBaseUrl, - _defaultLocalRuntimeBaseUrl), - ["remoteRuntimeBaseUrl"] = UserConfigRuntime.NormalizeBaseUrl( - config.RemoteRuntimeBaseUrl, - _defaultRemoteRuntimeBaseUrl), - }; - if (config.MaxToolRounds > 0) - payload["maxToolRounds"] = config.MaxToolRounds; - var json = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions); - await _blobClient.UploadAsync(remoteContext, json, "application/json", cancellationToken); - } - - private UserConfig CreateDefaultConfig() => - new( - DefaultModel: string.Empty, - PreferredLlmRoute: UserConfigLlmRouteDefaults.Gateway, - RuntimeMode: UserConfigRuntimeDefaults.LocalMode, - LocalRuntimeBaseUrl: _defaultLocalRuntimeBaseUrl, - RemoteRuntimeBaseUrl: _defaultRemoteRuntimeBaseUrl); -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserMemoryStore.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserMemoryStore.cs deleted file mode 100644 index 2b83ff40..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageUserMemoryStore.cs +++ /dev/null @@ -1,258 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Aevatar.AI.Abstractions.LLMProviders; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageUserMemoryStore : IUserMemoryStore -{ - private const string MemoryFileName = "user-memory.json"; - private const int MaxEntries = 50; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly ChronoStorageCatalogBlobClient _blobClient; - private readonly ConnectorCatalogStorageOptions _options; - private readonly ILogger _logger; - - public ChronoStorageUserMemoryStore( - ChronoStorageCatalogBlobClient blobClient, - IOptions options, - ILogger logger) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task GetAsync(CancellationToken ct = default) - { - try - { - var context = _blobClient.TryResolveContext(_options.UserConfigPrefix, MemoryFileName); - if (context is null) - return UserMemoryDocument.Empty; - - var payload = await _blobClient.TryDownloadAsync(context, ct); - if (payload is null) - return UserMemoryDocument.Empty; - - var dto = JsonSerializer.Deserialize(payload, JsonOptions); - if (dto is null) - return UserMemoryDocument.Empty; - - var entries = (dto.Entries ?? []) - .Where(e => !string.IsNullOrWhiteSpace(e.Id) && !string.IsNullOrWhiteSpace(e.Content)) - .Select(e => new UserMemoryEntry( - Id: e.Id!, - Category: NormalizeCategory(e.Category), - Content: e.Content!, - Source: NormalizeSource(e.Source), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt)) - .ToList(); - - return new UserMemoryDocument(dto.Version > 0 ? dto.Version : 1, entries); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to read user memory from chrono-storage; returning empty"); - return UserMemoryDocument.Empty; - } - } - - public async Task SaveAsync(UserMemoryDocument document, CancellationToken ct = default) - { - var context = _blobClient.TryResolveContext(_options.UserConfigPrefix, MemoryFileName); - if (context is null) - throw new InvalidOperationException( - "User memory storage is not available. Chrono-storage is disabled or the remote context could not be resolved."); - - var dto = new UserMemoryDocumentDto - { - Version = document.Version, - Entries = document.Entries.Select(e => new UserMemoryEntryDto - { - Id = e.Id, - Category = e.Category, - Content = e.Content, - Source = e.Source, - CreatedAt = e.CreatedAt, - UpdatedAt = e.UpdatedAt, - }).ToList(), - }; - - var json = JsonSerializer.SerializeToUtf8Bytes(dto, JsonOptions); - await _blobClient.UploadAsync(context, json, "application/json", ct); - } - - public async Task AddEntryAsync( - string category, string content, string source, CancellationToken ct = default) - { - var doc = await GetAsync(ct); - var entries = new List(doc.Entries); - - var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var entry = new UserMemoryEntry( - Id: GenerateId(), - Category: NormalizeCategory(category), - Content: content.Trim(), - Source: NormalizeSource(source), - CreatedAt: now, - UpdatedAt: now); - - entries.Add(entry); - - // Enforce cap: evict oldest in same category first, then globally oldest. - while (entries.Count > MaxEntries) - { - var normalizedCategory = entry.Category; - var oldestSameCategory = entries - .Where(e => e.Category == normalizedCategory && e.Id != entry.Id) - .OrderBy(e => e.CreatedAt) - .FirstOrDefault(); - - if (oldestSameCategory is not null) - { - entries.Remove(oldestSameCategory); - } - else - { - var globallyOldest = entries - .Where(e => e.Id != entry.Id) - .OrderBy(e => e.CreatedAt) - .FirstOrDefault(); - if (globallyOldest is not null) - entries.Remove(globallyOldest); - else - break; - } - } - - await SaveAsync(new UserMemoryDocument(doc.Version, entries), ct); - return entry; - } - - public async Task RemoveEntryAsync(string id, CancellationToken ct = default) - { - var doc = await GetAsync(ct); - var entries = doc.Entries.ToList(); - var index = entries.FindIndex(e => string.Equals(e.Id, id, StringComparison.Ordinal)); - if (index < 0) - return false; - - entries.RemoveAt(index); - await SaveAsync(new UserMemoryDocument(doc.Version, entries), ct); - return true; - } - - public async Task BuildPromptSectionAsync(int maxChars = 2000, CancellationToken ct = default) - { - UserMemoryDocument doc; - try - { - doc = await GetAsync(ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Failed to load user memory for prompt injection"); - return string.Empty; - } - - if (doc.Entries.Count == 0) - return string.Empty; - - var sb = new StringBuilder(); - sb.AppendLine(""); - - var categoryOrder = new[] - { - UserMemoryCategories.Preference, - UserMemoryCategories.Instruction, - UserMemoryCategories.Context, - }; - - var grouped = doc.Entries - .GroupBy(e => e.Category) - .OrderBy(g => Array.IndexOf(categoryOrder, g.Key) is var i && i >= 0 ? i : int.MaxValue); - - foreach (var group in grouped) - { - var header = group.Key switch - { - UserMemoryCategories.Preference => "## Preferences", - UserMemoryCategories.Instruction => "## Instructions", - UserMemoryCategories.Context => "## Context", - _ => $"## {Capitalize(group.Key)}", - }; - sb.AppendLine(header); - foreach (var entry in group.OrderByDescending(e => e.UpdatedAt)) - sb.AppendLine($"- {entry.Content}"); - sb.AppendLine(); - } - - sb.Append(""); - - var result = sb.ToString(); - if (result.Length <= maxChars) - return result; - - // Truncate to maxChars at a newline boundary. - var truncated = result[..maxChars]; - var lastNewline = truncated.LastIndexOf('\n'); - return lastNewline > 0 - ? truncated[..lastNewline] + "\n" - : truncated; - } - - private static string GenerateId() - { - var bytes = RandomNumberGenerator.GetBytes(6); - return Convert.ToHexString(bytes).ToLowerInvariant(); - } - - private static string NormalizeCategory(string? value) => - value?.Trim().ToLowerInvariant() switch - { - UserMemoryCategories.Preference => UserMemoryCategories.Preference, - UserMemoryCategories.Instruction => UserMemoryCategories.Instruction, - UserMemoryCategories.Context => UserMemoryCategories.Context, - null or "" => UserMemoryCategories.Context, - var v => v, - }; - - private static string NormalizeSource(string? value) => - string.Equals(value?.Trim(), UserMemorySources.Explicit, StringComparison.OrdinalIgnoreCase) - ? UserMemorySources.Explicit - : UserMemorySources.Inferred; - - private static string Capitalize(string s) => - s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; - - // ── DTOs for JSON serialization ───────────────────────────────────────── - - private sealed class UserMemoryDocumentDto - { - public int Version { get; set; } = 1; - public List? Entries { get; set; } - } - - private sealed class UserMemoryEntryDto - { - public string? Id { get; set; } - public string? Category { get; set; } - public string? Content { get; set; } - public string? Source { get; set; } - public long CreatedAt { get; set; } - public long UpdatedAt { get; set; } - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs deleted file mode 100644 index b62a32b2..00000000 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Text; -using Aevatar.Studio.Application.Studio.Abstractions; - -namespace Aevatar.Studio.Infrastructure.Storage; - -internal sealed class ChronoStorageWorkflowStoragePort : IWorkflowStoragePort -{ - private readonly ChronoStorageCatalogBlobClient _blobClient; - - public ChronoStorageWorkflowStoragePort(ChronoStorageCatalogBlobClient blobClient) - { - _blobClient = blobClient ?? throw new ArgumentNullException(nameof(blobClient)); - } - - public async Task UploadWorkflowYamlAsync(string workflowId, string workflowName, string yaml, CancellationToken ct) - { - var yamlBytes = Encoding.UTF8.GetBytes(yaml); - var key = $"workflows/{workflowId}.yaml"; - var context = _blobClient.TryResolveContext(string.Empty, key); - if (context == null) return; - - await _blobClient.UploadAsync(context, yamlBytes, "text/yaml", ct); - } -} diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs new file mode 100644 index 00000000..fe098cec --- /dev/null +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs @@ -0,0 +1,1046 @@ +using Aevatar.GAgents.ChatHistory; +using Aevatar.GAgents.Registry; +using Aevatar.GAgents.StreamingProxyParticipant; +using Aevatar.GAgents.UserConfig; +using Aevatar.GAgents.UserMemory; +using Aevatar.Foundation.Core.EventSourcing; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Tools.Cli.Tests; + +/// +/// Unit tests for actor-backed GAgent state transition semantics. +/// +/// Each GAgent's TransitionState is protected override with +/// private static Apply* helpers, so these tests replicate the exact +/// transition logic using the public to +/// verify proto definitions and state-change semantics are correct. +/// +public sealed class ActorBackedGAgentStateTransitionTests +{ + // ═══════════════════════════════════════════════════════════════════ + // Helpers — replicate each GAgent's TransitionState using the + // same StateTransitionMatcher + Apply* pattern. + // ═══════════════════════════════════════════════════════════════════ + + #region GAgentRegistry helpers + + private static GAgentRegistryState ApplyRegistry(GAgentRegistryState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyRegistered) + .On(ApplyUnregistered) + .OrCurrent(); + + private static GAgentRegistryState ApplyRegistered( + GAgentRegistryState state, ActorRegisteredEvent evt) + { + var next = state.Clone(); + var group = next.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + + if (group is null) + { + group = new GAgentRegistryEntry { GagentType = evt.GagentType }; + next.Groups.Add(group); + } + + if (!group.ActorIds.Contains(evt.ActorId)) + group.ActorIds.Add(evt.ActorId); + + return next; + } + + private static GAgentRegistryState ApplyUnregistered( + GAgentRegistryState state, ActorUnregisteredEvent evt) + { + var next = state.Clone(); + var group = next.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + + if (group is null) + return next; + + group.ActorIds.Remove(evt.ActorId); + + if (group.ActorIds.Count == 0) + next.Groups.Remove(group); + + return next; + } + + #endregion + + #region StreamingProxyParticipant helpers + + private static StreamingProxyParticipantGAgentState ApplyParticipant( + StreamingProxyParticipantGAgentState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyParticipantAdded) + .On(ApplyRoomRemoved) + .OrCurrent(); + + private static StreamingProxyParticipantGAgentState ApplyParticipantAdded( + StreamingProxyParticipantGAgentState state, ParticipantAddedEvent evt) + { + var next = state.Clone(); + + if (!next.Rooms.TryGetValue(evt.RoomId, out var list)) + { + list = new ParticipantList(); + next.Rooms[evt.RoomId] = list; + } + + var existing = list.Participants.FirstOrDefault(p => + string.Equals(p.AgentId, evt.AgentId, StringComparison.Ordinal)); + if (existing is not null) + list.Participants.Remove(existing); + + list.Participants.Add(new ParticipantEntry + { + AgentId = evt.AgentId, + DisplayName = evt.DisplayName, + JoinedAt = evt.JoinedAt, + }); + + return next; + } + + private static StreamingProxyParticipantGAgentState ApplyRoomRemoved( + StreamingProxyParticipantGAgentState state, RoomParticipantsRemovedEvent evt) + { + var next = state.Clone(); + next.Rooms.Remove(evt.RoomId); + return next; + } + + #endregion + + #region UserConfig helpers + + private static UserConfigGAgentState ApplyConfig( + UserConfigGAgentState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyConfigUpdated) + .OrCurrent(); + + private static UserConfigGAgentState ApplyConfigUpdated( + UserConfigGAgentState state, UserConfigUpdatedEvent evt) => + new() + { + DefaultModel = evt.DefaultModel, + PreferredLlmRoute = evt.PreferredLlmRoute, + RuntimeMode = evt.RuntimeMode, + LocalRuntimeBaseUrl = evt.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl = evt.RemoteRuntimeBaseUrl, + MaxToolRounds = evt.MaxToolRounds, + }; + + #endregion + + #region UserMemory helpers + + private const int MaxEntries = 50; // mirrors UserMemoryGAgent.MaxEntries + + private static UserMemoryState ApplyMemory( + UserMemoryState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyMemoryAdded) + .On(ApplyMemoryRemoved) + .On(ApplyMemoryCleared) + .OrCurrent(); + + private static UserMemoryState ApplyMemoryAdded( + UserMemoryState state, MemoryEntryAddedEvent evt) + { + var next = state.Clone(); + next.Entries.Add(evt.Entry.Clone()); + + while (next.Entries.Count > MaxEntries) + { + var category = evt.Entry.Category; + var oldestSameCategory = next.Entries + .Where(e => string.Equals(e.Category, category, StringComparison.Ordinal) + && !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal)) + .OrderBy(e => e.CreatedAt) + .FirstOrDefault(); + + if (oldestSameCategory is not null) + { + next.Entries.Remove(oldestSameCategory); + } + else + { + var globallyOldest = next.Entries + .Where(e => !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal)) + .OrderBy(e => e.CreatedAt) + .FirstOrDefault(); + + if (globallyOldest is not null) + next.Entries.Remove(globallyOldest); + else + break; + } + } + + return next; + } + + private static UserMemoryState ApplyMemoryRemoved( + UserMemoryState state, MemoryEntryRemovedEvent evt) + { + var next = state.Clone(); + var entry = next.Entries.FirstOrDefault(e => + string.Equals(e.Id, evt.EntryId, StringComparison.Ordinal)); + + if (entry is not null) + next.Entries.Remove(entry); + + return next; + } + + private static UserMemoryState ApplyMemoryCleared( + UserMemoryState state, MemoryEntriesClearedEvent _) + { + var next = state.Clone(); + next.Entries.Clear(); + return next; + } + + #endregion + + #region ChatConversation helpers + + private const int MaxMessages = 500; // mirrors ChatConversationGAgent.MaxMessages + + private static ChatConversationState ApplyConversation( + ChatConversationState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyMessagesReplaced) + .On(ApplyConversationDeleted) + .OrCurrent(); + + private static ChatConversationState ApplyMessagesReplaced( + ChatConversationState state, MessagesReplacedEvent evt) + { + var next = new ChatConversationState { Meta = evt.Meta?.Clone() }; + next.Messages.AddRange(evt.Messages); + return next; + } + + private static ChatConversationState ApplyConversationDeleted( + ChatConversationState state, ConversationDeletedEvent evt) => + new(); + + #endregion + + #region ChatHistoryIndex helpers + + private static ChatHistoryIndexState ApplyHistoryIndex( + ChatHistoryIndexState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyConversationUpserted) + .On(ApplyConversationRemoved) + .OrCurrent(); + + private static ChatHistoryIndexState ApplyConversationUpserted( + ChatHistoryIndexState state, ConversationUpsertedEvent evt) + { + var next = state.Clone(); + + var existing = next.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.Meta.Id, StringComparison.Ordinal)); + if (existing is not null) + next.Conversations.Remove(existing); + + next.Conversations.Add(evt.Meta.Clone()); + return next; + } + + private static ChatHistoryIndexState ApplyConversationRemoved( + ChatHistoryIndexState state, ConversationRemovedEvent evt) + { + var next = state.Clone(); + + var existing = next.Conversations.FirstOrDefault(c => + string.Equals(c.Id, evt.ConversationId, StringComparison.Ordinal)); + if (existing is not null) + next.Conversations.Remove(existing); + + return next; + } + + #endregion + + // ═══════════════════════════════════════════════════════════════════ + // 1. GAgentRegistryGAgent + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public void Registry_RegisterActor_AddsToNewGroup() + { + var state = new GAgentRegistryState(); + var evt = new ActorRegisteredEvent { GagentType = "TypeA", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].GagentType.Should().Be("TypeA"); + next.Groups[0].ActorIds.Should().ContainSingle().Which.Should().Be("a1"); + } + + [Fact] + public void Registry_RegisterActor_AddsToExistingGroup() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1" }, + }); + + var evt = new ActorRegisteredEvent { GagentType = "TypeA", ActorId = "a2" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].ActorIds.Should().BeEquivalentTo(new[] { "a1", "a2" }); + } + + [Fact] + public void Registry_RegisterActor_Idempotent_DoesNotDuplicate() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1" }, + }); + + var evt = new ActorRegisteredEvent { GagentType = "TypeA", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].ActorIds.Should().ContainSingle().Which.Should().Be("a1"); + } + + [Fact] + public void Registry_RegisterActor_MultipleGroups() + { + var state = new GAgentRegistryState(); + + var s1 = ApplyRegistry(state, new ActorRegisteredEvent { GagentType = "TypeA", ActorId = "a1" }); + var s2 = ApplyRegistry(s1, new ActorRegisteredEvent { GagentType = "TypeB", ActorId = "b1" }); + + s2.Groups.Should().HaveCount(2); + s2.Groups.Should().Contain(g => g.GagentType == "TypeA"); + s2.Groups.Should().Contain(g => g.GagentType == "TypeB"); + } + + [Fact] + public void Registry_UnregisterActor_RemovesFromGroup() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1", "a2" }, + }); + + var evt = new ActorUnregisteredEvent { GagentType = "TypeA", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].ActorIds.Should().ContainSingle().Which.Should().Be("a2"); + } + + [Fact] + public void Registry_UnregisterActor_RemovesEmptyGroup() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1" }, + }); + + var evt = new ActorUnregisteredEvent { GagentType = "TypeA", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().BeEmpty(); + } + + [Fact] + public void Registry_UnregisterActor_NonexistentGroup_ReturnsUnchanged() + { + var state = new GAgentRegistryState(); + + var evt = new ActorUnregisteredEvent { GagentType = "NoSuchType", ActorId = "a1" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().BeEmpty(); + } + + [Fact] + public void Registry_UnregisterActor_NonexistentId_ReturnsUnchanged() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "TypeA", + ActorIds = { "a1" }, + }); + + var evt = new ActorUnregisteredEvent { GagentType = "TypeA", ActorId = "no-such-id" }; + + var next = ApplyRegistry(state, evt); + + next.Groups.Should().HaveCount(1); + next.Groups[0].ActorIds.Should().ContainSingle().Which.Should().Be("a1"); + } + + [Fact] + public void Registry_EmptyState_IsValid() + { + var state = new GAgentRegistryState(); + + state.Groups.Should().BeEmpty(); + } + + [Fact] + public void Registry_UnknownEvent_ReturnsCurrentState() + { + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry { GagentType = "T", ActorIds = { "x" } }); + + // ParticipantAddedEvent is unrelated to the registry matcher + var unrelated = new ParticipantAddedEvent { RoomId = "r1", AgentId = "ag1" }; + + var next = ApplyRegistry(state, unrelated); + + next.Should().BeSameAs(state); + } + + // ═══════════════════════════════════════════════════════════════════ + // 2. StreamingProxyParticipantGAgent + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public void Participant_Add_CreatesRoom() + { + var state = new StreamingProxyParticipantGAgentState(); + var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + var evt = new ParticipantAddedEvent + { + RoomId = "room1", + AgentId = "agent1", + DisplayName = "Agent One", + JoinedAt = now, + }; + + var next = ApplyParticipant(state, evt); + + next.Rooms.Should().ContainKey("room1"); + next.Rooms["room1"].Participants.Should().HaveCount(1); + next.Rooms["room1"].Participants[0].AgentId.Should().Be("agent1"); + next.Rooms["room1"].Participants[0].DisplayName.Should().Be("Agent One"); + next.Rooms["room1"].Participants[0].JoinedAt.Should().Be(now); + } + + [Fact] + public void Participant_Add_MultipleToSameRoom() + { + var state = new StreamingProxyParticipantGAgentState(); + var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var s1 = ApplyParticipant(state, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent1", DisplayName = "A1", JoinedAt = now, + }); + var s2 = ApplyParticipant(s1, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent2", DisplayName = "A2", JoinedAt = now, + }); + + s2.Rooms["room1"].Participants.Should().HaveCount(2); + } + + [Fact] + public void Participant_Add_DuplicateAgentId_UpsertsEntry() + { + var state = new StreamingProxyParticipantGAgentState(); + var time1 = Timestamp.FromDateTimeOffset(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var time2 = Timestamp.FromDateTimeOffset(new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero)); + + var s1 = ApplyParticipant(state, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent1", DisplayName = "OldName", JoinedAt = time1, + }); + var s2 = ApplyParticipant(s1, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent1", DisplayName = "NewName", JoinedAt = time2, + }); + + s2.Rooms["room1"].Participants.Should().HaveCount(1); + s2.Rooms["room1"].Participants[0].DisplayName.Should().Be("NewName"); + s2.Rooms["room1"].Participants[0].JoinedAt.Should().Be(time2); + } + + [Fact] + public void Participant_RemoveRoom_RemovesAllParticipants() + { + var state = new StreamingProxyParticipantGAgentState(); + var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var s1 = ApplyParticipant(state, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent1", DisplayName = "A1", JoinedAt = now, + }); + var s2 = ApplyParticipant(s1, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "agent2", DisplayName = "A2", JoinedAt = now, + }); + + var s3 = ApplyParticipant(s2, new RoomParticipantsRemovedEvent { RoomId = "room1" }); + + s3.Rooms.Should().NotContainKey("room1"); + } + + [Fact] + public void Participant_RemoveRoom_NonexistentRoom_ReturnsUnchanged() + { + var state = new StreamingProxyParticipantGAgentState(); + + var next = ApplyParticipant(state, new RoomParticipantsRemovedEvent { RoomId = "no-room" }); + + next.Rooms.Should().BeEmpty(); + } + + [Fact] + public void Participant_RemoveRoom_DoesNotAffectOtherRooms() + { + var state = new StreamingProxyParticipantGAgentState(); + var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var s1 = ApplyParticipant(state, new ParticipantAddedEvent + { + RoomId = "room1", AgentId = "a1", DisplayName = "A1", JoinedAt = now, + }); + var s2 = ApplyParticipant(s1, new ParticipantAddedEvent + { + RoomId = "room2", AgentId = "a2", DisplayName = "A2", JoinedAt = now, + }); + + var s3 = ApplyParticipant(s2, new RoomParticipantsRemovedEvent { RoomId = "room1" }); + + s3.Rooms.Should().ContainKey("room2"); + s3.Rooms.Should().NotContainKey("room1"); + } + + [Fact] + public void Participant_EmptyState_IsValid() + { + var state = new StreamingProxyParticipantGAgentState(); + + state.Rooms.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════ + // 3. UserConfigGAgent + // ═══════════════════════════════════════════════════════════════════ + + [Fact] + public void UserConfig_Update_ReplacesAllFields() + { + var state = new UserConfigGAgentState + { + DefaultModel = "old-model", + PreferredLlmRoute = "old-route", + RuntimeMode = "remote", + LocalRuntimeBaseUrl = "http://old-local", + RemoteRuntimeBaseUrl = "http://old-remote", + MaxToolRounds = 5, + }; + + var evt = new UserConfigUpdatedEvent + { + DefaultModel = "new-model", + PreferredLlmRoute = "new-route", + RuntimeMode = "local", + LocalRuntimeBaseUrl = "http://new-local", + RemoteRuntimeBaseUrl = "http://new-remote", + MaxToolRounds = 10, + }; + + var next = ApplyConfig(state, evt); + + next.DefaultModel.Should().Be("new-model"); + next.PreferredLlmRoute.Should().Be("new-route"); + next.RuntimeMode.Should().Be("local"); + next.LocalRuntimeBaseUrl.Should().Be("http://new-local"); + next.RemoteRuntimeBaseUrl.Should().Be("http://new-remote"); + next.MaxToolRounds.Should().Be(10); + } + + [Fact] + public void UserConfig_Update_FromEmptyState() + { + var state = new UserConfigGAgentState(); + + var evt = new UserConfigUpdatedEvent + { + DefaultModel = "gpt-4", + PreferredLlmRoute = "azure", + RuntimeMode = "remote", + LocalRuntimeBaseUrl = "", + RemoteRuntimeBaseUrl = "https://api.example.com", + MaxToolRounds = 3, + }; + + var next = ApplyConfig(state, evt); + + next.DefaultModel.Should().Be("gpt-4"); + next.MaxToolRounds.Should().Be(3); + } + + [Fact] + public void UserConfig_Update_FullReplacement_DoesNotRetainOldFields() + { + var state = new UserConfigGAgentState + { + DefaultModel = "old-model", + MaxToolRounds = 99, + }; + + // Event with zero/empty values for some fields + var evt = new UserConfigUpdatedEvent + { + DefaultModel = "new-model", + PreferredLlmRoute = "", + RuntimeMode = "", + LocalRuntimeBaseUrl = "", + RemoteRuntimeBaseUrl = "", + MaxToolRounds = 0, + }; + + var next = ApplyConfig(state, evt); + + next.DefaultModel.Should().Be("new-model"); + next.MaxToolRounds.Should().Be(0); + next.PreferredLlmRoute.Should().BeEmpty(); + } + + [Fact] + public void UserConfig_UnknownEvent_ReturnsCurrentState() + { + var state = new UserConfigGAgentState { DefaultModel = "keep-me" }; + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyConfig(state, unrelated); + + next.Should().BeSameAs(state); + } + + // ═══════════════════════════════════════════════════════════════════ + // 4. UserMemoryGAgent + // ═══════════════════════════════════════════════════════════════════ + + private static UserMemoryEntryProto MakeEntry(string id, string category, long createdAt) => + new() + { + Id = id, + Category = category, + Content = $"content-{id}", + Source = "test", + CreatedAt = createdAt, + UpdatedAt = createdAt, + }; + + [Fact] + public void Memory_AddEntry_ToEmptyState() + { + var state = new UserMemoryState(); + var entry = MakeEntry("e1", "cat-a", 1000); + var evt = new MemoryEntryAddedEvent { Entry = entry }; + + var next = ApplyMemory(state, evt); + + next.Entries.Should().HaveCount(1); + next.Entries[0].Id.Should().Be("e1"); + next.Entries[0].Category.Should().Be("cat-a"); + } + + [Fact] + public void Memory_AddEntry_MultipleEntries() + { + var state = new UserMemoryState(); + + var s1 = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e1", "cat-a", 1000) }); + var s2 = ApplyMemory(s1, new MemoryEntryAddedEvent { Entry = MakeEntry("e2", "cat-b", 2000) }); + + s2.Entries.Should().HaveCount(2); + } + + [Fact] + public void Memory_AddEntry_EvictsOldestSameCategoryAtCap() + { + var state = new UserMemoryState(); + + // Fill to capacity with entries in "cat-a" + for (var i = 0; i < MaxEntries; i++) + { + state = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry($"e{i}", "cat-a", i * 100), + }); + } + + state.Entries.Should().HaveCount(MaxEntries); + + // Add one more in same category — should evict the oldest (e0) + var next = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry("new-entry", "cat-a", MaxEntries * 100), + }); + + next.Entries.Should().HaveCount(MaxEntries); + next.Entries.Should().NotContain(e => e.Id == "e0"); + next.Entries.Should().Contain(e => e.Id == "new-entry"); + } + + [Fact] + public void Memory_AddEntry_EvictsGloballyOldestWhenNoSameCategory() + { + var state = new UserMemoryState(); + + // Fill to capacity: all entries in "cat-a" + for (var i = 0; i < MaxEntries; i++) + { + state = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry($"e{i}", "cat-a", i * 100), + }); + } + + // Add in a different category — no same-category to evict, + // so globally oldest (e0) should be evicted + var next = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry("diff-cat", "cat-b", MaxEntries * 100), + }); + + next.Entries.Should().HaveCount(MaxEntries); + next.Entries.Should().NotContain(e => e.Id == "e0"); + next.Entries.Should().Contain(e => e.Id == "diff-cat"); + } + + [Fact] + public void Memory_AddEntry_ExactlyAtCap_NoEviction() + { + var state = new UserMemoryState(); + + for (var i = 0; i < MaxEntries; i++) + { + state = ApplyMemory(state, new MemoryEntryAddedEvent + { + Entry = MakeEntry($"e{i}", "cat-a", i * 100), + }); + } + + state.Entries.Should().HaveCount(MaxEntries); + // All entries still present — no eviction at exactly cap + state.Entries.Should().Contain(e => e.Id == "e0"); + state.Entries.Should().Contain(e => e.Id == $"e{MaxEntries - 1}"); + } + + [Fact] + public void Memory_RemoveEntry_RemovesById() + { + var state = new UserMemoryState(); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e1", "cat-a", 1000) }); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e2", "cat-a", 2000) }); + + var next = ApplyMemory(state, new MemoryEntryRemovedEvent { EntryId = "e1" }); + + next.Entries.Should().HaveCount(1); + next.Entries[0].Id.Should().Be("e2"); + } + + [Fact] + public void Memory_RemoveEntry_NonexistentId_ReturnsUnchanged() + { + var state = new UserMemoryState(); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e1", "cat-a", 1000) }); + + var next = ApplyMemory(state, new MemoryEntryRemovedEvent { EntryId = "no-such-id" }); + + next.Entries.Should().HaveCount(1); + next.Entries[0].Id.Should().Be("e1"); + } + + [Fact] + public void Memory_ClearEntries_RemovesAll() + { + var state = new UserMemoryState(); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e1", "cat-a", 1000) }); + state = ApplyMemory(state, new MemoryEntryAddedEvent { Entry = MakeEntry("e2", "cat-b", 2000) }); + + var next = ApplyMemory(state, new MemoryEntriesClearedEvent()); + + next.Entries.Should().BeEmpty(); + } + + [Fact] + public void Memory_ClearEntries_OnEmptyState_ReturnsEmpty() + { + var state = new UserMemoryState(); + + var next = ApplyMemory(state, new MemoryEntriesClearedEvent()); + + next.Entries.Should().BeEmpty(); + } + + [Fact] + public void Memory_EmptyState_IsValid() + { + var state = new UserMemoryState(); + + state.Entries.Should().BeEmpty(); + } + + // ═══════════════════════════════════════════════════════════════════ + // 5a. ChatConversationGAgent + // ═══════════════════════════════════════════════════════════════════ + + private static StoredChatMessageProto MakeMessage(string id, string role, string content) => + new() + { + Id = id, + Role = role, + Content = content, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + + [Fact] + public void Conversation_ReplaceMessages_SetsState() + { + var state = new ChatConversationState(); + var meta = new ConversationMetaProto + { + Id = "conv1", + Title = "Test Conversation", + MessageCount = 2, + }; + var evt = new MessagesReplacedEvent { Meta = meta }; + evt.Messages.Add(MakeMessage("m1", "user", "Hello")); + evt.Messages.Add(MakeMessage("m2", "assistant", "Hi")); + + var next = ApplyConversation(state, evt); + + next.Meta.Should().NotBeNull(); + next.Meta!.Id.Should().Be("conv1"); + next.Meta.Title.Should().Be("Test Conversation"); + next.Messages.Should().HaveCount(2); + next.Messages[0].Role.Should().Be("user"); + next.Messages[1].Role.Should().Be("assistant"); + } + + [Fact] + public void Conversation_ReplaceMessages_OverwritesPreviousState() + { + var state = new ChatConversationState + { + Meta = new ConversationMetaProto { Id = "conv1", Title = "Old Title" }, + }; + state.Messages.Add(MakeMessage("old1", "user", "old content")); + + var newMeta = new ConversationMetaProto { Id = "conv1", Title = "New Title", MessageCount = 1 }; + var evt = new MessagesReplacedEvent { Meta = newMeta }; + evt.Messages.Add(MakeMessage("new1", "assistant", "new content")); + + var next = ApplyConversation(state, evt); + + next.Meta!.Title.Should().Be("New Title"); + next.Messages.Should().HaveCount(1); + next.Messages[0].Id.Should().Be("new1"); + } + + [Fact] + public void Conversation_Delete_ClearsState() + { + var state = new ChatConversationState + { + Meta = new ConversationMetaProto { Id = "conv1", Title = "Will be deleted" }, + }; + state.Messages.Add(MakeMessage("m1", "user", "content")); + + var evt = new ConversationDeletedEvent { ConversationId = "conv1" }; + + var next = ApplyConversation(state, evt); + + next.Meta.Should().BeNull(); + next.Messages.Should().BeEmpty(); + } + + [Fact] + public void Conversation_Delete_OnEmptyState_ReturnsEmpty() + { + var state = new ChatConversationState(); + + var evt = new ConversationDeletedEvent { ConversationId = "conv1" }; + + var next = ApplyConversation(state, evt); + + next.Meta.Should().BeNull(); + next.Messages.Should().BeEmpty(); + } + + [Fact] + public void Conversation_EmptyState_IsValid() + { + var state = new ChatConversationState(); + + state.Meta.Should().BeNull(); + state.Messages.Should().BeEmpty(); + } + + [Fact] + public void Conversation_UnknownEvent_ReturnsCurrentState() + { + var state = new ChatConversationState + { + Meta = new ConversationMetaProto { Id = "keep" }, + }; + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyConversation(state, unrelated); + + next.Should().BeSameAs(state); + } + + // ═══════════════════════════════════════════════════════════════════ + // 5b. ChatHistoryIndexGAgent + // ═══════════════════════════════════════════════════════════════════ + + private static ConversationMetaProto MakeMeta(string id, string title) => + new() + { + Id = id, + Title = title, + CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + + [Fact] + public void HistoryIndex_UpsertConversation_AddsNew() + { + var state = new ChatHistoryIndexState(); + var evt = new ConversationUpsertedEvent { Meta = MakeMeta("c1", "First Chat") }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().HaveCount(1); + next.Conversations[0].Id.Should().Be("c1"); + next.Conversations[0].Title.Should().Be("First Chat"); + } + + [Fact] + public void HistoryIndex_UpsertConversation_UpdatesExisting() + { + var state = new ChatHistoryIndexState(); + state.Conversations.Add(MakeMeta("c1", "Old Title")); + + var evt = new ConversationUpsertedEvent { Meta = MakeMeta("c1", "New Title") }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().HaveCount(1); + next.Conversations[0].Title.Should().Be("New Title"); + } + + [Fact] + public void HistoryIndex_UpsertConversation_MultipleConversations() + { + var state = new ChatHistoryIndexState(); + + var s1 = ApplyHistoryIndex(state, new ConversationUpsertedEvent { Meta = MakeMeta("c1", "Chat 1") }); + var s2 = ApplyHistoryIndex(s1, new ConversationUpsertedEvent { Meta = MakeMeta("c2", "Chat 2") }); + + s2.Conversations.Should().HaveCount(2); + } + + [Fact] + public void HistoryIndex_RemoveConversation_RemovesById() + { + var state = new ChatHistoryIndexState(); + state.Conversations.Add(MakeMeta("c1", "Chat 1")); + state.Conversations.Add(MakeMeta("c2", "Chat 2")); + + var evt = new ConversationRemovedEvent { ConversationId = "c1" }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().HaveCount(1); + next.Conversations[0].Id.Should().Be("c2"); + } + + [Fact] + public void HistoryIndex_RemoveConversation_NonexistentId_ReturnsUnchanged() + { + var state = new ChatHistoryIndexState(); + state.Conversations.Add(MakeMeta("c1", "Chat 1")); + + var evt = new ConversationRemovedEvent { ConversationId = "no-such-id" }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().HaveCount(1); + next.Conversations[0].Id.Should().Be("c1"); + } + + [Fact] + public void HistoryIndex_RemoveConversation_FromEmptyState_ReturnsEmpty() + { + var state = new ChatHistoryIndexState(); + + var evt = new ConversationRemovedEvent { ConversationId = "c1" }; + + var next = ApplyHistoryIndex(state, evt); + + next.Conversations.Should().BeEmpty(); + } + + [Fact] + public void HistoryIndex_EmptyState_IsValid() + { + var state = new ChatHistoryIndexState(); + + state.Conversations.Should().BeEmpty(); + } + + [Fact] + public void HistoryIndex_UnknownEvent_ReturnsCurrentState() + { + var state = new ChatHistoryIndexState(); + state.Conversations.Add(MakeMeta("c1", "Keep")); + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyHistoryIndex(state, unrelated); + + next.Should().BeSameAs(state); + } +} diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs new file mode 100644 index 00000000..ad129f7d --- /dev/null +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -0,0 +1,672 @@ +using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.GAgents.ScriptStorage; +using Aevatar.GAgents.UserConfig; +using Aevatar.GAgents.WorkflowStorage; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ActorBacked; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Type = System.Type; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Aevatar.Studio.Infrastructure.Storage; + +namespace Aevatar.Tools.Cli.Tests; + +public sealed class ActorBackedStoreAdapterTests +{ + // ════════════════════════════════════════════════════════════ + // Fakes + // ════════════════════════════════════════════════════════════ + + private sealed class FakeActor : IActor + { + private readonly List _received = []; + + public FakeActor(string id) => Id = id; + + public string Id { get; } + public IAgent Agent => throw new NotSupportedException(); + public IReadOnlyList ReceivedEnvelopes => _received; + + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) + { + _received.Add(envelope); + return Task.CompletedTask; + } + + public Task GetParentIdAsync() => Task.FromResult(null); + public Task> GetChildrenIdsAsync() => + Task.FromResult>([]); + } + + /// + /// Fake runtime that supports an optional callback when an actor is created. + /// This lets tests wire up auto-delivery of readmodel snapshots. + /// + private sealed class FakeActorRuntime : IActorRuntime + { + private readonly Dictionary _actors = new(StringComparer.Ordinal); + public IReadOnlyDictionary Actors => _actors; + + /// + /// Optional callback invoked after an actor is created. + /// Used to simulate the readmodel actor's OnActivateAsync publishing a snapshot. + /// + public Func? OnActorCreated { get; set; } + + public async Task CreateAsync(string? id = null, CancellationToken ct = default) + where TAgent : IAgent + { + var actorId = id ?? Guid.NewGuid().ToString("N"); + var actor = new FakeActor(actorId); + _actors[actorId] = actor; + if (OnActorCreated is not null) + await OnActorCreated(actorId); + return actor; + } + + public async Task CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) + { + var actorId = id ?? Guid.NewGuid().ToString("N"); + var actor = new FakeActor(actorId); + _actors[actorId] = actor; + if (OnActorCreated is not null) + await OnActorCreated(actorId); + return actor; + } + + public Task DestroyAsync(string id, CancellationToken ct = default) + { + _actors.Remove(id); + return Task.CompletedTask; + } + + public Task GetAsync(string id) => + Task.FromResult(_actors.GetValueOrDefault(id)); + + public Task ExistsAsync(string id) => + Task.FromResult(_actors.ContainsKey(id)); + + public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => + Task.CompletedTask; + + public Task UnlinkAsync(string childId, CancellationToken ct = default) => + Task.CompletedTask; + } + + private sealed class FakeSubscriptionHandle : IAsyncDisposable + { + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + /// + /// Fake subscription provider that supports both manual delivery and + /// auto-delivery via queued messages per actor ID. + /// When a handler subscribes to an actorId that has queued messages, + /// those messages are delivered immediately (simulating OnActivateAsync publish). + /// + private sealed class FakeSubscriptionProvider : IActorEventSubscriptionProvider + { + private readonly Dictionary _handlers = new(StringComparer.Ordinal); + private readonly Dictionary> _queued = new(StringComparer.Ordinal); + + /// + /// Queue a message to be auto-delivered when a handler subscribes to the given actor ID. + /// + public void EnqueueForDelivery(string actorId, TMessage message) + where TMessage : class, IMessage, new() + { + if (!_queued.TryGetValue(actorId, out var list)) + { + list = []; + _queued[actorId] = list; + } + list.Add(message); + } + + public async Task SubscribeAsync( + string actorId, + Func handler, + CancellationToken ct = default) + where TMessage : class, IMessage, new() + { + _handlers[actorId] = handler; + + // Auto-deliver queued messages + if (_queued.TryGetValue(actorId, out var queued)) + { + foreach (var msg in queued) + { + if (msg is TMessage typed) + await handler(typed); + } + } + + return new FakeSubscriptionHandle(); + } + + /// Deliver a message to the handler registered for the given actor ID. + public async Task DeliverAsync(string actorId, TMessage message) + where TMessage : class, IMessage, new() + { + if (_handlers.TryGetValue(actorId, out var handler) && handler is Func typedHandler) + await typedHandler(message); + } + } + + private sealed class FakeScopeResolver : IAppScopeResolver + { + public string? ScopeIdToReturn { get; set; } + + public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => + ScopeIdToReturn is not null + ? new AppScopeContext(ScopeIdToReturn, "test") + : null; + } + + // ── Helpers: wire up readmodel snapshot auto-delivery ── + + /// + /// Configures fakes so that the readmodel actor auto-delivers the + /// given snapshot when the store subscribes. + /// + private static void WireUpUserConfigReadModel( + FakeSubscriptionProvider subscriptions, + string readModelActorId, + UserConfigGAgentState? snapshot) + { + if (snapshot is not null) + { + var snapshotEvent = new UserConfigStateSnapshotEvent { Snapshot = snapshot }; + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(snapshotEvent), + }; + subscriptions.EnqueueForDelivery(readModelActorId, envelope); + } + } + + // ════════════════════════════════════════════════════════════ + // UserConfigStore: defaults when snapshot is null (timeout) + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task UserConfigStore_GetAsync_NullSnapshot_ReturnsDefaults() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "test-user" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedUserConfigStore( + runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + + // No snapshot queued, readmodel timeout returns defaults + var config = await store.GetAsync(); + + config.DefaultModel.Should().BeEmpty(); + config.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); + config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.LocalMode); + config.LocalRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.LocalRuntimeBaseUrl); + config.RemoteRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl); + config.MaxToolRounds.Should().Be(0); + } + + // ════════════════════════════════════════════════════════════ + // UserConfigStore: snapshot mapping + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task UserConfigStore_GetAsync_WithSnapshot_MapsFieldsCorrectly() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-42" }; + var logger = NullLogger.Instance; + + // Queue snapshot for readmodel actor + WireUpUserConfigReadModel(subscriptions, "user-config-user-42-readmodel", + new UserConfigGAgentState + { + DefaultModel = "gpt-4", + PreferredLlmRoute = "/api/v1/proxy/s/custom", + RuntimeMode = "remote", + LocalRuntimeBaseUrl = "http://localhost:9090", + RemoteRuntimeBaseUrl = "https://remote.example.com", + MaxToolRounds = 5, + }); + + var store = new ActorBackedUserConfigStore( + runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + + var config = await store.GetAsync(); + + config.DefaultModel.Should().Be("gpt-4"); + config.PreferredLlmRoute.Should().Be("/api/v1/proxy/s/custom"); + config.RuntimeMode.Should().Be("remote"); + config.LocalRuntimeBaseUrl.Should().Be("http://localhost:9090"); + config.RemoteRuntimeBaseUrl.Should().Be("https://remote.example.com"); + config.MaxToolRounds.Should().Be(5); + } + + // ════════════════════════════════════════════════════════════ + // UserConfigStore: empty string fields apply defaults + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task UserConfigStore_GetAsync_EmptyStringFields_ApplyDefaults() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-empty" }; + var logger = NullLogger.Instance; + + // Queue snapshot with empty strings (proto default) + WireUpUserConfigReadModel(subscriptions, "user-config-user-empty-readmodel", + new UserConfigGAgentState + { + DefaultModel = "claude-3", + // Intentionally leave others as empty string (proto default) + }); + + var store = new ActorBackedUserConfigStore( + runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + + var config = await store.GetAsync(); + + config.DefaultModel.Should().Be("claude-3"); + config.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway, + "empty PreferredLlmRoute should fall back to Gateway default"); + config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.LocalMode, + "empty RuntimeMode should fall back to local"); + config.LocalRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.LocalRuntimeBaseUrl, + "empty LocalRuntimeBaseUrl should fall back to default"); + config.RemoteRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl, + "empty RemoteRuntimeBaseUrl should fall back to default"); + } + + // ════════════════════════════════════════════════════════════ + // UserConfigStore: SaveAsync sends UserConfigUpdatedEvent + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task UserConfigStore_SaveAsync_SendsUserConfigUpdatedEvent() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "save-scope" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedUserConfigStore( + runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + + var config = new UserConfig( + DefaultModel: "gpt-4-turbo", + PreferredLlmRoute: "/api/v1/proxy/s/openai", + RuntimeMode: "remote", + LocalRuntimeBaseUrl: "http://127.0.0.1:8080", + RemoteRuntimeBaseUrl: "https://api.example.com", + MaxToolRounds: 10); + + await store.SaveAsync(config); + + var actorId = "user-config-save-scope"; + runtime.Actors.Should().ContainKey(actorId); + + var actor = runtime.Actors[actorId]; + actor.ReceivedEnvelopes.Should().HaveCount(1); + + var envelope = actor.ReceivedEnvelopes[0]; + envelope.Payload.Should().NotBeNull(); + envelope.Payload.Is(UserConfigUpdatedEvent.Descriptor).Should().BeTrue(); + + var evt = envelope.Payload.Unpack(); + evt.DefaultModel.Should().Be("gpt-4-turbo"); + evt.MaxToolRounds.Should().Be(10); + + // Verify envelope routing targets the correct actor + envelope.Route.Should().NotBeNull(); + envelope.Route.Direct.Should().NotBeNull(); + envelope.Route.Direct.TargetActorId.Should().Be(actorId); + } + + // ════════════════════════════════════════════════════════════ + // NyxIdUserLlmPreferencesStore: delegation + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task NyxIdUserLlmPreferencesStore_ExtractsDefaultModelAndRoute() + { + var stubConfigStore = new StubUserConfigStore(new UserConfig( + DefaultModel: "claude-opus", + PreferredLlmRoute: "/api/v1/proxy/s/anthropic", + MaxToolRounds: 7)); + + var store = new ActorBackedNyxIdUserLlmPreferencesStore(stubConfigStore); + + var prefs = await store.GetAsync(); + + prefs.DefaultModel.Should().Be("claude-opus"); + prefs.PreferredRoute.Should().Be("/api/v1/proxy/s/anthropic"); + prefs.MaxToolRounds.Should().Be(7); + } + + [Fact] + public async Task NyxIdUserLlmPreferencesStore_NormalizesGatewayRoute() + { + var stubConfigStore = new StubUserConfigStore(new UserConfig( + DefaultModel: "gpt-4", + PreferredLlmRoute: "gateway")); + + var store = new ActorBackedNyxIdUserLlmPreferencesStore(stubConfigStore); + + var prefs = await store.GetAsync(); + + prefs.PreferredRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway, + "gateway should normalize to empty string"); + } + + [Fact] + public async Task NyxIdUserLlmPreferencesStore_DefaultConfig_ReturnsEmptyDefaults() + { + var stubConfigStore = new StubUserConfigStore(new UserConfig( + DefaultModel: string.Empty)); + + var store = new ActorBackedNyxIdUserLlmPreferencesStore(stubConfigStore); + + var prefs = await store.GetAsync(); + + prefs.DefaultModel.Should().BeEmpty(); + prefs.PreferredRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); + prefs.MaxToolRounds.Should().Be(0); + } + + // ════════════════════════════════════════════════════════════ + // Scope isolation: different scopes get different actor IDs + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task UserConfigStore_DifferentScopes_ProduceDifferentActorIds() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver(); + var logger = NullLogger.Instance; + + var store = new ActorBackedUserConfigStore( + runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + + // First scope + scopeResolver.ScopeIdToReturn = "scope-alpha"; + _ = await store.GetAsync(); + + // Second scope + scopeResolver.ScopeIdToReturn = "scope-beta"; + _ = await store.GetAsync(); + + // Both readmodel actors should be created + runtime.Actors.Should().ContainKey("user-config-scope-alpha-readmodel"); + runtime.Actors.Should().ContainKey("user-config-scope-beta-readmodel"); + } + + [Fact] + public async Task UserConfigStore_NullScope_FallsBackToDefault() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = null }; + var logger = NullLogger.Instance; + + var store = new ActorBackedUserConfigStore( + runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + + _ = await store.GetAsync(); + + runtime.Actors.Should().ContainKey("user-config-default-readmodel", + "null scope should resolve to 'default' suffix"); + } + + // ════════════════════════════════════════════════════════════ + // GAgentActorStore: scope isolation + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task GAgentActorStore_ScopeIsolation_DifferentScopesGetDifferentActors() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver(); + var logger = NullLogger.Instance; + + var store = new ActorBackedGAgentActorStore( + runtime, subscriptions, scopeResolver, logger); + + scopeResolver.ScopeIdToReturn = "tenant-a"; + _ = await store.GetAsync(); + + scopeResolver.ScopeIdToReturn = "tenant-b"; + _ = await store.GetAsync(); + + runtime.Actors.Should().ContainKey("gagent-registry-tenant-a-readmodel"); + runtime.Actors.Should().ContainKey("gagent-registry-tenant-b-readmodel"); + } + + [Fact] + public async Task GAgentActorStore_GetAsync_NullSnapshot_ReturnsEmptyList() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "empty-scope" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedGAgentActorStore( + runtime, subscriptions, scopeResolver, logger); + + var groups = await store.GetAsync(); + + groups.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════ + // WorkflowStoragePort: command construction + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task WorkflowStoragePort_UploadAsync_SendsWorkflowYamlUploadedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + + var port = new ActorBackedWorkflowStoragePort(runtime, logger); + + await port.UploadWorkflowYamlAsync("wf-001", "My Workflow", "name: test\nsteps: []", CancellationToken.None); + + const string expectedActorId = "workflow-storage"; + runtime.Actors.Should().ContainKey(expectedActorId); + + var actor = runtime.Actors[expectedActorId]; + actor.ReceivedEnvelopes.Should().HaveCount(1); + + var envelope = actor.ReceivedEnvelopes[0]; + envelope.Payload.Is(WorkflowYamlUploadedEvent.Descriptor).Should().BeTrue(); + + var evt = envelope.Payload.Unpack(); + evt.WorkflowId.Should().Be("wf-001"); + evt.WorkflowName.Should().Be("My Workflow"); + evt.Yaml.Should().Be("name: test\nsteps: []"); + + // Verify direct routing + envelope.Route.Direct.TargetActorId.Should().Be(expectedActorId); + } + + [Fact] + public async Task WorkflowStoragePort_MultipleUploads_ReusesSameActor() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + + var port = new ActorBackedWorkflowStoragePort(runtime, logger); + + await port.UploadWorkflowYamlAsync("wf-1", "First", "yaml1", CancellationToken.None); + await port.UploadWorkflowYamlAsync("wf-2", "Second", "yaml2", CancellationToken.None); + + runtime.Actors.Should().HaveCount(1, "actor should be reused across uploads"); + + var actor = runtime.Actors["workflow-storage"]; + actor.ReceivedEnvelopes.Should().HaveCount(2); + } + + // ════════════════════════════════════════════════════════════ + // ScriptStoragePort: command construction + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ScriptStoragePort_UploadAsync_SendsScriptUploadedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + + var port = new ActorBackedScriptStoragePort(runtime, logger); + + await port.UploadScriptAsync("script-42", "console.log('hello');", CancellationToken.None); + + const string expectedActorId = "script-storage"; + runtime.Actors.Should().ContainKey(expectedActorId); + + var actor = runtime.Actors[expectedActorId]; + actor.ReceivedEnvelopes.Should().HaveCount(1); + + var envelope = actor.ReceivedEnvelopes[0]; + envelope.Payload.Is(ScriptUploadedEvent.Descriptor).Should().BeTrue(); + + var evt = envelope.Payload.Unpack(); + evt.ScriptId.Should().Be("script-42"); + evt.SourceText.Should().Be("console.log('hello');"); + + // Verify direct routing + envelope.Route.Direct.TargetActorId.Should().Be(expectedActorId); + } + + [Fact] + public async Task ScriptStoragePort_MultipleUploads_ReusesSameActor() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + + var port = new ActorBackedScriptStoragePort(runtime, logger); + + await port.UploadScriptAsync("s1", "code1", CancellationToken.None); + await port.UploadScriptAsync("s2", "code2", CancellationToken.None); + + runtime.Actors.Should().HaveCount(1, "actor should be reused"); + runtime.Actors["script-storage"].ReceivedEnvelopes.Should().HaveCount(2); + } + + // ════════════════════════════════════════════════════════════ + // Envelope structure verification + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task AllStores_EnvelopeContainsIdAndTimestamp() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + + var port = new ActorBackedScriptStoragePort(runtime, logger); + var beforeUtc = DateTimeOffset.UtcNow; + + await port.UploadScriptAsync("ts-check", "body", CancellationToken.None); + + var afterUtc = DateTimeOffset.UtcNow; + var envelope = runtime.Actors["script-storage"].ReceivedEnvelopes[0]; + + envelope.Id.Should().NotBeNullOrWhiteSpace("envelope must have a unique ID"); + envelope.Id.Length.Should().Be(32, "ID should be a Guid without dashes"); + + var ts = envelope.Timestamp.ToDateTimeOffset(); + ts.Should().BeOnOrAfter(beforeUtc).And.BeOnOrBefore(afterUtc); + } + + // ════════════════════════════════════════════════════════════ + // GAgentActorStore: AddActorAsync command construction + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task GAgentActorStore_AddActorAsync_SendsActorRegisteredEvent() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "cmd-scope" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedGAgentActorStore( + runtime, subscriptions, scopeResolver, logger); + + await store.AddActorAsync("MyGAgent", "actor-123"); + + var actorId = "gagent-registry-cmd-scope"; + runtime.Actors.Should().ContainKey(actorId); + + var actor = runtime.Actors[actorId]; + actor.ReceivedEnvelopes.Should().HaveCountGreaterThanOrEqualTo(1); + + // The last envelope should be the ActorRegisteredEvent command + var envelope = actor.ReceivedEnvelopes.Last(); + envelope.Payload.Is(Aevatar.GAgents.Registry.ActorRegisteredEvent.Descriptor).Should().BeTrue(); + + var evt = envelope.Payload.Unpack(); + evt.GagentType.Should().Be("MyGAgent"); + evt.ActorId.Should().Be("actor-123"); + } + + [Fact] + public async Task GAgentActorStore_RemoveActorAsync_SendsActorUnregisteredEvent() + { + var runtime = new FakeActorRuntime(); + var subscriptions = new FakeSubscriptionProvider(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "cmd-scope" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedGAgentActorStore( + runtime, subscriptions, scopeResolver, logger); + + await store.RemoveActorAsync("MyGAgent", "actor-456"); + + var actorId = "gagent-registry-cmd-scope"; + var actor = runtime.Actors[actorId]; + var envelope = actor.ReceivedEnvelopes.Last(); + envelope.Payload.Is(Aevatar.GAgents.Registry.ActorUnregisteredEvent.Descriptor).Should().BeTrue(); + + var evt = envelope.Payload.Unpack(); + evt.GagentType.Should().Be("MyGAgent"); + evt.ActorId.Should().Be("actor-456"); + } + + // ════════════════════════════════════════════════════════════ + // Helper: stub IUserConfigStore for NyxId delegation tests + // ════════════════════════════════════════════════════════════ + + private sealed class StubUserConfigStore : IUserConfigStore + { + private readonly UserConfig _config; + + public StubUserConfigStore(UserConfig config) => _config = config; + + public Task GetAsync(CancellationToken cancellationToken = default) => + Task.FromResult(_config); + + public Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } +} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageChatHistoryStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageChatHistoryStoreTests.cs deleted file mode 100644 index b398ba6b..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageChatHistoryStoreTests.cs +++ /dev/null @@ -1,372 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageChatHistoryStoreTests -{ - [Fact] - public async Task GetIndexAsync_ShouldBuildConversationListFromJsonlFiles() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/index.json", - "{\"conversations\":[{\"id\":\"stale\",\"title\":\"stale\"}]}"); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/NyxIdChat:scope-a.jsonl", - """ - {"id":"u1","role":"user","content":"你好","timestamp":1711968000000,"status":"complete"} - {"id":"a1","role":"assistant","content":"hi","timestamp":1711968060000,"status":"complete"} - """); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/workflowtest:run-1.jsonl", - """ - {"id":"u2","role":"user","content":"workflow question","timestamp":1711968120000,"status":"complete"} - {"id":"a2","role":"assistant","content":"workflow answer","timestamp":1711968180000,"status":"complete"} - """); - - var store = CreateStore(storageServer); - - var index = await store.GetIndexAsync("scope-a"); - - index.Conversations.Select(static conversation => conversation.Id).Should() - .Equal("workflowtest:run-1", "NyxIdChat:scope-a"); - index.Conversations[0].Title.Should().Be("workflow question"); - index.Conversations[0].ServiceId.Should().Be("workflowtest"); - index.Conversations[0].ServiceKind.Should().Be("service"); - index.Conversations[0].MessageCount.Should().Be(2); - index.Conversations[1].Title.Should().Be("你好"); - index.Conversations[1].ServiceId.Should().Be("nyxid-chat"); - index.Conversations[1].ServiceKind.Should().Be("nyxid-chat"); - } - - [Fact] - public async Task GetIndexAsync_ShouldUseSidecarWhenFresh() - { - var storageServer = new InMemoryChronoStorageServer(); - // .jsonl written at T0 - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/NyxIdChat:scope-a.jsonl", - """ - {"id":"u1","role":"user","content":"original question","timestamp":1711968000000,"status":"complete"} - """, - "2026-04-01T00:00:00Z"); - // sidecar written at T1 (after .jsonl) — fresh - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/_meta/NyxIdChat:scope-a.json", - """{"title":"original question","serviceId":"nyxid-chat","serviceKind":"nyxid-chat","createdAtMs":1711968000000,"updatedAtMs":1711968000000,"messageCount":1,"llmRoute":"","llmModel":"gpt-5.3-codex"}""", - "2026-04-01T00:01:00Z"); - - var store = CreateStore(storageServer); - var index = await store.GetIndexAsync("scope-a"); - - index.Conversations.Should().HaveCount(1); - index.Conversations[0].Title.Should().Be("original question"); - index.Conversations[0].MessageCount.Should().Be(1); - index.Conversations[0].LlmRoute.Should().Be(""); - index.Conversations[0].LlmModel.Should().Be("gpt-5.3-codex"); - } - - [Fact] - public async Task GetIndexAsync_ShouldFallbackWhenSidecarMissing() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/NyxIdChat:scope-a.jsonl", - """ - {"id":"u1","role":"user","content":"legacy conversation","timestamp":1711968000000,"status":"complete"} - {"id":"a1","role":"assistant","content":"reply","timestamp":1711968060000,"status":"complete"} - """); - // No sidecar file at all - - var store = CreateStore(storageServer); - var index = await store.GetIndexAsync("scope-a"); - - index.Conversations.Should().HaveCount(1); - index.Conversations[0].Title.Should().Be("legacy conversation"); - index.Conversations[0].MessageCount.Should().Be(2); - - // Backfill should have written a sidecar (best-effort, give it a moment) - await Task.Delay(200); - storageServer.Objects.Should().ContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/_meta/NyxIdChat:scope-a.json"); - } - - [Fact] - public async Task GetIndexAsync_ShouldFallbackWhenSidecarStale() - { - var storageServer = new InMemoryChronoStorageServer(); - // sidecar written at T0 - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/_meta/conv-1.json", - """{"title":"old title","serviceId":"nyxid-chat","serviceKind":"nyxid-chat","createdAtMs":1711968000000,"updatedAtMs":1711968000000,"messageCount":1}""", - "2026-04-01T00:00:00Z"); - // .jsonl updated at T1 (after sidecar) — sidecar is stale - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/conv-1.jsonl", - """ - {"id":"u1","role":"user","content":"new question","timestamp":1711968000000,"status":"complete"} - {"id":"a1","role":"assistant","content":"new answer","timestamp":1711968060000,"status":"complete"} - {"id":"u2","role":"user","content":"follow up","timestamp":1711968120000,"status":"complete"} - """, - "2026-04-01T01:00:00Z"); - - var store = CreateStore(storageServer); - var index = await store.GetIndexAsync("scope-a"); - - index.Conversations.Should().HaveCount(1); - index.Conversations[0].Title.Should().Be("new question"); - index.Conversations[0].MessageCount.Should().Be(3); - } - - [Fact] - public async Task SaveMessagesAsync_ShouldWriteSidecar() - { - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore(storageServer); - var messages = new[] - { - new StoredChatMessage("u1", "user", "hello sidecar", 1711968000000, "complete"), - new StoredChatMessage("a1", "assistant", "hi there", 1711968060000, "complete"), - }; - var meta = new ConversationMeta( - "test-conv", - "hello sidecar", - "nyxid-chat", - "nyxid-chat", - DateTimeOffset.FromUnixTimeMilliseconds(1711968000000), - DateTimeOffset.FromUnixTimeMilliseconds(1711968060000), - 2, - LlmRoute: "", - LlmModel: "gpt-5.3-codex"); - - await store.SaveMessagesAsync("scope-a", "test-conv", meta, messages); - - storageServer.Objects.Should().ContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/test-conv.jsonl"); - storageServer.Objects.Should().ContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/_meta/test-conv.json"); - var sidecarJson = Encoding.UTF8.GetString(storageServer.Objects["aevatar-studio:user-prefix/scope-a/chat-histories/_meta/test-conv.json"]); - sidecarJson.Should().Contain("\"llmRoute\":\"\""); - sidecarJson.Should().Contain("\"llmModel\":\"gpt-5.3-codex\""); - } - - [Fact] - public async Task DeleteConversationAsync_ShouldDeleteSidecarAndJsonl() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/conv-del.jsonl", - """{"id":"u1","role":"user","content":"bye","timestamp":1711968000000,"status":"complete"}"""); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/_meta/conv-del.json", - """{"title":"bye","serviceId":"nyxid-chat","serviceKind":"nyxid-chat","createdAtMs":1711968000000,"updatedAtMs":1711968000000,"messageCount":1}"""); - - var store = CreateStore(storageServer); - await store.DeleteConversationAsync("scope-a", "conv-del"); - - storageServer.Objects.Should().NotContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/conv-del.jsonl"); - storageServer.Objects.Should().NotContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/_meta/conv-del.json"); - } - - [Fact] - public async Task SaveMessagesAsync_ShouldDeleteLegacyIndexFile() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.StoreText( - "aevatar-studio", - "user-prefix/scope-a/chat-histories/index.json", - "{\"conversations\":[]}"); - - var store = CreateStore(storageServer); - var messages = new[] - { - new StoredChatMessage("u1", "user", "hello", 1711968000000, "complete"), - }; - var meta = new ConversationMeta( - "NyxIdChat:scope-a", - "hello", - "nyxid-chat", - "nyxid-chat", - DateTimeOffset.FromUnixTimeMilliseconds(1711968000000), - DateTimeOffset.FromUnixTimeMilliseconds(1711968000000), - 1); - - await store.SaveMessagesAsync("scope-a", "NyxIdChat:scope-a", meta, messages); - - storageServer.Objects.Should().ContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/NyxIdChat:scope-a.jsonl"); - storageServer.Objects.Should().NotContainKey("aevatar-studio:user-prefix/scope-a/chat-histories/index.json"); - } - - private static ChronoStorageChatHistoryStore CreateStore(InMemoryChronoStorageServer storageServer) - { - var blobClient = new ChronoStorageCatalogBlobClient( - new StubAppScopeResolver("scope-a"), - storageServer.CreateHttpClientFactory(), - Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - Prefix = "connectors-prefix", - RolesPrefix = "roles-prefix", - UserConfigPrefix = "user-prefix", - })); - - return new ChronoStorageChatHistoryStore( - blobClient, - Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - Prefix = "connectors-prefix", - RolesPrefix = "roles-prefix", - UserConfigPrefix = "user-prefix", - }), - NullLogger.Instance); - } - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryChronoStorageServer - { - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - public Dictionary Timestamps { get; } = new(StringComparer.Ordinal); - - public void StoreText(string bucket, string key, string content, string? lastModified = null) - { - Objects[$"{bucket}:{key}"] = Encoding.UTF8.GetBytes(content); - Timestamps[$"{bucket}:{key}"] = lastModified ?? "2026-04-01T00:00:00Z"; - } - - public IHttpClientFactory CreateHttpClientFactory() => - new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) - { - _server = server; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - var path = uri.AbsolutePath.Trim('/'); - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var prefix = GetRequiredQueryValue(uri, "prefix"); - var objects = _server.Objects - .Where(static entry => entry.Key.StartsWith("aevatar-studio:", StringComparison.Ordinal)) - .Select(static entry => entry.Key["aevatar-studio:".Length..]) - .Where(key => key.StartsWith(prefix, StringComparison.Ordinal)) - .OrderBy(static key => key, StringComparer.Ordinal) - .Select(key => new - { - key, - lastModified = _server.Timestamps.GetValueOrDefault($"aevatar-studio:{key}", "2026-04-01T00:00:00Z"), - size = _server.Objects[$"aevatar-studio:{key}"].LongLength, - }) - .ToList(); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { objects }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - _server.Timestamps[$"aevatar-studio:{key}"] = DateTimeOffset.UtcNow.ToString("O"); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.TryGetValue($"aevatar-studio:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - - if (request.Method == HttpMethod.Delete && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects.Remove($"aevatar-studio:{key}"); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { deleted = true }, error = (object?)null }); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) - { - _client = client; - } - - public HttpClient CreateClient(string name) => _client; - } -} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageConnectorCatalogStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageConnectorCatalogStoreTests.cs deleted file mode 100644 index 712d1ac2..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageConnectorCatalogStoreTests.cs +++ /dev/null @@ -1,600 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageConnectorCatalogStoreTests -{ - [Fact] - public async Task SaveAndGetCatalogAsync_WhenRemoteEnabled_ShouldRoundTripScopeCatalog() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-alpha"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var catalog = new StoredConnectorCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Connectors: - [ - CreateConnector("scope_web", "https://example.com/api"), - ]); - - var saved = await store.SaveConnectorCatalogAsync(catalog); - var loaded = await store.GetConnectorCatalogAsync(); - - saved.FileExists.Should().BeTrue(); - saved.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-alpha/connectors.json"); - loaded.Connectors.Should().BeEquivalentTo(catalog.Connectors); - storageServer.Objects.Should().ContainKey("aevatar-studio:scope-alpha/connectors.json"); - Encoding.UTF8.GetString(storageServer.Objects["aevatar-studio:scope-alpha/connectors.json"]) - .Should().Contain("scope_web"); - } - - [Fact] - public async Task ImportLocalCatalogAsync_WhenRemoteEnabled_ShouldUploadLocalCatalog() - { - using var workspaceRoot = new TemporaryDirectory(); - var localStore = new InMemoryStudioWorkspaceStore - { - ConnectorCatalog = new StoredConnectorCatalog( - HomeDirectory: "/tmp/.aevatar", - FilePath: "/tmp/.aevatar/connectors.json", - FileExists: true, - Connectors: - [ - CreateConnector("imported_http", "https://import.example.com"), - ]), - }; - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - localStore, - new StubAppScopeResolver("scope-import"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var imported = await store.ImportLocalCatalogAsync(); - - imported.SourceFilePath.Should().Be("/tmp/.aevatar/connectors.json"); - imported.Catalog.FileExists.Should().BeTrue(); - imported.Catalog.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-import/connectors.json"); - imported.Catalog.Connectors.Should().BeEquivalentTo(localStore.ConnectorCatalog.Connectors); - storageServer.Objects.Should().ContainKey("aevatar-studio:scope-import/connectors.json"); - } - - [Fact] - public async Task SaveAndGetCatalogAsync_WhenRemoteEnabled_ShouldPreserveNyxidRemoteMcpFields() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-nyxid"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var catalog = new StoredConnectorCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Connectors: - [ - CreateRemoteMcpConnector(), - ]); - - await store.SaveConnectorCatalogAsync(catalog); - var loaded = await store.GetConnectorCatalogAsync(); - - loaded.Connectors.Should().BeEquivalentTo(catalog.Connectors); - } - - [Fact] - public async Task DraftOperations_WhenRemoteEnabled_ShouldUseScopeScopedRemoteDraftFiles() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var draft = new StoredConnectorDraft( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - UpdatedAtUtc: DateTimeOffset.Parse("2026-03-18T09:30:00Z"), - Draft: CreateConnector("draft_connector", "https://draft.example.com")); - var scopeAStore = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-a"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var scopeBStore = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-b"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var savedDraft = await scopeAStore.SaveConnectorDraftAsync(draft); - var loadedDraft = await scopeAStore.GetConnectorDraftAsync(); - var otherScopeDraft = await scopeBStore.GetConnectorDraftAsync(); - - savedDraft.FileExists.Should().BeTrue(); - savedDraft.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-a/connectors.draft.json"); - loadedDraft.Draft.Should().BeEquivalentTo(draft.Draft); - otherScopeDraft.FileExists.Should().BeFalse(); - otherScopeDraft.Draft.Should().BeNull(); - storageServer.Objects.Should().ContainKey("aevatar-studio:scope-a/connectors.draft.json"); - } - - [Fact] - public async Task DeleteConnectorDraftAsync_WhenRemoteEnabled_ShouldDeleteRemoteDraftObject() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-delete"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - await store.SaveConnectorDraftAsync( - new StoredConnectorDraft( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - UpdatedAtUtc: DateTimeOffset.Parse("2026-03-18T09:30:00Z"), - Draft: CreateConnector("draft_connector", "https://draft.example.com"))); - - await store.DeleteConnectorDraftAsync(); - - storageServer.Objects.Should().NotContainKey("aevatar-studio:scope-delete/connectors.draft.json"); - var loadedDraft = await store.GetConnectorDraftAsync(); - loadedDraft.FileExists.Should().BeFalse(); - loadedDraft.Draft.Should().BeNull(); - } - - [Fact] - public async Task GetConnectorCatalogAsync_WhenDownloadUrlReturnsNotFound_ShouldTreatCatalogAsMissing() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new BrokenDownloadChronoStorageServer(); - storageServer.MarkObjectPresent("aevatar-studio", "scope-missing/connectors.json"); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-missing"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var catalog = await store.GetConnectorCatalogAsync(); - - catalog.FileExists.Should().BeFalse(); - catalog.Connectors.Should().BeEmpty(); - catalog.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-missing/connectors.json"); - } - - private static ChronoStorageConnectorCatalogStore CreateStore( - IStudioWorkspaceStore localStore, - IAppScopeResolver scopeResolver, - IHttpClientFactory httpClientFactory, - string workspaceRoot) - { - var options = CreateOptions(); - var blobClient = new ChronoStorageCatalogBlobClient(scopeResolver, httpClientFactory, options); - return new ChronoStorageConnectorCatalogStore( - localStore, - blobClient, - options, - Options.Create(new StudioStorageOptions - { - RootDirectory = workspaceRoot, - })); - } - - private static IOptions CreateOptions() => - Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - Prefix = string.Empty, - RolesPrefix = string.Empty, - }); - - private static StoredConnectorDefinition CreateConnector(string name, string baseUrl) => - new( - Name: name, - Type: "http", - Enabled: true, - TimeoutMs: 30_000, - Retry: 1, - Http: new StoredHttpConnectorConfig( - BaseUrl: baseUrl, - AllowedMethods: ["POST"], - AllowedPaths: ["/"], - AllowedInputKeys: ["input"], - DefaultHeaders: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["Authorization"] = "Bearer demo", - }, - Auth: new StoredConnectorAuthConfig( - Type: "client_credentials", - TokenUrl: "https://auth.example.com/oauth/token", - ClientId: "http-client", - ClientSecret: "http-secret", - Scope: "proxy:*")), - Cli: new StoredCliConnectorConfig( - Command: string.Empty, - FixedArguments: [], - AllowedOperations: [], - AllowedInputKeys: [], - WorkingDirectory: string.Empty, - Environment: new Dictionary(StringComparer.OrdinalIgnoreCase)), - Mcp: new StoredMcpConnectorConfig( - ServerName: string.Empty, - Command: string.Empty, - Url: string.Empty, - Arguments: [], - Environment: new Dictionary(StringComparer.OrdinalIgnoreCase), - AdditionalHeaders: new Dictionary(StringComparer.OrdinalIgnoreCase), - Auth: new StoredConnectorAuthConfig(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty), - DefaultTool: string.Empty, - AllowedTools: [], - AllowedInputKeys: [])); - - private static StoredConnectorDefinition CreateRemoteMcpConnector() => - new( - Name: "nyxid_mcp", - Type: "mcp", - Enabled: true, - TimeoutMs: 60_000, - Retry: 1, - Http: new StoredHttpConnectorConfig( - BaseUrl: string.Empty, - AllowedMethods: [], - AllowedPaths: [], - AllowedInputKeys: [], - DefaultHeaders: new Dictionary(StringComparer.OrdinalIgnoreCase), - Auth: new StoredConnectorAuthConfig(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty)), - Cli: new StoredCliConnectorConfig( - Command: string.Empty, - FixedArguments: [], - AllowedOperations: [], - AllowedInputKeys: [], - WorkingDirectory: string.Empty, - Environment: new Dictionary(StringComparer.OrdinalIgnoreCase)), - Mcp: new StoredMcpConnectorConfig( - ServerName: "nyxid", - Command: string.Empty, - Url: "https://nyxid.example.com/mcp", - Arguments: [], - Environment: new Dictionary(StringComparer.OrdinalIgnoreCase), - AdditionalHeaders: new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["x-tenant"] = "demo", - }, - Auth: new StoredConnectorAuthConfig( - Type: "client_credentials", - TokenUrl: "https://auth.example.com/oauth/token", - ClientId: "mcp-client", - ClientSecret: "mcp-secret", - Scope: "proxy:*"), - DefaultTool: "chrono-graph__query", - AllowedTools: ["chrono-graph__query"], - AllowedInputKeys: ["query"])); - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryStudioWorkspaceStore : IStudioWorkspaceStore - { - public StoredConnectorCatalog ConnectorCatalog { get; set; } = new( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Connectors: []); - - public Task GetSettingsAsync(CancellationToken cancellationToken = default) => - Task.FromResult(new StudioWorkspaceSettings("http://127.0.0.1:5100", [], "blue", "light")); - - public Task SaveSettingsAsync(StudioWorkspaceSettings settings, CancellationToken cancellationToken = default) => Task.CompletedTask; - - public Task> ListWorkflowFilesAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task GetWorkflowFileAsync(string workflowId, CancellationToken cancellationToken = default) => - Task.FromResult(null); - - public Task SaveWorkflowFileAsync(StoredWorkflowFile workflowFile, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task> ListExecutionsAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task GetExecutionAsync(string executionId, CancellationToken cancellationToken = default) => - Task.FromResult(null); - - public Task SaveExecutionAsync(StoredExecutionRecord execution, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetConnectorCatalogAsync(CancellationToken cancellationToken = default) => - Task.FromResult(ConnectorCatalog); - - public Task SaveConnectorCatalogAsync(StoredConnectorCatalog catalog, CancellationToken cancellationToken = default) - { - ConnectorCatalog = catalog with { FileExists = true }; - return Task.FromResult(ConnectorCatalog); - } - - public Task GetConnectorDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveConnectorDraftAsync(StoredConnectorDraft draft, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveRoleCatalogAsync(StoredRoleCatalog catalog, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetRoleDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveRoleDraftAsync(StoredRoleDraft draft, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - } - - private sealed class InMemoryChronoStorageServer - { - private readonly HashSet _buckets = []; - - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - - public IHttpClientFactory CreateHttpClientFactory() => new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) - { - _server = server; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - if (string.Equals(uri.Host, "download.local", StringComparison.OrdinalIgnoreCase)) - { - return _server.HandleDownload(uri); - } - - var path = uri.AbsolutePath.Trim('/'); - if (request.Method == HttpMethod.Head && path.StartsWith("api/buckets/", StringComparison.Ordinal)) - { - var bucket = path["api/buckets/".Length..]; - return new HttpResponseMessage(_server._buckets.Contains(bucket) ? HttpStatusCode.OK : HttpStatusCode.NotFound); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets", StringComparison.Ordinal)) - { - var payload = await request.Content!.ReadFromJsonAsync(cancellationToken); - var name = payload.GetProperty("name").GetString() ?? string.Empty; - _server._buckets.Add(name); - return CreateJsonResponse(HttpStatusCode.Created, new { data = new { name, created = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server._buckets.Add("aevatar-studio"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Delete && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects.Remove($"aevatar-studio:{key}"); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { deleted = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/presigned-url", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.ContainsKey($"aevatar-studio:{key}")) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return CreateJsonResponse( - HttpStatusCode.OK, - new - { - data = new - { - url = $"http://download.local/aevatar-studio/{Uri.EscapeDataString(key)}", - }, - error = (object?)null, - }); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - - private HttpResponseMessage HandleDownload(Uri uri) - { - var segments = uri.AbsolutePath.Trim('/').Split('/', 2, StringSplitOptions.RemoveEmptyEntries); - var bucket = segments[0]; - var key = Uri.UnescapeDataString(segments[1]); - if (!Objects.TryGetValue($"{bucket}:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - } - - private sealed class BrokenDownloadChronoStorageServer - { - private readonly HashSet _presentObjects = []; - - public void MarkObjectPresent(string bucket, string objectKey) => - _presentObjects.Add($"{bucket}:{objectKey}"); - - public IHttpClientFactory CreateHttpClientFactory() - { - var client = new HttpClient(new Handler(_presentObjects)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }; - return new StubHttpClientFactory(client); - } - - private sealed class Handler : HttpMessageHandler - { - private readonly IReadOnlySet _presentObjects; - - public Handler(IReadOnlySet presentObjects) - { - _presentObjects = presentObjects; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - if (request.Method == HttpMethod.Get && - string.Equals(uri.AbsolutePath.Trim('/'), "api/buckets/aevatar-studio/presigned-url", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_presentObjects.Contains($"aevatar-studio:{key}")) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - return Task.FromResult(CreateJsonResponse( - HttpStatusCode.OK, - new - { - data = new - { - presignedUrl = $"http://download.local/missing/{Uri.EscapeDataString(key)}", - }, - error = (object?)null, - })); - } - - if (string.Equals(uri.Host, "download.local", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) - { - _client = client; - } - - public HttpClient CreateClient(string name) => _client; - } - - private sealed class TemporaryDirectory : IDisposable - { - public TemporaryDirectory() - { - Path = System.IO.Path.Combine( - System.IO.Path.GetTempPath(), - "aevatar-connector-store-tests", - Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - - public void Dispose() - { - if (Directory.Exists(Path)) - { - Directory.Delete(Path, recursive: true); - } - } - } -} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageRoleCatalogStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageRoleCatalogStoreTests.cs deleted file mode 100644 index 2b32408d..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageRoleCatalogStoreTests.cs +++ /dev/null @@ -1,502 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageRoleCatalogStoreTests -{ - [Fact] - public async Task SaveAndGetCatalogAsync_WhenRemoteEnabled_ShouldRoundTripScopeCatalog() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("role-scope"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var catalog = new StoredRoleCatalog( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Roles: - [ - CreateRole("assistant", "Main Assistant"), - ]); - - var saved = await store.SaveRoleCatalogAsync(catalog); - var loaded = await store.GetRoleCatalogAsync(); - - saved.FileExists.Should().BeTrue(); - saved.FilePath.Should().Be("chrono-storage://aevatar-studio/role-scope/roles.json"); - loaded.Roles.Should().BeEquivalentTo(catalog.Roles); - storageServer.Objects.Should().ContainKey("aevatar-studio:role-scope/roles.json"); - Encoding.UTF8.GetString(storageServer.Objects["aevatar-studio:role-scope/roles.json"]) - .Should().Contain("Main Assistant"); - } - - [Fact] - public async Task ImportLocalCatalogAsync_WhenRemoteEnabled_ShouldUploadLocalRoles() - { - using var workspaceRoot = new TemporaryDirectory(); - var localStore = new InMemoryStudioWorkspaceStore - { - RoleCatalog = new StoredRoleCatalog( - HomeDirectory: "/tmp/.aevatar", - FilePath: "/tmp/.aevatar/roles.json", - FileExists: true, - Roles: - [ - CreateRole("reviewer", "Reviewer"), - ]), - }; - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - localStore, - new StubAppScopeResolver("role-import"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var imported = await store.ImportLocalCatalogAsync(); - - imported.SourceFilePath.Should().Be("/tmp/.aevatar/roles.json"); - imported.Catalog.FileExists.Should().BeTrue(); - imported.Catalog.FilePath.Should().Be("chrono-storage://aevatar-studio/role-import/roles.json"); - imported.Catalog.Roles.Should().BeEquivalentTo(localStore.RoleCatalog.Roles); - storageServer.Objects.Should().ContainKey("aevatar-studio:role-import/roles.json"); - } - - [Fact] - public async Task DraftOperations_WhenRemoteEnabled_ShouldUseScopeScopedRemoteDraftFiles() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var draft = new StoredRoleDraft( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - UpdatedAtUtc: DateTimeOffset.Parse("2026-03-18T09:30:00Z"), - Draft: CreateRole("catalog_admin", "Catalog Admin")); - var scopeAStore = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-a"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - var scopeBStore = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-b"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var savedDraft = await scopeAStore.SaveRoleDraftAsync(draft); - var loadedDraft = await scopeAStore.GetRoleDraftAsync(); - var otherScopeDraft = await scopeBStore.GetRoleDraftAsync(); - - savedDraft.FileExists.Should().BeTrue(); - savedDraft.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-a/roles.draft.json"); - loadedDraft.Draft.Should().BeEquivalentTo(draft.Draft); - otherScopeDraft.FileExists.Should().BeFalse(); - otherScopeDraft.Draft.Should().BeNull(); - storageServer.Objects.Should().ContainKey("aevatar-studio:scope-a/roles.draft.json"); - } - - [Fact] - public async Task DeleteRoleDraftAsync_WhenRemoteEnabled_ShouldDeleteRemoteDraftObject() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-delete"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - await store.SaveRoleDraftAsync( - new StoredRoleDraft( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - UpdatedAtUtc: DateTimeOffset.Parse("2026-03-18T09:30:00Z"), - Draft: CreateRole("catalog_admin", "Catalog Admin"))); - - await store.DeleteRoleDraftAsync(); - - storageServer.Objects.Should().NotContainKey("aevatar-studio:scope-delete/roles.draft.json"); - var loadedDraft = await store.GetRoleDraftAsync(); - loadedDraft.FileExists.Should().BeFalse(); - loadedDraft.Draft.Should().BeNull(); - } - - [Fact] - public async Task GetRoleCatalogAsync_WhenDownloadUrlReturnsNotFound_ShouldTreatCatalogAsMissing() - { - using var workspaceRoot = new TemporaryDirectory(); - var storageServer = new BrokenDownloadChronoStorageServer(); - storageServer.MarkObjectPresent("aevatar-studio", "scope-missing/roles.json"); - var store = CreateStore( - new InMemoryStudioWorkspaceStore(), - new StubAppScopeResolver("scope-missing"), - storageServer.CreateHttpClientFactory(), - workspaceRoot.Path); - - var catalog = await store.GetRoleCatalogAsync(); - - catalog.FileExists.Should().BeFalse(); - catalog.Roles.Should().BeEmpty(); - catalog.FilePath.Should().Be("chrono-storage://aevatar-studio/scope-missing/roles.json"); - } - - private static ChronoStorageRoleCatalogStore CreateStore( - IStudioWorkspaceStore localStore, - IAppScopeResolver scopeResolver, - IHttpClientFactory httpClientFactory, - string workspaceRoot) - { - var options = CreateOptions(); - var blobClient = new ChronoStorageCatalogBlobClient(scopeResolver, httpClientFactory, options); - return new ChronoStorageRoleCatalogStore( - localStore, - blobClient, - options, - Options.Create(new StudioStorageOptions - { - RootDirectory = workspaceRoot, - })); - } - - private static IOptions CreateOptions() => - Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - Prefix = string.Empty, - RolesPrefix = string.Empty, - }); - - private static StoredRoleDefinition CreateRole(string id, string name) => - new( - Id: id, - Name: name, - SystemPrompt: "You are helpful.", - Provider: "openai-main", - Model: "gpt-test", - Connectors: ["scope_web"]); - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryStudioWorkspaceStore : IStudioWorkspaceStore - { - public StoredRoleCatalog RoleCatalog { get; set; } = new( - HomeDirectory: string.Empty, - FilePath: string.Empty, - FileExists: false, - Roles: []); - - public Task GetSettingsAsync(CancellationToken cancellationToken = default) => - Task.FromResult(new StudioWorkspaceSettings("http://127.0.0.1:5100", [], "blue", "light")); - - public Task SaveSettingsAsync(StudioWorkspaceSettings settings, CancellationToken cancellationToken = default) => Task.CompletedTask; - - public Task> ListWorkflowFilesAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task GetWorkflowFileAsync(string workflowId, CancellationToken cancellationToken = default) => - Task.FromResult(null); - - public Task SaveWorkflowFileAsync(StoredWorkflowFile workflowFile, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task> ListExecutionsAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task GetExecutionAsync(string executionId, CancellationToken cancellationToken = default) => - Task.FromResult(null); - - public Task SaveExecutionAsync(StoredExecutionRecord execution, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetConnectorCatalogAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveConnectorCatalogAsync(StoredConnectorCatalog catalog, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetConnectorDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveConnectorDraftAsync(StoredConnectorDraft draft, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) => - Task.FromResult(RoleCatalog); - - public Task SaveRoleCatalogAsync(StoredRoleCatalog catalog, CancellationToken cancellationToken = default) - { - RoleCatalog = catalog with { FileExists = true }; - return Task.FromResult(RoleCatalog); - } - - public Task GetRoleDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task SaveRoleDraftAsync(StoredRoleDraft draft, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - - public Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) => - throw new NotSupportedException(); - } - - private sealed class InMemoryChronoStorageServer - { - private readonly HashSet _buckets = []; - - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - - public IHttpClientFactory CreateHttpClientFactory() => new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) - { - _server = server; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - if (string.Equals(uri.Host, "download.local", StringComparison.OrdinalIgnoreCase)) - { - return _server.HandleDownload(uri); - } - - var path = uri.AbsolutePath.Trim('/'); - if (request.Method == HttpMethod.Head && path.StartsWith("api/buckets/", StringComparison.Ordinal)) - { - var bucket = path["api/buckets/".Length..]; - return new HttpResponseMessage(_server._buckets.Contains(bucket) ? HttpStatusCode.OK : HttpStatusCode.NotFound); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets", StringComparison.Ordinal)) - { - var payload = await request.Content!.ReadFromJsonAsync(cancellationToken); - var name = payload.GetProperty("name").GetString() ?? string.Empty; - _server._buckets.Add(name); - return CreateJsonResponse(HttpStatusCode.Created, new { data = new { name, created = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server._buckets.Add("aevatar-studio"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Delete && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects.Remove($"aevatar-studio:{key}"); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { deleted = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/presigned-url", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.ContainsKey($"aevatar-studio:{key}")) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return CreateJsonResponse( - HttpStatusCode.OK, - new - { - data = new - { - url = $"http://download.local/aevatar-studio/{Uri.EscapeDataString(key)}", - }, - error = (object?)null, - }); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - - private HttpResponseMessage HandleDownload(Uri uri) - { - var segments = uri.AbsolutePath.Trim('/').Split('/', 2, StringSplitOptions.RemoveEmptyEntries); - var bucket = segments[0]; - var key = Uri.UnescapeDataString(segments[1]); - if (!Objects.TryGetValue($"{bucket}:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - } - - private sealed class BrokenDownloadChronoStorageServer - { - private readonly HashSet _presentObjects = []; - - public void MarkObjectPresent(string bucket, string objectKey) => - _presentObjects.Add($"{bucket}:{objectKey}"); - - public IHttpClientFactory CreateHttpClientFactory() - { - var client = new HttpClient(new Handler(_presentObjects)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }; - return new StubHttpClientFactory(client); - } - - private sealed class Handler : HttpMessageHandler - { - private readonly IReadOnlySet _presentObjects; - - public Handler(IReadOnlySet presentObjects) - { - _presentObjects = presentObjects; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - if (request.Method == HttpMethod.Get && - string.Equals(uri.AbsolutePath.Trim('/'), "api/buckets/aevatar-studio/presigned-url", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_presentObjects.Contains($"aevatar-studio:{key}")) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - return Task.FromResult(CreateJsonResponse( - HttpStatusCode.OK, - new - { - data = new - { - presignedUrl = $"http://download.local/missing/{Uri.EscapeDataString(key)}", - }, - error = (object?)null, - })); - } - - if (string.Equals(uri.Host, "download.local", StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) - { - _client = client; - } - - public HttpClient CreateClient(string name) => _client; - } - - private sealed class TemporaryDirectory : IDisposable - { - public TemporaryDirectory() - { - Path = System.IO.Path.Combine( - System.IO.Path.GetTempPath(), - "aevatar-role-store-tests", - Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(Path); - } - - public string Path { get; } - - public void Dispose() - { - if (Directory.Exists(Path)) - { - Directory.Delete(Path, recursive: true); - } - } - } -} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserConfigStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserConfigStoreTests.cs deleted file mode 100644 index 0101b38e..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserConfigStoreTests.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageUserConfigStoreTests -{ - [Fact] - public async Task GetAsync_WhenConfigIsMissing_ShouldReturnLocalDefaults() - { - var store = CreateStore( - new InMemoryChronoStorageServer(), - scopeId: "scope-missing", - defaultLocalRuntimeBaseUrl: "http://127.0.0.1:6001", - defaultRemoteRuntimeBaseUrl: "https://remote-default.example"); - - var config = await store.GetAsync(); - - config.DefaultModel.Should().BeEmpty(); - config.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); - config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.LocalMode); - config.LocalRuntimeBaseUrl.Should().Be("http://127.0.0.1:6001"); - config.RemoteRuntimeBaseUrl.Should().Be("https://remote-default.example"); - } - - [Fact] - public async Task GetAsync_WhenLegacyRuntimeBaseUrlIsRemote_ShouldMigrateToExplicitRemoteMode() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.Objects["aevatar-studio:profiles/scope-remote/config.json"] = Encoding.UTF8.GetBytes( - """ - { - "defaultModel": "gpt-4.1", - "runtimeBaseUrl": "https://runtime.example" - } - """); - var store = CreateStore(storageServer, scopeId: "scope-remote"); - - var config = await store.GetAsync(); - - config.DefaultModel.Should().Be("gpt-4.1"); - config.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); - config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.RemoteMode); - config.LocalRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.LocalRuntimeBaseUrl); - config.RemoteRuntimeBaseUrl.Should().Be("https://runtime.example"); - } - - [Fact] - public async Task GetAsync_WhenUserConfigProvidesRuntimeUrl_ShouldOverrideAppSettingsDefaults() - { - var storageServer = new InMemoryChronoStorageServer(); - storageServer.Objects["aevatar-studio:profiles/scope-override/config.json"] = Encoding.UTF8.GetBytes( - """ - { - "defaultModel": "gpt-4.1", - "preferredLlmRoute": "chrono-llm", - "runtimeMode": "remote", - "remoteRuntimeBaseUrl": "https://user-remote.example" - } - """); - var store = CreateStore( - storageServer, - scopeId: "scope-override", - defaultLocalRuntimeBaseUrl: "http://127.0.0.1:6001", - defaultRemoteRuntimeBaseUrl: "https://remote-default.example"); - - var config = await store.GetAsync(); - - config.DefaultModel.Should().Be("gpt-4.1"); - config.PreferredLlmRoute.Should().Be("chrono-llm"); - config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.RemoteMode); - config.LocalRuntimeBaseUrl.Should().Be("http://127.0.0.1:6001"); - config.RemoteRuntimeBaseUrl.Should().Be("https://user-remote.example"); - } - - [Fact] - public async Task SaveAsync_ShouldPersistExplicitRuntimeModeAndUrls() - { - var storageServer = new InMemoryChronoStorageServer(); - var store = CreateStore(storageServer, scopeId: "scope-save"); - var config = new UserConfig( - DefaultModel: "claude-sonnet-4-5-20250929", - PreferredLlmRoute: "chrono-llm", - RuntimeMode: UserConfigRuntimeDefaults.RemoteMode, - LocalRuntimeBaseUrl: "http://127.0.0.1:5080", - RemoteRuntimeBaseUrl: "https://runtime-save.example"); - - await store.SaveAsync(config); - - storageServer.Objects.Should().ContainKey("aevatar-studio:profiles/scope-save/config.json"); - using var json = JsonDocument.Parse(storageServer.Objects["aevatar-studio:profiles/scope-save/config.json"]); - json.RootElement.GetProperty("defaultModel").GetString().Should().Be("claude-sonnet-4-5-20250929"); - json.RootElement.GetProperty("preferredLlmRoute").GetString().Should().Be("chrono-llm"); - json.RootElement.GetProperty("runtimeMode").GetString().Should().Be(UserConfigRuntimeDefaults.RemoteMode); - json.RootElement.GetProperty("localRuntimeBaseUrl").GetString().Should().Be("http://127.0.0.1:5080"); - json.RootElement.GetProperty("remoteRuntimeBaseUrl").GetString().Should().Be("https://runtime-save.example"); - json.RootElement.TryGetProperty("runtimeBaseUrl", out _).Should().BeFalse(); - } - - private static ChronoStorageUserConfigStore CreateStore( - InMemoryChronoStorageServer storageServer, - string scopeId, - string? defaultLocalRuntimeBaseUrl = null, - string? defaultRemoteRuntimeBaseUrl = null) - { - var options = Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - UserConfigPrefix = "profiles", - }); - var studioStorageOptions = Options.Create(new StudioStorageOptions - { - DefaultLocalRuntimeBaseUrl = defaultLocalRuntimeBaseUrl ?? UserConfigRuntimeDefaults.LocalRuntimeBaseUrl, - DefaultRemoteRuntimeBaseUrl = defaultRemoteRuntimeBaseUrl ?? UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl, - }); - var blobClient = new ChronoStorageCatalogBlobClient( - new StubAppScopeResolver(scopeId), - storageServer.CreateHttpClientFactory(), - options); - return new ChronoStorageUserConfigStore( - blobClient, - options, - studioStorageOptions, - NullLogger.Instance); - } - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(Microsoft.AspNetCore.Http.HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryChronoStorageServer - { - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - - public IHttpClientFactory CreateHttpClientFactory() => - new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) - { - _server = server; - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - var path = uri.AbsolutePath.Trim('/'); - - if (request.Method == HttpMethod.Post && string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && string.Equals(path, "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - var key = GetRequiredQueryValue(uri, "key"); - if (!_server.Objects.TryGetValue($"aevatar-studio:{key}", out var payload)) - { - return new HttpResponseMessage(HttpStatusCode.NotFound); - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetRequiredQueryValue(Uri uri, string key) - { - var query = uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(pair => pair.Split('=', 2)) - .ToDictionary( - pair => Uri.UnescapeDataString(pair[0]), - pair => pair.Length > 1 ? Uri.UnescapeDataString(pair[1]) : string.Empty, - StringComparer.Ordinal); - return query.TryGetValue(key, out var value) - ? value - : throw new InvalidOperationException($"Missing query key '{key}'."); - } - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) - { - Content = JsonContent.Create(payload), - }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) - { - _client = client; - } - - public HttpClient CreateClient(string name) => _client; - } -} diff --git a/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserMemoryStoreTests.cs b/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserMemoryStoreTests.cs deleted file mode 100644 index 9a567709..00000000 --- a/test/Aevatar.Tools.Cli.Tests/ChronoStorageUserMemoryStoreTests.cs +++ /dev/null @@ -1,351 +0,0 @@ -using System.Net; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using FluentAssertions; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace Aevatar.Tools.Cli.Tests; - -public sealed class ChronoStorageUserMemoryStoreTests -{ - // ─── GetAsync ────────────────────────────────────────────────────────── - - [Fact] - public async Task GetAsync_WhenFileMissing_ShouldReturnEmptyDocument() - { - var store = CreateStore(new InMemoryChronoStorageServer(), scopeId: "scope-empty"); - - var doc = await store.GetAsync(); - - doc.Should().Be(UserMemoryDocument.Empty); - doc.Entries.Should().BeEmpty(); - } - - [Fact] - public async Task GetAsync_WhenFileExists_ShouldDeserializeEntries() - { - var server = new InMemoryChronoStorageServer(); - server.Objects["aevatar-studio:profiles/scope-read/user-memory.json"] = Encoding.UTF8.GetBytes( - """ - { - "version": 1, - "entries": [ - { - "id": "aabbcc112233", - "category": "preference", - "content": "Prefers concise replies", - "source": "explicit", - "createdAt": 1000, - "updatedAt": 2000 - } - ] - } - """); - - var store = CreateStore(server, scopeId: "scope-read"); - - var doc = await store.GetAsync(); - - doc.Version.Should().Be(1); - doc.Entries.Should().HaveCount(1); - var entry = doc.Entries[0]; - entry.Id.Should().Be("aabbcc112233"); - entry.Category.Should().Be(UserMemoryCategories.Preference); - entry.Content.Should().Be("Prefers concise replies"); - entry.Source.Should().Be(UserMemorySources.Explicit); - entry.CreatedAt.Should().Be(1000); - entry.UpdatedAt.Should().Be(2000); - } - - // ─── SaveAsync ───────────────────────────────────────────────────────── - - [Fact] - public async Task SaveAsync_ShouldPersistDocument() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-save"); - var entry = new UserMemoryEntry("id1", UserMemoryCategories.Instruction, "Always use code examples", UserMemorySources.Explicit, 100, 200); - var doc = new UserMemoryDocument(1, [entry]); - - await store.SaveAsync(doc); - - server.Objects.Should().ContainKey("aevatar-studio:profiles/scope-save/user-memory.json"); - using var json = JsonDocument.Parse(server.Objects["aevatar-studio:profiles/scope-save/user-memory.json"]); - json.RootElement.GetProperty("version").GetInt32().Should().Be(1); - var entries = json.RootElement.GetProperty("entries").EnumerateArray().ToList(); - entries.Should().HaveCount(1); - entries[0].GetProperty("id").GetString().Should().Be("id1"); - entries[0].GetProperty("category").GetString().Should().Be("instruction"); - entries[0].GetProperty("content").GetString().Should().Be("Always use code examples"); - entries[0].GetProperty("source").GetString().Should().Be("explicit"); - } - - // ─── AddEntryAsync ───────────────────────────────────────────────────── - - [Fact] - public async Task AddEntryAsync_ShouldPersistAndReturnNewEntry() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-add"); - - var entry = await store.AddEntryAsync( - UserMemoryCategories.Preference, "Uses Chinese for communication", UserMemorySources.Explicit); - - entry.Category.Should().Be(UserMemoryCategories.Preference); - entry.Content.Should().Be("Uses Chinese for communication"); - entry.Source.Should().Be(UserMemorySources.Explicit); - entry.Id.Should().HaveLength(12); // 6 bytes hex - entry.CreatedAt.Should().BeGreaterThan(0); - - // Round-trip - var doc = await store.GetAsync(); - doc.Entries.Should().HaveCount(1); - doc.Entries[0].Id.Should().Be(entry.Id); - } - - [Fact] - public async Task AddEntryAsync_WhenAtCapacity_ShouldEvictOldestSameCategoryFirst() - { - const int max = 50; - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-cap"); - - // Fill with 50 preference entries (oldest first). - for (var i = 0; i < max; i++) - { - await store.AddEntryAsync(UserMemoryCategories.Preference, $"pref-{i}", UserMemorySources.Inferred); - await Task.Delay(1); // Ensure distinct timestamps - } - - // The 51st entry should evict the oldest preference entry. - var newEntry = await store.AddEntryAsync(UserMemoryCategories.Preference, "newest-pref", UserMemorySources.Explicit); - - var doc = await store.GetAsync(); - doc.Entries.Should().HaveCount(max); - doc.Entries.Should().Contain(e => e.Id == newEntry.Id); - doc.Entries.Should().NotContain(e => e.Content == "pref-0"); - } - - [Fact] - public async Task AddEntryAsync_WhenNoSameCategoryToEvict_ShouldEvictGloballyOldest() - { - const int max = 50; - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-global-evict"); - - // Fill with 50 preference entries. - for (var i = 0; i < max; i++) - { - await store.AddEntryAsync(UserMemoryCategories.Preference, $"pref-{i}", UserMemorySources.Inferred); - await Task.Delay(1); - } - - // Add an instruction entry — should evict the globally oldest (pref-0). - var newEntry = await store.AddEntryAsync(UserMemoryCategories.Instruction, "always show code", UserMemorySources.Explicit); - - var doc = await store.GetAsync(); - doc.Entries.Should().HaveCount(max); - doc.Entries.Should().Contain(e => e.Id == newEntry.Id); - doc.Entries.Should().NotContain(e => e.Content == "pref-0"); - } - - // ─── RemoveEntryAsync ────────────────────────────────────────────────── - - [Fact] - public async Task RemoveEntryAsync_WhenEntryExists_ShouldRemoveAndReturnTrue() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-remove"); - var entry = await store.AddEntryAsync(UserMemoryCategories.Context, "Working on Aevatar", UserMemorySources.Inferred); - - var removed = await store.RemoveEntryAsync(entry.Id); - - removed.Should().BeTrue(); - var doc = await store.GetAsync(); - doc.Entries.Should().BeEmpty(); - } - - [Fact] - public async Task RemoveEntryAsync_WhenEntryMissing_ShouldReturnFalse() - { - var store = CreateStore(new InMemoryChronoStorageServer(), scopeId: "scope-missing-remove"); - - var removed = await store.RemoveEntryAsync("nonexistent"); - - removed.Should().BeFalse(); - } - - // ─── BuildPromptSectionAsync ─────────────────────────────────────────── - - [Fact] - public async Task BuildPromptSectionAsync_WhenEmpty_ShouldReturnEmptyString() - { - var store = CreateStore(new InMemoryChronoStorageServer(), scopeId: "scope-no-memory"); - - var section = await store.BuildPromptSectionAsync(); - - section.Should().BeEmpty(); - } - - [Fact] - public async Task BuildPromptSectionAsync_ShouldGroupByCategoryWithXmlTags() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-prompt"); - await store.AddEntryAsync(UserMemoryCategories.Preference, "Concise replies", UserMemorySources.Explicit); - await store.AddEntryAsync(UserMemoryCategories.Instruction, "Always show code", UserMemorySources.Explicit); - await store.AddEntryAsync(UserMemoryCategories.Context, "Working on Aevatar", UserMemorySources.Inferred); - - var section = await store.BuildPromptSectionAsync(); - - section.Should().StartWith(""); - section.Should().EndWith(""); - section.Should().Contain("## Preferences"); - section.Should().Contain("## Instructions"); - section.Should().Contain("## Context"); - section.Should().Contain("Concise replies"); - section.Should().Contain("Always show code"); - section.Should().Contain("Working on Aevatar"); - } - - [Fact] - public async Task BuildPromptSectionAsync_WhenExceedsMaxChars_ShouldTruncate() - { - var server = new InMemoryChronoStorageServer(); - var store = CreateStore(server, scopeId: "scope-truncate"); - var longContent = new string('x', 300); - await store.AddEntryAsync(UserMemoryCategories.Preference, longContent, UserMemorySources.Inferred); - - var section = await store.BuildPromptSectionAsync(maxChars: 100); - - section.Length.Should().BeLessThanOrEqualTo(100 + "".Length); - } - - // ─── Scope isolation ─────────────────────────────────────────────────── - - [Fact] - public async Task StoreIsIsolatedByScope() - { - var server = new InMemoryChronoStorageServer(); - var storeA = CreateStore(server, scopeId: "user-a"); - var storeB = CreateStore(server, scopeId: "user-b"); - - await storeA.AddEntryAsync(UserMemoryCategories.Preference, "A's pref", UserMemorySources.Explicit); - - var docB = await storeB.GetAsync(); - docB.Entries.Should().BeEmpty(); - } - - // ─── Factory / helpers ───────────────────────────────────────────────── - - private static ChronoStorageUserMemoryStore CreateStore( - InMemoryChronoStorageServer server, - string scopeId) - { - var options = Options.Create(new ConnectorCatalogStorageOptions - { - Enabled = true, - UseNyxProxy = false, - BaseUrl = "http://chrono-storage.test", - Bucket = "aevatar-studio", - UserConfigPrefix = "profiles", - }); - var blobClient = new ChronoStorageCatalogBlobClient( - new StubAppScopeResolver(scopeId), - server.CreateHttpClientFactory(), - options); - return new ChronoStorageUserMemoryStore( - blobClient, - options, - NullLogger.Instance); - } - - private sealed class StubAppScopeResolver : IAppScopeResolver - { - private readonly AppScopeContext _context; - - public StubAppScopeResolver(string scopeId) - { - _context = new AppScopeContext(scopeId, "test"); - } - - public AppScopeContext? Resolve(HttpContext? httpContext = null) => _context; - } - - private sealed class InMemoryChronoStorageServer - { - public Dictionary Objects { get; } = new(StringComparer.Ordinal); - - public IHttpClientFactory CreateHttpClientFactory() => - new StubHttpClientFactory(new HttpClient(new Handler(this)) - { - BaseAddress = new Uri("http://chrono-storage.test/"), - }); - - private sealed class Handler : HttpMessageHandler - { - private readonly InMemoryChronoStorageServer _server; - - public Handler(InMemoryChronoStorageServer server) => _server = server; - - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) - { - var uri = request.RequestUri ?? throw new InvalidOperationException("Request URI is required."); - var path = uri.AbsolutePath.Trim('/'); - - if (request.Method == HttpMethod.Post && - string.Equals(path, "api/buckets/aevatar-studio/objects", StringComparison.Ordinal)) - { - var key = GetQueryValue(uri, "key"); - _server.Objects[$"aevatar-studio:{key}"] = await request.Content!.ReadAsByteArrayAsync(cancellationToken); - return CreateJsonResponse(HttpStatusCode.OK, new { data = new { stored = true }, error = (object?)null }); - } - - if (request.Method == HttpMethod.Get && - string.Equals(path, "api/buckets/aevatar-studio/objects/download", StringComparison.Ordinal)) - { - var key = GetQueryValue(uri, "key"); - if (!_server.Objects.TryGetValue($"aevatar-studio:{key}", out var payload)) - return new HttpResponseMessage(HttpStatusCode.NotFound); - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(payload), - }; - } - - throw new InvalidOperationException($"Unhandled request {request.Method} {uri}."); - } - - private static string GetQueryValue(Uri uri, string key) => - uri.Query.TrimStart('?') - .Split('&', StringSplitOptions.RemoveEmptyEntries) - .Select(p => p.Split('=', 2)) - .ToDictionary( - p => Uri.UnescapeDataString(p[0]), - p => p.Length > 1 ? Uri.UnescapeDataString(p[1]) : string.Empty, - StringComparer.Ordinal) - .GetValueOrDefault(key) - ?? throw new InvalidOperationException($"Missing query key '{key}'."); - - private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, object payload) => - new(statusCode) { Content = JsonContent.Create(payload) }; - } - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpClient _client; - - public StubHttpClientFactory(HttpClient client) => _client = client; - - public HttpClient CreateClient(string name) => _client; - } -} From e3040b6b8085c5dae173686bd5fa8e52756e42ef Mon Sep 17 00:00:00 2001 From: eanzhao Date: Thu, 9 Apr 2026 13:47:00 +0800 Subject: [PATCH 06/21] feat(agents): ensure read model actors exist before pushing updates Add runtime checks in PushToReadModelAsync methods to create read model actors if missing Add scope isolation to storage ports and catalog stores using IAppScopeResolver Update ActorBackedUserConfigStore to use storage options for default runtime URLs Add EnsureIndexActorAsync in ChatConversationGAgent to create index actors on demand Update tests to reflect scope-aware actor IDs --- .../ChatConversationGAgent.cs | 13 +++++++ .../ChatHistoryIndexGAgent.cs | 4 +++ .../ConnectorCatalogGAgent.cs | 4 +++ .../GAgentRegistryGAgent.cs | 4 +++ .../RoleCatalogGAgent.cs | 4 +++ .../StreamingProxyParticipantGAgent.cs | 4 +++ .../UserConfigGAgent.cs | 4 +++ .../UserMemoryGAgent.cs | 4 +++ .../ActorBackedConnectorCatalogStore.cs | 23 +++++++++--- .../ActorBackedRoleCatalogStore.cs | 35 +++++++++++++------ .../ActorBackedScriptStoragePort.cs | 20 +++++++++-- .../ActorBacked/ActorBackedUserConfigStore.cs | 4 +-- .../ActorBackedWorkflowStoragePort.cs | 20 +++++++++-- .../ActorBackedStoreAdapterTests.cs | 20 +++++------ 14 files changed, 131 insertions(+), 32 deletions(-) diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs index 86ebea13..c650c228 100644 --- a/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs +++ b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; namespace Aevatar.GAgents.ChatHistory; @@ -38,6 +39,7 @@ public async Task HandleMessagesReplaced(MessagesReplacedEvent evt) if (!string.IsNullOrWhiteSpace(evt.ScopeId)) { var indexActorId = IndexActorId(evt.ScopeId); + await EnsureIndexActorAsync(indexActorId); var indexMeta = State.Meta?.Clone(); if (indexMeta is not null) { @@ -64,6 +66,7 @@ public async Task HandleConversationDeleted(ConversationDeletedEvent evt) if (!string.IsNullOrWhiteSpace(evt.ScopeId)) { var indexActorId = IndexActorId(evt.ScopeId); + await EnsureIndexActorAsync(indexActorId); await SendToAsync(indexActorId, new ConversationRemovedEvent { ConversationId = evt.ConversationId }); } } @@ -117,9 +120,19 @@ private static ChatConversationState ApplyConversationDeleted( private async Task PushToReadModelAsync() { var readModelActorId = Id + "-readmodel"; + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(readModelActorId) is null) + await runtime.CreateAsync(readModelActorId); var update = new ChatConversationReadModelUpdateEvent { Snapshot = State.Clone() }; await SendToAsync(readModelActorId, update); } + private async Task EnsureIndexActorAsync(string indexActorId) + { + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(indexActorId) is null) + await runtime.CreateAsync(indexActorId); + } + private static string IndexActorId(string scopeId) => $"chat-index-{scopeId}"; } diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs index 06ae1b4d..3cf94e69 100644 --- a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs +++ b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; namespace Aevatar.GAgents.ChatHistory; @@ -87,6 +88,9 @@ private static ChatHistoryIndexState ApplyConversationRemoved( private async Task PushToReadModelAsync() { var readModelActorId = Id + "-readmodel"; + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(readModelActorId) is null) + await runtime.CreateAsync(readModelActorId); var update = new ChatHistoryIndexReadModelUpdateEvent { Snapshot = State.Clone() }; await SendToAsync(readModelActorId, update); } diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs index bf807bf9..8548df9b 100644 --- a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs +++ b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; namespace Aevatar.GAgents.ConnectorCatalog; @@ -92,6 +93,9 @@ private static ConnectorCatalogState ApplyDraftDeleted( private async Task PushToReadModelAsync() { var readModelActorId = Id + "-readmodel"; + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(readModelActorId) is null) + await runtime.CreateAsync(readModelActorId); var update = new ConnectorCatalogReadModelUpdateEvent { Snapshot = State.Clone() }; await SendToAsync(readModelActorId, update); } diff --git a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs index 791caa3b..615b590b 100644 --- a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs +++ b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.Registry; @@ -106,6 +107,9 @@ private static GAgentRegistryState ApplyUnregistered( private async Task PushToReadModelAsync() { var readModelActorId = Id + "-readmodel"; + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(readModelActorId) is null) + await runtime.CreateAsync(readModelActorId); var update = new GAgentRegistryReadModelUpdateEvent { Snapshot = State.Clone() }; await SendToAsync(readModelActorId, update); } diff --git a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs index 7de2ca63..a65ca381 100644 --- a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs +++ b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.RoleCatalog; @@ -92,6 +93,9 @@ private static RoleCatalogState ApplyDraftDeleted( private async Task PushToReadModelAsync() { var readModelActorId = Id + "-readmodel"; + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(readModelActorId) is null) + await runtime.CreateAsync(readModelActorId); var update = new RoleCatalogReadModelUpdateEvent { Snapshot = State.Clone() }; await SendToAsync(readModelActorId, update); } diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs index b58d5b39..1ddb8756 100644 --- a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs @@ -4,6 +4,7 @@ using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.StreamingProxyParticipant; @@ -98,6 +99,9 @@ private static StreamingProxyParticipantGAgentState ApplyRoomRemoved( private async Task PushToReadModelAsync() { var readModelActorId = Id + "-readmodel"; + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(readModelActorId) is null) + await runtime.CreateAsync(readModelActorId); var update = new StreamingProxyParticipantReadModelUpdateEvent { Snapshot = State.Clone() }; await SendToAsync(readModelActorId, update); } diff --git a/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs index 087a542c..bb4f362d 100644 --- a/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs +++ b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; namespace Aevatar.GAgents.UserConfig; @@ -56,6 +57,9 @@ private static UserConfigGAgentState ApplyConfigUpdated( private async Task PushToReadModelAsync() { var readModelActorId = Id + "-readmodel"; + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(readModelActorId) is null) + await runtime.CreateAsync(readModelActorId); var update = new UserConfigReadModelUpdateEvent { Snapshot = State.Clone() }; await SendToAsync(readModelActorId, update); } diff --git a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs index 9bfc1789..fe3ef519 100644 --- a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs +++ b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; namespace Aevatar.GAgents.UserMemory; @@ -142,6 +143,9 @@ private static UserMemoryState ApplyCleared( private async Task PushToReadModelAsync() { var readModelActorId = Id + "-readmodel"; + var runtime = Services.GetRequiredService(); + if (await runtime.GetAsync(readModelActorId) is null) + await runtime.CreateAsync(readModelActorId); var update = new UserMemoryReadModelUpdateEvent { Snapshot = State.Clone() }; await SendToAsync(readModelActorId, update); } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs index dfd8f502..79430ca9 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -2,6 +2,7 @@ using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.ConnectorCatalog; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -14,26 +15,30 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// Reads use per-request temporary subscription to the ReadModel GAgent. /// Writes send commands to the Write GAgent. /// Local workspace operations (import, draft backup) delegate to . +/// Per-scope isolation: each scope gets its own connector-catalog-{scopeId} actor. /// internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogStore { - private const string WriteActorId = "connector-catalog"; + private const string WriteActorIdPrefix = "connector-catalog-"; private const string ActorHomeDirectory = "actor://connector-catalog"; private const string ActorFilePath = "actor://connector-catalog/connectors"; private readonly IActorRuntime _runtime; private readonly IActorEventSubscriptionProvider _subscriptions; + private readonly IAppScopeResolver _scopeResolver; private readonly IStudioWorkspaceStore _workspaceStore; private readonly ILogger _logger; public ActorBackedConnectorCatalogStore( IActorRuntime runtime, IActorEventSubscriptionProvider subscriptions, + IAppScopeResolver scopeResolver, IStudioWorkspaceStore workspaceStore, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _workspaceStore = workspaceStore ?? throw new ArgumentNullException(nameof(workspaceStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -162,7 +167,7 @@ public async Task DeleteConnectorDraftAsync(CancellationToken cancellationToken private async Task ReadFromReadModelAsync(CancellationToken ct) { - var readModelActorId = WriteActorId + "-readmodel"; + var readModelActorId = ResolveReadModelActorId(); var tcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); @@ -198,10 +203,20 @@ public async Task DeleteConnectorDraftAsync(CancellationToken cancellationToken // ── Actor resolution ── + private string ResolveScopeId() + { + var scope = _scopeResolver.Resolve(); + return scope?.ScopeId ?? "default"; + } + + private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; + private async Task EnsureWriteActorAsync(CancellationToken ct) { - var actor = await _runtime.GetAsync(WriteActorId); - return actor ?? await _runtime.CreateAsync(WriteActorId, ct); + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return actor ?? await _runtime.CreateAsync(actorId, ct); } private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs index 801c45da..9a3f4aa6 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs @@ -2,6 +2,7 @@ using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.RoleCatalog; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -15,27 +16,30 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// Writes send commands to the Write GAgent. /// Local workspace operations (ImportLocalCatalogAsync) delegate to /// . +/// Per-scope isolation: each scope gets its own role-catalog-{scopeId} actor. /// internal sealed class ActorBackedRoleCatalogStore : IRoleCatalogStore { - private const string WriteActorId = "role-catalog"; - private const string ReadModelActorId = "role-catalog-readmodel"; + private const string WriteActorIdPrefix = "role-catalog-"; private const string ActorHomeDirectory = "actor://role-catalog"; private const string ActorFilePath = "actor://role-catalog/roles"; private readonly IActorRuntime _runtime; private readonly IActorEventSubscriptionProvider _subscriptions; + private readonly IAppScopeResolver _scopeResolver; private readonly IStudioWorkspaceStore _localWorkspaceStore; private readonly ILogger _logger; public ActorBackedRoleCatalogStore( IActorRuntime runtime, IActorEventSubscriptionProvider subscriptions, + IAppScopeResolver scopeResolver, IStudioWorkspaceStore localWorkspaceStore, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _localWorkspaceStore = localWorkspaceStore ?? throw new ArgumentNullException(nameof(localWorkspaceStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -147,11 +151,12 @@ public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = def private async Task ReadFromReadModelAsync(CancellationToken ct) { + var readModelActorId = ResolveReadModelActorId(); var tcs = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); await using var sub = await _subscriptions.SubscribeAsync( - ReadModelActorId, + readModelActorId, envelope => { if (envelope.Payload?.Is(RoleCatalogStateSnapshotEvent.Descriptor) == true) @@ -164,7 +169,7 @@ public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = def ct); // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) - await EnsureReadModelActorAsync(ct); + await EnsureReadModelActorAsync(readModelActorId, ct); // Wait for snapshot with timeout using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); @@ -175,24 +180,34 @@ public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = def } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", ReadModelActorId); + _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); return null; } } // ── Actor resolution ── + private string ResolveScopeId() + { + var scope = _scopeResolver.Resolve(); + return scope?.ScopeId ?? "default"; + } + + private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; + private async Task EnsureWriteActorAsync(CancellationToken ct) { - var actor = await _runtime.GetAsync(WriteActorId); - return actor ?? await _runtime.CreateAsync(WriteActorId, ct); + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return actor ?? await _runtime.CreateAsync(actorId, ct); } - private async Task EnsureReadModelActorAsync(CancellationToken ct) + private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) { - var actor = await _runtime.GetAsync(ReadModelActorId); + var actor = await _runtime.GetAsync(readModelActorId); if (actor is null) - await _runtime.CreateAsync(ReadModelActorId, ct); + await _runtime.CreateAsync(readModelActorId, ct); } private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs index bf8227b4..241bc29f 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs @@ -1,6 +1,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.ScriptStorage; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -13,19 +14,23 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// This port is write-only — no readmodel subscription is needed since /// the interface only exposes . +/// Per-scope isolation: each scope gets its own script-storage-{scopeId} actor. /// internal sealed class ActorBackedScriptStoragePort : IScriptStoragePort { - private const string ScriptStorageActorId = "script-storage"; + private const string ScriptStorageActorIdPrefix = "script-storage-"; private readonly IActorRuntime _runtime; + private readonly IAppScopeResolver _scopeResolver; private readonly ILogger _logger; public ActorBackedScriptStoragePort( IActorRuntime runtime, + IAppScopeResolver scopeResolver, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -42,13 +47,22 @@ public async Task UploadScriptAsync(string scriptId, string sourceText, Cancella _logger.LogDebug("Script {ScriptId} uploaded to actor-backed storage", scriptId); } + private string ResolveScopeId() + { + var scope = _scopeResolver.Resolve(); + return scope?.ScopeId ?? "default"; + } + + private string ResolveStorageActorId() => ScriptStorageActorIdPrefix + ResolveScopeId(); + private async Task EnsureActorAsync(CancellationToken ct) { - var actor = await _runtime.GetAsync(ScriptStorageActorId); + var actorId = ResolveStorageActorId(); + var actor = await _runtime.GetAsync(actorId); if (actor is not null) return actor; - return await _runtime.CreateAsync(ScriptStorageActorId, ct); + return await _runtime.CreateAsync(actorId, ct); } private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs index 82305cfb..5b3b4627 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs @@ -75,10 +75,10 @@ public async Task SaveAsync(UserConfig config, CancellationToken cancellationTok RuntimeMode = UserConfigRuntime.NormalizeMode(config.RuntimeMode), LocalRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( config.LocalRuntimeBaseUrl, - UserConfigRuntimeDefaults.LocalRuntimeBaseUrl), + _storageOptions.ResolveDefaultLocalRuntimeBaseUrl()), RemoteRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( config.RemoteRuntimeBaseUrl, - UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl), + _storageOptions.ResolveDefaultRemoteRuntimeBaseUrl()), MaxToolRounds = config.MaxToolRounds, }; await SendCommandAsync(actor, evt, cancellationToken); diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs index a233c99a..a31a80d5 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs @@ -1,6 +1,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.WorkflowStorage; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -10,19 +11,23 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . /// Writes go through event handlers. +/// Per-scope isolation: each scope gets its own workflow-storage-{scopeId} actor. /// internal sealed class ActorBackedWorkflowStoragePort : IWorkflowStoragePort { - private const string StorageActorId = "workflow-storage"; + private const string StorageActorIdPrefix = "workflow-storage-"; private readonly IActorRuntime _runtime; + private readonly IAppScopeResolver _scopeResolver; private readonly ILogger _logger; public ActorBackedWorkflowStoragePort( IActorRuntime runtime, + IAppScopeResolver scopeResolver, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -40,13 +45,22 @@ public async Task UploadWorkflowYamlAsync( _logger.LogDebug("Workflow YAML uploaded via actor: {WorkflowId}", workflowId); } + private string ResolveScopeId() + { + var scope = _scopeResolver.Resolve(); + return scope?.ScopeId ?? "default"; + } + + private string ResolveStorageActorId() => StorageActorIdPrefix + ResolveScopeId(); + private async Task EnsureActorAsync(CancellationToken ct) { - var actor = await _runtime.GetAsync(StorageActorId); + var actorId = ResolveStorageActorId(); + var actor = await _runtime.GetAsync(actorId); if (actor is not null) return actor; - return await _runtime.CreateAsync(StorageActorId, ct); + return await _runtime.CreateAsync(actorId, ct); } private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index ad129f7d..587dcc5d 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -488,11 +488,11 @@ public async Task WorkflowStoragePort_UploadAsync_SendsWorkflowYamlUploadedEvent var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var port = new ActorBackedWorkflowStoragePort(runtime, logger); + var port = new ActorBackedWorkflowStoragePort(runtime, new FakeScopeResolver(), logger); await port.UploadWorkflowYamlAsync("wf-001", "My Workflow", "name: test\nsteps: []", CancellationToken.None); - const string expectedActorId = "workflow-storage"; + const string expectedActorId = "workflow-storage-default"; runtime.Actors.Should().ContainKey(expectedActorId); var actor = runtime.Actors[expectedActorId]; @@ -516,14 +516,14 @@ public async Task WorkflowStoragePort_MultipleUploads_ReusesSameActor() var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var port = new ActorBackedWorkflowStoragePort(runtime, logger); + var port = new ActorBackedWorkflowStoragePort(runtime, new FakeScopeResolver(), logger); await port.UploadWorkflowYamlAsync("wf-1", "First", "yaml1", CancellationToken.None); await port.UploadWorkflowYamlAsync("wf-2", "Second", "yaml2", CancellationToken.None); runtime.Actors.Should().HaveCount(1, "actor should be reused across uploads"); - var actor = runtime.Actors["workflow-storage"]; + var actor = runtime.Actors["workflow-storage-default"]; actor.ReceivedEnvelopes.Should().HaveCount(2); } @@ -537,11 +537,11 @@ public async Task ScriptStoragePort_UploadAsync_SendsScriptUploadedEvent() var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var port = new ActorBackedScriptStoragePort(runtime, logger); + var port = new ActorBackedScriptStoragePort(runtime, new FakeScopeResolver(), logger); await port.UploadScriptAsync("script-42", "console.log('hello');", CancellationToken.None); - const string expectedActorId = "script-storage"; + const string expectedActorId = "script-storage-default"; runtime.Actors.Should().ContainKey(expectedActorId); var actor = runtime.Actors[expectedActorId]; @@ -564,13 +564,13 @@ public async Task ScriptStoragePort_MultipleUploads_ReusesSameActor() var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var port = new ActorBackedScriptStoragePort(runtime, logger); + var port = new ActorBackedScriptStoragePort(runtime, new FakeScopeResolver(), logger); await port.UploadScriptAsync("s1", "code1", CancellationToken.None); await port.UploadScriptAsync("s2", "code2", CancellationToken.None); runtime.Actors.Should().HaveCount(1, "actor should be reused"); - runtime.Actors["script-storage"].ReceivedEnvelopes.Should().HaveCount(2); + runtime.Actors["script-storage-default"].ReceivedEnvelopes.Should().HaveCount(2); } // ════════════════════════════════════════════════════════════ @@ -583,13 +583,13 @@ public async Task AllStores_EnvelopeContainsIdAndTimestamp() var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var port = new ActorBackedScriptStoragePort(runtime, logger); + var port = new ActorBackedScriptStoragePort(runtime, new FakeScopeResolver(), logger); var beforeUtc = DateTimeOffset.UtcNow; await port.UploadScriptAsync("ts-check", "body", CancellationToken.None); var afterUtc = DateTimeOffset.UtcNow; - var envelope = runtime.Actors["script-storage"].ReceivedEnvelopes[0]; + var envelope = runtime.Actors["script-storage-default"].ReceivedEnvelopes[0]; envelope.Id.Should().NotBeNullOrWhiteSpace("envelope must have a unique ID"); envelope.Id.Length.Should().Be(32, "ID should be a Guid without dashes"); From c2638f4e1324cd72358972584f05cc75ee178f9d Mon Sep 17 00:00:00 2001 From: eanzhao Date: Thu, 9 Apr 2026 15:39:48 +0800 Subject: [PATCH 07/21] refactor(actor-backed): extract shared command dispatch and readmodel utilities Introduce ActorCommandDispatcher and ReadModelSnapshotReader to eliminate duplicated SendCommandAsync and readmodel subscription logic across all actor-backed stores. Add AppScopeResolverExtensions for consistent scope ID resolution with default fallback. This reduces code duplication and centralizes actor interaction patterns. --- .../ActorBackedChatHistoryStore.cs | 119 +++--------------- .../ActorBackedConnectorCatalogStore.cs | 80 +++--------- .../ActorBackedGAgentActorStore.cs | 77 ++---------- .../ActorBackedRoleCatalogStore.cs | 80 +++--------- .../ActorBackedScriptStoragePort.cs | 27 +--- ...torBackedStreamingProxyParticipantStore.cs | 68 ++-------- .../ActorBacked/ActorBackedUserConfigStore.cs | 75 ++--------- .../ActorBacked/ActorBackedUserMemoryStore.cs | 81 +++--------- .../ActorBackedWorkflowStoragePort.cs | 27 +--- .../ActorBacked/ActorCommandDispatcher.cs | 27 ++++ .../ActorBacked/AppScopeResolverExtensions.cs | 15 +++ .../ActorBacked/ReadModelSnapshotReader.cs | 79 ++++++++++++ 12 files changed, 222 insertions(+), 533 deletions(-) create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ReadModelSnapshotReader.cs diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs index 290261e8..0683aa5e 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs @@ -2,8 +2,6 @@ using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.ChatHistory; using Aevatar.Studio.Application.Studio.Abstractions; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; namespace Aevatar.Studio.Infrastructure.ActorBacked; @@ -69,7 +67,7 @@ public async Task SaveMessagesAsync( foreach (var msg in messages) replaceEvt.Messages.Add(ToStoredChatMessageProto(msg)); - await SendCommandAsync(conversationActor, replaceEvt, ct); + await ActorCommandDispatcher.SendAsync(conversationActor, replaceEvt, ct); } public async Task DeleteConversationAsync( @@ -82,83 +80,37 @@ public async Task DeleteConversationAsync( ConversationId = conversationId, ScopeId = scopeId, }; - await SendCommandAsync(conversationActor, deleteEvt, ct); + await ActorCommandDispatcher.SendAsync(conversationActor, deleteEvt, ct); } // ── Per-request readmodel reads (no service-level state) ─── - private async Task ReadIndexFromReadModelAsync( + private Task ReadIndexFromReadModelAsync( string scopeId, CancellationToken ct) { - var readModelActorId = IndexActorId(scopeId) + "-readmodel"; - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await _subscriptions.SubscribeAsync( - readModelActorId, - envelope => - { - if (envelope.Payload?.Is(ChatHistoryIndexStateSnapshotEvent.Descriptor) == true) - { - var snapshot = envelope.Payload.Unpack(); - tcs.TrySetResult(snapshot.Snapshot); - } - return Task.CompletedTask; - }, + return ReadModelSnapshotReader.ReadAsync( + _subscriptions, + _runtime, + IndexActorId(scopeId) + "-readmodel", + typeof(ChatHistoryIndexReadModelGAgent), + ChatHistoryIndexStateSnapshotEvent.Descriptor, + evt => evt.Snapshot, + _logger, ct); - - // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) - await EnsureIndexReadModelActorAsync(readModelActorId, ct); - - // Wait for snapshot with timeout - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - return await tcs.Task.WaitAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - _logger.LogWarning("Timeout waiting for index readmodel snapshot from {ActorId}", readModelActorId); - return null; - } } - private async Task ReadConversationFromReadModelAsync( + private Task ReadConversationFromReadModelAsync( string scopeId, string conversationId, CancellationToken ct) { - var readModelActorId = ConversationActorId(scopeId, conversationId) + "-readmodel"; - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await _subscriptions.SubscribeAsync( - readModelActorId, - envelope => - { - if (envelope.Payload?.Is(ChatConversationStateSnapshotEvent.Descriptor) == true) - { - var snapshot = envelope.Payload.Unpack(); - tcs.TrySetResult(snapshot.Snapshot); - } - return Task.CompletedTask; - }, + return ReadModelSnapshotReader.ReadAsync( + _subscriptions, + _runtime, + ConversationActorId(scopeId, conversationId) + "-readmodel", + typeof(ChatConversationReadModelGAgent), + ChatConversationStateSnapshotEvent.Descriptor, + evt => evt.Snapshot, + _logger, ct); - - // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) - await EnsureConversationReadModelActorAsync(readModelActorId, ct); - - // Wait for snapshot with timeout - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - return await tcs.Task.WaitAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - _logger.LogWarning("Timeout waiting for conversation readmodel snapshot from {ActorId}", readModelActorId); - return null; - } } // ── Actor resolution ─────────────────────────────────────── @@ -171,42 +123,11 @@ private async Task EnsureConversationActorAsync( return actor ?? await _runtime.CreateAsync(actorId, ct); } - private async Task EnsureIndexReadModelActorAsync(string readModelActorId, CancellationToken ct) - { - var actor = await _runtime.GetAsync(readModelActorId); - if (actor is null) - await _runtime.CreateAsync(readModelActorId, ct); - } - - private async Task EnsureConversationReadModelActorAsync(string readModelActorId, CancellationToken ct) - { - var actor = await _runtime.GetAsync(readModelActorId); - if (actor is null) - await _runtime.CreateAsync(readModelActorId, ct); - } - // ── Actor ID conventions ─────────────────────────────────── private static string IndexActorId(string scopeId) => $"chat-index-{scopeId}"; private static string ConversationActorId(string scopeId, string conversationId) => $"chat-{scopeId}-{conversationId}"; - // ── Command dispatch ─────────────────────────────────────── - - private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - await actor.HandleEventAsync(envelope, ct); - } - // ── Mapping helpers ──────────────────────────────────────── private static ConversationMeta ToConversationMeta(ConversationMetaProto proto) => diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs index 79430ca9..0c52b716 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -3,7 +3,6 @@ using Aevatar.GAgents.ConnectorCatalog; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; -using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -75,7 +74,7 @@ public async Task SaveConnectorCatalogAsync( var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new ConnectorCatalogSavedEvent(); evt.Connectors.AddRange(catalog.Connectors.Select(ToProtoConnectorDefinition)); - await SendCommandAsync(actor, evt, cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); return new StoredConnectorCatalog( HomeDirectory: ActorHomeDirectory, @@ -97,7 +96,7 @@ public async Task ImportLocalCatalogAsync( var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new ConnectorCatalogSavedEvent(); evt.Connectors.AddRange(localCatalog.Connectors.Select(ToProtoConnectorDefinition)); - await SendCommandAsync(actor, evt, cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); var importedCatalog = new StoredConnectorCatalog( HomeDirectory: ActorHomeDirectory, @@ -142,7 +141,7 @@ public async Task SaveConnectorDraftAsync( Draft = draft.Draft is not null ? ToProtoConnectorDefinition(draft.Draft) : null, UpdatedAtUtc = Timestamp.FromDateTimeOffset(updatedAtUtc), }; - await SendCommandAsync(actor, evt, cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); // Also persist to local workspace for offline access await _workspaceStore.SaveConnectorDraftAsync(draft, cancellationToken); @@ -158,58 +157,29 @@ public async Task SaveConnectorDraftAsync( public async Task DeleteConnectorDraftAsync(CancellationToken cancellationToken = default) { var actor = await EnsureWriteActorAsync(cancellationToken); - await SendCommandAsync(actor, new ConnectorDraftDeletedEvent(), cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, new ConnectorDraftDeletedEvent(), cancellationToken); await _workspaceStore.DeleteConnectorDraftAsync(cancellationToken); } // ── Per-request readmodel read (no service-level state) ── - private async Task ReadFromReadModelAsync(CancellationToken ct) + private Task ReadFromReadModelAsync(CancellationToken ct) { - var readModelActorId = ResolveReadModelActorId(); - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await _subscriptions.SubscribeAsync( - readModelActorId, - envelope => - { - if (envelope.Payload?.Is(ConnectorCatalogStateSnapshotEvent.Descriptor) == true) - { - var snapshot = envelope.Payload.Unpack(); - tcs.TrySetResult(snapshot.Snapshot); - } - return Task.CompletedTask; - }, + return ReadModelSnapshotReader.ReadAsync( + _subscriptions, + _runtime, + ResolveReadModelActorId(), + typeof(ConnectorCatalogReadModelGAgent), + ConnectorCatalogStateSnapshotEvent.Descriptor, + evt => evt.Snapshot, + _logger, ct); - - // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) - await EnsureReadModelActorAsync(readModelActorId, ct); - - // Wait for snapshot with timeout - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - return await tcs.Task.WaitAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); - return null; - } } // ── Actor resolution ── - private string ResolveScopeId() - { - var scope = _scopeResolver.Resolve(); - return scope?.ScopeId ?? "default"; - } - - private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; private async Task EnsureWriteActorAsync(CancellationToken ct) @@ -219,28 +189,6 @@ private async Task EnsureWriteActorAsync(CancellationToken ct) return actor ?? await _runtime.CreateAsync(actorId, ct); } - private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) - { - var actor = await _runtime.GetAsync(readModelActorId); - if (actor is null) - await _runtime.CreateAsync(readModelActorId, ct); - } - - private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - await actor.HandleEventAsync(envelope, ct); - } - // ── Proto <-> Domain mapping ── private static StoredConnectorDefinition ToStoredConnectorDefinition(ConnectorDefinitionEntry entry) => diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs index 27ce5928..afbbc65f 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs @@ -3,8 +3,6 @@ using Aevatar.GAgents.Registry; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; namespace Aevatar.Studio.Infrastructure.ActorBacked; @@ -56,7 +54,7 @@ public async Task AddActorAsync( CancellationToken cancellationToken = default) { var actor = await EnsureWriteActorAsync(cancellationToken); - await SendCommandAsync(actor, new ActorRegisteredEvent + await ActorCommandDispatcher.SendAsync(actor, new ActorRegisteredEvent { GagentType = gagentType, ActorId = actorId, @@ -68,7 +66,7 @@ public async Task RemoveActorAsync( CancellationToken cancellationToken = default) { var actor = await EnsureWriteActorAsync(cancellationToken); - await SendCommandAsync(actor, new ActorUnregisteredEvent + await ActorCommandDispatcher.SendAsync(actor, new ActorUnregisteredEvent { GagentType = gagentType, ActorId = actorId, @@ -77,51 +75,22 @@ public async Task RemoveActorAsync( // ── Per-request readmodel read (no service-level state) ── - private async Task ReadFromReadModelAsync(CancellationToken ct) + private Task ReadFromReadModelAsync(CancellationToken ct) { - var readModelActorId = ResolveReadModelActorId(); - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await _subscriptions.SubscribeAsync( - readModelActorId, - envelope => - { - if (envelope.Payload?.Is(GAgentRegistryStateSnapshotEvent.Descriptor) == true) - { - var snapshot = envelope.Payload.Unpack(); - tcs.TrySetResult(snapshot.Snapshot); - } - return Task.CompletedTask; - }, + return ReadModelSnapshotReader.ReadAsync( + _subscriptions, + _runtime, + ResolveReadModelActorId(), + typeof(GAgentRegistryReadModelGAgent), + GAgentRegistryStateSnapshotEvent.Descriptor, + evt => evt.Snapshot, + _logger, ct); - - // Activate readmodel actor (triggers OnActivateAsync → PublishAsync snapshot) - await EnsureReadModelActorAsync(readModelActorId, ct); - - // Wait for snapshot with timeout - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - return await tcs.Task.WaitAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); - return null; - } } // ── Actor resolution ── - private string ResolveScopeId() - { - var scope = _scopeResolver.Resolve(); - return scope?.ScopeId ?? "default"; - } - - private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; private async Task EnsureWriteActorAsync(CancellationToken ct) @@ -130,26 +99,4 @@ private async Task EnsureWriteActorAsync(CancellationToken ct) var actor = await _runtime.GetAsync(actorId); return actor ?? await _runtime.CreateAsync(actorId, ct); } - - private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) - { - var actor = await _runtime.GetAsync(readModelActorId); - if (actor is null) - await _runtime.CreateAsync(readModelActorId, ct); - } - - private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - await actor.HandleEventAsync(envelope, ct); - } } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs index 9a3f4aa6..b132c4f6 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs @@ -3,7 +3,6 @@ using Aevatar.GAgents.RoleCatalog; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; -using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -67,7 +66,7 @@ public async Task SaveRoleCatalogAsync( var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new RoleCatalogSavedEvent(); evt.Roles.AddRange(catalog.Roles.Select(ToProtoRoleDefinition)); - await SendCommandAsync(actor, evt, cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); return new StoredRoleCatalog( HomeDirectory: ActorHomeDirectory, @@ -87,7 +86,7 @@ public async Task ImportLocalCatalogAsync(CancellationToken var actor = await EnsureWriteActorAsync(cancellationToken); var evt = new RoleCatalogSavedEvent(); evt.Roles.AddRange(localCatalog.Roles.Select(ToProtoRoleDefinition)); - await SendCommandAsync(actor, evt, cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); var importedCatalog = new StoredRoleCatalog( HomeDirectory: ActorHomeDirectory, @@ -131,7 +130,7 @@ public async Task SaveRoleDraftAsync( Draft = draft.Draft is not null ? ToProtoRoleDefinition(draft.Draft) : null, UpdatedAtUtc = Timestamp.FromDateTimeOffset(updatedAtUtc), }; - await SendCommandAsync(actor, evt, cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); return new StoredRoleDraft( HomeDirectory: ActorHomeDirectory, @@ -144,56 +143,27 @@ public async Task SaveRoleDraftAsync( public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = default) { var actor = await EnsureWriteActorAsync(cancellationToken); - await SendCommandAsync(actor, new RoleDraftDeletedEvent(), cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, new RoleDraftDeletedEvent(), cancellationToken); } // ── Per-request readmodel read (no service-level state) ── - private async Task ReadFromReadModelAsync(CancellationToken ct) + private Task ReadFromReadModelAsync(CancellationToken ct) { - var readModelActorId = ResolveReadModelActorId(); - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await _subscriptions.SubscribeAsync( - readModelActorId, - envelope => - { - if (envelope.Payload?.Is(RoleCatalogStateSnapshotEvent.Descriptor) == true) - { - var snapshot = envelope.Payload.Unpack(); - tcs.TrySetResult(snapshot.Snapshot); - } - return Task.CompletedTask; - }, + return ReadModelSnapshotReader.ReadAsync( + _subscriptions, + _runtime, + ResolveReadModelActorId(), + typeof(RoleCatalogReadModelGAgent), + RoleCatalogStateSnapshotEvent.Descriptor, + evt => evt.Snapshot, + _logger, ct); - - // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) - await EnsureReadModelActorAsync(readModelActorId, ct); - - // Wait for snapshot with timeout - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - return await tcs.Task.WaitAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); - return null; - } } // ── Actor resolution ── - private string ResolveScopeId() - { - var scope = _scopeResolver.Resolve(); - return scope?.ScopeId ?? "default"; - } - - private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; private async Task EnsureWriteActorAsync(CancellationToken ct) @@ -203,28 +173,6 @@ private async Task EnsureWriteActorAsync(CancellationToken ct) return actor ?? await _runtime.CreateAsync(actorId, ct); } - private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) - { - var actor = await _runtime.GetAsync(readModelActorId); - if (actor is null) - await _runtime.CreateAsync(readModelActorId, ct); - } - - private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - await actor.HandleEventAsync(envelope, ct); - } - private static StoredRoleDefinition ToStoredRoleDefinition(RoleDefinitionEntry entry) => new( Id: entry.Id, diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs index 241bc29f..e994e8e7 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs @@ -2,8 +2,6 @@ using Aevatar.GAgents.ScriptStorage; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; namespace Aevatar.Studio.Infrastructure.ActorBacked; @@ -42,18 +40,12 @@ public async Task UploadScriptAsync(string scriptId, string sourceText, Cancella ScriptId = scriptId, SourceText = sourceText, }; - await SendCommandAsync(actor, evt, ct); + await ActorCommandDispatcher.SendAsync(actor, evt, ct); _logger.LogDebug("Script {ScriptId} uploaded to actor-backed storage", scriptId); } - private string ResolveScopeId() - { - var scope = _scopeResolver.Resolve(); - return scope?.ScopeId ?? "default"; - } - - private string ResolveStorageActorId() => ScriptStorageActorIdPrefix + ResolveScopeId(); + private string ResolveStorageActorId() => ScriptStorageActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); private async Task EnsureActorAsync(CancellationToken ct) { @@ -64,19 +56,4 @@ private async Task EnsureActorAsync(CancellationToken ct) return await _runtime.CreateAsync(actorId, ct); } - - private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - await actor.HandleEventAsync(envelope, ct); - } } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs index 1d5a0c0a..7435abe4 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs @@ -2,7 +2,6 @@ using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.StreamingProxyParticipant; using Aevatar.Studio.Application.Studio.Abstractions; -using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -64,7 +63,7 @@ public async Task AddAsync( DisplayName = displayName, JoinedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), }; - await SendCommandAsync(actor, evt, cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); } public async Task RemoveRoomAsync( @@ -75,45 +74,22 @@ public async Task RemoveRoomAsync( { RoomId = roomId, }; - await SendCommandAsync(actor, evt, cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); } // ── Per-request readmodel read (no service-level state) ── - private async Task ReadFromReadModelAsync(CancellationToken ct) + private Task ReadFromReadModelAsync(CancellationToken ct) { - var readModelActorId = WriteActorId + "-readmodel"; - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await _subscriptions.SubscribeAsync( - readModelActorId, - envelope => - { - if (envelope.Payload?.Is(StreamingProxyParticipantStateSnapshotEvent.Descriptor) == true) - { - var snapshot = envelope.Payload.Unpack(); - tcs.TrySetResult(snapshot.Snapshot); - } - return Task.CompletedTask; - }, + return ReadModelSnapshotReader.ReadAsync( + _subscriptions, + _runtime, + WriteActorId + "-readmodel", + typeof(StreamingProxyParticipantReadModelGAgent), + StreamingProxyParticipantStateSnapshotEvent.Descriptor, + evt => evt.Snapshot, + _logger, ct); - - // Activate readmodel actor (triggers OnActivateAsync → PublishAsync snapshot) - await EnsureReadModelActorAsync(readModelActorId, ct); - - // Wait for snapshot with timeout - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - return await tcs.Task.WaitAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); - return null; - } } // ── Actor resolution ── @@ -123,26 +99,4 @@ private async Task EnsureWriteActorAsync(CancellationToken ct) var actor = await _runtime.GetAsync(WriteActorId); return actor ?? await _runtime.CreateAsync(WriteActorId, ct); } - - private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) - { - var actor = await _runtime.GetAsync(readModelActorId); - if (actor is null) - await _runtime.CreateAsync(readModelActorId, ct); - } - - private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - await actor.HandleEventAsync(envelope, ct); - } } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs index 5b3b4627..5cff347e 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs @@ -4,8 +4,6 @@ using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; using Aevatar.Studio.Infrastructure.Storage; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -81,56 +79,27 @@ public async Task SaveAsync(UserConfig config, CancellationToken cancellationTok _storageOptions.ResolveDefaultRemoteRuntimeBaseUrl()), MaxToolRounds = config.MaxToolRounds, }; - await SendCommandAsync(actor, evt, cancellationToken); + await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); } // ── Per-request readmodel read (no service-level state) ── - private async Task ReadFromReadModelAsync(CancellationToken ct) + private Task ReadFromReadModelAsync(CancellationToken ct) { - var readModelActorId = ResolveReadModelActorId(); - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await _subscriptions.SubscribeAsync( - readModelActorId, - envelope => - { - if (envelope.Payload?.Is(UserConfigStateSnapshotEvent.Descriptor) == true) - { - var snapshot = envelope.Payload.Unpack(); - tcs.TrySetResult(snapshot.Snapshot); - } - return Task.CompletedTask; - }, + return ReadModelSnapshotReader.ReadAsync( + _subscriptions, + _runtime, + ResolveReadModelActorId(), + typeof(UserConfigReadModelGAgent), + UserConfigStateSnapshotEvent.Descriptor, + evt => evt.Snapshot, + _logger, ct); - - // Activate readmodel actor (triggers OnActivateAsync → PublishAsync snapshot) - await EnsureReadModelActorAsync(readModelActorId, ct); - - // Wait for snapshot with timeout - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - return await tcs.Task.WaitAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); - return null; - } } // ── Actor resolution ── - private string ResolveScopeId() - { - var scope = _scopeResolver.Resolve(); - return scope?.ScopeId ?? "default"; - } - - private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); + private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; private async Task EnsureWriteActorAsync(CancellationToken ct) @@ -140,28 +109,6 @@ private async Task EnsureWriteActorAsync(CancellationToken ct) return actor ?? await _runtime.CreateAsync(actorId, ct); } - private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) - { - var actor = await _runtime.GetAsync(readModelActorId); - if (actor is null) - await _runtime.CreateAsync(readModelActorId, ct); - } - - private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - await actor.HandleEventAsync(envelope, ct); - } - private UserConfig CreateDefaultConfig() => new( DefaultModel: string.Empty, diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs index 4ccef425..a8830a50 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs @@ -5,8 +5,6 @@ using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.UserMemory; using Aevatar.Studio.Infrastructure.ScopeResolution; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; namespace Aevatar.Studio.Infrastructure.ActorBacked; @@ -91,7 +89,7 @@ public async Task SaveAsync(UserMemoryDocument document, CancellationToken ct = UpdatedAt = entry.UpdatedAt, }, }; - await SendCommandAsync(actor, evt, ct); + await ActorCommandDispatcher.SendAsync(actor, evt, ct); } } @@ -111,7 +109,7 @@ public async Task AddEntryAsync( }; var evt = new MemoryEntryAddedEvent { Entry = entry }; - await SendCommandAsync(actor, evt, ct); + await ActorCommandDispatcher.SendAsync(actor, evt, ct); return new UserMemoryEntry( Id: entry.Id, @@ -130,7 +128,7 @@ public async Task RemoveEntryAsync(string id, CancellationToken ct = defau var actor = await EnsureWriteActorAsync(ct); var evt = new MemoryEntryRemovedEvent { EntryId = id }; - await SendCommandAsync(actor, evt, ct); + await ActorCommandDispatcher.SendAsync(actor, evt, ct); return true; } @@ -195,52 +193,25 @@ public async Task BuildPromptSectionAsync(int maxChars = 2000, Cancellat // ── Per-request readmodel read (no service-level state) ── - private async Task ReadFromReadModelAsync(CancellationToken ct) + private Task ReadFromReadModelAsync(CancellationToken ct) { - var readModelActorId = ResolveReadModelActorId(); - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await _subscriptions.SubscribeAsync( - readModelActorId, - envelope => - { - if (envelope.Payload?.Is(UserMemoryStateSnapshotEvent.Descriptor) == true) - { - var snapshot = envelope.Payload.Unpack(); - tcs.TrySetResult(snapshot.Snapshot); - } - return Task.CompletedTask; - }, + return ReadModelSnapshotReader.ReadAsync( + _subscriptions, + _runtime, + ResolveReadModelActorId(), + typeof(UserMemoryReadModelGAgent), + UserMemoryStateSnapshotEvent.Descriptor, + evt => evt.Snapshot, + _logger, ct); - - // Activate readmodel actor (triggers OnActivateAsync -> PublishAsync snapshot) - await EnsureReadModelActorAsync(readModelActorId, ct); - - // Wait for snapshot with timeout - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(5)); - try - { - return await tcs.Task.WaitAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - _logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); - return null; - } } // ── Actor resolution ── private string ResolveScopeId() - { - var scope = _scopeResolver.Resolve(); - if (scope is null) - throw new InvalidOperationException( - "User memory store requires an authenticated user scope. No scope could be resolved."); - return scope.ScopeId; - } + => _scopeResolver.Resolve()?.ScopeId + ?? throw new InvalidOperationException( + "User memory store requires an authenticated user scope. No scope could be resolved."); private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; @@ -252,28 +223,6 @@ private async Task EnsureWriteActorAsync(CancellationToken ct) return actor ?? await _runtime.CreateAsync(actorId, ct); } - private async Task EnsureReadModelActorAsync(string readModelActorId, CancellationToken ct) - { - var actor = await _runtime.GetAsync(readModelActorId); - if (actor is null) - await _runtime.CreateAsync(readModelActorId, ct); - } - - private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - await actor.HandleEventAsync(envelope, ct); - } - private static string GenerateId() { var bytes = RandomNumberGenerator.GetBytes(6); diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs index a31a80d5..10debd2d 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs @@ -2,8 +2,6 @@ using Aevatar.GAgents.WorkflowStorage; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; namespace Aevatar.Studio.Infrastructure.ActorBacked; @@ -41,17 +39,11 @@ public async Task UploadWorkflowYamlAsync( WorkflowName = workflowName, Yaml = yaml, }; - await SendCommandAsync(actor, evt, ct); + await ActorCommandDispatcher.SendAsync(actor, evt, ct); _logger.LogDebug("Workflow YAML uploaded via actor: {WorkflowId}", workflowId); } - private string ResolveScopeId() - { - var scope = _scopeResolver.Resolve(); - return scope?.ScopeId ?? "default"; - } - - private string ResolveStorageActorId() => StorageActorIdPrefix + ResolveScopeId(); + private string ResolveStorageActorId() => StorageActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); private async Task EnsureActorAsync(CancellationToken ct) { @@ -62,19 +54,4 @@ private async Task EnsureActorAsync(CancellationToken ct) return await _runtime.CreateAsync(actorId, ct); } - - private static async Task SendCommandAsync(IActor actor, IMessage command, CancellationToken ct) - { - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, - }; - await actor.HandleEventAsync(envelope, ct); - } } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs new file mode 100644 index 00000000..97c99e7b --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs @@ -0,0 +1,27 @@ +using Aevatar.Foundation.Abstractions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Shared utility for dispatching command events to actors via . +/// Eliminates duplicated SendCommandAsync across all ActorBacked stores. +/// +internal static class ActorCommandDispatcher +{ + public static async Task SendAsync(IActor actor, IMessage command, CancellationToken ct) + { + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(command), + Route = new EnvelopeRoute + { + Direct = new DirectRoute { TargetActorId = actor.Id }, + }, + }; + await actor.HandleEventAsync(envelope, ct); + } +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs new file mode 100644 index 00000000..c3a8d5cf --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs @@ -0,0 +1,15 @@ +using Aevatar.Studio.Infrastructure.ScopeResolution; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Extension methods for used by ActorBacked stores. +/// +internal static class AppScopeResolverExtensions +{ + /// + /// Resolves the current scope ID, falling back to "default" if no scope is available. + /// + public static string ResolveScopeIdOrDefault(this IAppScopeResolver resolver) + => resolver.Resolve()?.ScopeId ?? "default"; +} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ReadModelSnapshotReader.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ReadModelSnapshotReader.cs new file mode 100644 index 00000000..8b27216e --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ReadModelSnapshotReader.cs @@ -0,0 +1,79 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Streaming; +using Google.Protobuf; +using Google.Protobuf.Reflection; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +/// +/// Shared utility for reading a snapshot from a ReadModel GAgent via per-request +/// temporary subscription. Eliminates duplicated ReadFromReadModelAsync +/// across all ActorBacked stores. +/// +/// Pattern: subscribe → activate readmodel actor → wait for snapshot → unsubscribe. +/// Method-local TaskCompletionSource only. No service-level state. +/// +internal static class ReadModelSnapshotReader +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5); + + /// + /// Subscribe to a ReadModel GAgent, activate it (triggering OnActivateAsync → snapshot + /// publish), wait for the snapshot event, and return the unpacked state. + /// + /// The protobuf state type (e.g., GAgentRegistryState). + /// The snapshot event type (e.g., GAgentRegistryStateSnapshotEvent). + /// Subscription provider for actor event streams. + /// Actor runtime for activating actors. + /// The readmodel actor ID to subscribe to. + /// The readmodel GAgent type (for CreateAsync if not yet activated). + /// Protobuf descriptor for the snapshot event. + /// Function to unpack the snapshot state from the event. + /// Logger for timeout warnings. + /// Cancellation token. + public static async Task ReadAsync( + IActorEventSubscriptionProvider subscriptions, + IActorRuntime runtime, + string readModelActorId, + Type readModelActorType, + MessageDescriptor snapshotDescriptor, + Func unpackSnapshot, + ILogger logger, + CancellationToken ct) + where TState : class, IMessage + where TSnapshotEvent : class, IMessage, new() + { + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + await using var sub = await subscriptions.SubscribeAsync( + readModelActorId, + envelope => + { + if (envelope.Payload?.Is(snapshotDescriptor) == true) + { + var snapshotEvent = envelope.Payload.Unpack(); + tcs.TrySetResult(unpackSnapshot(snapshotEvent)); + } + return Task.CompletedTask; + }, + ct); + + // Activate readmodel actor (triggers OnActivateAsync → PublishAsync snapshot) + if (await runtime.GetAsync(readModelActorId) is null) + await runtime.CreateAsync(readModelActorType, readModelActorId, ct); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(DefaultTimeout); + try + { + return await tcs.Task.WaitAsync(cts.Token); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); + return null; + } + } +} From 11031836966fe8548c146fb12b9e132a11178f3a Mon Sep 17 00:00:00 2001 From: eanzhao Date: Thu, 9 Apr 2026 15:55:57 +0800 Subject: [PATCH 08/21] feat(projection): introduce projection system for current-state read models - Add new projection library (Aevatar.Studio.Projection) for current-state read models - Replace per-actor read model GAgents with centralized projection system - Implement UserConfig projection with query port for pure-read operations - Remove legacy ScriptStorage and WorkflowStorage GAgents and ports - Clean up obsolete read model events from all GAgent protobuf definitions - Update dependency injection and solution configuration --- aevatar.slnx | 4 +- .../ChatConversationGAgent.cs | 18 +- .../ChatConversationReadModelGAgent.cs | 55 --- .../ChatHistoryIndexGAgent.cs | 16 - .../ChatHistoryIndexReadModelGAgent.cs | 55 --- .../chat_history_messages.proto | 23 - .../ConnectorCatalogGAgent.cs | 17 - .../ConnectorCatalogReadModelGAgent.cs | 55 --- .../connector_catalog_messages.proto | 11 - .../GAgentRegistryGAgent.cs | 17 - .../GAgentRegistryReadModelGAgent.cs | 55 --- .../gagent_registry_messages.proto | 11 - .../RoleCatalogGAgent.cs | 18 - .../RoleCatalogReadModelGAgent.cs | 55 --- .../role_catalog_messages.proto | 12 - .../Aevatar.GAgents.ScriptStorage.csproj | 24 - .../ScriptStorageGAgent.cs | 45 -- .../script_storage_messages.proto | 25 - .../StreamingProxyParticipantGAgent.cs | 18 - ...treamingProxyParticipantReadModelGAgent.cs | 56 --- ...streaming_proxy_participant_messages.proto | 11 - .../UserConfigGAgent.cs | 15 - .../UserConfigReadModelGAgent.cs | 55 --- .../user_config_messages.proto | 11 - .../UserMemoryGAgent.cs | 16 - .../UserMemoryReadModelGAgent.cs | 55 --- .../user_memory_messages.proto | 12 - .../Aevatar.GAgents.WorkflowStorage.csproj | 24 - .../WorkflowStorageGAgent.cs | 49 -- .../workflow_storage_messages.proto | 31 -- .../ActorBackedChatHistoryStore.cs | 41 +- .../ActorBackedConnectorCatalogStore.cs | 28 +- .../ActorBackedGAgentActorStore.cs | 26 +- .../ActorBackedRoleCatalogStore.cs | 28 +- .../ActorBackedScriptStoragePort.cs | 59 --- ...torBackedStreamingProxyParticipantStore.cs | 24 +- .../ActorBacked/ActorBackedUserConfigStore.cs | 26 +- .../ActorBacked/ActorBackedUserMemoryStore.cs | 28 +- .../ActorBackedWorkflowStoragePort.cs | 57 --- .../ActorBacked/ActorCommandDispatcher.cs | 21 +- .../ActorBacked/ReadModelSnapshotReader.cs | 79 ---- .../Aevatar.Studio.Infrastructure.csproj | 2 - .../ServiceCollectionExtensions.cs | 2 - .../Aevatar.Studio.Projection.csproj | 31 ++ .../ServiceCollectionExtensions.cs | 55 +++ ...figCurrentStateDocumentMetadataProvider.cs | 17 + .../StudioMaterializationContext.cs | 14 + .../StudioMaterializationRuntimeLease.cs | 17 + .../UserConfigCurrentStateProjector.cs | 71 +++ .../QueryPorts/IUserConfigQueryPort.cs | 12 + .../ProjectionUserConfigQueryPort.cs | 60 +++ .../UserConfigCurrentStateDocument.Partial.cs | 18 + .../studio_projection_readmodels.proto | 23 + .../ActorBackedStoreAdapterTests.cs | 431 ++++-------------- 54 files changed, 481 insertions(+), 1558 deletions(-) delete mode 100644 agents/Aevatar.GAgents.ChatHistory/ChatConversationReadModelGAgent.cs delete mode 100644 agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexReadModelGAgent.cs delete mode 100644 agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogReadModelGAgent.cs delete mode 100644 agents/Aevatar.GAgents.Registry/GAgentRegistryReadModelGAgent.cs delete mode 100644 agents/Aevatar.GAgents.RoleCatalog/RoleCatalogReadModelGAgent.cs delete mode 100644 agents/Aevatar.GAgents.ScriptStorage/Aevatar.GAgents.ScriptStorage.csproj delete mode 100644 agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs delete mode 100644 agents/Aevatar.GAgents.ScriptStorage/script_storage_messages.proto delete mode 100644 agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantReadModelGAgent.cs delete mode 100644 agents/Aevatar.GAgents.UserConfig/UserConfigReadModelGAgent.cs delete mode 100644 agents/Aevatar.GAgents.UserMemory/UserMemoryReadModelGAgent.cs delete mode 100644 agents/Aevatar.GAgents.WorkflowStorage/Aevatar.GAgents.WorkflowStorage.csproj delete mode 100644 agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs delete mode 100644 agents/Aevatar.GAgents.WorkflowStorage/workflow_storage_messages.proto delete mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ReadModelSnapshotReader.cs create mode 100644 src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj create mode 100644 src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Aevatar.Studio.Projection/Metadata/UserConfigCurrentStateDocumentMetadataProvider.cs create mode 100644 src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationContext.cs create mode 100644 src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationRuntimeLease.cs create mode 100644 src/Aevatar.Studio.Projection/Projectors/UserConfigCurrentStateProjector.cs create mode 100644 src/Aevatar.Studio.Projection/QueryPorts/IUserConfigQueryPort.cs create mode 100644 src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/UserConfigCurrentStateDocument.Partial.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto diff --git a/aevatar.slnx b/aevatar.slnx index defcc634..51c13ab5 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -5,8 +5,6 @@ - - @@ -30,7 +28,6 @@ - @@ -103,6 +100,7 @@ + diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs index c650c228..31ba900c 100644 --- a/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs +++ b/agents/Aevatar.GAgents.ChatHistory/ChatConversationGAgent.cs @@ -11,10 +11,7 @@ namespace Aevatar.GAgents.ChatHistory; /// Per-conversation actor that holds all messages for a single conversation. /// Actor ID: chat-{scopeId}-{conversationId}. /// -/// After each state change, pushes the current state to the paired -/// via SendToAsync. -/// -/// When messages are replaced or the conversation is deleted, also forwards +/// When messages are replaced or the conversation is deleted, forwards /// the change to the via SendToAsync, /// ensuring transactional consistency between conversation and index actors. /// @@ -33,7 +30,6 @@ public async Task HandleMessagesReplaced(MessagesReplacedEvent evt) var trimmed = TrimMessages(evt); await PersistDomainEventAsync(trimmed); - await PushToReadModelAsync(); // Forward index upsert to the index actor if (!string.IsNullOrWhiteSpace(evt.ScopeId)) @@ -60,7 +56,6 @@ public async Task HandleConversationDeleted(ConversationDeletedEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); // Forward index removal to the index actor if (!string.IsNullOrWhiteSpace(evt.ScopeId)) @@ -74,7 +69,6 @@ public async Task HandleConversationDeleted(ConversationDeletedEvent evt) protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PushToReadModelAsync(); } protected override ChatConversationState TransitionState( @@ -117,16 +111,6 @@ private static ChatConversationState ApplyConversationDeleted( return new ChatConversationState(); } - private async Task PushToReadModelAsync() - { - var readModelActorId = Id + "-readmodel"; - var runtime = Services.GetRequiredService(); - if (await runtime.GetAsync(readModelActorId) is null) - await runtime.CreateAsync(readModelActorId); - var update = new ChatConversationReadModelUpdateEvent { Snapshot = State.Clone() }; - await SendToAsync(readModelActorId, update); - } - private async Task EnsureIndexActorAsync(string indexActorId) { var runtime = Services.GetRequiredService(); diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatConversationReadModelGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatConversationReadModelGAgent.cs deleted file mode 100644 index 535317a7..00000000 --- a/agents/Aevatar.GAgents.ChatHistory/ChatConversationReadModelGAgent.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.ChatHistory; - -/// -/// Persistent readmodel actor for a single conversation. -/// Receives state snapshots from via -/// (SendToAsync) and persists them. -/// -/// Actor ID convention: {writeActorId}-readmodel. -/// -/// On activation and after each update, publishes -/// so per-request subscribers -/// (ActorBackedStore) can receive the current projected state. -/// -public sealed class ChatConversationReadModelGAgent : GAgentBase -{ - [EventHandler(EndpointName = "updateReadModel")] - public async Task HandleReadModelUpdate(ChatConversationReadModelUpdateEvent evt) - { - await PersistDomainEventAsync(evt); - await PublishSnapshotAsync(); - } - - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishSnapshotAsync(); - } - - protected override ChatConversationState TransitionState( - ChatConversationState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyUpdate) - .OrCurrent(); - } - - private static ChatConversationState ApplyUpdate( - ChatConversationState _, ChatConversationReadModelUpdateEvent evt) - { - return evt.Snapshot?.Clone() ?? new ChatConversationState(); - } - - private async Task PublishSnapshotAsync() - { - var snapshot = new ChatConversationStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } -} diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs index 3cf94e69..5ce65866 100644 --- a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs +++ b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexGAgent.cs @@ -3,16 +3,12 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; -using Microsoft.Extensions.DependencyInjection; namespace Aevatar.GAgents.ChatHistory; /// /// Per-user index actor that holds conversation list and metadata. /// Actor ID: chat-index-{scopeId}. -/// -/// After each state change, pushes the current state to the paired -/// via SendToAsync. /// public sealed class ChatHistoryIndexGAgent : GAgentBase { @@ -23,7 +19,6 @@ public async Task HandleConversationUpserted(ConversationUpsertedEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } [EventHandler(EndpointName = "removeConversation")] @@ -39,13 +34,11 @@ public async Task HandleConversationRemoved(ConversationRemovedEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PushToReadModelAsync(); } protected override ChatHistoryIndexState TransitionState( @@ -85,13 +78,4 @@ private static ChatHistoryIndexState ApplyConversationRemoved( return next; } - private async Task PushToReadModelAsync() - { - var readModelActorId = Id + "-readmodel"; - var runtime = Services.GetRequiredService(); - if (await runtime.GetAsync(readModelActorId) is null) - await runtime.CreateAsync(readModelActorId); - var update = new ChatHistoryIndexReadModelUpdateEvent { Snapshot = State.Clone() }; - await SendToAsync(readModelActorId, update); - } } diff --git a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexReadModelGAgent.cs b/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexReadModelGAgent.cs deleted file mode 100644 index 17df1972..00000000 --- a/agents/Aevatar.GAgents.ChatHistory/ChatHistoryIndexReadModelGAgent.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.ChatHistory; - -/// -/// Persistent readmodel actor for the chat history index. -/// Receives state snapshots from via -/// (SendToAsync) and persists them. -/// -/// Actor ID convention: {writeActorId}-readmodel. -/// -/// On activation and after each update, publishes -/// so per-request subscribers -/// (ActorBackedStore) can receive the current projected state. -/// -public sealed class ChatHistoryIndexReadModelGAgent : GAgentBase -{ - [EventHandler(EndpointName = "updateReadModel")] - public async Task HandleReadModelUpdate(ChatHistoryIndexReadModelUpdateEvent evt) - { - await PersistDomainEventAsync(evt); - await PublishSnapshotAsync(); - } - - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishSnapshotAsync(); - } - - protected override ChatHistoryIndexState TransitionState( - ChatHistoryIndexState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyUpdate) - .OrCurrent(); - } - - private static ChatHistoryIndexState ApplyUpdate( - ChatHistoryIndexState _, ChatHistoryIndexReadModelUpdateEvent evt) - { - return evt.Snapshot?.Clone() ?? new ChatHistoryIndexState(); - } - - private async Task PublishSnapshotAsync() - { - var snapshot = new ChatHistoryIndexStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } -} diff --git a/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto index 3f7cbe2c..6cbca659 100644 --- a/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto +++ b/agents/Aevatar.GAgents.ChatHistory/chat_history_messages.proto @@ -46,12 +46,6 @@ message ConversationDeletedEvent { string scope_id = 2; } -// ─── ChatConversationGAgent Snapshot ─── - -message ChatConversationStateSnapshotEvent { - ChatConversationState snapshot = 1; -} - // ─── ChatHistoryIndexGAgent State ─── message ChatHistoryIndexState { @@ -68,20 +62,3 @@ message ConversationRemovedEvent { string conversation_id = 1; } -// ─── ChatHistoryIndexGAgent Snapshot ─── - -message ChatHistoryIndexStateSnapshotEvent { - ChatHistoryIndexState snapshot = 1; -} - -// ─── ReadModel Update Events (sent via SendToAsync from write actors) ─── - -// Sent from ChatConversationGAgent to ChatConversationReadModelGAgent. -message ChatConversationReadModelUpdateEvent { - ChatConversationState snapshot = 1; -} - -// Sent from ChatHistoryIndexGAgent to ChatHistoryIndexReadModelGAgent. -message ChatHistoryIndexReadModelUpdateEvent { - ChatHistoryIndexState snapshot = 1; -} diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs index 8548df9b..7c6d8123 100644 --- a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs +++ b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogGAgent.cs @@ -3,7 +3,6 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; -using Microsoft.Extensions.DependencyInjection; namespace Aevatar.GAgents.ConnectorCatalog; @@ -13,9 +12,6 @@ namespace Aevatar.GAgents.ConnectorCatalog; /// for remote persistence concerns. /// /// Actor ID: connector-catalog (cluster-scoped singleton). -/// -/// After each state change, pushes the current state to the paired -/// via SendToAsync. /// public sealed class ConnectorCatalogGAgent : GAgentBase { @@ -23,14 +19,12 @@ public sealed class ConnectorCatalogGAgent : GAgentBase public async Task HandleCatalogSaved(ConnectorCatalogSavedEvent evt) { await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } [EventHandler(EndpointName = "saveDraft")] public async Task HandleDraftSaved(ConnectorDraftSavedEvent evt) { await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } [EventHandler(EndpointName = "deleteDraft")] @@ -41,13 +35,11 @@ public async Task HandleDraftDeleted(ConnectorDraftDeletedEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PushToReadModelAsync(); } protected override ConnectorCatalogState TransitionState( @@ -90,13 +82,4 @@ private static ConnectorCatalogState ApplyDraftDeleted( return next; } - private async Task PushToReadModelAsync() - { - var readModelActorId = Id + "-readmodel"; - var runtime = Services.GetRequiredService(); - if (await runtime.GetAsync(readModelActorId) is null) - await runtime.CreateAsync(readModelActorId); - var update = new ConnectorCatalogReadModelUpdateEvent { Snapshot = State.Clone() }; - await SendToAsync(readModelActorId, update); - } } diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogReadModelGAgent.cs b/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogReadModelGAgent.cs deleted file mode 100644 index 333311db..00000000 --- a/agents/Aevatar.GAgents.ConnectorCatalog/ConnectorCatalogReadModelGAgent.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.ConnectorCatalog; - -/// -/// Persistent readmodel actor for the connector catalog. -/// Receives state snapshots from via -/// (SendToAsync) and persists them. -/// -/// Actor ID convention: {writeActorId}-readmodel. -/// -/// On activation and after each update, publishes -/// so per-request subscribers -/// (ActorBackedStore) can receive the current projected state. -/// -public sealed class ConnectorCatalogReadModelGAgent : GAgentBase -{ - [EventHandler(EndpointName = "updateReadModel")] - public async Task HandleReadModelUpdate(ConnectorCatalogReadModelUpdateEvent evt) - { - await PersistDomainEventAsync(evt); - await PublishSnapshotAsync(); - } - - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishSnapshotAsync(); - } - - protected override ConnectorCatalogState TransitionState( - ConnectorCatalogState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyUpdate) - .OrCurrent(); - } - - private static ConnectorCatalogState ApplyUpdate( - ConnectorCatalogState _, ConnectorCatalogReadModelUpdateEvent evt) - { - return evt.Snapshot?.Clone() ?? new ConnectorCatalogState(); - } - - private async Task PublishSnapshotAsync() - { - var snapshot = new ConnectorCatalogStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } -} diff --git a/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto index 7a8f6772..6fa2e3b3 100644 --- a/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto +++ b/agents/Aevatar.GAgents.ConnectorCatalog/connector_catalog_messages.proto @@ -82,14 +82,3 @@ message ConnectorDraftSavedEvent { message ConnectorDraftDeletedEvent { } -// ─── Readmodel ─── - -// Published by ReadModel GAgent so subscribers can observe the current projected state. -message ConnectorCatalogStateSnapshotEvent { - ConnectorCatalogState snapshot = 1; -} - -// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. -message ConnectorCatalogReadModelUpdateEvent { - ConnectorCatalogState snapshot = 1; -} diff --git a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs index 615b590b..3f9919e6 100644 --- a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs +++ b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs @@ -3,8 +3,6 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.Registry; @@ -13,9 +11,6 @@ namespace Aevatar.GAgents.Registry; /// Replaces the chrono-storage backed ChronoStorageGAgentActorStore. /// /// Actor ID: gagent-registry-{scopeId} (per-scope). -/// -/// After each state change, pushes the current state to the paired -/// via SendToAsync. /// public sealed class GAgentRegistryGAgent : GAgentBase { @@ -32,7 +27,6 @@ public async Task HandleActorRegistered(ActorRegisteredEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } [EventHandler(EndpointName = "unregisterActor")] @@ -48,13 +42,11 @@ public async Task HandleActorUnregistered(ActorUnregisteredEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PushToReadModelAsync(); } protected override GAgentRegistryState TransitionState( @@ -104,13 +96,4 @@ private static GAgentRegistryState ApplyUnregistered( return next; } - private async Task PushToReadModelAsync() - { - var readModelActorId = Id + "-readmodel"; - var runtime = Services.GetRequiredService(); - if (await runtime.GetAsync(readModelActorId) is null) - await runtime.CreateAsync(readModelActorId); - var update = new GAgentRegistryReadModelUpdateEvent { Snapshot = State.Clone() }; - await SendToAsync(readModelActorId, update); - } } diff --git a/agents/Aevatar.GAgents.Registry/GAgentRegistryReadModelGAgent.cs b/agents/Aevatar.GAgents.Registry/GAgentRegistryReadModelGAgent.cs deleted file mode 100644 index a3e6a591..00000000 --- a/agents/Aevatar.GAgents.Registry/GAgentRegistryReadModelGAgent.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.Registry; - -/// -/// Persistent readmodel actor for the GAgent registry. -/// Receives state snapshots from via -/// (SendToAsync) and persists them. -/// -/// Actor ID convention: {writeActorId}-readmodel. -/// -/// On activation and after each update, publishes -/// so per-request subscribers -/// (ActorBackedStore) can receive the current projected state. -/// -public sealed class GAgentRegistryReadModelGAgent : GAgentBase -{ - [EventHandler(EndpointName = "updateReadModel")] - public async Task HandleReadModelUpdate(GAgentRegistryReadModelUpdateEvent evt) - { - await PersistDomainEventAsync(evt); - await PublishSnapshotAsync(); - } - - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishSnapshotAsync(); - } - - protected override GAgentRegistryState TransitionState( - GAgentRegistryState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyUpdate) - .OrCurrent(); - } - - private static GAgentRegistryState ApplyUpdate( - GAgentRegistryState _, GAgentRegistryReadModelUpdateEvent evt) - { - return evt.Snapshot?.Clone() ?? new GAgentRegistryState(); - } - - private async Task PublishSnapshotAsync() - { - var snapshot = new GAgentRegistryStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } -} diff --git a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto index 28381e77..619ffb2b 100644 --- a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto +++ b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto @@ -25,14 +25,3 @@ message ActorUnregisteredEvent { string actor_id = 2; } -// ─── Readmodel ─── - -// Published by ReadModel GAgent so subscribers can observe the current projected state. -message GAgentRegistryStateSnapshotEvent { - GAgentRegistryState snapshot = 1; -} - -// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. -message GAgentRegistryReadModelUpdateEvent { - GAgentRegistryState snapshot = 1; -} diff --git a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs index a65ca381..f8eda8de 100644 --- a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs +++ b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogGAgent.cs @@ -3,8 +3,6 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.RoleCatalog; @@ -14,9 +12,6 @@ namespace Aevatar.GAgents.RoleCatalog; /// for remote persistence concerns. /// /// Actor ID: role-catalog (cluster-scoped singleton). -/// -/// After each state change, pushes the current state to the paired -/// via SendToAsync. /// public sealed class RoleCatalogGAgent : GAgentBase { @@ -24,14 +19,12 @@ public sealed class RoleCatalogGAgent : GAgentBase public async Task HandleCatalogSaved(RoleCatalogSavedEvent evt) { await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } [EventHandler(EndpointName = "saveDraft")] public async Task HandleDraftSaved(RoleDraftSavedEvent evt) { await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } [EventHandler(EndpointName = "deleteDraft")] @@ -41,13 +34,11 @@ public async Task HandleDraftDeleted(RoleDraftDeletedEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PushToReadModelAsync(); } protected override RoleCatalogState TransitionState( @@ -90,13 +81,4 @@ private static RoleCatalogState ApplyDraftDeleted( return next; } - private async Task PushToReadModelAsync() - { - var readModelActorId = Id + "-readmodel"; - var runtime = Services.GetRequiredService(); - if (await runtime.GetAsync(readModelActorId) is null) - await runtime.CreateAsync(readModelActorId); - var update = new RoleCatalogReadModelUpdateEvent { Snapshot = State.Clone() }; - await SendToAsync(readModelActorId, update); - } } diff --git a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogReadModelGAgent.cs b/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogReadModelGAgent.cs deleted file mode 100644 index d1ebc48d..00000000 --- a/agents/Aevatar.GAgents.RoleCatalog/RoleCatalogReadModelGAgent.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.RoleCatalog; - -/// -/// Persistent readmodel actor for the role catalog. -/// Receives state snapshots from via -/// (SendToAsync) and persists them. -/// -/// Actor ID convention: {writeActorId}-readmodel. -/// -/// On activation and after each update, publishes -/// so per-request subscribers -/// (ActorBackedStore) can receive the current projected state. -/// -public sealed class RoleCatalogReadModelGAgent : GAgentBase -{ - [EventHandler(EndpointName = "updateReadModel")] - public async Task HandleReadModelUpdate(RoleCatalogReadModelUpdateEvent evt) - { - await PersistDomainEventAsync(evt); - await PublishSnapshotAsync(); - } - - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishSnapshotAsync(); - } - - protected override RoleCatalogState TransitionState( - RoleCatalogState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyUpdate) - .OrCurrent(); - } - - private static RoleCatalogState ApplyUpdate( - RoleCatalogState _, RoleCatalogReadModelUpdateEvent evt) - { - return evt.Snapshot?.Clone() ?? new RoleCatalogState(); - } - - private async Task PublishSnapshotAsync() - { - var snapshot = new RoleCatalogStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } -} diff --git a/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto b/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto index 5cdb7d58..c3b995e1 100644 --- a/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto +++ b/agents/Aevatar.GAgents.RoleCatalog/role_catalog_messages.proto @@ -38,15 +38,3 @@ message RoleDraftSavedEvent { message RoleDraftDeletedEvent {} -// ─── Readmodel ─── - -// Published by the GAgent after each state change so subscribers can -// maintain an up-to-date readmodel without reading write-model state. -message RoleCatalogStateSnapshotEvent { - RoleCatalogState snapshot = 1; -} - -// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. -message RoleCatalogReadModelUpdateEvent { - RoleCatalogState snapshot = 1; -} diff --git a/agents/Aevatar.GAgents.ScriptStorage/Aevatar.GAgents.ScriptStorage.csproj b/agents/Aevatar.GAgents.ScriptStorage/Aevatar.GAgents.ScriptStorage.csproj deleted file mode 100644 index 17983f13..00000000 --- a/agents/Aevatar.GAgents.ScriptStorage/Aevatar.GAgents.ScriptStorage.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - net10.0 - enable - enable - Aevatar.GAgents.ScriptStorage - Aevatar.GAgents.ScriptStorage - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - diff --git a/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs b/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs deleted file mode 100644 index 87814dc3..00000000 --- a/agents/Aevatar.GAgents.ScriptStorage/ScriptStorageGAgent.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.ScriptStorage; - -/// -/// Singleton actor that stores uploaded script source artifacts. -/// Replaces the chrono-storage backed ChronoStorageScriptStoragePort. -/// -/// Actor ID: script-storage (cluster-scoped singleton). -/// -/// Write-only: no readmodel needed since the only port method is -/// UploadScriptAsync. -/// -public sealed class ScriptStorageGAgent : GAgentBase -{ - [EventHandler(EndpointName = "uploadScript")] - public async Task HandleScriptUploaded(ScriptUploadedEvent evt) - { - if (string.IsNullOrWhiteSpace(evt.ScriptId) || string.IsNullOrWhiteSpace(evt.SourceText)) - return; - - await PersistDomainEventAsync(evt); - } - - protected override ScriptStorageState TransitionState( - ScriptStorageState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyScriptUploaded) - .OrCurrent(); - } - - private static ScriptStorageState ApplyScriptUploaded( - ScriptStorageState state, ScriptUploadedEvent evt) - { - var next = state.Clone(); - next.Scripts[evt.ScriptId] = evt.SourceText; - return next; - } -} diff --git a/agents/Aevatar.GAgents.ScriptStorage/script_storage_messages.proto b/agents/Aevatar.GAgents.ScriptStorage/script_storage_messages.proto deleted file mode 100644 index 3b5416c8..00000000 --- a/agents/Aevatar.GAgents.ScriptStorage/script_storage_messages.proto +++ /dev/null @@ -1,25 +0,0 @@ -syntax = "proto3"; -package aevatar.gagents.script_storage; -option csharp_namespace = "Aevatar.GAgents.ScriptStorage"; - -// ─── State ─── - -message ScriptStorageState { - // scriptId → sourceText - map scripts = 1; -} - -// ─── Events ─── - -message ScriptUploadedEvent { - string script_id = 1; - string source_text = 2; -} - -// ─── Readmodel ─── - -// Published by the GAgent after each state change so subscribers can -// maintain an up-to-date readmodel without reading write-model state. -message ScriptStorageStateSnapshotEvent { - ScriptStorageState snapshot = 1; -} diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs index 1ddb8756..735fe628 100644 --- a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantGAgent.cs @@ -3,9 +3,6 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Aevatar.GAgents.StreamingProxyParticipant; @@ -14,9 +11,6 @@ namespace Aevatar.GAgents.StreamingProxyParticipant; /// Replaces the chrono-storage backed ChronoStorageStreamingProxyParticipantStore. /// /// Actor ID: streaming-proxy-participants (cluster-scoped singleton). -/// -/// After each state change, pushes the current state to the paired -/// via SendToAsync. /// public sealed class StreamingProxyParticipantGAgent : GAgentBase @@ -28,7 +22,6 @@ public async Task HandleParticipantAdded(ParticipantAddedEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } [EventHandler(EndpointName = "removeRoomParticipants")] @@ -42,13 +35,11 @@ public async Task HandleRoomParticipantsRemoved(RoomParticipantsRemovedEvent evt return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PushToReadModelAsync(); } protected override StreamingProxyParticipantGAgentState TransitionState( @@ -96,13 +87,4 @@ private static StreamingProxyParticipantGAgentState ApplyRoomRemoved( return next; } - private async Task PushToReadModelAsync() - { - var readModelActorId = Id + "-readmodel"; - var runtime = Services.GetRequiredService(); - if (await runtime.GetAsync(readModelActorId) is null) - await runtime.CreateAsync(readModelActorId); - var update = new StreamingProxyParticipantReadModelUpdateEvent { Snapshot = State.Clone() }; - await SendToAsync(readModelActorId, update); - } } diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantReadModelGAgent.cs b/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantReadModelGAgent.cs deleted file mode 100644 index 075d52aa..00000000 --- a/agents/Aevatar.GAgents.StreamingProxyParticipant/StreamingProxyParticipantReadModelGAgent.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.StreamingProxyParticipant; - -/// -/// Persistent readmodel actor for streaming proxy participants. -/// Receives state snapshots from via -/// (SendToAsync) and persists them. -/// -/// Actor ID convention: {writeActorId}-readmodel. -/// -/// On activation and after each update, publishes -/// so per-request subscribers -/// (ActorBackedStore) can receive the current projected state. -/// -public sealed class StreamingProxyParticipantReadModelGAgent - : GAgentBase -{ - [EventHandler(EndpointName = "updateReadModel")] - public async Task HandleReadModelUpdate(StreamingProxyParticipantReadModelUpdateEvent evt) - { - await PersistDomainEventAsync(evt); - await PublishSnapshotAsync(); - } - - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishSnapshotAsync(); - } - - protected override StreamingProxyParticipantGAgentState TransitionState( - StreamingProxyParticipantGAgentState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyUpdate) - .OrCurrent(); - } - - private static StreamingProxyParticipantGAgentState ApplyUpdate( - StreamingProxyParticipantGAgentState _, StreamingProxyParticipantReadModelUpdateEvent evt) - { - return evt.Snapshot?.Clone() ?? new StreamingProxyParticipantGAgentState(); - } - - private async Task PublishSnapshotAsync() - { - var snapshot = new StreamingProxyParticipantStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } -} diff --git a/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto b/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto index ff8d0bfa..c02210eb 100644 --- a/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto +++ b/agents/Aevatar.GAgents.StreamingProxyParticipant/streaming_proxy_participant_messages.proto @@ -34,14 +34,3 @@ message RoomParticipantsRemovedEvent { string room_id = 1; } -// ─── Readmodel ─── - -// Published by ReadModel GAgent so subscribers can observe the current projected state. -message StreamingProxyParticipantStateSnapshotEvent { - StreamingProxyParticipantGAgentState snapshot = 1; -} - -// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. -message StreamingProxyParticipantReadModelUpdateEvent { - StreamingProxyParticipantGAgentState snapshot = 1; -} diff --git a/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs index bb4f362d..05053036 100644 --- a/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs +++ b/agents/Aevatar.GAgents.UserConfig/UserConfigGAgent.cs @@ -3,7 +3,6 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; -using Microsoft.Extensions.DependencyInjection; namespace Aevatar.GAgents.UserConfig; @@ -12,9 +11,6 @@ namespace Aevatar.GAgents.UserConfig; /// Replaces the chrono-storage backed ChronoStorageUserConfigStore. /// /// Actor ID: user-config-{scopeId} (per-scope). -/// -/// After each state change, pushes the current state to the paired -/// via SendToAsync. /// public sealed class UserConfigGAgent : GAgentBase { @@ -22,13 +18,11 @@ public sealed class UserConfigGAgent : GAgentBase public async Task HandleConfigUpdated(UserConfigUpdatedEvent evt) { await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PushToReadModelAsync(); } protected override UserConfigGAgentState TransitionState( @@ -54,13 +48,4 @@ private static UserConfigGAgentState ApplyConfigUpdated( }; } - private async Task PushToReadModelAsync() - { - var readModelActorId = Id + "-readmodel"; - var runtime = Services.GetRequiredService(); - if (await runtime.GetAsync(readModelActorId) is null) - await runtime.CreateAsync(readModelActorId); - var update = new UserConfigReadModelUpdateEvent { Snapshot = State.Clone() }; - await SendToAsync(readModelActorId, update); - } } diff --git a/agents/Aevatar.GAgents.UserConfig/UserConfigReadModelGAgent.cs b/agents/Aevatar.GAgents.UserConfig/UserConfigReadModelGAgent.cs deleted file mode 100644 index 1a6f1ee6..00000000 --- a/agents/Aevatar.GAgents.UserConfig/UserConfigReadModelGAgent.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.UserConfig; - -/// -/// Persistent readmodel actor for user configuration. -/// Receives state snapshots from via -/// (SendToAsync) and persists them. -/// -/// Actor ID convention: {writeActorId}-readmodel. -/// -/// On activation and after each update, publishes -/// so per-request subscribers -/// (ActorBackedStore) can receive the current projected state. -/// -public sealed class UserConfigReadModelGAgent : GAgentBase -{ - [EventHandler(EndpointName = "updateReadModel")] - public async Task HandleReadModelUpdate(UserConfigReadModelUpdateEvent evt) - { - await PersistDomainEventAsync(evt); - await PublishSnapshotAsync(); - } - - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishSnapshotAsync(); - } - - protected override UserConfigGAgentState TransitionState( - UserConfigGAgentState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyUpdate) - .OrCurrent(); - } - - private static UserConfigGAgentState ApplyUpdate( - UserConfigGAgentState _, UserConfigReadModelUpdateEvent evt) - { - return evt.Snapshot?.Clone() ?? new UserConfigGAgentState(); - } - - private async Task PublishSnapshotAsync() - { - var snapshot = new UserConfigStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } -} diff --git a/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto b/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto index 89bd6812..ad0676e7 100644 --- a/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto +++ b/agents/Aevatar.GAgents.UserConfig/user_config_messages.proto @@ -24,14 +24,3 @@ message UserConfigUpdatedEvent { int32 max_tool_rounds = 6; } -// ─── Readmodel ─── - -// Published by ReadModel GAgent so subscribers can observe the current projected state. -message UserConfigStateSnapshotEvent { - UserConfigGAgentState snapshot = 1; -} - -// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. -message UserConfigReadModelUpdateEvent { - UserConfigGAgentState snapshot = 1; -} diff --git a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs index fe3ef519..c3975cd7 100644 --- a/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs +++ b/agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs @@ -3,7 +3,6 @@ using Aevatar.Foundation.Core; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; -using Microsoft.Extensions.DependencyInjection; namespace Aevatar.GAgents.UserMemory; @@ -17,8 +16,6 @@ namespace Aevatar.GAgents.UserMemory; /// evict the oldest entry in the same category first. /// 2. If no same-category entry remains, evict the globally oldest entry. /// -/// After each state change, pushes the current state to the paired -/// via SendToAsync. /// public sealed class UserMemoryGAgent : GAgentBase { @@ -37,7 +34,6 @@ public async Task HandleMemoryEntryAdded(MemoryEntryAddedEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } [EventHandler(EndpointName = "removeMemoryEntry")] @@ -51,7 +47,6 @@ public async Task HandleMemoryEntryRemoved(MemoryEntryRemovedEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } [EventHandler(EndpointName = "clearMemoryEntries")] @@ -61,13 +56,11 @@ public async Task HandleMemoryEntriesCleared(MemoryEntriesClearedEvent evt) return; await PersistDomainEventAsync(evt); - await PushToReadModelAsync(); } protected override async Task OnActivateAsync(CancellationToken ct) { await base.OnActivateAsync(ct); - await PushToReadModelAsync(); } protected override UserMemoryState TransitionState( @@ -140,13 +133,4 @@ private static UserMemoryState ApplyCleared( return next; } - private async Task PushToReadModelAsync() - { - var readModelActorId = Id + "-readmodel"; - var runtime = Services.GetRequiredService(); - if (await runtime.GetAsync(readModelActorId) is null) - await runtime.CreateAsync(readModelActorId); - var update = new UserMemoryReadModelUpdateEvent { Snapshot = State.Clone() }; - await SendToAsync(readModelActorId, update); - } } diff --git a/agents/Aevatar.GAgents.UserMemory/UserMemoryReadModelGAgent.cs b/agents/Aevatar.GAgents.UserMemory/UserMemoryReadModelGAgent.cs deleted file mode 100644 index 24d6197e..00000000 --- a/agents/Aevatar.GAgents.UserMemory/UserMemoryReadModelGAgent.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.UserMemory; - -/// -/// Persistent readmodel actor for user memory. -/// Receives state snapshots from via -/// (SendToAsync) and persists them. -/// -/// Actor ID convention: {writeActorId}-readmodel. -/// -/// On activation and after each update, publishes -/// so per-request subscribers -/// (ActorBackedStore) can receive the current projected state. -/// -public sealed class UserMemoryReadModelGAgent : GAgentBase -{ - [EventHandler(EndpointName = "updateReadModel")] - public async Task HandleReadModelUpdate(UserMemoryReadModelUpdateEvent evt) - { - await PersistDomainEventAsync(evt); - await PublishSnapshotAsync(); - } - - protected override async Task OnActivateAsync(CancellationToken ct) - { - await base.OnActivateAsync(ct); - await PublishSnapshotAsync(); - } - - protected override UserMemoryState TransitionState( - UserMemoryState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyUpdate) - .OrCurrent(); - } - - private static UserMemoryState ApplyUpdate( - UserMemoryState _, UserMemoryReadModelUpdateEvent evt) - { - return evt.Snapshot?.Clone() ?? new UserMemoryState(); - } - - private async Task PublishSnapshotAsync() - { - var snapshot = new UserMemoryStateSnapshotEvent { Snapshot = State.Clone() }; - await PublishAsync(snapshot, TopologyAudience.Parent); - } -} diff --git a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto index 2b03d820..8b71ffc5 100644 --- a/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto +++ b/agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto @@ -30,15 +30,3 @@ message MemoryEntryRemovedEvent { message MemoryEntriesClearedEvent { } -// ─── Readmodel ─── - -// Published by the GAgent after each state change so subscribers can -// maintain an up-to-date readmodel without reading write-model state. -message UserMemoryStateSnapshotEvent { - UserMemoryState snapshot = 1; -} - -// Sent from WriteGAgent to ReadModelGAgent via SendToAsync to push state updates. -message UserMemoryReadModelUpdateEvent { - UserMemoryState snapshot = 1; -} diff --git a/agents/Aevatar.GAgents.WorkflowStorage/Aevatar.GAgents.WorkflowStorage.csproj b/agents/Aevatar.GAgents.WorkflowStorage/Aevatar.GAgents.WorkflowStorage.csproj deleted file mode 100644 index 88358f81..00000000 --- a/agents/Aevatar.GAgents.WorkflowStorage/Aevatar.GAgents.WorkflowStorage.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - net10.0 - enable - enable - Aevatar.GAgents.WorkflowStorage - Aevatar.GAgents.WorkflowStorage - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - diff --git a/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs b/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs deleted file mode 100644 index c5934253..00000000 --- a/agents/Aevatar.GAgents.WorkflowStorage/WorkflowStorageGAgent.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Attributes; -using Aevatar.Foundation.Core; -using Aevatar.Foundation.Core.EventSourcing; -using Google.Protobuf; - -namespace Aevatar.GAgents.WorkflowStorage; - -/// -/// Singleton actor that stores workflow YAML artifacts keyed by workflow ID. -/// Replaces the chrono-storage backed ChronoStorageWorkflowStoragePort. -/// -/// Actor ID: workflow-storage (cluster-scoped singleton). -/// -/// Write-only: no readmodel needed since the only port method is -/// UploadWorkflowYamlAsync. -/// -public sealed class WorkflowStorageGAgent : GAgentBase -{ - [EventHandler(EndpointName = "uploadWorkflowYaml")] - public async Task HandleWorkflowYamlUploaded(WorkflowYamlUploadedEvent evt) - { - if (string.IsNullOrWhiteSpace(evt.WorkflowId) || string.IsNullOrWhiteSpace(evt.Yaml)) - return; - - await PersistDomainEventAsync(evt); - } - - protected override WorkflowStorageState TransitionState( - WorkflowStorageState current, IMessage evt) - { - return StateTransitionMatcher - .Match(current, evt) - .On(ApplyYamlUploaded) - .OrCurrent(); - } - - private static WorkflowStorageState ApplyYamlUploaded( - WorkflowStorageState state, WorkflowYamlUploadedEvent evt) - { - var next = state.Clone(); - next.Workflows[evt.WorkflowId] = new WorkflowEntry - { - WorkflowName = evt.WorkflowName, - Yaml = evt.Yaml, - }; - return next; - } -} diff --git a/agents/Aevatar.GAgents.WorkflowStorage/workflow_storage_messages.proto b/agents/Aevatar.GAgents.WorkflowStorage/workflow_storage_messages.proto deleted file mode 100644 index 9101249d..00000000 --- a/agents/Aevatar.GAgents.WorkflowStorage/workflow_storage_messages.proto +++ /dev/null @@ -1,31 +0,0 @@ -syntax = "proto3"; -package aevatar.gagents.workflow_storage; -option csharp_namespace = "Aevatar.GAgents.WorkflowStorage"; - -// ─── State ─── - -message WorkflowEntry { - string workflow_name = 1; - string yaml = 2; -} - -message WorkflowStorageState { - // workflowId → (name, yaml) - map workflows = 1; -} - -// ─── Events ─── - -message WorkflowYamlUploadedEvent { - string workflow_id = 1; - string workflow_name = 2; - string yaml = 3; -} - -// ─── Readmodel ─── - -// Published by the GAgent after each state change so subscribers can -// maintain an up-to-date readmodel without reading write-model state. -message WorkflowStorageStateSnapshotEvent { - WorkflowStorageState snapshot = 1; -} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs index 0683aa5e..4980cfbd 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs @@ -1,5 +1,4 @@ using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.ChatHistory; using Aevatar.Studio.Application.Studio.Abstractions; using Microsoft.Extensions.Logging; @@ -8,30 +7,26 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Completely stateless: no fields hold snapshot or subscription state. -/// Reads use per-request temporary subscription to the ReadModel GAgents. +/// Reads the write actors' state directly. /// Writes send commands only to /// (index updates are handled internally by the conversation actor). /// internal sealed class ActorBackedChatHistoryStore : IChatHistoryStore { private readonly IActorRuntime _runtime; - private readonly IActorEventSubscriptionProvider _subscriptions; private readonly ILogger _logger; public ActorBackedChatHistoryStore( IActorRuntime runtime, - IActorEventSubscriptionProvider subscriptions, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetIndexAsync(string scopeId, CancellationToken ct = default) { - var state = await ReadIndexFromReadModelAsync(scopeId, ct); + var state = await ReadIndexActorStateAsync(scopeId, ct); if (state is null) return new ChatHistoryIndex([]); @@ -46,7 +41,7 @@ public async Task GetIndexAsync(string scopeId, CancellationTo public async Task> GetMessagesAsync( string scopeId, string conversationId, CancellationToken ct = default) { - var state = await ReadConversationFromReadModelAsync(scopeId, conversationId, ct); + var state = await ReadConversationActorStateAsync(scopeId, conversationId, ct); if (state is null || state.Messages.Count == 0) return []; @@ -83,34 +78,22 @@ public async Task DeleteConversationAsync( await ActorCommandDispatcher.SendAsync(conversationActor, deleteEvt, ct); } - // ── Per-request readmodel reads (no service-level state) ─── + // ── Read write actor state directly ─── - private Task ReadIndexFromReadModelAsync( + private async Task ReadIndexActorStateAsync( string scopeId, CancellationToken ct) { - return ReadModelSnapshotReader.ReadAsync( - _subscriptions, - _runtime, - IndexActorId(scopeId) + "-readmodel", - typeof(ChatHistoryIndexReadModelGAgent), - ChatHistoryIndexStateSnapshotEvent.Descriptor, - evt => evt.Snapshot, - _logger, - ct); + var actorId = IndexActorId(scopeId); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; } - private Task ReadConversationFromReadModelAsync( + private async Task ReadConversationActorStateAsync( string scopeId, string conversationId, CancellationToken ct) { - return ReadModelSnapshotReader.ReadAsync( - _subscriptions, - _runtime, - ConversationActorId(scopeId, conversationId) + "-readmodel", - typeof(ChatConversationReadModelGAgent), - ChatConversationStateSnapshotEvent.Descriptor, - evt => evt.Snapshot, - _logger, - ct); + var actorId = ConversationActorId(scopeId, conversationId); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; } // ── Actor resolution ─────────────────────────────────────── diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs index 0c52b716..857bc490 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -1,5 +1,4 @@ using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.ConnectorCatalog; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; @@ -10,8 +9,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Completely stateless: no fields hold snapshot or subscription state. -/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Reads the write actor's state directly. /// Writes send commands to the Write GAgent. /// Local workspace operations (import, draft backup) delegate to . /// Per-scope isolation: each scope gets its own connector-catalog-{scopeId} actor. @@ -23,20 +21,17 @@ internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogStore private const string ActorFilePath = "actor://connector-catalog/connectors"; private readonly IActorRuntime _runtime; - private readonly IActorEventSubscriptionProvider _subscriptions; private readonly IAppScopeResolver _scopeResolver; private readonly IStudioWorkspaceStore _workspaceStore; private readonly ILogger _logger; public ActorBackedConnectorCatalogStore( IActorRuntime runtime, - IActorEventSubscriptionProvider subscriptions, IAppScopeResolver scopeResolver, IStudioWorkspaceStore workspaceStore, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _workspaceStore = workspaceStore ?? throw new ArgumentNullException(nameof(workspaceStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -45,7 +40,7 @@ public ActorBackedConnectorCatalogStore( public async Task GetConnectorCatalogAsync( CancellationToken cancellationToken = default) { - var state = await ReadFromReadModelAsync(cancellationToken); + var state = await ReadWriteActorStateAsync(cancellationToken); if (state is null) { return new StoredConnectorCatalog( @@ -110,7 +105,7 @@ public async Task ImportLocalCatalogAsync( public async Task GetConnectorDraftAsync( CancellationToken cancellationToken = default) { - var state = await ReadFromReadModelAsync(cancellationToken); + var state = await ReadWriteActorStateAsync(cancellationToken); var draftEntry = state?.Draft; if (draftEntry is null) { @@ -162,25 +157,18 @@ public async Task DeleteConnectorDraftAsync(CancellationToken cancellationToken await _workspaceStore.DeleteConnectorDraftAsync(cancellationToken); } - // ── Per-request readmodel read (no service-level state) ── + // ── Read write actor state directly ── - private Task ReadFromReadModelAsync(CancellationToken ct) + private async Task ReadWriteActorStateAsync(CancellationToken ct) { - return ReadModelSnapshotReader.ReadAsync( - _subscriptions, - _runtime, - ResolveReadModelActorId(), - typeof(ConnectorCatalogReadModelGAgent), - ConnectorCatalogStateSnapshotEvent.Descriptor, - evt => evt.Snapshot, - _logger, - ct); + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; } // ── Actor resolution ── private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); - private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; private async Task EnsureWriteActorAsync(CancellationToken ct) { diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs index afbbc65f..be24518b 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs @@ -1,5 +1,4 @@ using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.Registry; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; @@ -9,8 +8,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Completely stateless: no fields hold snapshot or subscription state. -/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Reads the write actor's state directly. /// Writes send commands to the Write GAgent. /// internal sealed class ActorBackedGAgentActorStore : IGAgentActorStore @@ -18,18 +16,15 @@ internal sealed class ActorBackedGAgentActorStore : IGAgentActorStore private const string WriteActorIdPrefix = "gagent-registry-"; private readonly IActorRuntime _runtime; - private readonly IActorEventSubscriptionProvider _subscriptions; private readonly IAppScopeResolver _scopeResolver; private readonly ILogger _logger; public ActorBackedGAgentActorStore( IActorRuntime runtime, - IActorEventSubscriptionProvider subscriptions, IAppScopeResolver scopeResolver, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -37,7 +32,7 @@ public ActorBackedGAgentActorStore( public async Task> GetAsync( CancellationToken cancellationToken = default) { - var state = await ReadFromReadModelAsync(cancellationToken); + var state = await ReadWriteActorStateAsync(cancellationToken); if (state is null) return []; @@ -73,25 +68,18 @@ public async Task RemoveActorAsync( }, cancellationToken); } - // ── Per-request readmodel read (no service-level state) ── + // ── Read write actor state directly ── - private Task ReadFromReadModelAsync(CancellationToken ct) + private async Task ReadWriteActorStateAsync(CancellationToken ct) { - return ReadModelSnapshotReader.ReadAsync( - _subscriptions, - _runtime, - ResolveReadModelActorId(), - typeof(GAgentRegistryReadModelGAgent), - GAgentRegistryStateSnapshotEvent.Descriptor, - evt => evt.Snapshot, - _logger, - ct); + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; } // ── Actor resolution ── private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); - private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; private async Task EnsureWriteActorAsync(CancellationToken ct) { diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs index b132c4f6..7c3aee04 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs @@ -1,5 +1,4 @@ using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.RoleCatalog; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; @@ -10,8 +9,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Completely stateless: no fields hold snapshot or subscription state. -/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Reads the write actor's state directly. /// Writes send commands to the Write GAgent. /// Local workspace operations (ImportLocalCatalogAsync) delegate to /// . @@ -24,20 +22,17 @@ internal sealed class ActorBackedRoleCatalogStore : IRoleCatalogStore private const string ActorFilePath = "actor://role-catalog/roles"; private readonly IActorRuntime _runtime; - private readonly IActorEventSubscriptionProvider _subscriptions; private readonly IAppScopeResolver _scopeResolver; private readonly IStudioWorkspaceStore _localWorkspaceStore; private readonly ILogger _logger; public ActorBackedRoleCatalogStore( IActorRuntime runtime, - IActorEventSubscriptionProvider subscriptions, IAppScopeResolver scopeResolver, IStudioWorkspaceStore localWorkspaceStore, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _localWorkspaceStore = localWorkspaceStore ?? throw new ArgumentNullException(nameof(localWorkspaceStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -45,7 +40,7 @@ public ActorBackedRoleCatalogStore( public async Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) { - var state = await ReadFromReadModelAsync(cancellationToken); + var state = await ReadWriteActorStateAsync(cancellationToken); var roles = state?.Roles .Select(ToStoredRoleDefinition) .ToList() @@ -99,7 +94,7 @@ public async Task ImportLocalCatalogAsync(CancellationToken public async Task GetRoleDraftAsync(CancellationToken cancellationToken = default) { - var state = await ReadFromReadModelAsync(cancellationToken); + var state = await ReadWriteActorStateAsync(cancellationToken); var draftEntry = state?.Draft; if (draftEntry is null) { @@ -146,25 +141,18 @@ public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = def await ActorCommandDispatcher.SendAsync(actor, new RoleDraftDeletedEvent(), cancellationToken); } - // ── Per-request readmodel read (no service-level state) ── + // ── Read write actor state directly ── - private Task ReadFromReadModelAsync(CancellationToken ct) + private async Task ReadWriteActorStateAsync(CancellationToken ct) { - return ReadModelSnapshotReader.ReadAsync( - _subscriptions, - _runtime, - ResolveReadModelActorId(), - typeof(RoleCatalogReadModelGAgent), - RoleCatalogStateSnapshotEvent.Descriptor, - evt => evt.Snapshot, - _logger, - ct); + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; } // ── Actor resolution ── private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); - private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; private async Task EnsureWriteActorAsync(CancellationToken ct) { diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs deleted file mode 100644 index e994e8e7..00000000 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedScriptStoragePort.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.GAgents.ScriptStorage; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Microsoft.Extensions.Logging; - -namespace Aevatar.Studio.Infrastructure.ActorBacked; - -/// -/// Actor-backed implementation of . -/// Writes go through event handlers. -/// -/// This port is write-only — no readmodel subscription is needed since -/// the interface only exposes . -/// Per-scope isolation: each scope gets its own script-storage-{scopeId} actor. -/// -internal sealed class ActorBackedScriptStoragePort : IScriptStoragePort -{ - private const string ScriptStorageActorIdPrefix = "script-storage-"; - - private readonly IActorRuntime _runtime; - private readonly IAppScopeResolver _scopeResolver; - private readonly ILogger _logger; - - public ActorBackedScriptStoragePort( - IActorRuntime runtime, - IAppScopeResolver scopeResolver, - ILogger logger) - { - _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task UploadScriptAsync(string scriptId, string sourceText, CancellationToken ct) - { - var actor = await EnsureActorAsync(ct); - var evt = new ScriptUploadedEvent - { - ScriptId = scriptId, - SourceText = sourceText, - }; - await ActorCommandDispatcher.SendAsync(actor, evt, ct); - - _logger.LogDebug("Script {ScriptId} uploaded to actor-backed storage", scriptId); - } - - private string ResolveStorageActorId() => ScriptStorageActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); - - private async Task EnsureActorAsync(CancellationToken ct) - { - var actorId = ResolveStorageActorId(); - var actor = await _runtime.GetAsync(actorId); - if (actor is not null) - return actor; - - return await _runtime.CreateAsync(actorId, ct); - } -} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs index 7435abe4..3d65cc84 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs @@ -1,5 +1,4 @@ using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.StreamingProxyParticipant; using Aevatar.Studio.Application.Studio.Abstractions; using Google.Protobuf.WellKnownTypes; @@ -9,8 +8,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Completely stateless: no fields hold snapshot or subscription state. -/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Reads the write actor's state directly. /// Writes send commands to the Write GAgent. /// internal sealed class ActorBackedStreamingProxyParticipantStore @@ -19,23 +17,20 @@ internal sealed class ActorBackedStreamingProxyParticipantStore private const string WriteActorId = "streaming-proxy-participants"; private readonly IActorRuntime _runtime; - private readonly IActorEventSubscriptionProvider _subscriptions; private readonly ILogger _logger; public ActorBackedStreamingProxyParticipantStore( IActorRuntime runtime, - IActorEventSubscriptionProvider subscriptions, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task> ListAsync( string roomId, CancellationToken cancellationToken = default) { - var state = await ReadFromReadModelAsync(cancellationToken); + var state = await ReadWriteActorStateAsync(cancellationToken); if (state is null) return []; @@ -77,19 +72,12 @@ public async Task RemoveRoomAsync( await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); } - // ── Per-request readmodel read (no service-level state) ── + // ── Read write actor state directly ── - private Task ReadFromReadModelAsync(CancellationToken ct) + private async Task ReadWriteActorStateAsync(CancellationToken ct) { - return ReadModelSnapshotReader.ReadAsync( - _subscriptions, - _runtime, - WriteActorId + "-readmodel", - typeof(StreamingProxyParticipantReadModelGAgent), - StreamingProxyParticipantStateSnapshotEvent.Descriptor, - evt => evt.Snapshot, - _logger, - ct); + var actor = await _runtime.GetAsync(WriteActorId); + return (actor?.Agent as IAgent)?.State; } // ── Actor resolution ── diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs index 5cff347e..c6d1d950 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs @@ -1,5 +1,4 @@ using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.UserConfig; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; @@ -11,8 +10,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Completely stateless: no fields hold snapshot or subscription state. -/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Reads the write actor's state directly. /// Writes send commands to the Write GAgent. /// Per-scope isolation: each scope gets its own user-config-{scopeId} actor. /// @@ -21,20 +19,17 @@ internal sealed class ActorBackedUserConfigStore : IUserConfigStore private const string WriteActorIdPrefix = "user-config-"; private readonly IActorRuntime _runtime; - private readonly IActorEventSubscriptionProvider _subscriptions; private readonly IAppScopeResolver _scopeResolver; private readonly StudioStorageOptions _storageOptions; private readonly ILogger _logger; public ActorBackedUserConfigStore( IActorRuntime runtime, - IActorEventSubscriptionProvider subscriptions, IAppScopeResolver scopeResolver, IOptions storageOptions, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _storageOptions = storageOptions?.Value ?? new StudioStorageOptions(); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -42,7 +37,7 @@ public ActorBackedUserConfigStore( public async Task GetAsync(CancellationToken cancellationToken = default) { - var state = await ReadFromReadModelAsync(cancellationToken); + var state = await ReadWriteActorStateAsync(cancellationToken); if (state is null) return CreateDefaultConfig(); @@ -82,25 +77,18 @@ public async Task SaveAsync(UserConfig config, CancellationToken cancellationTok await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); } - // ── Per-request readmodel read (no service-level state) ── + // ── Read write actor state directly ── - private Task ReadFromReadModelAsync(CancellationToken ct) + private async Task ReadWriteActorStateAsync(CancellationToken ct) { - return ReadModelSnapshotReader.ReadAsync( - _subscriptions, - _runtime, - ResolveReadModelActorId(), - typeof(UserConfigReadModelGAgent), - UserConfigStateSnapshotEvent.Descriptor, - evt => evt.Snapshot, - _logger, - ct); + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; } // ── Actor resolution ── private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); - private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; private async Task EnsureWriteActorAsync(CancellationToken ct) { diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs index a8830a50..4302d091 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs @@ -2,7 +2,6 @@ using System.Text; using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.GAgents.UserMemory; using Aevatar.Studio.Infrastructure.ScopeResolution; using Microsoft.Extensions.Logging; @@ -11,8 +10,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Completely stateless: no fields hold snapshot or subscription state. -/// Reads use per-request temporary subscription to the ReadModel GAgent. +/// Reads the write actor's state directly. /// Writes send commands to the Write GAgent. /// internal sealed class ActorBackedUserMemoryStore : IUserMemoryStore @@ -20,25 +18,22 @@ internal sealed class ActorBackedUserMemoryStore : IUserMemoryStore private const string WriteActorIdPrefix = "user-memory-"; private readonly IActorRuntime _runtime; - private readonly IActorEventSubscriptionProvider _subscriptions; private readonly IAppScopeResolver _scopeResolver; private readonly ILogger _logger; public ActorBackedUserMemoryStore( IActorRuntime runtime, - IActorEventSubscriptionProvider subscriptions, IAppScopeResolver scopeResolver, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _subscriptions = subscriptions ?? throw new ArgumentNullException(nameof(subscriptions)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetAsync(CancellationToken ct = default) { - var state = await ReadFromReadModelAsync(ct); + var state = await ReadWriteActorStateAsync(ct); if (state is null) return UserMemoryDocument.Empty; @@ -122,7 +117,7 @@ public async Task AddEntryAsync( public async Task RemoveEntryAsync(string id, CancellationToken ct = default) { - var state = await ReadFromReadModelAsync(ct); + var state = await ReadWriteActorStateAsync(ct); if (state is null || !state.Entries.Any(e => string.Equals(e.Id, id, StringComparison.Ordinal))) return false; @@ -191,19 +186,13 @@ public async Task BuildPromptSectionAsync(int maxChars = 2000, Cancellat : truncated; } - // ── Per-request readmodel read (no service-level state) ── + // ── Read write actor state directly ── - private Task ReadFromReadModelAsync(CancellationToken ct) + private async Task ReadWriteActorStateAsync(CancellationToken ct) { - return ReadModelSnapshotReader.ReadAsync( - _subscriptions, - _runtime, - ResolveReadModelActorId(), - typeof(UserMemoryReadModelGAgent), - UserMemoryStateSnapshotEvent.Descriptor, - evt => evt.Snapshot, - _logger, - ct); + var actorId = ResolveWriteActorId(); + var actor = await _runtime.GetAsync(actorId); + return (actor?.Agent as IAgent)?.State; } // ── Actor resolution ── @@ -214,7 +203,6 @@ private string ResolveScopeId() "User memory store requires an authenticated user scope. No scope could be resolved."); private string ResolveWriteActorId() => WriteActorIdPrefix + ResolveScopeId(); - private string ResolveReadModelActorId() => ResolveWriteActorId() + "-readmodel"; private async Task EnsureWriteActorAsync(CancellationToken ct) { diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs deleted file mode 100644 index 10debd2d..00000000 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedWorkflowStoragePort.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.GAgents.WorkflowStorage; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Microsoft.Extensions.Logging; - -namespace Aevatar.Studio.Infrastructure.ActorBacked; - -/// -/// Actor-backed implementation of . -/// Writes go through event handlers. -/// Per-scope isolation: each scope gets its own workflow-storage-{scopeId} actor. -/// -internal sealed class ActorBackedWorkflowStoragePort : IWorkflowStoragePort -{ - private const string StorageActorIdPrefix = "workflow-storage-"; - - private readonly IActorRuntime _runtime; - private readonly IAppScopeResolver _scopeResolver; - private readonly ILogger _logger; - - public ActorBackedWorkflowStoragePort( - IActorRuntime runtime, - IAppScopeResolver scopeResolver, - ILogger logger) - { - _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task UploadWorkflowYamlAsync( - string workflowId, string workflowName, string yaml, CancellationToken ct) - { - var actor = await EnsureActorAsync(ct); - var evt = new WorkflowYamlUploadedEvent - { - WorkflowId = workflowId, - WorkflowName = workflowName, - Yaml = yaml, - }; - await ActorCommandDispatcher.SendAsync(actor, evt, ct); - _logger.LogDebug("Workflow YAML uploaded via actor: {WorkflowId}", workflowId); - } - - private string ResolveStorageActorId() => StorageActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); - - private async Task EnsureActorAsync(CancellationToken ct) - { - var actorId = ResolveStorageActorId(); - var actor = await _runtime.GetAsync(actorId); - if (actor is not null) - return actor; - - return await _runtime.CreateAsync(actorId, ct); - } -} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs index 97c99e7b..33f223d9 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorCommandDispatcher.cs @@ -5,23 +5,24 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// -/// Shared utility for dispatching command events to actors via . -/// Eliminates duplicated SendCommandAsync across all ActorBacked stores. +/// Sends a domain event (command) to a target actor by wrapping it in an +/// directed to the actor's own inbox. /// internal static class ActorCommandDispatcher { - public static async Task SendAsync(IActor actor, IMessage command, CancellationToken ct) + public static Task SendAsync( + IActor actor, TEvent evt, CancellationToken ct = default) + where TEvent : IMessage { var envelope = new EventEnvelope { Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(command), - Route = new EnvelopeRoute - { - Direct = new DirectRoute { TargetActorId = actor.Id }, - }, + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(evt), + Route = EnvelopeRouteSemantics.CreateTopologyPublication( + actor.Id, TopologyAudience.Self), }; - await actor.HandleEventAsync(envelope, ct); + + return actor.HandleEventAsync(envelope, ct); } } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ReadModelSnapshotReader.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ReadModelSnapshotReader.cs deleted file mode 100644 index 8b27216e..00000000 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ReadModelSnapshotReader.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; -using Google.Protobuf; -using Google.Protobuf.Reflection; -using Microsoft.Extensions.Logging; - -namespace Aevatar.Studio.Infrastructure.ActorBacked; - -/// -/// Shared utility for reading a snapshot from a ReadModel GAgent via per-request -/// temporary subscription. Eliminates duplicated ReadFromReadModelAsync -/// across all ActorBacked stores. -/// -/// Pattern: subscribe → activate readmodel actor → wait for snapshot → unsubscribe. -/// Method-local TaskCompletionSource only. No service-level state. -/// -internal static class ReadModelSnapshotReader -{ - private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5); - - /// - /// Subscribe to a ReadModel GAgent, activate it (triggering OnActivateAsync → snapshot - /// publish), wait for the snapshot event, and return the unpacked state. - /// - /// The protobuf state type (e.g., GAgentRegistryState). - /// The snapshot event type (e.g., GAgentRegistryStateSnapshotEvent). - /// Subscription provider for actor event streams. - /// Actor runtime for activating actors. - /// The readmodel actor ID to subscribe to. - /// The readmodel GAgent type (for CreateAsync if not yet activated). - /// Protobuf descriptor for the snapshot event. - /// Function to unpack the snapshot state from the event. - /// Logger for timeout warnings. - /// Cancellation token. - public static async Task ReadAsync( - IActorEventSubscriptionProvider subscriptions, - IActorRuntime runtime, - string readModelActorId, - Type readModelActorType, - MessageDescriptor snapshotDescriptor, - Func unpackSnapshot, - ILogger logger, - CancellationToken ct) - where TState : class, IMessage - where TSnapshotEvent : class, IMessage, new() - { - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - - await using var sub = await subscriptions.SubscribeAsync( - readModelActorId, - envelope => - { - if (envelope.Payload?.Is(snapshotDescriptor) == true) - { - var snapshotEvent = envelope.Payload.Unpack(); - tcs.TrySetResult(unpackSnapshot(snapshotEvent)); - } - return Task.CompletedTask; - }, - ct); - - // Activate readmodel actor (triggers OnActivateAsync → PublishAsync snapshot) - if (await runtime.GetAsync(readModelActorId) is null) - await runtime.CreateAsync(readModelActorType, readModelActorId, ct); - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(DefaultTimeout); - try - { - return await tcs.Task.WaitAsync(cts.Token); - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - logger.LogWarning("Timeout waiting for readmodel snapshot from {ActorId}", readModelActorId); - return null; - } - } -} diff --git a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj index 4c8a9237..62f6a017 100644 --- a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj +++ b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj @@ -13,8 +13,6 @@ - - diff --git a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index cdba0979..93a2ae3d 100644 --- a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -39,8 +39,6 @@ public static IServiceCollection AddStudioInfrastructure( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj new file mode 100644 index 00000000..deb82bbf --- /dev/null +++ b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj @@ -0,0 +1,31 @@ + + + net10.0 + enable + enable + Aevatar.Studio.Projection + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..1b5bde54 --- /dev/null +++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.DependencyInjection; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.Metadata; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.Projectors; +using Aevatar.Studio.Projection.QueryPorts; +using Aevatar.Studio.Projection.ReadModels; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.Studio.Projection.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers Studio projection components: materialization runtime, + /// projectors, query ports, and document metadata providers. + /// + public static IServiceCollection AddStudioProjectionComponents(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + // Projection clock (shared, idempotent registration) + services.TryAddSingleton(); + + // Materialization runtime for Studio current-state projections + services.AddProjectionMaterializationRuntimeCore< + StudioMaterializationContext, + StudioMaterializationRuntimeLease, + ProjectionMaterializationScopeGAgent>( + scopeKey => new StudioMaterializationContext + { + RootActorId = scopeKey.RootActorId, + ProjectionKind = scopeKey.ProjectionKind, + }, + context => new StudioMaterializationRuntimeLease(context)); + + // UserConfig projector + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + UserConfigCurrentStateProjector>(); + + // Document metadata providers (for index creation in Elasticsearch) + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + UserConfigCurrentStateDocumentMetadataProvider>(); + + // Query ports + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Aevatar.Studio.Projection/Metadata/UserConfigCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/UserConfigCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 00000000..148a00a9 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/UserConfigCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class UserConfigCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-user-config", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationContext.cs b/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationContext.cs new file mode 100644 index 00000000..9c2bf12c --- /dev/null +++ b/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationContext.cs @@ -0,0 +1,14 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; + +namespace Aevatar.Studio.Projection.Orchestration; + +/// +/// Materialization context for Studio projection scopes. +/// Shared by all Studio current-state projectors (UserConfig, etc.). +/// +public sealed class StudioMaterializationContext : IProjectionMaterializationContext +{ + public required string RootActorId { get; init; } + + public required string ProjectionKind { get; init; } +} diff --git a/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationRuntimeLease.cs b/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationRuntimeLease.cs new file mode 100644 index 00000000..3d692673 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Orchestration/StudioMaterializationRuntimeLease.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; + +namespace Aevatar.Studio.Projection.Orchestration; + +public sealed class StudioMaterializationRuntimeLease + : ProjectionRuntimeLeaseBase, + IProjectionContextRuntimeLease +{ + public StudioMaterializationRuntimeLease(StudioMaterializationContext context) + : base(context.RootActorId) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public StudioMaterializationContext Context { get; } +} diff --git a/src/Aevatar.Studio.Projection/Projectors/UserConfigCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/UserConfigCurrentStateProjector.cs new file mode 100644 index 00000000..05369dd0 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/UserConfigCurrentStateProjector.cs @@ -0,0 +1,71 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.UserConfig; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// in the projection document store. +/// +/// Follows the pattern +/// from the scripting module. +/// +public sealed class UserConfigCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public UserConfigCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new UserConfigCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + DefaultModel = state.DefaultModel, + PreferredLlmRoute = state.PreferredLlmRoute, + RuntimeMode = state.RuntimeMode, + LocalRuntimeBaseUrl = state.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl = state.RemoteRuntimeBaseUrl, + MaxToolRounds = state.MaxToolRounds, + }; + + await _writeDispatcher.UpsertAsync(document, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/QueryPorts/IUserConfigQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/IUserConfigQueryPort.cs new file mode 100644 index 00000000..b8bfacbe --- /dev/null +++ b/src/Aevatar.Studio.Projection/QueryPorts/IUserConfigQueryPort.cs @@ -0,0 +1,12 @@ +using Aevatar.Studio.Application.Studio.Abstractions; + +namespace Aevatar.Studio.Projection.QueryPorts; + +/// +/// Pure-read query port for user configuration. +/// Reads from the projection document store, not from actor state. +/// +public interface IUserConfigQueryPort +{ + Task GetAsync(CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs new file mode 100644 index 00000000..9f7c9c8e --- /dev/null +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs @@ -0,0 +1,60 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Projection.ReadModels; +using Microsoft.Extensions.Options; + +namespace Aevatar.Studio.Projection.QueryPorts; + +/// +/// Reads user configuration from the projection document store. +/// Zero write path. Pure query semantics. +/// +public sealed class ProjectionUserConfigQueryPort : IUserConfigQueryPort +{ + private const string WriteActorIdPrefix = "user-config-"; + + private readonly IProjectionDocumentReader _documentReader; + private readonly IAppScopeResolver _scopeResolver; + + public ProjectionUserConfigQueryPort( + IProjectionDocumentReader documentReader, + IAppScopeResolver scopeResolver) + { + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + } + + public async Task GetAsync(CancellationToken ct = default) + { + var actorId = WriteActorIdPrefix + (_scopeResolver.Resolve()?.ScopeId ?? "default"); + var document = await _documentReader.GetAsync(actorId, ct); + + if (document is null) + return CreateDefaultConfig(); + + return new UserConfig( + DefaultModel: document.DefaultModel, + PreferredLlmRoute: string.IsNullOrEmpty(document.PreferredLlmRoute) + ? UserConfigLlmRouteDefaults.Gateway + : document.PreferredLlmRoute, + RuntimeMode: string.IsNullOrEmpty(document.RuntimeMode) + ? UserConfigRuntimeDefaults.LocalMode + : document.RuntimeMode, + LocalRuntimeBaseUrl: string.IsNullOrEmpty(document.LocalRuntimeBaseUrl) + ? UserConfigRuntimeDefaults.LocalRuntimeBaseUrl + : document.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl: string.IsNullOrEmpty(document.RemoteRuntimeBaseUrl) + ? UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl + : document.RemoteRuntimeBaseUrl, + MaxToolRounds: document.MaxToolRounds); + } + + private static UserConfig CreateDefaultConfig() => + new( + DefaultModel: string.Empty, + PreferredLlmRoute: UserConfigLlmRouteDefaults.Gateway, + RuntimeMode: UserConfigRuntimeDefaults.LocalMode, + LocalRuntimeBaseUrl: UserConfigRuntimeDefaults.LocalRuntimeBaseUrl, + RemoteRuntimeBaseUrl: UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl); +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/UserConfigCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/UserConfigCurrentStateDocument.Partial.cs new file mode 100644 index 00000000..46515290 --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/UserConfigCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class UserConfigCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto new file mode 100644 index 00000000..9f9ef2bf --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; +package aevatar.studio.projection; +option csharp_namespace = "Aevatar.Studio.Projection.ReadModels"; + +import "google/protobuf/timestamp.proto"; + +// ─── UserConfig Current State ReadModel ─── + +message UserConfigCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + + // Business fields (projected from UserConfigGAgentState) + string default_model = 10; + string preferred_llm_route = 11; + string runtime_mode = 12; + string local_runtime_base_url = 13; + string remote_runtime_base_url = 14; + int32 max_tool_rounds = 15; +} diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index 587dcc5d..db752f13 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -1,9 +1,6 @@ using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; -using Aevatar.GAgents.ScriptStorage; using Aevatar.GAgents.UserConfig; -using Aevatar.GAgents.WorkflowStorage; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ActorBacked; using Aevatar.Studio.Infrastructure.ScopeResolution; @@ -24,14 +21,38 @@ public sealed class ActorBackedStoreAdapterTests // Fakes // ════════════════════════════════════════════════════════════ + private sealed class FakeAgent : IAgent + { + public FakeAgent(string id, UserConfigGAgentState state) + { + Id = id; + State = state; + } + + public string Id { get; } + public UserConfigGAgentState State { get; } + + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => + Task.CompletedTask; + public Task GetDescriptionAsync() => Task.FromResult(string.Empty); + public Task> GetSubscribedEventTypesAsync() => + Task.FromResult>([]); + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + } + private sealed class FakeActor : IActor { private readonly List _received = []; - public FakeActor(string id) => Id = id; + public FakeActor(string id, IAgent? agent = null) + { + Id = id; + Agent = agent ?? new FakeAgent(id, new UserConfigGAgentState()); + } public string Id { get; } - public IAgent Agent => throw new NotSupportedException(); + public IAgent Agent { get; } public IReadOnlyList ReceivedEnvelopes => _received; public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; @@ -49,39 +70,33 @@ public Task> GetChildrenIdsAsync() => } /// - /// Fake runtime that supports an optional callback when an actor is created. - /// This lets tests wire up auto-delivery of readmodel snapshots. + /// Fake runtime that supports typed agent state for read tests. /// private sealed class FakeActorRuntime : IActorRuntime { private readonly Dictionary _actors = new(StringComparer.Ordinal); public IReadOnlyDictionary Actors => _actors; - /// - /// Optional callback invoked after an actor is created. - /// Used to simulate the readmodel actor's OnActivateAsync publishing a snapshot. - /// - public Func? OnActorCreated { get; set; } + public void RegisterActor(string id, IAgent agent) + { + _actors[id] = new FakeActor(id, agent); + } - public async Task CreateAsync(string? id = null, CancellationToken ct = default) + public Task CreateAsync(string? id = null, CancellationToken ct = default) where TAgent : IAgent { var actorId = id ?? Guid.NewGuid().ToString("N"); - var actor = new FakeActor(actorId); - _actors[actorId] = actor; - if (OnActorCreated is not null) - await OnActorCreated(actorId); - return actor; + if (!_actors.ContainsKey(actorId)) + _actors[actorId] = new FakeActor(actorId); + return Task.FromResult(_actors[actorId]); } - public async Task CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) + public Task CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) { var actorId = id ?? Guid.NewGuid().ToString("N"); - var actor = new FakeActor(actorId); - _actors[actorId] = actor; - if (OnActorCreated is not null) - await OnActorCreated(actorId); - return actor; + if (!_actors.ContainsKey(actorId)) + _actors[actorId] = new FakeActor(actorId); + return Task.FromResult(_actors[actorId]); } public Task DestroyAsync(string id, CancellationToken ct = default) @@ -103,66 +118,6 @@ public Task UnlinkAsync(string childId, CancellationToken ct = default) => Task.CompletedTask; } - private sealed class FakeSubscriptionHandle : IAsyncDisposable - { - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } - - /// - /// Fake subscription provider that supports both manual delivery and - /// auto-delivery via queued messages per actor ID. - /// When a handler subscribes to an actorId that has queued messages, - /// those messages are delivered immediately (simulating OnActivateAsync publish). - /// - private sealed class FakeSubscriptionProvider : IActorEventSubscriptionProvider - { - private readonly Dictionary _handlers = new(StringComparer.Ordinal); - private readonly Dictionary> _queued = new(StringComparer.Ordinal); - - /// - /// Queue a message to be auto-delivered when a handler subscribes to the given actor ID. - /// - public void EnqueueForDelivery(string actorId, TMessage message) - where TMessage : class, IMessage, new() - { - if (!_queued.TryGetValue(actorId, out var list)) - { - list = []; - _queued[actorId] = list; - } - list.Add(message); - } - - public async Task SubscribeAsync( - string actorId, - Func handler, - CancellationToken ct = default) - where TMessage : class, IMessage, new() - { - _handlers[actorId] = handler; - - // Auto-deliver queued messages - if (_queued.TryGetValue(actorId, out var queued)) - { - foreach (var msg in queued) - { - if (msg is TMessage typed) - await handler(typed); - } - } - - return new FakeSubscriptionHandle(); - } - - /// Deliver a message to the handler registered for the given actor ID. - public async Task DeliverAsync(string actorId, TMessage message) - where TMessage : class, IMessage, new() - { - if (_handlers.TryGetValue(actorId, out var handler) && handler is Func typedHandler) - await typedHandler(message); - } - } - private sealed class FakeScopeResolver : IAppScopeResolver { public string? ScopeIdToReturn { get; set; } @@ -173,46 +128,21 @@ ScopeIdToReturn is not null : null; } - // ── Helpers: wire up readmodel snapshot auto-delivery ── - - /// - /// Configures fakes so that the readmodel actor auto-delivers the - /// given snapshot when the store subscribes. - /// - private static void WireUpUserConfigReadModel( - FakeSubscriptionProvider subscriptions, - string readModelActorId, - UserConfigGAgentState? snapshot) - { - if (snapshot is not null) - { - var snapshotEvent = new UserConfigStateSnapshotEvent { Snapshot = snapshot }; - var envelope = new EventEnvelope - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(snapshotEvent), - }; - subscriptions.EnqueueForDelivery(readModelActorId, envelope); - } - } - // ════════════════════════════════════════════════════════════ - // UserConfigStore: defaults when snapshot is null (timeout) + // UserConfigStore: defaults when actor does not exist // ════════════════════════════════════════════════════════════ [Fact] - public async Task UserConfigStore_GetAsync_NullSnapshot_ReturnsDefaults() + public async Task UserConfigStore_GetAsync_NoActor_ReturnsDefaults() { var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "test-user" }; var logger = NullLogger.Instance; var store = new ActorBackedUserConfigStore( - runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); - // No snapshot queued, readmodel timeout returns defaults + // No actor exists, returns defaults var config = await store.GetAsync(); config.DefaultModel.Should().BeEmpty(); @@ -224,31 +154,30 @@ public async Task UserConfigStore_GetAsync_NullSnapshot_ReturnsDefaults() } // ════════════════════════════════════════════════════════════ - // UserConfigStore: snapshot mapping + // UserConfigStore: state mapping // ════════════════════════════════════════════════════════════ [Fact] - public async Task UserConfigStore_GetAsync_WithSnapshot_MapsFieldsCorrectly() + public async Task UserConfigStore_GetAsync_WithState_MapsFieldsCorrectly() { var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-42" }; var logger = NullLogger.Instance; - // Queue snapshot for readmodel actor - WireUpUserConfigReadModel(subscriptions, "user-config-user-42-readmodel", - new UserConfigGAgentState - { - DefaultModel = "gpt-4", - PreferredLlmRoute = "/api/v1/proxy/s/custom", - RuntimeMode = "remote", - LocalRuntimeBaseUrl = "http://localhost:9090", - RemoteRuntimeBaseUrl = "https://remote.example.com", - MaxToolRounds = 5, - }); + // Register write actor with state + var state = new UserConfigGAgentState + { + DefaultModel = "gpt-4", + PreferredLlmRoute = "/api/v1/proxy/s/custom", + RuntimeMode = "remote", + LocalRuntimeBaseUrl = "http://localhost:9090", + RemoteRuntimeBaseUrl = "https://remote.example.com", + MaxToolRounds = 5, + }; + runtime.RegisterActor("user-config-user-42", new FakeAgent("user-config-user-42", state)); var store = new ActorBackedUserConfigStore( - runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); var config = await store.GetAsync(); @@ -268,20 +197,15 @@ public async Task UserConfigStore_GetAsync_WithSnapshot_MapsFieldsCorrectly() public async Task UserConfigStore_GetAsync_EmptyStringFields_ApplyDefaults() { var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-empty" }; var logger = NullLogger.Instance; - // Queue snapshot with empty strings (proto default) - WireUpUserConfigReadModel(subscriptions, "user-config-user-empty-readmodel", - new UserConfigGAgentState - { - DefaultModel = "claude-3", - // Intentionally leave others as empty string (proto default) - }); + // Register write actor with mostly-empty state + var state = new UserConfigGAgentState { DefaultModel = "claude-3" }; + runtime.RegisterActor("user-config-user-empty", new FakeAgent("user-config-user-empty", state)); var store = new ActorBackedUserConfigStore( - runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); var config = await store.GetAsync(); @@ -304,12 +228,11 @@ public async Task UserConfigStore_GetAsync_EmptyStringFields_ApplyDefaults() public async Task UserConfigStore_SaveAsync_SendsUserConfigUpdatedEvent() { var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "save-scope" }; var logger = NullLogger.Instance; var store = new ActorBackedUserConfigStore( - runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); var config = new UserConfig( DefaultModel: "gpt-4-turbo", @@ -334,11 +257,6 @@ public async Task UserConfigStore_SaveAsync_SendsUserConfigUpdatedEvent() var evt = envelope.Payload.Unpack(); evt.DefaultModel.Should().Be("gpt-4-turbo"); evt.MaxToolRounds.Should().Be(10); - - // Verify envelope routing targets the correct actor - envelope.Route.Should().NotBeNull(); - envelope.Route.Direct.Should().NotBeNull(); - envelope.Route.Direct.TargetActorId.Should().Be(actorId); } // ════════════════════════════════════════════════════════════ @@ -392,212 +310,25 @@ public async Task NyxIdUserLlmPreferencesStore_DefaultConfig_ReturnsEmptyDefault prefs.MaxToolRounds.Should().Be(0); } - // ════════════════════════════════════════════════════════════ - // Scope isolation: different scopes get different actor IDs - // ════════════════════════════════════════════════════════════ - - [Fact] - public async Task UserConfigStore_DifferentScopes_ProduceDifferentActorIds() - { - var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); - var scopeResolver = new FakeScopeResolver(); - var logger = NullLogger.Instance; - - var store = new ActorBackedUserConfigStore( - runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); - - // First scope - scopeResolver.ScopeIdToReturn = "scope-alpha"; - _ = await store.GetAsync(); - - // Second scope - scopeResolver.ScopeIdToReturn = "scope-beta"; - _ = await store.GetAsync(); - - // Both readmodel actors should be created - runtime.Actors.Should().ContainKey("user-config-scope-alpha-readmodel"); - runtime.Actors.Should().ContainKey("user-config-scope-beta-readmodel"); - } - - [Fact] - public async Task UserConfigStore_NullScope_FallsBackToDefault() - { - var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); - var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = null }; - var logger = NullLogger.Instance; - - var store = new ActorBackedUserConfigStore( - runtime, subscriptions, scopeResolver, Options.Create(new StudioStorageOptions()), logger); - - _ = await store.GetAsync(); - - runtime.Actors.Should().ContainKey("user-config-default-readmodel", - "null scope should resolve to 'default' suffix"); - } - // ════════════════════════════════════════════════════════════ // GAgentActorStore: scope isolation // ════════════════════════════════════════════════════════════ [Fact] - public async Task GAgentActorStore_ScopeIsolation_DifferentScopesGetDifferentActors() + public async Task GAgentActorStore_GetAsync_NoActor_ReturnsEmptyList() { var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); - var scopeResolver = new FakeScopeResolver(); - var logger = NullLogger.Instance; - - var store = new ActorBackedGAgentActorStore( - runtime, subscriptions, scopeResolver, logger); - - scopeResolver.ScopeIdToReturn = "tenant-a"; - _ = await store.GetAsync(); - - scopeResolver.ScopeIdToReturn = "tenant-b"; - _ = await store.GetAsync(); - - runtime.Actors.Should().ContainKey("gagent-registry-tenant-a-readmodel"); - runtime.Actors.Should().ContainKey("gagent-registry-tenant-b-readmodel"); - } - - [Fact] - public async Task GAgentActorStore_GetAsync_NullSnapshot_ReturnsEmptyList() - { - var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "empty-scope" }; var logger = NullLogger.Instance; var store = new ActorBackedGAgentActorStore( - runtime, subscriptions, scopeResolver, logger); + runtime, scopeResolver, logger); var groups = await store.GetAsync(); groups.Should().BeEmpty(); } - // ════════════════════════════════════════════════════════════ - // WorkflowStoragePort: command construction - // ════════════════════════════════════════════════════════════ - - [Fact] - public async Task WorkflowStoragePort_UploadAsync_SendsWorkflowYamlUploadedEvent() - { - var runtime = new FakeActorRuntime(); - var logger = NullLogger.Instance; - - var port = new ActorBackedWorkflowStoragePort(runtime, new FakeScopeResolver(), logger); - - await port.UploadWorkflowYamlAsync("wf-001", "My Workflow", "name: test\nsteps: []", CancellationToken.None); - - const string expectedActorId = "workflow-storage-default"; - runtime.Actors.Should().ContainKey(expectedActorId); - - var actor = runtime.Actors[expectedActorId]; - actor.ReceivedEnvelopes.Should().HaveCount(1); - - var envelope = actor.ReceivedEnvelopes[0]; - envelope.Payload.Is(WorkflowYamlUploadedEvent.Descriptor).Should().BeTrue(); - - var evt = envelope.Payload.Unpack(); - evt.WorkflowId.Should().Be("wf-001"); - evt.WorkflowName.Should().Be("My Workflow"); - evt.Yaml.Should().Be("name: test\nsteps: []"); - - // Verify direct routing - envelope.Route.Direct.TargetActorId.Should().Be(expectedActorId); - } - - [Fact] - public async Task WorkflowStoragePort_MultipleUploads_ReusesSameActor() - { - var runtime = new FakeActorRuntime(); - var logger = NullLogger.Instance; - - var port = new ActorBackedWorkflowStoragePort(runtime, new FakeScopeResolver(), logger); - - await port.UploadWorkflowYamlAsync("wf-1", "First", "yaml1", CancellationToken.None); - await port.UploadWorkflowYamlAsync("wf-2", "Second", "yaml2", CancellationToken.None); - - runtime.Actors.Should().HaveCount(1, "actor should be reused across uploads"); - - var actor = runtime.Actors["workflow-storage-default"]; - actor.ReceivedEnvelopes.Should().HaveCount(2); - } - - // ════════════════════════════════════════════════════════════ - // ScriptStoragePort: command construction - // ════════════════════════════════════════════════════════════ - - [Fact] - public async Task ScriptStoragePort_UploadAsync_SendsScriptUploadedEvent() - { - var runtime = new FakeActorRuntime(); - var logger = NullLogger.Instance; - - var port = new ActorBackedScriptStoragePort(runtime, new FakeScopeResolver(), logger); - - await port.UploadScriptAsync("script-42", "console.log('hello');", CancellationToken.None); - - const string expectedActorId = "script-storage-default"; - runtime.Actors.Should().ContainKey(expectedActorId); - - var actor = runtime.Actors[expectedActorId]; - actor.ReceivedEnvelopes.Should().HaveCount(1); - - var envelope = actor.ReceivedEnvelopes[0]; - envelope.Payload.Is(ScriptUploadedEvent.Descriptor).Should().BeTrue(); - - var evt = envelope.Payload.Unpack(); - evt.ScriptId.Should().Be("script-42"); - evt.SourceText.Should().Be("console.log('hello');"); - - // Verify direct routing - envelope.Route.Direct.TargetActorId.Should().Be(expectedActorId); - } - - [Fact] - public async Task ScriptStoragePort_MultipleUploads_ReusesSameActor() - { - var runtime = new FakeActorRuntime(); - var logger = NullLogger.Instance; - - var port = new ActorBackedScriptStoragePort(runtime, new FakeScopeResolver(), logger); - - await port.UploadScriptAsync("s1", "code1", CancellationToken.None); - await port.UploadScriptAsync("s2", "code2", CancellationToken.None); - - runtime.Actors.Should().HaveCount(1, "actor should be reused"); - runtime.Actors["script-storage-default"].ReceivedEnvelopes.Should().HaveCount(2); - } - - // ════════════════════════════════════════════════════════════ - // Envelope structure verification - // ════════════════════════════════════════════════════════════ - - [Fact] - public async Task AllStores_EnvelopeContainsIdAndTimestamp() - { - var runtime = new FakeActorRuntime(); - var logger = NullLogger.Instance; - - var port = new ActorBackedScriptStoragePort(runtime, new FakeScopeResolver(), logger); - var beforeUtc = DateTimeOffset.UtcNow; - - await port.UploadScriptAsync("ts-check", "body", CancellationToken.None); - - var afterUtc = DateTimeOffset.UtcNow; - var envelope = runtime.Actors["script-storage-default"].ReceivedEnvelopes[0]; - - envelope.Id.Should().NotBeNullOrWhiteSpace("envelope must have a unique ID"); - envelope.Id.Length.Should().Be(32, "ID should be a Guid without dashes"); - - var ts = envelope.Timestamp.ToDateTimeOffset(); - ts.Should().BeOnOrAfter(beforeUtc).And.BeOnOrBefore(afterUtc); - } - // ════════════════════════════════════════════════════════════ // GAgentActorStore: AddActorAsync command construction // ════════════════════════════════════════════════════════════ @@ -606,12 +337,11 @@ public async Task AllStores_EnvelopeContainsIdAndTimestamp() public async Task GAgentActorStore_AddActorAsync_SendsActorRegisteredEvent() { var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "cmd-scope" }; var logger = NullLogger.Instance; var store = new ActorBackedGAgentActorStore( - runtime, subscriptions, scopeResolver, logger); + runtime, scopeResolver, logger); await store.AddActorAsync("MyGAgent", "actor-123"); @@ -634,12 +364,11 @@ public async Task GAgentActorStore_AddActorAsync_SendsActorRegisteredEvent() public async Task GAgentActorStore_RemoveActorAsync_SendsActorUnregisteredEvent() { var runtime = new FakeActorRuntime(); - var subscriptions = new FakeSubscriptionProvider(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "cmd-scope" }; var logger = NullLogger.Instance; var store = new ActorBackedGAgentActorStore( - runtime, subscriptions, scopeResolver, logger); + runtime, scopeResolver, logger); await store.RemoveActorAsync("MyGAgent", "actor-456"); @@ -653,6 +382,34 @@ public async Task GAgentActorStore_RemoveActorAsync_SendsActorUnregisteredEvent( evt.ActorId.Should().Be("actor-456"); } + // ════════════════════════════════════════════════════════════ + // Envelope structure verification + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task SaveAsync_EnvelopeContainsIdAndTimestamp() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "ts-scope" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedUserConfigStore( + runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); + + var beforeUtc = DateTimeOffset.UtcNow; + + await store.SaveAsync(new UserConfig(DefaultModel: "model")); + + var afterUtc = DateTimeOffset.UtcNow; + var envelope = runtime.Actors["user-config-ts-scope"].ReceivedEnvelopes[0]; + + envelope.Id.Should().NotBeNullOrWhiteSpace("envelope must have a unique ID"); + envelope.Id.Length.Should().Be(32, "ID should be a Guid without dashes"); + + var ts = envelope.Timestamp.ToDateTimeOffset(); + ts.Should().BeOnOrAfter(beforeUtc).And.BeOnOrBefore(afterUtc); + } + // ════════════════════════════════════════════════════════════ // Helper: stub IUserConfigStore for NyxId delegation tests // ════════════════════════════════════════════════════════════ From a48351139dd46b915545b182752cb158519487ed Mon Sep 17 00:00:00 2001 From: eanzhao Date: Fri, 10 Apr 2026 15:50:06 +0800 Subject: [PATCH 09/21] refactor(user-config): replace actor-backed store with CQRS projection - Introduce IUserConfigQueryPort for read-only access to projection store - Introduce IUserConfigCommandService for dispatching commands to actor - Replace IUserConfigStore usage in endpoints and services with query port - Update NyxIdUserLlmPreferencesStore to use query port instead of store - Add projection components registration and document store configuration - Remove ActorBackedUserConfigStore and related tests - Update test mocks to use new interfaces --- .../Abstractions/IUserConfigCommandService.cs | 10 + .../Abstractions}/IUserConfigQueryPort.cs | 4 +- .../Studio/Abstractions/IUserConfigStore.cs | 6 - .../Studio/Services/ExecutionService.cs | 4 +- .../Aevatar.Studio.Hosting.csproj | 1 + .../Controllers/UserConfigController.cs | 26 +- ...tudioHostingServiceCollectionExtensions.cs | 2 + ...ActorBackedNyxIdUserLlmPreferencesStore.cs | 13 +- .../ActorBacked/ActorBackedUserConfigStore.cs | 107 -- .../ServiceCollectionExtensions.cs | 1 - .../Aevatar.Studio.Projection.csproj | 1 + .../ActorDispatchUserConfigCommandService.cs | 58 + .../ServiceCollectionExtensions.cs | 13 +- .../ProjectionUserConfigQueryPort.cs | 1 - .../Aevatar.GAgentService.Hosting.csproj | 1 + .../ServiceCollectionExtensions.cs | 10 + .../Endpoints/ScopeGAgentEndpoints.cs | 2 +- .../Endpoints/ScopeServiceEndpoints.cs | 2 +- .../Endpoints/ScopeWorkflowEndpoints.cs | 2 +- .../NyxIdChatEndpointsCoverageTests.cs | 1095 ----------------- .../NyxIdChatSupportCoverageTests.cs | 116 -- .../StreamingProxyCoverageTests.cs | 569 --------- .../ScopeServiceEndpointsTests.cs | 16 +- .../ActorBackedStoreAdapterTests.cs | 170 +-- 24 files changed, 127 insertions(+), 2103 deletions(-) create mode 100644 src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigCommandService.cs rename src/{Aevatar.Studio.Projection/QueryPorts => Aevatar.Studio.Application/Studio/Abstractions}/IUserConfigQueryPort.cs (70%) delete mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs create mode 100644 src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs delete mode 100644 test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs delete mode 100644 test/Aevatar.AI.Tests/NyxIdChatSupportCoverageTests.cs delete mode 100644 test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigCommandService.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigCommandService.cs new file mode 100644 index 00000000..44395464 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigCommandService.cs @@ -0,0 +1,10 @@ +namespace Aevatar.Studio.Application.Studio.Abstractions; + +/// +/// Pure-write command service for user configuration. +/// Dispatches commands to the UserConfigGAgent actor. +/// +public interface IUserConfigCommandService +{ + Task SaveAsync(UserConfig config, CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Projection/QueryPorts/IUserConfigQueryPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigQueryPort.cs similarity index 70% rename from src/Aevatar.Studio.Projection/QueryPorts/IUserConfigQueryPort.cs rename to src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigQueryPort.cs index b8bfacbe..a2bdb625 100644 --- a/src/Aevatar.Studio.Projection/QueryPorts/IUserConfigQueryPort.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigQueryPort.cs @@ -1,6 +1,4 @@ -using Aevatar.Studio.Application.Studio.Abstractions; - -namespace Aevatar.Studio.Projection.QueryPorts; +namespace Aevatar.Studio.Application.Studio.Abstractions; /// /// Pure-read query port for user configuration. diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigStore.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigStore.cs index fa42b12f..9f6c76dc 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigStore.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IUserConfigStore.cs @@ -1,11 +1,5 @@ namespace Aevatar.Studio.Application.Studio.Abstractions; -public interface IUserConfigStore -{ - Task GetAsync(CancellationToken cancellationToken = default); - Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default); -} - public static class UserConfigLlmRouteDefaults { public const string Gateway = ""; diff --git a/src/Aevatar.Studio.Application/Studio/Services/ExecutionService.cs b/src/Aevatar.Studio.Application/Studio/Services/ExecutionService.cs index 8b9e6990..ab531a03 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/ExecutionService.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/ExecutionService.cs @@ -17,7 +17,7 @@ public sealed class ExecutionService private const string ExecutionStreamFailedCode = "EXECUTION_STREAM_FAILED"; private readonly IStudioWorkspaceStore _store; - private readonly IUserConfigStore? _userConfigStore; + private readonly IUserConfigQueryPort? _userConfigStore; private readonly IHttpClientFactory _httpClientFactory; private readonly IStudioBackendRequestAuthSnapshotProvider? _authSnapshotProvider; private readonly string _observationSessionId = Guid.NewGuid().ToString("N"); @@ -26,7 +26,7 @@ public ExecutionService( IStudioWorkspaceStore store, IHttpClientFactory httpClientFactory, IStudioBackendRequestAuthSnapshotProvider? authSnapshotProvider = null, - IUserConfigStore? userConfigStore = null) + IUserConfigQueryPort? userConfigStore = null) { _store = store; _httpClientFactory = httpClientFactory; diff --git a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj index 39ddea5b..a11a1b53 100644 --- a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj +++ b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs b/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs index e053c181..0591495a 100644 --- a/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs +++ b/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs @@ -2,7 +2,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; @@ -21,18 +20,21 @@ public sealed class UserConfigController : ControllerBase PropertyNameCaseInsensitive = true, }; - private readonly IUserConfigStore _userConfigStore; + private readonly IUserConfigQueryPort _queryPort; + private readonly IUserConfigCommandService _commandService; private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; private readonly ILogger _logger; public UserConfigController( - IUserConfigStore userConfigStore, + IUserConfigQueryPort queryPort, + IUserConfigCommandService commandService, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) { - _userConfigStore = userConfigStore; + _queryPort = queryPort; + _commandService = commandService; _httpClientFactory = httpClientFactory; _configuration = configuration; _logger = logger; @@ -43,12 +45,7 @@ public async Task> Get(CancellationToken cancellationTo { try { - return Ok(await _userConfigStore.GetAsync(cancellationToken)); - } - catch (ChronoStorageServiceException exception) - { - _logger.LogWarning(exception, "Chrono-storage is unavailable when reading user config"); - return ChronoStorageErrorResponses.ToActionResult(exception); + return Ok(await _queryPort.GetAsync(cancellationToken)); } catch (InvalidOperationException exception) { @@ -68,21 +65,16 @@ public async Task> Save( { try { - var current = await _userConfigStore.GetAsync(cancellationToken); + var current = await _queryPort.GetAsync(cancellationToken); var merged = new UserConfig( DefaultModel: request.DefaultModel is null ? current.DefaultModel : request.DefaultModel.Trim(), PreferredLlmRoute: request.PreferredLlmRoute is null ? current.PreferredLlmRoute : UserConfigLlmRoute.Normalize(request.PreferredLlmRoute), RuntimeMode: request.RuntimeMode is null ? current.RuntimeMode : request.RuntimeMode.Trim(), LocalRuntimeBaseUrl: request.LocalRuntimeBaseUrl is null ? current.LocalRuntimeBaseUrl : request.LocalRuntimeBaseUrl.Trim(), RemoteRuntimeBaseUrl: request.RemoteRuntimeBaseUrl is null ? current.RemoteRuntimeBaseUrl : request.RemoteRuntimeBaseUrl.Trim()); - await _userConfigStore.SaveAsync(merged, cancellationToken); + await _commandService.SaveAsync(merged, cancellationToken); return Ok(merged); } - catch (ChronoStorageServiceException exception) - { - _logger.LogWarning(exception, "Chrono-storage is unavailable when saving user config"); - return ChronoStorageErrorResponses.ToActionResult(exception); - } catch (InvalidOperationException exception) { return BadRequest(new { message = exception.Message }); diff --git a/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs b/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs index 393e8f42..d0053d84 100644 --- a/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Aevatar.Studio.Hosting.Endpoints; using Aevatar.Studio.Infrastructure.DependencyInjection; using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Projection.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -29,6 +30,7 @@ internal static IServiceCollection AddStudioHostingCore( services.AddSingleton(); services.AddStudioApplication(); services.AddStudioInfrastructure(configuration); + services.AddStudioProjectionComponents(); return services; } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs index 6fe694aa..0db51661 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedNyxIdUserLlmPreferencesStore.cs @@ -4,22 +4,21 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// -/// Actor-backed implementation of . -/// Read-only view that extracts LLM preferences from the same -/// backed by UserConfigGAgent. +/// Read-only view that extracts LLM preferences from the user config +/// projection via . /// internal sealed class ActorBackedNyxIdUserLlmPreferencesStore : INyxIdUserLlmPreferencesStore { - private readonly IUserConfigStore _userConfigStore; + private readonly IUserConfigQueryPort _queryPort; - public ActorBackedNyxIdUserLlmPreferencesStore(IUserConfigStore userConfigStore) + public ActorBackedNyxIdUserLlmPreferencesStore(IUserConfigQueryPort queryPort) { - _userConfigStore = userConfigStore ?? throw new ArgumentNullException(nameof(userConfigStore)); + _queryPort = queryPort ?? throw new ArgumentNullException(nameof(queryPort)); } public async Task GetAsync(CancellationToken cancellationToken = default) { - var config = await _userConfigStore.GetAsync(cancellationToken); + var config = await _queryPort.GetAsync(cancellationToken); return new NyxIdUserLlmPreferences( config.DefaultModel, UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs deleted file mode 100644 index c6d1d950..00000000 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserConfigStore.cs +++ /dev/null @@ -1,107 +0,0 @@ -using Aevatar.Foundation.Abstractions; -using Aevatar.GAgents.UserConfig; -using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; -using Aevatar.Studio.Infrastructure.Storage; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Aevatar.Studio.Infrastructure.ActorBacked; - -/// -/// Actor-backed implementation of . -/// Reads the write actor's state directly. -/// Writes send commands to the Write GAgent. -/// Per-scope isolation: each scope gets its own user-config-{scopeId} actor. -/// -internal sealed class ActorBackedUserConfigStore : IUserConfigStore -{ - private const string WriteActorIdPrefix = "user-config-"; - - private readonly IActorRuntime _runtime; - private readonly IAppScopeResolver _scopeResolver; - private readonly StudioStorageOptions _storageOptions; - private readonly ILogger _logger; - - public ActorBackedUserConfigStore( - IActorRuntime runtime, - IAppScopeResolver scopeResolver, - IOptions storageOptions, - ILogger logger) - { - _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); - _storageOptions = storageOptions?.Value ?? new StudioStorageOptions(); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task GetAsync(CancellationToken cancellationToken = default) - { - var state = await ReadWriteActorStateAsync(cancellationToken); - if (state is null) - return CreateDefaultConfig(); - - return new UserConfig( - DefaultModel: state.DefaultModel, - PreferredLlmRoute: string.IsNullOrEmpty(state.PreferredLlmRoute) - ? UserConfigLlmRouteDefaults.Gateway - : state.PreferredLlmRoute, - RuntimeMode: string.IsNullOrEmpty(state.RuntimeMode) - ? UserConfigRuntimeDefaults.LocalMode - : state.RuntimeMode, - LocalRuntimeBaseUrl: string.IsNullOrEmpty(state.LocalRuntimeBaseUrl) - ? _storageOptions.ResolveDefaultLocalRuntimeBaseUrl() - : state.LocalRuntimeBaseUrl, - RemoteRuntimeBaseUrl: string.IsNullOrEmpty(state.RemoteRuntimeBaseUrl) - ? _storageOptions.ResolveDefaultRemoteRuntimeBaseUrl() - : state.RemoteRuntimeBaseUrl, - MaxToolRounds: state.MaxToolRounds); - } - - public async Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) - { - var actor = await EnsureWriteActorAsync(cancellationToken); - var evt = new UserConfigUpdatedEvent - { - DefaultModel = config.DefaultModel, - PreferredLlmRoute = UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), - RuntimeMode = UserConfigRuntime.NormalizeMode(config.RuntimeMode), - LocalRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( - config.LocalRuntimeBaseUrl, - _storageOptions.ResolveDefaultLocalRuntimeBaseUrl()), - RemoteRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( - config.RemoteRuntimeBaseUrl, - _storageOptions.ResolveDefaultRemoteRuntimeBaseUrl()), - MaxToolRounds = config.MaxToolRounds, - }; - await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); - } - - // ── Read write actor state directly ── - - private async Task ReadWriteActorStateAsync(CancellationToken ct) - { - var actorId = ResolveWriteActorId(); - var actor = await _runtime.GetAsync(actorId); - return (actor?.Agent as IAgent)?.State; - } - - // ── Actor resolution ── - - private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); - - private async Task EnsureWriteActorAsync(CancellationToken ct) - { - var actorId = ResolveWriteActorId(); - var actor = await _runtime.GetAsync(actorId); - return actor ?? await _runtime.CreateAsync(actorId, ct); - } - - private UserConfig CreateDefaultConfig() => - new( - DefaultModel: string.Empty, - PreferredLlmRoute: UserConfigLlmRouteDefaults.Gateway, - RuntimeMode: UserConfigRuntimeDefaults.LocalMode, - LocalRuntimeBaseUrl: _storageOptions.ResolveDefaultLocalRuntimeBaseUrl(), - RemoteRuntimeBaseUrl: _storageOptions.ResolveDefaultRemoteRuntimeBaseUrl()); -} diff --git a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 93a2ae3d..a9999f13 100644 --- a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -33,7 +33,6 @@ public static IServiceCollection AddStudioInfrastructure( // ── Actor-backed stores (replacing ChronoStorage* implementations) ── services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj index deb82bbf..eae0e9f0 100644 --- a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj +++ b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs new file mode 100644 index 00000000..1eb16bfc --- /dev/null +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs @@ -0,0 +1,58 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.UserConfig; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.CommandServices; + +/// +/// Dispatches user-config write commands to the UserConfigGAgent +/// via . +/// +internal sealed class ActorDispatchUserConfigCommandService : IUserConfigCommandService +{ + private const string ActorIdPrefix = "user-config-"; + + private readonly IActorDispatchPort _dispatchPort; + private readonly IAppScopeResolver _scopeResolver; + + public ActorDispatchUserConfigCommandService( + IActorDispatchPort dispatchPort, + IAppScopeResolver scopeResolver) + { + _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + } + + public Task SaveAsync(UserConfig config, CancellationToken ct = default) + { + var actorId = ActorIdPrefix + (_scopeResolver.Resolve()?.ScopeId ?? "default"); + + var evt = new UserConfigUpdatedEvent + { + DefaultModel = config.DefaultModel, + PreferredLlmRoute = UserConfigLlmRoute.Normalize(config.PreferredLlmRoute), + RuntimeMode = UserConfigRuntime.NormalizeMode(config.RuntimeMode), + LocalRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( + config.LocalRuntimeBaseUrl, + UserConfigRuntimeDefaults.LocalRuntimeBaseUrl), + RemoteRuntimeBaseUrl = UserConfigRuntime.NormalizeBaseUrl( + config.RemoteRuntimeBaseUrl, + UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl), + MaxToolRounds = config.MaxToolRounds, + }; + + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(evt), + Route = EnvelopeRouteSemantics.CreateTopologyPublication( + actorId, TopologyAudience.Self), + }; + + return _dispatchPort.DispatchAsync(actorId, envelope, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 1b5bde54..af843b8b 100644 --- a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,7 +1,10 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.DependencyInjection; using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.DependencyInjection; using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Projection.CommandServices; using Aevatar.Studio.Projection.Metadata; using Aevatar.Studio.Projection.Orchestration; using Aevatar.Studio.Projection.Projectors; @@ -16,12 +19,15 @@ public static class ServiceCollectionExtensions { /// /// Registers Studio projection components: materialization runtime, - /// projectors, query ports, and document metadata providers. + /// projectors, query ports, command services, and document metadata providers. /// public static IServiceCollection AddStudioProjectionComponents(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); + // Projection read-model runtime (write dispatcher + sink bindings) + services.AddProjectionReadModelRuntime(); + // Projection clock (shared, idempotent registration) services.TryAddSingleton(); @@ -47,9 +53,12 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl IProjectionDocumentMetadataProvider, UserConfigCurrentStateDocumentMetadataProvider>(); - // Query ports + // Query ports (read side) services.TryAddSingleton(); + // Command services (write side) + services.TryAddSingleton(); + return services; } } diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs index 9f7c9c8e..5c0019d5 100644 --- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs @@ -2,7 +2,6 @@ using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; using Aevatar.Studio.Projection.ReadModels; -using Microsoft.Extensions.Options; namespace Aevatar.Studio.Projection.QueryPorts; diff --git a/src/platform/Aevatar.GAgentService.Hosting/Aevatar.GAgentService.Hosting.csproj b/src/platform/Aevatar.GAgentService.Hosting/Aevatar.GAgentService.Hosting.csproj index 7854f1fb..a81a28d8 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Aevatar.GAgentService.Hosting.csproj +++ b/src/platform/Aevatar.GAgentService.Hosting/Aevatar.GAgentService.Hosting.csproj @@ -18,6 +18,7 @@ + diff --git a/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs b/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs index 679bc1bf..801f5ee9 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/DependencyInjection/ServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using Aevatar.GAgentService.Projection.DependencyInjection; using Aevatar.GAgentService.Projection.ReadModels; using Aevatar.Scripting.Core.Ports; +using Aevatar.Studio.Projection.ReadModels; using Aevatar.Scripting.Hosting.DependencyInjection; using Aevatar.Workflow.Application.Abstractions.Queries; using Aevatar.Workflow.Infrastructure.DependencyInjection; @@ -130,6 +131,11 @@ public static IServiceCollection AddGAgentServiceProjectionReadModelProviders( metadataFactory: sp => sp.GetRequiredService>().Metadata, keySelector: readModel => readModel.Id, keyFormatter: key => key); + services.AddElasticsearchDocumentProjectionStore( + optionsFactory: _ => BuildElasticsearchDocumentOptions(configuration), + metadataFactory: sp => sp.GetRequiredService>().Metadata, + keySelector: readModel => readModel.Id, + keyFormatter: key => key); } else { @@ -157,6 +163,10 @@ public static IServiceCollection AddGAgentServiceProjectionReadModelProviders( keySelector: readModel => readModel.Id, keyFormatter: key => key, defaultSortSelector: readModel => readModel.UpdatedAt); + services.AddInMemoryDocumentProjectionStore( + keySelector: readModel => readModel.Id, + keyFormatter: key => key, + defaultSortSelector: readModel => readModel.UpdatedAt); } return services; diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs index 35e00108..3dfe1344 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs @@ -395,7 +395,7 @@ or AGUIEvent.EventOneofCase.RunError chatRequest.Metadata[LLMRequestMetadataKeys.NyxIdAccessToken] = bearerToken; // Forward the user's preferred model from their config. - var userConfigStore = http.RequestServices.GetService(); + var userConfigStore = http.RequestServices.GetService(); if (userConfigStore != null) { try diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs index 57ad6afa..89eebc93 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs @@ -1881,7 +1881,7 @@ private static async Task> BuildScopedHeadersAsync( InjectBearerToken(http, scopedHeaders); if (http != null) { - var userConfigStore = http.RequestServices.GetService(); + var userConfigStore = http.RequestServices.GetService(); if (userConfigStore != null) { try diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowEndpoints.cs index 9e17b8e5..e6c4e2f0 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeWorkflowEndpoints.cs @@ -530,7 +530,7 @@ private static async Task> BuildScopedHeadersAsync( scopedHeaders[ConnectorRequest.HttpAuthorizationMetadataKey] = $"Bearer {bearerToken}"; } - var userConfigStore = http.RequestServices.GetService(); + var userConfigStore = http.RequestServices.GetService(); if (userConfigStore != null) { try diff --git a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs deleted file mode 100644 index bda9c3f3..00000000 --- a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs +++ /dev/null @@ -1,1095 +0,0 @@ -using System.Reflection; -using System.Text; -using System.Text.Json; -using Aevatar.AI.Abstractions; -using Aevatar.AI.Abstractions.LLMProviders; -using Aevatar.Authentication.Abstractions; -using Aevatar.Foundation.Abstractions; -using Aevatar.Foundation.Abstractions.Streaming; -using Aevatar.GAgents.NyxidChat; -using FluentAssertions; -using Google.Protobuf; -using Google.Protobuf.Collections; -using Any = Google.Protobuf.WellKnownTypes.Any; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Hosting; - -namespace Aevatar.AI.Tests; - -public class NyxIdChatEndpointsCoverageTests -{ - private static readonly System.Type EndpointsType = typeof(NyxIdChatEndpoints); - - [Fact] - public void MapNyxIdChatEndpoints_ShouldRegisterExpectedRoutes() - { - var builder = WebApplication.CreateBuilder(new WebApplicationOptions - { - EnvironmentName = Environments.Development, - }); - - var app = builder.Build(); - var routeBuilder = (IEndpointRouteBuilder)app; - app.MapNyxIdChatEndpoints(); - - var routes = routeBuilder.DataSources - .SelectMany(x => x.Endpoints) - .OfType() - .Select(x => x.RoutePattern.RawText) - .ToHashSet(StringComparer.Ordinal); - - routes.Should().Contain("/api/scopes/{scopeId}/nyxid-chat/conversations"); - routes.Should().Contain("/api/scopes/{scopeId}/nyxid-chat/conversations/{actorId}:stream"); - routes.Should().Contain("/api/scopes/{scopeId}/nyxid-chat/conversations/{actorId}:approve"); - routes.Should().Contain("/api/webhooks/nyxid-relay"); - } - - [Fact] - public async Task HandleCreateConversationAsync_ShouldReturnCreatedConversation() - { - var store = new NyxIdChatActorStore(); - var result = await InvokeResultAsync( - "HandleCreateConversationAsync", - new DefaultHttpContext(), - "scope-a", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - using var doc = JsonDocument.Parse(response.Body); - doc.RootElement.TryGetProperty("actorId", out var actorId).Should().BeTrue(); - actorId.GetString().Should().NotBeNullOrWhiteSpace(); - doc.RootElement.TryGetProperty("createdAt", out _).Should().BeTrue(); - (await store.ListActorsAsync("scope-a")).Should().ContainSingle(); - } - - [Fact] - public async Task HandleListConversationsAsync_ShouldReturnList() - { - var store = new NyxIdChatActorStore(); - await store.CreateActorAsync("scope-a"); - - var result = await InvokeResultAsync( - "HandleListConversationsAsync", - new DefaultHttpContext(), - "scope-a", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - using var doc = JsonDocument.Parse(response.Body); - doc.RootElement.GetArrayLength().Should().Be(1); - } - - [Fact] - public async Task HandleDeleteConversationAsync_ShouldReturnNotFound_WhenMissing() - { - var store = new NyxIdChatActorStore(); - var result = await InvokeResultAsync( - "HandleDeleteConversationAsync", - new DefaultHttpContext(), - "scope-a", - "missing-id", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldRejectWithoutAuthorization() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer"; - var runtime = new StubActorRuntime(); - var subscriptions = new StubSubscriptionProvider(); - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello"), - runtime, - subscriptions, - NullLoggerFactory.Instance, - CancellationToken.None); - context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldRejectWhenNoPromptAndNoInputParts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - var runtime = new StubActorRuntime(); - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest(null), - runtime, - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - } - - [Fact] - public async Task HandleApproveAsync_ShouldRejectWithoutAuthorization() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer"; - var runtime = new StubActorRuntime(); - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest("req"), - runtime, - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); - } - - [Fact] - public async Task HandleApproveAsync_ShouldRejectWhenRequestIdMissing() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - var runtime = new StubActorRuntime(); - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest(null), - runtime, - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldDispatchChatRequest_AndWriteRunFinished() - { - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .AddSingleton(new StubPreferencesStore("relay-model", "/relay-route", 7)) - .AddSingleton(new StubUserMemoryStore("remember this")) - .BuildServiceProvider(), - }; - context.Request.Headers.Authorization = "Bearer valid-token"; - context.Response.Body = new MemoryStream(); - - var runtime = new StubActorRuntime(); - var subscriptions = new StubSubscriptionProvider - { - Messages = - { - new EventEnvelope { Payload = Any.Pack(new TextMessageStartEvent()) }, - new EventEnvelope { Payload = Any.Pack(new TextMessageContentEvent { Delta = "hello" }) }, - new EventEnvelope { Payload = Any.Pack(new TextMessageEndEvent { Content = "done" }) }, - }, - }; - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello there"), - runtime, - subscriptions, - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - runtime.CreateCalls.Should().ContainSingle(); - var actor = runtime.Actors["actor-1"].Should().BeOfType().Subject; - var chatRequest = actor.HandledEnvelopes.Should().ContainSingle().Subject.Payload.Unpack(); - chatRequest.Prompt.Should().Be("hello there"); - chatRequest.ScopeId.Should().Be("scope-a"); - chatRequest.Metadata[LLMRequestMetadataKeys.NyxIdAccessToken].Should().Be("valid-token"); - chatRequest.Metadata["scope_id"].Should().Be("scope-a"); - chatRequest.Metadata[LLMRequestMetadataKeys.ModelOverride].Should().Be("relay-model"); - chatRequest.Metadata[LLMRequestMetadataKeys.NyxIdRoutePreference].Should().Be("/relay-route"); - chatRequest.Metadata[LLMRequestMetadataKeys.MaxToolRoundsOverride].Should().Be("7"); - chatRequest.Metadata[LLMRequestMetadataKeys.UserMemoryPrompt].Should().Be("remember this"); - - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("RUN_STARTED"); - body.Should().Contain("TEXT_MESSAGE_START"); - body.Should().Contain("hello"); - body.Should().Contain("TEXT_MESSAGE_END"); - body.Should().Contain("RUN_FINISHED"); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldReturn500_WhenFailureOccursBeforeWriterStarts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello"), - new ThrowingActorRuntime(new InvalidOperationException("runtime failed")), - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - } - - [Fact] - public async Task HandleStreamMessageAsync_ShouldWriteRunError_WhenFailureOccursAfterWriterStarts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - context.Response.Body = new MemoryStream(); - - var runtime = new StubActorRuntime(); - runtime.Actors["actor-1"] = new StubActor("actor-1"); - - await InvokeTaskAsync( - "HandleStreamMessageAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello"), - runtime, - new ThrowingSubscriptionProvider(new InvalidOperationException("subscription failed")), - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("RUN_STARTED"); - body.Should().Contain("RUN_ERROR"); - body.Should().Contain("subscription failed"); - } - - [Fact] - public async Task HandleApproveAsync_ShouldDispatchDecision_AndWriteRunFinished() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - context.Response.Body = new MemoryStream(); - - var runtime = new StubActorRuntime(); - runtime.Actors["actor-1"] = new StubActor("actor-1"); - var subscriptions = new StubSubscriptionProvider - { - Messages = - { - new EventEnvelope { Payload = Any.Pack(new TextMessageStartEvent()) }, - new EventEnvelope { Payload = Any.Pack(new TextMessageEndEvent { Content = "done" }) }, - }, - }; - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest("req-1", Approved: false, Reason: "deny", SessionId: "session-1"), - runtime, - subscriptions, - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - var actor = runtime.Actors["actor-1"].Should().BeOfType().Subject; - var decision = actor.HandledEnvelopes.Should().ContainSingle().Subject.Payload.Unpack(); - decision.RequestId.Should().Be("req-1"); - decision.Approved.Should().BeFalse(); - decision.Reason.Should().Be("deny"); - decision.SessionId.Should().Be("session-1"); - - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("RUN_STARTED"); - body.Should().Contain("RUN_FINISHED"); - } - - [Fact] - public async Task HandleApproveAsync_ShouldReturn500_WhenFailureOccursBeforeWriterStarts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest("req-1"), - new ThrowingActorRuntime(new InvalidOperationException("runtime failed")), - new StubSubscriptionProvider(), - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); - } - - [Fact] - public async Task HandleApproveAsync_ShouldWriteRunError_WhenFailureOccursAfterWriterStarts() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Authorization = "Bearer valid-token"; - context.Response.Body = new MemoryStream(); - - var runtime = new StubActorRuntime(); - runtime.Actors["actor-1"] = new StubActor("actor-1"); - - await InvokeTaskAsync( - "HandleApproveAsync", - context, - "scope-a", - "actor-1", - new NyxIdChatEndpoints.NyxIdApprovalRequest("req-1"), - runtime, - new ThrowingSubscriptionProvider(new InvalidOperationException("approval subscription failed")), - NullLoggerFactory.Instance, - CancellationToken.None); - - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("RUN_STARTED"); - body.Should().Contain("RUN_ERROR"); - body.Should().Contain("approval subscription failed"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldReturnParseError_ForInvalidJson() - { - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{ invalid")); - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - new StubActorRuntime(), - new StubSubscriptionProvider(), - new NyxIdChatActorStore(), - new NyxIdRelayOptions(), - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("couldn't understand"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldRejectMissingText() - { - var payload = """{"content":{}}"""; - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(payload)); - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - new StubActorRuntime(), - new StubSubscriptionProvider(), - new NyxIdChatActorStore(), - new NyxIdRelayOptions(), - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("empty message"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldRejectWhenUserTokenMissing() - { - var payload = """{"content":{"text":"hello"}}"""; - var context = new DefaultHttpContext(); - context.Request.ContentType = "application/json"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(payload)); - context.RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(); - - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - new StubActorRuntime(), - new StubSubscriptionProvider(), - new NyxIdChatActorStore(), - new NyxIdRelayOptions(), - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("Authentication is not configured properly"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldReturnPartialResponse_WhenTimedOut() - { - var payload = """ - { - "message_id":"msg-1", - "platform":"slack", - "agent":{"api_key_id":"scope-a"}, - "conversation":{"platform_id":"room-1"}, - "content":{"text":"hello"} - } - """; - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(), - }; - context.Request.ContentType = "application/json"; - context.Request.Headers["X-NyxID-User-Token"] = "not-a-jwt"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(payload)); - - var runtime = new StubActorRuntime(); - var subscriptions = new StubSubscriptionProvider - { - Messages = - { - new EventEnvelope { Payload = Any.Pack(new TextMessageContentEvent { Delta = "partial reply" }) }, - }, - }; - var store = new NyxIdChatActorStore(); - - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - runtime, - subscriptions, - store, - new NyxIdRelayOptions { ResponseTimeoutSeconds = 0 }, - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("partial reply"); - (await store.ListActorsAsync("scope-a")).Should().ContainSingle("nyxid-relay-slack-room-1"); - } - - [Fact] - public async Task HandleRelayWebhookAsync_ShouldClassifyError_AndAppendDiagnostics() - { - var payload = """ - { - "message_id":"msg-2", - "platform":"discord", - "agent":{"api_key_id":"scope-b"}, - "conversation":{"id":"conv-1","platform_id":"room-2"}, - "content":{"text":"hello"} - } - """; - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .AddSingleton(new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Aevatar:NyxId:DefaultModel"] = "server-fallback", - }) - .Build()) - .BuildServiceProvider(), - }; - context.Request.ContentType = "application/json"; - context.Request.Headers["X-NyxID-User-Token"] = "not-a-jwt"; - context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(payload)); - - var result = await InvokeResultAsync( - "HandleRelayWebhookAsync", - context, - new StubActorRuntime(), - new StubSubscriptionProvider - { - Messages = - { - new EventEnvelope - { - Payload = Any.Pack(new TextMessageEndEvent - { - Content = "[[AEVATAR_LLM_ERROR]]request failed with 403", - }), - }, - }, - }, - new NyxIdChatActorStore(), - new NyxIdRelayOptions - { - ResponseTimeoutSeconds = 1, - EnableDebugDiagnostics = true, - }, - NullLoggerFactory.Instance, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("403 Forbidden"); - response.Body.Should().Contain("[Debug]"); - response.Body.Should().Contain("Route: gateway"); - response.Body.Should().Contain("Token: present"); - response.Body.Should().Contain("Scope: scope-b"); - } - - [Fact] - public void ExtractBearerToken_ShouldParseBearerHeaderAndIgnoreOthers() - { - var context = new DefaultHttpContext(); - var method = EndpointsType.GetMethod("ExtractBearerToken", BindingFlags.NonPublic | BindingFlags.Static)!; - - context.Request.Headers.Authorization = "Basic abc"; - method.Invoke(null, [context]).Should().BeNull(); - - context.Request.Headers.Authorization = "Bearer my-token"; - method.Invoke(null, [context]).Should().Be("my-token"); - } - - [Fact] - public void ComputeTokenHash_ShouldBeDeterministicShortLowercaseHex() - { - var method = EndpointsType.GetMethod("ComputeTokenHash", BindingFlags.NonPublic | BindingFlags.Static)!; - var first = method.Invoke(null, ["abc"])!.Should().NotBeNull().And.BeOfType().Subject; - var second = method.Invoke(null, ["abc"])!.Should().NotBeNull().And.BeOfType().Subject; - - first.Should().Be(second); - first.Length.Should().Be(16); - first.Should().MatchRegex("^[a-f0-9]{16}$"); - } - - [Fact] - public void BuildConnectedServicesContext_ShouldRenderServiceHintsAndFallbackMessage() - { - var method = EndpointsType.GetMethod("BuildConnectedServicesContext", BindingFlags.NonPublic | BindingFlags.Static)!; - - var arrayPayload = """ - [ - {"slug":"calendar","label":"Calendar","base_url":"https://api.example.com"} - ] - """; - var arrayContext = (string)method.Invoke(null, [arrayPayload])!; - arrayContext.Should().Contain("calendar"); - arrayContext.Should().Contain("Use nyxid_proxy"); - - var emptyContext = (string)method.Invoke(null, ["""{"services":[]}"""])!; - emptyContext.Should().Contain("No services connected yet"); - } - - [Fact] - public void BuildConnectedServicesContext_ShouldHandleDataShape_AndInvalidJson() - { - var method = EndpointsType.GetMethod("BuildConnectedServicesContext", BindingFlags.NonPublic | BindingFlags.Static)!; - - var dataPayload = """ - { - "data":[ - {"slug":"github","name":"GitHub","endpoint_url":"https://api.github.com"} - ] - } - """; - var dataContext = (string)method.Invoke(null, [dataPayload])!; - dataContext.Should().Contain("GitHub"); - dataContext.Should().Contain("https://api.github.com"); - - var invalidContext = (string)method.Invoke(null, ["{ invalid"])!; - invalidContext.Should().Contain("No services connected yet"); - invalidContext.Should().Contain("Use nyxid_proxy"); - } - - [Fact] - public void ClassifyError_ShouldMapKnownCodePatterns() - { - var method = EndpointsType.GetMethod("ClassifyError", BindingFlags.NonPublic | BindingFlags.Static)!; - - method.Invoke(null, ["request failed with 403"])!.Should().Be( - "Sorry, I can't reach the AI service right now (403 Forbidden)."); - method.Invoke(null, ["status=401 unauthorized"])!.Should().Be( - "Sorry, authentication with the AI service failed (401)."); - method.Invoke(null, ["service rate limit reached"])!.Should().Be( - "Sorry, the AI service is busy right now (429). Please wait a moment and try again."); - method.Invoke(null, ["LLM request timeout"])!.Should().Be( - "Sorry, the AI service took too long to respond. Please try again."); - method.Invoke(null, ["model `gpt-5` not found"])!.Should().Be( - "Sorry, the configured AI model is not available."); - method.Invoke(null, ["unknown issue"])!.Should().Be( - "Sorry, something went wrong while generating a response."); - } - - [Fact] - public void BuildRelayDiagnostic_ShouldUseServerDefaultsAndTokenFlag() - { - var method = EndpointsType.GetMethod("BuildRelayDiagnostic", BindingFlags.NonPublic | BindingFlags.Static)!; - var metadata = new MapField - { - [LLMRequestMetadataKeys.NyxIdRoutePreference] = "direct", - [LLMRequestMetadataKeys.ModelOverride] = "deepseek-chat", - [AevatarStandardClaimTypes.ScopeId] = "scope-a", - [LLMRequestMetadataKeys.NyxIdAccessToken] = "secret", - }; - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Aevatar:NyxId:DefaultModel"] = "fallback-model", - }) - .Build(); - - var diag = method.Invoke(null, [metadata, configuration, "LLM request failed: timeout"])!.Should() - .NotBeNull() - .And.BeOfType() - .Subject; - - diag.Should().Contain("Model: deepseek-chat (from config.json)"); - diag.Should().Contain("Route: direct"); - diag.Should().Contain("Scope: scope-a"); - diag.Should().Contain("Token: present"); - diag.Should().Contain("timeout"); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task MapAndWriteEventAsync_ShouldSerializePayloads(bool hasError) - { - var envelope = hasError - ? new EventEnvelope { Payload = Any.Pack(new TextMessageEndEvent { Content = hasError ? "[[AEVATAR_LLM_ERROR]]boom" : string.Empty }) } - : new EventEnvelope { Payload = Any.Pack(new TextMessageStartEvent()) }; - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(NyxIdChatEndpoints).Assembly, - "Aevatar.GAgents.NyxidChat.NyxIdChatSseWriter", - context.Response); - - var method = EndpointsType.GetMethod("MapAndWriteEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; - var terminal = await InvokeValueTaskAsync(method, envelope, "m-1", writer); - - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - if (hasError) - { - terminal.Should().Be("RUN_ERROR"); - body.Should().Contain("RUN_ERROR"); - } - else - { - terminal.Should().BeNull(); - body.Should().Contain("TEXT_MESSAGE_START"); - } - } - - [Fact] - public async Task MapAndWriteEventAsync_ShouldSerializeContentToolingMediaAndNormalEnd() - { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(NyxIdChatEndpoints).Assembly, - "Aevatar.GAgents.NyxidChat.NyxIdChatSseWriter", - context.Response); - - var method = EndpointsType.GetMethod("MapAndWriteEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; - - (await InvokeValueTaskAsync( - method, - new EventEnvelope { Payload = Any.Pack(new TextMessageContentEvent { Delta = "delta-1" }) }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new ToolCallEvent - { - ToolName = "search", - CallId = "call-1", - ArgumentsJson = "{\"q\":\"abc\"}", - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new ToolResultEvent - { - CallId = "call-1", - ResultJson = "{\"ok\":true}", - Success = true, - Error = string.Empty, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new ToolApprovalRequestEvent - { - RequestId = "req-1", - SessionId = "s1", - ToolName = "connector.run", - ToolCallId = "call-1", - ArgumentsJson = "{}", - IsDestructive = true, - TimeoutSeconds = 30, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Image, - Uri = "https://example.com/cat.png", - MediaType = "image/png", - Name = "cat", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Audio, - DataBase64 = "YXVkaW8=", - MediaType = "audio/mpeg", - Name = "clip", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Video, - Uri = "https://example.com/video.mp4", - MediaType = "video/mp4", - Name = "clip-video", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Text, - Text = "inline note", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Unspecified, - Name = "mystery", - }, - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope - { - Payload = Any.Pack(new MediaContentEvent - { - SessionId = "session-1", - AgentId = "agent-1", - }), - }, - "m-2", - writer)).Should().BeNull(); - (await InvokeValueTaskAsync( - method, - new EventEnvelope { Payload = Any.Pack(new TextMessageEndEvent { Content = "done" }) }, - "m-2", - writer)).Should().Be("TEXT_MESSAGE_END"); - - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("delta-1"); - body.Should().Contain("search"); - body.Should().Contain("call-1"); - body.Should().Contain("req-1"); - body.Should().Contain("cat.png"); - body.Should().Contain("\"kind\":\"audio\""); - body.Should().Contain("\"kind\":\"video\""); - body.Should().Contain("\"kind\":\"text\""); - body.Should().Contain("\"kind\":\"unknown\""); - body.Should().Contain("TEXT_MESSAGE_END"); - } - - private static async Task InvokeResultAsync(string methodName, params object[] args) - { - var method = EndpointsType.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static)!; - var result = method.Invoke(null, args); - return result switch - { - Task task => await task, - ValueTask valueTask => await valueTask, - _ => throw new InvalidOperationException($"Unexpected return type: {result?.GetType().FullName}"), - }; - } - - private static async Task InvokeTaskAsync(string methodName, params object[] args) - { - var method = EndpointsType.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static)!; - var result = method.Invoke(null, args)!; - switch (result) - { - case ValueTask valueTask: - await valueTask; - return; - case Task task: - await task; - return; - default: - throw new InvalidOperationException($"Unexpected return type: {result.GetType().FullName}"); - } - } - - private static async Task InvokeValueTaskAsync(MethodInfo method, params object[] args) - { - var result = method.Invoke(null, args)!; - return result switch - { - ValueTask task => await task, - Task task => await task, - _ => throw new InvalidOperationException($"Unexpected return type: {result.GetType().FullName}"), - }; - } - - private static async Task<(int StatusCode, string Body)> ExecuteResultAsync(IResult result) - { - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(), - }; - await using var body = new MemoryStream(); - context.Response.Body = body; - - await result.ExecuteAsync(context); - context.Response.Body.Position = 0; - return (context.Response.StatusCode, await new StreamReader(context.Response.Body).ReadToEndAsync()); - } - - private sealed class StubActorRuntime : IActorRuntime - { - public Dictionary Actors { get; } = []; - public List<(System.Type Type, string? Id)> CreateCalls { get; } = []; - - public Task GetAsync(string id) => Task.FromResult(Actors.GetValueOrDefault(id)); - - public Task CreateAsync(string? id = null, CancellationToken ct = default) - where TAgent : IAgent => CreateAsync(typeof(TAgent), id, ct); - - public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) - { - var actor = new StubActor(id ?? Guid.NewGuid().ToString("N")); - Actors[id ?? actor.Id] = actor; - CreateCalls.Add((agentType, id)); - return Task.FromResult(actor); - } - - public Task DestroyAsync(string id, CancellationToken ct = default) => Task.CompletedTask; - public Task ExistsAsync(string id) => Task.FromResult(Actors.ContainsKey(id)); - public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => Task.CompletedTask; - public Task UnlinkAsync(string childId, CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubActor : IActor - { - public StubActor(string id) => Id = id; - - public string Id { get; } - public IAgent Agent { get; } = new StubAgent(); - public List HandledEnvelopes { get; } = []; - public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) - { - HandledEnvelopes.Add(envelope); - return Task.CompletedTask; - } - public Task GetParentIdAsync() => Task.FromResult(null); - public Task> GetChildrenIdsAsync() => Task.FromResult>([]); - } - - private sealed class StubAgent : IAgent - { - public string Id => "agent"; - public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => Task.CompletedTask; - public Task GetDescriptionAsync() => Task.FromResult("stub"); - public Task> GetSubscribedEventTypesAsync() => Task.FromResult>([]); - public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubSubscriptionProvider : IActorEventSubscriptionProvider - { - public List Messages { get; } = []; - - public Task SubscribeAsync( - string actorId, - Func handler, - CancellationToken ct = default) - where TMessage : class, IMessage, new() - { - _ = actorId; - _ = ct; - if (typeof(TMessage) == typeof(EventEnvelope)) - { - foreach (var message in Messages) - handler((TMessage)(object)message).GetAwaiter().GetResult(); - } - return Task.FromResult(new NoopDisposable()); - } - } - - private sealed class ThrowingSubscriptionProvider(Exception exception) : IActorEventSubscriptionProvider - { - public Task SubscribeAsync( - string actorId, - Func handler, - CancellationToken ct = default) - where TMessage : class, IMessage, new() - { - _ = actorId; - _ = handler; - _ = ct; - throw exception; - } - } - - private sealed class ThrowingActorRuntime(Exception exception) : IActorRuntime - { - public Task GetAsync(string id) - { - _ = id; - throw exception; - } - - public Task CreateAsync(string? id = null, CancellationToken ct = default) - where TAgent : IAgent => - throw exception; - - public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) - { - _ = agentType; - _ = id; - _ = ct; - throw exception; - } - - public Task DestroyAsync(string id, CancellationToken ct = default) => Task.CompletedTask; - public Task ExistsAsync(string id) => Task.FromResult(false); - public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => Task.CompletedTask; - public Task UnlinkAsync(string childId, CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubPreferencesStore(string model, string route, int maxToolRounds) : INyxIdUserLlmPreferencesStore - { - public Task GetAsync(CancellationToken cancellationToken = default) => - Task.FromResult(new NyxIdUserLlmPreferences(model, route, maxToolRounds)); - } - - private sealed class StubUserMemoryStore(string promptSection) : IUserMemoryStore - { - public Task GetAsync(CancellationToken ct = default) => - Task.FromResult(UserMemoryDocument.Empty); - - public Task SaveAsync(UserMemoryDocument document, CancellationToken ct = default) => Task.CompletedTask; - - public Task AddEntryAsync(string category, string content, string source, CancellationToken ct = default) => - Task.FromResult(new UserMemoryEntry("id", category, content, source, 0, 0)); - - public Task RemoveEntryAsync(string id, CancellationToken ct = default) => Task.FromResult(true); - - public Task BuildPromptSectionAsync(int maxChars = 2000, CancellationToken ct = default) => - Task.FromResult(promptSection); - } - - private sealed class NoopDisposable : IAsyncDisposable - { - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/test/Aevatar.AI.Tests/NyxIdChatSupportCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatSupportCoverageTests.cs deleted file mode 100644 index d205f4fd..00000000 --- a/test/Aevatar.AI.Tests/NyxIdChatSupportCoverageTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.IO; -using Aevatar.AI.Abstractions; -using Aevatar.GAgents.NyxidChat; -using FluentAssertions; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace Aevatar.AI.Tests; - -public class NyxIdChatSupportCoverageTests -{ - [Fact] - public async Task ActorStore_ShouldNormalizeScopes_AndKeepEnsureIdempotent() - { - var store = new NyxIdChatActorStore(); - - await store.EnsureActorAsync(" Scope-A ", "actor-1"); - await store.EnsureActorAsync("scope-a", "actor-1"); - var created = await store.CreateActorAsync("SCOPE-A"); - - var listed = await store.ListActorsAsync("scope-a"); - - listed.Should().HaveCount(2); - listed.Select(x => x.ActorId).Should().Contain(["actor-1", created.ActorId]); - - (await store.DeleteActorAsync("scope-a", "actor-1")).Should().BeTrue(); - (await store.DeleteActorAsync("scope-a", "actor-1")).Should().BeFalse(); - (await store.ListActorsAsync("scope-a")).Should().ContainSingle(x => x.ActorId == created.ActorId); - } - - [Fact] - public void AddNyxIdChat_ShouldRegisterSingletonStore_AndBindRelayOptions() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Aevatar:NyxId:Relay:ResponseTimeoutSeconds"] = "45", - ["Aevatar:NyxId:Relay:EnableDebugDiagnostics"] = "true", - }) - .Build(); - - var provider = new ServiceCollection() - .AddNyxIdChat(configuration) - .BuildServiceProvider(); - - var store1 = provider.GetRequiredService(); - var store2 = provider.GetRequiredService(); - var options = provider.GetRequiredService(); - - store1.Should().BeSameAs(store2); - options.ResponseTimeoutSeconds.Should().Be(45); - options.EnableDebugDiagnostics.Should().BeTrue(); - } - - [Fact] - public void GenerateActorId_ShouldUseStablePrefix_AndProduceUniqueValues() - { - var first = NyxIdChatServiceDefaults.GenerateActorId(); - var second = NyxIdChatServiceDefaults.GenerateActorId(); - - first.Should().StartWith($"{NyxIdChatServiceDefaults.ActorIdPrefix}-"); - second.Should().StartWith($"{NyxIdChatServiceDefaults.ActorIdPrefix}-"); - first.Should().NotBe(second); - } - - [Fact] - public async Task NyxIdChatSseWriter_ShouldStartStream_AndSerializeFrames() - { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(NyxIdChatGAgent).Assembly, - "Aevatar.GAgents.NyxidChat.NyxIdChatSseWriter", - context.Response); - - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteTextStartAsync", "msg-1", CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteTextDeltaAsync", "hello", CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync( - writer, - "WriteMediaContentAsync", - new MediaContentEvent - { - Part = new ChatContentPart - { - Kind = ChatContentPartKind.Image, - DataBase64 = "AQID", - MediaType = "image/png", - Name = "preview", - } - }, - CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync( - writer, - "WriteToolApprovalRequestAsync", - "req-1", - "connector.run", - "call-1", - """{"slug":"telegram"}""", - true, - 30, - CancellationToken.None); - - AgentCoverageTestSupport.GetBooleanProperty(writer, "Started").Should().BeTrue(); - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - context.Response.Headers.ContentType.ToString().Should().Be("text/event-stream; charset=utf-8"); - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("TEXT_MESSAGE_START"); - body.Should().Contain("TEXT_MESSAGE_CONTENT"); - body.Should().Contain("MEDIA_CONTENT"); - body.Should().Contain("\"kind\":\"image\""); - body.Should().Contain("TOOL_APPROVAL_REQUEST"); - } -} diff --git a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs deleted file mode 100644 index 99888d6b..00000000 --- a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs +++ /dev/null @@ -1,569 +0,0 @@ -using System.IO; -using System.Reflection; -using Aevatar.Foundation.Abstractions; -using Aevatar.AI.Abstractions; -using Aevatar.Foundation.Core.EventSourcing; -using Aevatar.Foundation.Abstractions.Streaming; -using Google.Protobuf; -using Any = Google.Protobuf.WellKnownTypes.Any; -using Aevatar.GAgents.StreamingProxy; -using FluentAssertions; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using static Aevatar.GAgents.StreamingProxy.StreamingProxyEndpoints; - -namespace Aevatar.AI.Tests; - -public class StreamingProxyCoverageTests -{ - [Fact] - public void AddStreamingProxy_ShouldRegisterSingletonStore() - { - var provider = new ServiceCollection() - .AddStreamingProxy() - .BuildServiceProvider(); - - var first = provider.GetRequiredService(); - var second = provider.GetRequiredService(); - - first.Should().BeSameAs(second); - } - - [Fact] - public void MapStreamingProxyEndpoints_ShouldRegisterExpectedRoutes() - { - var builder = WebApplication.CreateBuilder(new WebApplicationOptions - { - EnvironmentName = Environments.Development, - }); - var app = builder.Build(); - var routeBuilder = (IEndpointRouteBuilder)app; - - app.MapStreamingProxyEndpoints(); - - var routes = routeBuilder.DataSources - .SelectMany(x => x.Endpoints) - .OfType() - .Select(x => x.RoutePattern.RawText) - .ToHashSet(StringComparer.Ordinal); - - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms"); - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms/{roomId}:chat"); - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms/{roomId}/messages"); - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms/{roomId}/messages:stream"); - routes.Should().Contain("/api/scopes/{scopeId}/streaming-proxy/rooms/{roomId}/participants"); - } - - [Fact] - public async Task HandleCreateRoomAsync_ShouldCreateRoomAndInitActor() - { - var store = new StreamingProxyActorStore(); - var runtime = new StubActorRuntime(); - var request = new CreateRoomRequest("Project X"); - - var result = await InvokeResultAsync( - "HandleCreateRoomAsync", - new DefaultHttpContext(), - "scope-a", - request, - store, - runtime, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("roomName"); - (await store.ListRoomsAsync("scope-a")).Should().HaveCount(1); - runtime.CreateCalls.Should().ContainSingle(); - runtime.CreateCalls[0].agentType.Should().Be(typeof(StreamingProxyGAgent)); - } - - [Fact] - public async Task HandleListRoomsAsync_ShouldReturnRoomsForScope() - { - var store = new StreamingProxyActorStore(); - var room = await store.CreateRoomAsync("scope-a", "Room One"); - - var result = await InvokeResultAsync( - "HandleListRoomsAsync", - new DefaultHttpContext(), - "scope-a", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain(room.RoomId); - } - - [Fact] - public async Task HandleDeleteRoomAsync_ShouldReturnNotFound_WhenMissing() - { - var result = await InvokeResultAsync( - "HandleDeleteRoomAsync", - new DefaultHttpContext(), - "scope-a", - "missing", - new StreamingProxyActorStore(), - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } - - [Fact] - public async Task HandleChatAsync_ShouldRejectEmptyPrompt() - { - var context = new DefaultHttpContext(); - var runtime = new StubActorRuntime(); - var subscriptions = new StubSubscriptionProvider(); - - var method = typeof(StreamingProxyEndpoints).GetMethod( - "HandleChatAsync", - BindingFlags.NonPublic | BindingFlags.Static)!; - var task = method.Invoke(null, [context, "scope-a", "room-a", new ChatTopicRequest(null), runtime, subscriptions, NullLoggerFactory.Instance, CancellationToken.None]); - await InvokeTaskAsync(task); - - context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - } - - [Fact] - public async Task HandleMessageStreamAsync_ShouldRejectMissingRoom() - { - var context = new DefaultHttpContext(); - var runtime = new StubActorRuntime(); - var method = typeof(StreamingProxyEndpoints).GetMethod( - "HandleMessageStreamAsync", - BindingFlags.NonPublic | BindingFlags.Static)!; - - var task = method.Invoke( - null, - [context, "scope-a", "missing", runtime, new StubSubscriptionProvider(), NullLoggerFactory.Instance, CancellationToken.None]); - await InvokeTaskAsync(task); - - context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); - } - - [Fact] - public async Task HandlePostMessageAsync_ShouldRejectMissingFieldsAndReturnAccepted() - { - var result = await InvokeResultAsync( - "HandlePostMessageAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - new PostMessageRequest(null, "name", "content"), - new StubActorRuntime(), - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - - result = await InvokeResultAsync( - "HandlePostMessageAsync", - new DefaultHttpContext(), - "scope-a", - "missing-room", - new PostMessageRequest("agent", null, "content"), - new StubActorRuntime(), - CancellationToken.None); - - response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status404NotFound); - - var runtime = new StubActorRuntime(new List { new StubActor("room-a") }); - result = await InvokeResultAsync( - "HandlePostMessageAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - new PostMessageRequest("agent", null, "content"), - runtime, - CancellationToken.None); - - response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - ((StubActor)runtime.Actors["room-a"]).HandleEventCalls.Should().Be(1); - } - - [Fact] - public async Task HandleJoinAsync_ShouldRejectMissingAgentIdAndAddParticipant() - { - var store = new StreamingProxyActorStore(); - var runtime = new StubActorRuntime(new List { new StubActor("room-a") }); - - var result = await InvokeResultAsync( - "HandleJoinAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - new JoinRoomRequest(null, null), - runtime, - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); - - var joinRequest = new JoinRoomRequest("agent-1", "Alice"); - result = await InvokeResultAsync( - "HandleJoinAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - joinRequest, - runtime, - store, - CancellationToken.None); - - response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - store.ListParticipants("scope-a", "room-a").Should().ContainSingle(x => x.AgentId == "agent-1"); - ((StubActor)runtime.Actors["room-a"]).HandleEventCalls.Should().Be(1); - } - - [Fact] - public async Task MapAndWriteEventAsync_ShouldWriteTopicAndAgentFrames() - { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(StreamingProxyGAgent).Assembly, - "Aevatar.GAgents.StreamingProxy.StreamingProxySseWriter", - context.Response); - - var method = typeof(StreamingProxyEndpoints).GetMethod( - "MapAndWriteEventAsync", - BindingFlags.NonPublic | BindingFlags.Static)!; - var methodCalls = new[] - { - new EventEnvelope { Payload = Any.Pack(new GroupChatTopicEvent { Prompt = "topic", SessionId = "s1" }) }, - new EventEnvelope { Payload = Any.Pack(new GroupChatMessageEvent { AgentId = "a1", AgentName = "A1", Content = "hi", SessionId = "s1" }) }, - new EventEnvelope { Payload = Any.Pack(new GroupChatParticipantJoinedEvent { AgentId = "a1", DisplayName = "A1" }) }, - new EventEnvelope { Payload = Any.Pack(new GroupChatParticipantLeftEvent { AgentId = "a1" }) }, - }; - - foreach (var envelope in methodCalls) - { - var result = method.Invoke(null, [envelope, writer])!; - switch (result) - { - case ValueTask valueTask: - await valueTask; - break; - case Task task: - await task; - break; - default: - break; - } - } - - context.Response.Body.Position = 0; - var body = new StreamReader(context.Response.Body).ReadToEnd(); - body.Should().Contain("TOPIC_STARTED"); - body.Should().Contain("AGENT_MESSAGE"); - body.Should().Contain("PARTICIPANT_JOINED"); - body.Should().Contain("PARTICIPANT_LEFT"); - } - - [Fact] - public async Task HandleListParticipantsAsync_ShouldReturnStoreParticipants() - { - var store = new StreamingProxyActorStore(); - store.AddParticipant("scope-a", "room-a", "agent-1", "Alice"); - - var result = await InvokeResultAsync( - "HandleListParticipantsAsync", - new DefaultHttpContext(), - "scope-a", - "room-a", - store, - CancellationToken.None); - - var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status200OK); - response.Body.Should().Contain("Alice"); - } - - [Fact] - public async Task ActorStore_ShouldNormalizeScopes_ApplyDefaultRoomName_AndReplaceParticipants() - { - var store = new StreamingProxyActorStore(); - - store.ListParticipants("scope-a", "room-1").Should().BeEmpty(); - - var room = await store.CreateRoomAsync(" Scope-A ", roomName: null); - room.RoomName.Should().Be("Group Chat"); - - var rooms = await store.ListRoomsAsync("scope-a"); - rooms.Should().ContainSingle(x => x.RoomId == room.RoomId); - - store.AddParticipant("scope-a", room.RoomId, "agent-1", "Alice"); - store.AddParticipant("scope-a", room.RoomId, "agent-1", "Alice Updated"); - - var participants = store.ListParticipants("SCOPE-A", room.RoomId); - participants.Should().ContainSingle(); - participants[0].DisplayName.Should().Be("Alice Updated"); - - (await store.DeleteRoomAsync("scope-a", room.RoomId)).Should().BeTrue(); - (await store.DeleteRoomAsync("scope-a", room.RoomId)).Should().BeFalse(); - } - - [Fact] - public async Task GAgent_ShouldTrackRoomMessagesAndParticipantLifecycle() - { - using var provider = AgentCoverageTestSupport.BuildServiceProvider(); - var agent = CreateAgent(provider, "streaming-proxy-agent"); - var publisher = new TestRecordingEventPublisher(); - agent.EventPublisher = publisher; - - await agent.ActivateAsync(); - await agent.HandleGroupChatRoomInitialized(new GroupChatRoomInitializedEvent { RoomName = "Nyx Room" }); - await agent.HandleGroupChatParticipantJoined(new GroupChatParticipantJoinedEvent - { - AgentId = "agent-1", - DisplayName = "Alice", - }); - await agent.HandleGroupChatParticipantJoined(new GroupChatParticipantJoinedEvent - { - AgentId = "agent-1", - DisplayName = "Alice Updated", - }); - await agent.HandleChatRequest(new ChatRequestEvent - { - Prompt = "Discuss the webhook setup", - SessionId = "room-session", - }); - await agent.HandleGroupChatMessage(new GroupChatMessageEvent - { - AgentId = "agent-2", - AgentName = "Bob", - Content = "I can help with that.", - SessionId = "room-session", - }); - await agent.HandleGroupChatParticipantLeft(new GroupChatParticipantLeftEvent { AgentId = "agent-1" }); - - var state = AgentCoverageTestSupport.ReadPrivateField(agent, "_proxyState"); - state.RoomName.Should().Be("Nyx Room"); - state.NextSequence.Should().Be(2); - state.Messages.Should().HaveCount(2); - state.Messages[0].IsTopic.Should().BeTrue(); - state.Messages[0].SenderAgentId.Should().Be("user"); - state.Messages[0].Content.Should().Be("Discuss the webhook setup"); - state.Messages[1].IsTopic.Should().BeFalse(); - state.Messages[1].SenderAgentId.Should().Be("agent-2"); - state.Messages[1].SenderName.Should().Be("Bob"); - state.Participants.Should().BeEmpty(); - - publisher.Published.OfType().Should().HaveCount(2); - publisher.Published.OfType() - .Should() - .ContainSingle(x => x.Prompt == "Discuss the webhook setup" && x.SessionId == "room-session"); - publisher.Published.OfType() - .Should() - .ContainSingle(x => x.AgentId == "agent-2" && x.Content == "I can help with that."); - publisher.Published.OfType() - .Should() - .ContainSingle(x => x.AgentId == "agent-1"); - } - - [Fact] - public async Task StreamingProxySseWriter_ShouldStartStream_AndSerializeRoomFrames() - { - var context = new DefaultHttpContext(); - context.Response.Body = new MemoryStream(); - var writer = AgentCoverageTestSupport.CreateNonPublicInstance( - typeof(StreamingProxyGAgent).Assembly, - "Aevatar.GAgents.StreamingProxy.StreamingProxySseWriter", - context.Response); - - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteRoomCreatedAsync", "room-1", "Main Room", CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteAgentMessageAsync", "agent-1", "Alice", "hello", 7L, CancellationToken.None); - await AgentCoverageTestSupport.InvokeAsync(writer, "WriteRunErrorAsync", "boom", CancellationToken.None); - - AgentCoverageTestSupport.GetBooleanProperty(writer, "Started").Should().BeTrue(); - context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); - context.Response.Headers.ContentType.ToString().Should().Be("text/event-stream; charset=utf-8"); - context.Response.Body.Position = 0; - var body = await new StreamReader(context.Response.Body).ReadToEndAsync(); - body.Should().Contain("ROOM_CREATED"); - body.Should().Contain("AGENT_MESSAGE"); - body.Should().Contain("\"sequence\":7"); - body.Should().Contain("RUN_ERROR"); - } - - [Fact] - public void GenerateRoomId_ShouldUseStablePrefix_AndProduceUniqueValues() - { - var first = StreamingProxyDefaults.GenerateRoomId(); - var second = StreamingProxyDefaults.GenerateRoomId(); - - first.Should().StartWith($"{StreamingProxyDefaults.ActorIdPrefix}-"); - second.Should().StartWith($"{StreamingProxyDefaults.ActorIdPrefix}-"); - first.Should().NotBe(second); - } - - private static StreamingProxyGAgent CreateAgent(IServiceProvider provider, string actorId) - { - var agent = new StreamingProxyGAgent - { - Services = provider, - EventSourcingBehaviorFactory = provider.GetRequiredService>(), - }; - - AgentCoverageTestSupport.AssignActorId(agent, actorId); - return agent; - } - - private static async Task<(int StatusCode, string Body)> ExecuteResultAsync(IResult result) - { - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(), - }; - - await using var body = new MemoryStream(); - context.Response.Body = body; - - await result.ExecuteAsync(context); - context.Response.Body.Position = 0; - return (context.Response.StatusCode, await new StreamReader(context.Response.Body).ReadToEndAsync()); - } - - private static async Task InvokeResultAsync(string methodName, params object[] args) - { - var method = typeof(StreamingProxyEndpoints).GetMethod( - methodName, - BindingFlags.NonPublic | BindingFlags.Static)!; - var result = method.Invoke(null, args) - ?? throw new InvalidOperationException($"Method {methodName} returned null."); - - return result switch - { - Task task => await task, - _ => throw new InvalidOperationException($"Unexpected return type: {result.GetType()}"), - }; - } - - private static async Task InvokeTaskAsync(object? result) - { - result.Should().NotBeNull(); - - switch (result) - { - case Task task: - await task; - return; - case ValueTask valueTask: - await valueTask; - return; - default: - throw new InvalidOperationException($"Unexpected return type: {result!.GetType()}"); - } - } - - private sealed class StubActorRuntime : IActorRuntime - { - public StubActorRuntime(IEnumerable? initialActors = null) - { - if (initialActors is not null) - { - foreach (var actor in initialActors) - Actors[actor.Id] = actor; - } - } - - public Dictionary Actors { get; } = []; - - public List<(System.Type agentType, string actorId)> CreateCalls { get; } = []; - - public Task GetAsync(string id) => Task.FromResult(Actors.TryGetValue(id, out var actor) ? actor : null); - - public Task CreateAsync(string? id = null, CancellationToken ct = default) - where TAgent : IAgent => CreateAsync(typeof(TAgent), id, ct); - - public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) - { - var actorId = id ?? Guid.NewGuid().ToString("N"); - var actor = new StubActor(actorId); - Actors[actorId] = actor; - CreateCalls.Add((agentType, actorId)); - return Task.FromResult(actor); - } - - public Task DestroyAsync(string id, CancellationToken ct = default) - { - Actors.Remove(id); - return Task.CompletedTask; - } - - public Task ExistsAsync(string id) => Task.FromResult(Actors.ContainsKey(id)); - public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => Task.CompletedTask; - public Task UnlinkAsync(string childId, CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubActor : IActor - { - public StubActor(string id) => Id = id; - - public int HandleEventCalls { get; private set; } - - public string Id { get; } - - public IAgent Agent => new StubAgent(); - - public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; - - public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) - { - _ = envelope; - _ = ct; - HandleEventCalls++; - return Task.CompletedTask; - } - - public Task GetParentIdAsync() => Task.FromResult(null); - - public Task> GetChildrenIdsAsync() => - Task.FromResult>([]); - } - - private sealed class StubAgent : IAgent - { - public string Id => "agent"; - public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => Task.CompletedTask; - public Task GetDescriptionAsync() => Task.FromResult("stub"); - public Task> GetSubscribedEventTypesAsync() => Task.FromResult>([]); - public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; - public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; - } - - private sealed class StubSubscriptionProvider : IActorEventSubscriptionProvider - { - public Task SubscribeAsync( - string actorId, - Func handler, - CancellationToken ct = default) - where TMessage : class, IMessage, new() - { - _ = actorId; - _ = handler; - _ = ct; - return Task.FromResult(new NoopDisposable()); - } - } - - private sealed class NoopDisposable : IAsyncDisposable - { - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs index 448ec6a0..1c0695df 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs @@ -2316,7 +2316,7 @@ public async Task ScopeServiceEndpointHelpers_ShouldBuildScopedHeaders_AndIgnore var successContext = new DefaultHttpContext { RequestServices = new ServiceCollection() - .AddSingleton(new StubUserConfigStore( + .AddSingleton(new StubUserConfigStore( new UserConfig("user-model", "/preferred-route"))) .BuildServiceProvider(), }; @@ -2339,7 +2339,7 @@ public async Task ScopeServiceEndpointHelpers_ShouldBuildScopedHeaders_AndIgnore var failingContext = new DefaultHttpContext { RequestServices = new ServiceCollection() - .AddSingleton(new ThrowingUserConfigStore()) + .AddSingleton(new ThrowingUserConfigStore()) .BuildServiceProvider(), }; var failedHeaders = await InvokePrivateStaticTask>( @@ -3758,7 +3758,7 @@ private sealed class NoOpAsyncDisposable : IAsyncDisposable public ValueTask DisposeAsync() => ValueTask.CompletedTask; } - private sealed class StubUserConfigStore : IUserConfigStore + private sealed class StubUserConfigStore : IUserConfigQueryPort { private readonly UserConfig _config; @@ -3767,17 +3767,13 @@ public StubUserConfigStore(UserConfig config) _config = config; } - public Task GetAsync(CancellationToken cancellationToken = default) => Task.FromResult(_config); - - public Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task GetAsync(CancellationToken ct = default) => Task.FromResult(_config); } - private sealed class ThrowingUserConfigStore : IUserConfigStore + private sealed class ThrowingUserConfigStore : IUserConfigQueryPort { - public Task GetAsync(CancellationToken cancellationToken = default) => + public Task GetAsync(CancellationToken ct = default) => throw new InvalidOperationException("config unavailable"); - - public Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class RecordingResumeDispatchService diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index db752f13..581eb9e6 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -128,136 +128,9 @@ ScopeIdToReturn is not null : null; } - // ════════════════════════════════════════════════════════════ - // UserConfigStore: defaults when actor does not exist - // ════════════════════════════════════════════════════════════ - - [Fact] - public async Task UserConfigStore_GetAsync_NoActor_ReturnsDefaults() - { - var runtime = new FakeActorRuntime(); - var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "test-user" }; - var logger = NullLogger.Instance; - - var store = new ActorBackedUserConfigStore( - runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); - - // No actor exists, returns defaults - var config = await store.GetAsync(); - - config.DefaultModel.Should().BeEmpty(); - config.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); - config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.LocalMode); - config.LocalRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.LocalRuntimeBaseUrl); - config.RemoteRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl); - config.MaxToolRounds.Should().Be(0); - } - - // ════════════════════════════════════════════════════════════ - // UserConfigStore: state mapping - // ════════════════════════════════════════════════════════════ - - [Fact] - public async Task UserConfigStore_GetAsync_WithState_MapsFieldsCorrectly() - { - var runtime = new FakeActorRuntime(); - var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-42" }; - var logger = NullLogger.Instance; - - // Register write actor with state - var state = new UserConfigGAgentState - { - DefaultModel = "gpt-4", - PreferredLlmRoute = "/api/v1/proxy/s/custom", - RuntimeMode = "remote", - LocalRuntimeBaseUrl = "http://localhost:9090", - RemoteRuntimeBaseUrl = "https://remote.example.com", - MaxToolRounds = 5, - }; - runtime.RegisterActor("user-config-user-42", new FakeAgent("user-config-user-42", state)); - - var store = new ActorBackedUserConfigStore( - runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); - - var config = await store.GetAsync(); - - config.DefaultModel.Should().Be("gpt-4"); - config.PreferredLlmRoute.Should().Be("/api/v1/proxy/s/custom"); - config.RuntimeMode.Should().Be("remote"); - config.LocalRuntimeBaseUrl.Should().Be("http://localhost:9090"); - config.RemoteRuntimeBaseUrl.Should().Be("https://remote.example.com"); - config.MaxToolRounds.Should().Be(5); - } - - // ════════════════════════════════════════════════════════════ - // UserConfigStore: empty string fields apply defaults - // ════════════════════════════════════════════════════════════ - - [Fact] - public async Task UserConfigStore_GetAsync_EmptyStringFields_ApplyDefaults() - { - var runtime = new FakeActorRuntime(); - var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-empty" }; - var logger = NullLogger.Instance; - - // Register write actor with mostly-empty state - var state = new UserConfigGAgentState { DefaultModel = "claude-3" }; - runtime.RegisterActor("user-config-user-empty", new FakeAgent("user-config-user-empty", state)); - - var store = new ActorBackedUserConfigStore( - runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); - - var config = await store.GetAsync(); - - config.DefaultModel.Should().Be("claude-3"); - config.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway, - "empty PreferredLlmRoute should fall back to Gateway default"); - config.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.LocalMode, - "empty RuntimeMode should fall back to local"); - config.LocalRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.LocalRuntimeBaseUrl, - "empty LocalRuntimeBaseUrl should fall back to default"); - config.RemoteRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl, - "empty RemoteRuntimeBaseUrl should fall back to default"); - } - - // ════════════════════════════════════════════════════════════ - // UserConfigStore: SaveAsync sends UserConfigUpdatedEvent - // ════════════════════════════════════════════════════════════ - - [Fact] - public async Task UserConfigStore_SaveAsync_SendsUserConfigUpdatedEvent() - { - var runtime = new FakeActorRuntime(); - var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "save-scope" }; - var logger = NullLogger.Instance; - - var store = new ActorBackedUserConfigStore( - runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); - - var config = new UserConfig( - DefaultModel: "gpt-4-turbo", - PreferredLlmRoute: "/api/v1/proxy/s/openai", - RuntimeMode: "remote", - LocalRuntimeBaseUrl: "http://127.0.0.1:8080", - RemoteRuntimeBaseUrl: "https://api.example.com", - MaxToolRounds: 10); - - await store.SaveAsync(config); - - var actorId = "user-config-save-scope"; - runtime.Actors.Should().ContainKey(actorId); - - var actor = runtime.Actors[actorId]; - actor.ReceivedEnvelopes.Should().HaveCount(1); - - var envelope = actor.ReceivedEnvelopes[0]; - envelope.Payload.Should().NotBeNull(); - envelope.Payload.Is(UserConfigUpdatedEvent.Descriptor).Should().BeTrue(); - - var evt = envelope.Payload.Unpack(); - evt.DefaultModel.Should().Be("gpt-4-turbo"); - evt.MaxToolRounds.Should().Be(10); - } + // UserConfigStore tests removed — ActorBackedUserConfigStore replaced by + // IUserConfigQueryPort (projection) + IUserConfigCommandService (dispatch). + // See ActorDispatchUserConfigCommandService tests in projection test project. // ════════════════════════════════════════════════════════════ // NyxIdUserLlmPreferencesStore: delegation @@ -383,47 +256,16 @@ public async Task GAgentActorStore_RemoveActorAsync_SendsActorUnregisteredEvent( } // ════════════════════════════════════════════════════════════ - // Envelope structure verification - // ════════════════════════════════════════════════════════════ - - [Fact] - public async Task SaveAsync_EnvelopeContainsIdAndTimestamp() - { - var runtime = new FakeActorRuntime(); - var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "ts-scope" }; - var logger = NullLogger.Instance; - - var store = new ActorBackedUserConfigStore( - runtime, scopeResolver, Options.Create(new StudioStorageOptions()), logger); - - var beforeUtc = DateTimeOffset.UtcNow; - - await store.SaveAsync(new UserConfig(DefaultModel: "model")); - - var afterUtc = DateTimeOffset.UtcNow; - var envelope = runtime.Actors["user-config-ts-scope"].ReceivedEnvelopes[0]; - - envelope.Id.Should().NotBeNullOrWhiteSpace("envelope must have a unique ID"); - envelope.Id.Length.Should().Be(32, "ID should be a Guid without dashes"); - - var ts = envelope.Timestamp.ToDateTimeOffset(); - ts.Should().BeOnOrAfter(beforeUtc).And.BeOnOrBefore(afterUtc); - } - - // ════════════════════════════════════════════════════════════ - // Helper: stub IUserConfigStore for NyxId delegation tests + // Helper: stub IUserConfigQueryPort for NyxId delegation tests // ════════════════════════════════════════════════════════════ - private sealed class StubUserConfigStore : IUserConfigStore + private sealed class StubUserConfigStore : IUserConfigQueryPort { private readonly UserConfig _config; public StubUserConfigStore(UserConfig config) => _config = config; - public Task GetAsync(CancellationToken cancellationToken = default) => + public Task GetAsync(CancellationToken ct = default) => Task.FromResult(_config); - - public Task SaveAsync(UserConfig config, CancellationToken cancellationToken = default) => - Task.CompletedTask; } } From e63c36b2b3054604b135f5ddf35a16619cd0c365 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Fri, 10 Apr 2026 16:36:40 +0800 Subject: [PATCH 10/21] chore: ignore local configuration files Add entities.json and mempalace.yaml to .gitignore to prevent accidental commits of local configuration files. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 979fc40f..184217d8 100644 --- a/.gitignore +++ b/.gitignore @@ -497,3 +497,6 @@ docs/agents-working-space/* # Docs triage working directory docs/.triage/ + +entities.json +mempalace.yaml \ No newline at end of file From 052502519c865759f934966d9b89b84b01014382 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 14 Apr 2026 17:39:32 +0800 Subject: [PATCH 11/21] fix(role-catalog): add missing workspace sync for draft save/delete RoleCatalogStore.SaveRoleDraftAsync and DeleteRoleDraftAsync were missing workspace persistence calls, unlike ConnectorCatalogStore which persists drafts to local workspace for offline access consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ActorBacked/ActorBackedRoleCatalogStore.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs index 7c3aee04..b5fe8c4a 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs @@ -127,6 +127,8 @@ public async Task SaveRoleDraftAsync( }; await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); + await _localWorkspaceStore.SaveRoleDraftAsync(draft, cancellationToken); + return new StoredRoleDraft( HomeDirectory: ActorHomeDirectory, FilePath: ActorFilePath + "/draft", @@ -139,6 +141,8 @@ public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = def { var actor = await EnsureWriteActorAsync(cancellationToken); await ActorCommandDispatcher.SendAsync(actor, new RoleDraftDeletedEvent(), cancellationToken); + + await _localWorkspaceStore.DeleteRoleDraftAsync(cancellationToken); } // ── Read write actor state directly ── From 03ff9b32b12acbadc88e9df65f7949026f3affd2 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 14 Apr 2026 17:42:48 +0800 Subject: [PATCH 12/21] test(catalog): add ConnectorCatalog and RoleCatalog state transition tests Cover all state transition semantics for both catalog GAgents: SaveCatalog (replace all), SaveDraft (set/overwrite), DeleteDraft (clear), catalog-draft independence, connectors field, empty state, unknown events. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ActorBackedGAgentStateTransitionTests.cs | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs index fe098cec..b04e9135 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs @@ -1,5 +1,7 @@ using Aevatar.GAgents.ChatHistory; +using Aevatar.GAgents.ConnectorCatalog; using Aevatar.GAgents.Registry; +using Aevatar.GAgents.RoleCatalog; using Aevatar.GAgents.StreamingProxyParticipant; using Aevatar.GAgents.UserConfig; using Aevatar.GAgents.UserMemory; @@ -1043,4 +1045,422 @@ public void HistoryIndex_UnknownEvent_ReturnsCurrentState() next.Should().BeSameAs(state); } + + // ═══════════════════════════════════════════════════════════════════ + // 8. ConnectorCatalogGAgent + // ═══════════════════════════════════════════════════════════════════ + + #region ConnectorCatalog helpers + + private static ConnectorCatalogState ApplyConnectorCatalog( + ConnectorCatalogState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyConnectorCatalogSaved) + .On(ApplyConnectorDraftSaved) + .On(ApplyConnectorDraftDeleted) + .OrCurrent(); + + private static ConnectorCatalogState ApplyConnectorCatalogSaved( + ConnectorCatalogState state, ConnectorCatalogSavedEvent evt) + { + var next = state.Clone(); + next.Connectors.Clear(); + next.Connectors.AddRange(evt.Connectors); + return next; + } + + private static ConnectorCatalogState ApplyConnectorDraftSaved( + ConnectorCatalogState state, ConnectorDraftSavedEvent evt) + { + var next = state.Clone(); + next.Draft = new ConnectorDraftEntry + { + Draft = evt.Draft?.Clone(), + UpdatedAtUtc = evt.UpdatedAtUtc, + }; + return next; + } + + private static ConnectorCatalogState ApplyConnectorDraftDeleted( + ConnectorCatalogState state, ConnectorDraftDeletedEvent _) + { + var next = state.Clone(); + next.Draft = null; + return next; + } + + private static ConnectorDefinitionEntry MakeConnector(string name, string type = "http") => + new() + { + Name = name, + Type = type, + Enabled = true, + TimeoutMs = 30000, + Retry = 3, + }; + + #endregion + + [Fact] + public void ConnectorCatalog_SaveCatalog_ReplacesAll() + { + var state = new ConnectorCatalogState(); + state.Connectors.Add(MakeConnector("old-conn")); + + var evt = new ConnectorCatalogSavedEvent(); + evt.Connectors.Add(MakeConnector("new-conn-1")); + evt.Connectors.Add(MakeConnector("new-conn-2", "mcp")); + + var next = ApplyConnectorCatalog(state, evt); + + next.Connectors.Should().HaveCount(2); + next.Connectors[0].Name.Should().Be("new-conn-1"); + next.Connectors[1].Name.Should().Be("new-conn-2"); + next.Connectors[1].Type.Should().Be("mcp"); + } + + [Fact] + public void ConnectorCatalog_SaveCatalog_EmptyList_ClearsAll() + { + var state = new ConnectorCatalogState(); + state.Connectors.Add(MakeConnector("existing")); + + var evt = new ConnectorCatalogSavedEvent(); + + var next = ApplyConnectorCatalog(state, evt); + + next.Connectors.Should().BeEmpty(); + } + + [Fact] + public void ConnectorCatalog_SaveDraft_SetsNewDraft() + { + var state = new ConnectorCatalogState(); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new ConnectorDraftSavedEvent + { + Draft = MakeConnector("draft-conn", "cli"), + UpdatedAtUtc = ts, + }; + + var next = ApplyConnectorCatalog(state, evt); + + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Name.Should().Be("draft-conn"); + next.Draft.Draft.Type.Should().Be("cli"); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void ConnectorCatalog_SaveDraft_OverwritesPreviousDraft() + { + var state = new ConnectorCatalogState + { + Draft = new ConnectorDraftEntry + { + Draft = MakeConnector("old-draft"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddHours(-1)), + }, + }; + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new ConnectorDraftSavedEvent + { + Draft = MakeConnector("new-draft", "mcp"), + UpdatedAtUtc = ts, + }; + + var next = ApplyConnectorCatalog(state, evt); + + next.Draft!.Draft.Name.Should().Be("new-draft"); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void ConnectorCatalog_DeleteDraft_ClearsDraft() + { + var state = new ConnectorCatalogState + { + Draft = new ConnectorDraftEntry + { + Draft = MakeConnector("to-delete"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }, + }; + + var next = ApplyConnectorCatalog(state, new ConnectorDraftDeletedEvent()); + + next.Draft.Should().BeNull(); + } + + [Fact] + public void ConnectorCatalog_DeleteDraft_NoDraft_ReturnsNullDraft() + { + var state = new ConnectorCatalogState(); + + var next = ApplyConnectorCatalog(state, new ConnectorDraftDeletedEvent()); + + next.Draft.Should().BeNull(); + } + + [Fact] + public void ConnectorCatalog_SaveCatalog_DoesNotAffectDraft() + { + var draft = new ConnectorDraftEntry + { + Draft = MakeConnector("my-draft"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + var state = new ConnectorCatalogState { Draft = draft }; + + var evt = new ConnectorCatalogSavedEvent(); + evt.Connectors.Add(MakeConnector("catalog-conn")); + + var next = ApplyConnectorCatalog(state, evt); + + next.Connectors.Should().HaveCount(1); + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Name.Should().Be("my-draft"); + } + + [Fact] + public void ConnectorCatalog_EmptyState_IsValid() + { + var state = new ConnectorCatalogState(); + + state.Connectors.Should().BeEmpty(); + state.Draft.Should().BeNull(); + } + + [Fact] + public void ConnectorCatalog_UnknownEvent_ReturnsCurrentState() + { + var state = new ConnectorCatalogState(); + state.Connectors.Add(MakeConnector("keep")); + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyConnectorCatalog(state, unrelated); + + next.Should().BeSameAs(state); + } + + // ═══════════════════════════════════════════════════════════════════ + // 9. RoleCatalogGAgent + // ═══════════════════════════════════════════════════════════════════ + + #region RoleCatalog helpers + + private static RoleCatalogState ApplyRoleCatalog( + RoleCatalogState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyRoleCatalogSaved) + .On(ApplyRoleDraftSaved) + .On(ApplyRoleDraftDeleted) + .OrCurrent(); + + private static RoleCatalogState ApplyRoleCatalogSaved( + RoleCatalogState state, RoleCatalogSavedEvent evt) + { + var next = state.Clone(); + next.Roles.Clear(); + next.Roles.AddRange(evt.Roles); + return next; + } + + private static RoleCatalogState ApplyRoleDraftSaved( + RoleCatalogState state, RoleDraftSavedEvent evt) + { + var next = state.Clone(); + next.Draft = new RoleDraftEntry + { + Draft = evt.Draft?.Clone(), + UpdatedAtUtc = evt.UpdatedAtUtc, + }; + return next; + } + + private static RoleCatalogState ApplyRoleDraftDeleted( + RoleCatalogState state, RoleDraftDeletedEvent _) + { + var next = state.Clone(); + next.Draft = null; + return next; + } + + private static RoleDefinitionEntry MakeRole(string id, string name = "Test Role") => + new() + { + Id = id, + Name = name, + SystemPrompt = $"You are {name}", + Provider = "anthropic", + Model = "claude-opus", + }; + + #endregion + + [Fact] + public void RoleCatalog_SaveCatalog_ReplacesAll() + { + var state = new RoleCatalogState(); + state.Roles.Add(MakeRole("old-role")); + + var evt = new RoleCatalogSavedEvent(); + evt.Roles.Add(MakeRole("role-1", "Assistant")); + evt.Roles.Add(MakeRole("role-2", "Translator")); + + var next = ApplyRoleCatalog(state, evt); + + next.Roles.Should().HaveCount(2); + next.Roles[0].Id.Should().Be("role-1"); + next.Roles[0].Name.Should().Be("Assistant"); + next.Roles[1].Id.Should().Be("role-2"); + } + + [Fact] + public void RoleCatalog_SaveCatalog_EmptyList_ClearsAll() + { + var state = new RoleCatalogState(); + state.Roles.Add(MakeRole("existing")); + + var evt = new RoleCatalogSavedEvent(); + + var next = ApplyRoleCatalog(state, evt); + + next.Roles.Should().BeEmpty(); + } + + [Fact] + public void RoleCatalog_SaveDraft_SetsNewDraft() + { + var state = new RoleCatalogState(); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new RoleDraftSavedEvent + { + Draft = MakeRole("draft-role", "Draft Assistant"), + UpdatedAtUtc = ts, + }; + + var next = ApplyRoleCatalog(state, evt); + + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Id.Should().Be("draft-role"); + next.Draft.Draft.Name.Should().Be("Draft Assistant"); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void RoleCatalog_SaveDraft_OverwritesPreviousDraft() + { + var state = new RoleCatalogState + { + Draft = new RoleDraftEntry + { + Draft = MakeRole("old-draft"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddHours(-1)), + }, + }; + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new RoleDraftSavedEvent + { + Draft = MakeRole("new-draft", "Updated Role"), + UpdatedAtUtc = ts, + }; + + var next = ApplyRoleCatalog(state, evt); + + next.Draft!.Draft.Name.Should().Be("Updated Role"); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + + [Fact] + public void RoleCatalog_DeleteDraft_ClearsDraft() + { + var state = new RoleCatalogState + { + Draft = new RoleDraftEntry + { + Draft = MakeRole("to-delete"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }, + }; + + var next = ApplyRoleCatalog(state, new RoleDraftDeletedEvent()); + + next.Draft.Should().BeNull(); + } + + [Fact] + public void RoleCatalog_DeleteDraft_NoDraft_ReturnsNullDraft() + { + var state = new RoleCatalogState(); + + var next = ApplyRoleCatalog(state, new RoleDraftDeletedEvent()); + + next.Draft.Should().BeNull(); + } + + [Fact] + public void RoleCatalog_SaveCatalog_DoesNotAffectDraft() + { + var draft = new RoleDraftEntry + { + Draft = MakeRole("my-draft", "Keep This"), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + var state = new RoleCatalogState { Draft = draft }; + + var evt = new RoleCatalogSavedEvent(); + evt.Roles.Add(MakeRole("catalog-role")); + + var next = ApplyRoleCatalog(state, evt); + + next.Roles.Should().HaveCount(1); + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Name.Should().Be("Keep This"); + } + + [Fact] + public void RoleCatalog_SaveDraft_WithConnectors() + { + var state = new RoleCatalogState(); + var role = MakeRole("with-conn", "Connected Role"); + role.Connectors.Add("web-search"); + role.Connectors.Add("code-runner"); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new RoleDraftSavedEvent { Draft = role, UpdatedAtUtc = ts }; + + var next = ApplyRoleCatalog(state, evt); + + next.Draft!.Draft.Connectors.Should().BeEquivalentTo(["web-search", "code-runner"]); + } + + [Fact] + public void RoleCatalog_EmptyState_IsValid() + { + var state = new RoleCatalogState(); + + state.Roles.Should().BeEmpty(); + state.Draft.Should().BeNull(); + } + + [Fact] + public void RoleCatalog_UnknownEvent_ReturnsCurrentState() + { + var state = new RoleCatalogState(); + state.Roles.Add(MakeRole("keep")); + + var unrelated = new ActorRegisteredEvent { GagentType = "T", ActorId = "x" }; + + var next = ApplyRoleCatalog(state, unrelated); + + next.Should().BeSameAs(state); + } } From 5a34482689afe0ce3458c4f6d27fa80816baeaf0 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 14 Apr 2026 17:46:34 +0800 Subject: [PATCH 13/21] test(stores): add adapter tests for all actor-backed stores Cover command dispatch (event packing), state reading/mapping, and scope isolation for ChatHistory, StreamingProxyParticipant, UserMemory, ConnectorCatalog, and RoleCatalog stores. Also verifies the workspace sync fix in RoleCatalogStore.DeleteRoleDraftAsync. Refactors FakeAgent to generic FakeAgent to support typed state reads across different store implementations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ActorBackedStoreAdapterTests.cs | 445 +++++++++++++++++- 1 file changed, 441 insertions(+), 4 deletions(-) diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index 581eb9e6..cee7c10b 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -1,6 +1,12 @@ using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.ChatHistory; +using Aevatar.GAgents.ConnectorCatalog; +using Aevatar.GAgents.Registry; +using Aevatar.GAgents.RoleCatalog; +using Aevatar.GAgents.StreamingProxyParticipant; using Aevatar.GAgents.UserConfig; +using Aevatar.GAgents.UserMemory; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ActorBacked; using Aevatar.Studio.Infrastructure.ScopeResolution; @@ -21,16 +27,16 @@ public sealed class ActorBackedStoreAdapterTests // Fakes // ════════════════════════════════════════════════════════════ - private sealed class FakeAgent : IAgent + private sealed class FakeAgent : IAgent where TState : class, IMessage { - public FakeAgent(string id, UserConfigGAgentState state) + public FakeAgent(string id, TState state) { Id = id; State = state; } public string Id { get; } - public UserConfigGAgentState State { get; } + public TState State { get; } public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => Task.CompletedTask; @@ -48,7 +54,7 @@ private sealed class FakeActor : IActor public FakeActor(string id, IAgent? agent = null) { Id = id; - Agent = agent ?? new FakeAgent(id, new UserConfigGAgentState()); + Agent = agent ?? new FakeAgent(id, new UserConfigGAgentState()); } public string Id { get; } @@ -268,4 +274,435 @@ private sealed class StubUserConfigStore : IUserConfigQueryPort public Task GetAsync(CancellationToken ct = default) => Task.FromResult(_config); } + + // ════════════════════════════════════════════════════════════ + // ChatHistoryStore: command dispatch + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChatHistoryStore_SaveMessages_SendsMessagesReplacedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var meta = new ConversationMeta( + Id: "conv-1", Title: "Test", ServiceId: "svc", + ServiceKind: "chat", CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow, MessageCount: 1); + var messages = new List + { + new("msg-1", "user", "Hello", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), "sent"), + }; + + await store.SaveMessagesAsync("scope-1", "conv-1", meta, messages); + + var actorId = "chat-scope-1-conv-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + actor.ReceivedEnvelopes.Should().HaveCount(1); + actor.ReceivedEnvelopes[0].Payload.Is(MessagesReplacedEvent.Descriptor).Should().BeTrue(); + + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.ScopeId.Should().Be("scope-1"); + evt.Messages.Should().HaveCount(1); + evt.Messages[0].Role.Should().Be("user"); + } + + [Fact] + public async Task ChatHistoryStore_DeleteConversation_SendsConversationDeletedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + await store.DeleteConversationAsync("scope-1", "conv-1"); + + var actorId = "chat-scope-1-conv-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.ConversationId.Should().Be("conv-1"); + evt.ScopeId.Should().Be("scope-1"); + } + + [Fact] + public async Task ChatHistoryStore_GetIndex_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var index = await store.GetIndexAsync("scope-1"); + + index.Conversations.Should().BeEmpty(); + } + + [Fact] + public async Task ChatHistoryStore_GetIndex_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new ChatHistoryIndexState(); + state.Conversations.Add(new ConversationMetaProto + { + Id = "conv-1", + Title = "Test Chat", + ServiceId = "svc", + ServiceKind = "chat", + CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + MessageCount = 5, + LlmRoute = "/api/proxy", + LlmModel = "claude-opus", + }); + runtime.RegisterActor("chat-index-scope-1", + new FakeAgent("chat-index-scope-1", state)); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var index = await store.GetIndexAsync("scope-1"); + + index.Conversations.Should().HaveCount(1); + index.Conversations[0].Id.Should().Be("conv-1"); + index.Conversations[0].Title.Should().Be("Test Chat"); + index.Conversations[0].LlmRoute.Should().Be("/api/proxy"); + index.Conversations[0].LlmModel.Should().Be("claude-opus"); + index.Conversations[0].MessageCount.Should().Be(5); + } + + // ════════════════════════════════════════════════════════════ + // StreamingProxyParticipantStore: command dispatch + read + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ParticipantStore_AddAsync_SendsParticipantAddedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + + await store.AddAsync("room-1", "agent-abc", "Alice"); + + var actorId = "streaming-proxy-participants"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.RoomId.Should().Be("room-1"); + evt.AgentId.Should().Be("agent-abc"); + evt.DisplayName.Should().Be("Alice"); + } + + [Fact] + public async Task ParticipantStore_RemoveRoomAsync_SendsRoomParticipantsRemovedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + + await store.RemoveRoomAsync("room-1"); + + var actorId = "streaming-proxy-participants"; + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.RoomId.Should().Be("room-1"); + } + + [Fact] + public async Task ParticipantStore_ListAsync_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + + var participants = await store.ListAsync("room-1"); + + participants.Should().BeEmpty(); + } + + [Fact] + public async Task ParticipantStore_ListAsync_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new StreamingProxyParticipantGAgentState(); + var room = new ParticipantList(); + var joinedAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + room.Participants.Add(new ParticipantEntry + { + AgentId = "agent-1", + DisplayName = "Bot", + JoinedAt = joinedAt, + }); + state.Rooms["room-1"] = room; + runtime.RegisterActor("streaming-proxy-participants", + new FakeAgent( + "streaming-proxy-participants", state)); + var logger = NullLogger.Instance; + var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + + var participants = await store.ListAsync("room-1"); + + participants.Should().HaveCount(1); + participants[0].AgentId.Should().Be("agent-1"); + participants[0].DisplayName.Should().Be("Bot"); + } + + // ════════════════════════════════════════════════════════════ + // UserMemoryStore: command dispatch + read + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task UserMemoryStore_AddEntry_SendsMemoryEntryAddedEvent() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var entry = await store.AddEntryAsync("preference", "Dark mode", "explicit"); + + entry.Category.Should().Be("preference"); + entry.Content.Should().Be("Dark mode"); + entry.Source.Should().Be("explicit"); + entry.Id.Should().NotBeNullOrEmpty(); + + var actorId = "user-memory-user-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.Entry.Content.Should().Be("Dark mode"); + evt.Entry.Category.Should().Be("preference"); + } + + [Fact] + public async Task UserMemoryStore_GetAsync_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var doc = await store.GetAsync(); + + doc.Entries.Should().BeEmpty(); + } + + [Fact] + public async Task UserMemoryStore_GetAsync_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new UserMemoryState(); + state.Entries.Add(new UserMemoryEntryProto + { + Id = "mem-1", + Category = "context", + Content = "Works on ML project", + Source = "inferred", + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }); + runtime.RegisterActor("user-memory-user-1", + new FakeAgent("user-memory-user-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var doc = await store.GetAsync(); + + doc.Entries.Should().HaveCount(1); + doc.Entries[0].Id.Should().Be("mem-1"); + doc.Entries[0].Content.Should().Be("Works on ML project"); + doc.Entries[0].Category.Should().Be("context"); + } + + [Fact] + public async Task UserMemoryStore_NoScope_Throws() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = null }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var act = () => store.AddEntryAsync("preference", "test", "explicit"); + + await act.Should().ThrowAsync(); + } + + // ════════════════════════════════════════════════════════════ + // ConnectorCatalogStore: command dispatch + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ConnectorCatalogStore_SaveCatalog_SendsCatalogSavedEvent() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = new StoredConnectorCatalog( + HomeDirectory: "test", + FilePath: "test", + FileExists: true, + Connectors: + [ + new StoredConnectorDefinition( + "my-conn", "http", true, 30000, 3, + new StoredHttpConnectorConfig("https://api.example.com", [], [], [], + new Dictionary(), + new StoredConnectorAuthConfig("", "", "", "", "")), + new StoredCliConnectorConfig("", [], [], [], "", + new Dictionary()), + new StoredMcpConnectorConfig("", "", "", [], + new Dictionary(), + new Dictionary(), + new StoredConnectorAuthConfig("", "", "", "", ""), + "", [], [])), + ]); + + await store.SaveConnectorCatalogAsync(catalog); + + var actorId = "connector-catalog-scope-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.Connectors.Should().HaveCount(1); + evt.Connectors[0].Name.Should().Be("my-conn"); + evt.Connectors[0].Type.Should().Be("http"); + } + + [Fact] + public async Task ConnectorCatalogStore_GetCatalog_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = await store.GetConnectorCatalogAsync(); + + catalog.FileExists.Should().BeFalse(); + catalog.Connectors.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════ + // RoleCatalogStore: command dispatch + workspace sync + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task RoleCatalogStore_SaveCatalog_SendsCatalogSavedEvent() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = new StoredRoleCatalog( + HomeDirectory: "test", + FilePath: "test", + FileExists: true, + Roles: + [ + new StoredRoleDefinition("role-1", "Assistant", "You are helpful", + "anthropic", "claude-opus", []), + ]); + + await store.SaveRoleCatalogAsync(catalog); + + var actorId = "role-catalog-scope-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.Roles.Should().HaveCount(1); + evt.Roles[0].Name.Should().Be("Assistant"); + } + + [Fact] + public async Task RoleCatalogStore_DeleteDraft_SyncsToWorkspace() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + await store.DeleteRoleDraftAsync(); + + workspaceStore.RoleDraftDeleted.Should().BeTrue(); + } + + [Fact] + public async Task RoleCatalogStore_GetCatalog_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = await store.GetRoleCatalogAsync(); + + catalog.FileExists.Should().BeFalse(); + catalog.Roles.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════ + // Helper: stub IStudioWorkspaceStore for catalog tests + // ════════════════════════════════════════════════════════════ + + private sealed class StubWorkspaceStore : IStudioWorkspaceStore + { + public bool RoleDraftDeleted { get; private set; } + public bool ConnectorDraftDeleted { get; private set; } + + public Task GetSettingsAsync(CancellationToken ct = default) => + Task.FromResult(new StudioWorkspaceSettings("", [], "", "")); + public Task SaveSettingsAsync(StudioWorkspaceSettings settings, CancellationToken ct = default) => + Task.CompletedTask; + public Task> ListWorkflowFilesAsync(CancellationToken ct = default) => + Task.FromResult>([]); + public Task GetWorkflowFileAsync(string workflowId, CancellationToken ct = default) => + Task.FromResult(null); + public Task SaveWorkflowFileAsync(StoredWorkflowFile f, CancellationToken ct = default) => + Task.FromResult(f); + public Task> ListExecutionsAsync(CancellationToken ct = default) => + Task.FromResult>([]); + public Task GetExecutionAsync(string executionId, CancellationToken ct = default) => + Task.FromResult(null); + public Task SaveExecutionAsync(StoredExecutionRecord r, CancellationToken ct = default) => + Task.FromResult(r); + public Task GetConnectorCatalogAsync(CancellationToken ct = default) => + Task.FromResult(new StoredConnectorCatalog("", "", false, [])); + public Task SaveConnectorCatalogAsync(StoredConnectorCatalog c, CancellationToken ct = default) => + Task.FromResult(c); + public Task GetConnectorDraftAsync(CancellationToken ct = default) => + Task.FromResult(new StoredConnectorDraft("", "", false, null, null)); + public Task SaveConnectorDraftAsync(StoredConnectorDraft d, CancellationToken ct = default) => + Task.FromResult(d); + public Task DeleteConnectorDraftAsync(CancellationToken ct = default) + { + ConnectorDraftDeleted = true; + return Task.CompletedTask; + } + public Task GetRoleCatalogAsync(CancellationToken ct = default) => + Task.FromResult(new StoredRoleCatalog("", "", false, [])); + public Task SaveRoleCatalogAsync(StoredRoleCatalog c, CancellationToken ct = default) => + Task.FromResult(c); + public Task GetRoleDraftAsync(CancellationToken ct = default) => + Task.FromResult(new StoredRoleDraft("", "", false, null, null)); + public Task SaveRoleDraftAsync(StoredRoleDraft d, CancellationToken ct = default) => + Task.FromResult(d); + public Task DeleteRoleDraftAsync(CancellationToken ct = default) + { + RoleDraftDeleted = true; + return Task.CompletedTask; + } + } } From 003b2e8e47b591e515378877cd55d03d2256a40c Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 14 Apr 2026 17:47:42 +0800 Subject: [PATCH 14/21] docs(role-catalog): update doc comment to reflect draft backup delegation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ActorBacked/ActorBackedRoleCatalogStore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs index b5fe8c4a..d78937d4 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs @@ -11,7 +11,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// Actor-backed implementation of . /// Reads the write actor's state directly. /// Writes send commands to the Write GAgent. -/// Local workspace operations (ImportLocalCatalogAsync) delegate to +/// Local workspace operations (import, draft backup) delegate to /// . /// Per-scope isolation: each scope gets its own role-catalog-{scopeId} actor. /// From e2abd68837535f458f790970dcfdb57dcec46108 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 14 Apr 2026 17:49:44 +0800 Subject: [PATCH 15/21] test(catalog): cover null-draft SaveDraft transitions Both stores can emit SaveDraft events with Draft = null (via evt.Draft?.Clone()). Add tests to verify the entry is created with null payload while preserving the timestamp. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ActorBackedGAgentStateTransitionTests.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs index b04e9135..13bf7c3d 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedGAgentStateTransitionTests.cs @@ -1153,6 +1153,25 @@ public void ConnectorCatalog_SaveDraft_SetsNewDraft() next.Draft.UpdatedAtUtc.Should().Be(ts); } + [Fact] + public void ConnectorCatalog_SaveDraft_NullDraft_SetsEntryWithNullPayload() + { + var state = new ConnectorCatalogState(); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new ConnectorDraftSavedEvent + { + Draft = null, + UpdatedAtUtc = ts, + }; + + var next = ApplyConnectorCatalog(state, evt); + + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Should().BeNull(); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + [Fact] public void ConnectorCatalog_SaveDraft_OverwritesPreviousDraft() { @@ -1354,6 +1373,25 @@ public void RoleCatalog_SaveDraft_SetsNewDraft() next.Draft.UpdatedAtUtc.Should().Be(ts); } + [Fact] + public void RoleCatalog_SaveDraft_NullDraft_SetsEntryWithNullPayload() + { + var state = new RoleCatalogState(); + var ts = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var evt = new RoleDraftSavedEvent + { + Draft = null, + UpdatedAtUtc = ts, + }; + + var next = ApplyRoleCatalog(state, evt); + + next.Draft.Should().NotBeNull(); + next.Draft!.Draft.Should().BeNull(); + next.Draft.UpdatedAtUtc.Should().Be(ts); + } + [Fact] public void RoleCatalog_SaveDraft_OverwritesPreviousDraft() { From 698902ff5d7ff1ef13f5467e503426fa23111051 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 14 Apr 2026 17:55:49 +0800 Subject: [PATCH 16/21] test(stores): deepen adapter test coverage per Codex review Address Codex review gaps: - ChatHistory: add GetMessagesAsync read mapping + empty state - RoleCatalog: add SaveDraft workspace sync, DeleteDraft event+sync, GetCatalog read mapping from actor state - ConnectorCatalog: add GetCatalog read mapping from actor state - Scope isolation: verify two scopes use different registry actors - StubWorkspaceStore: track SaveRoleDraft/SaveConnectorDraft calls Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ActorBackedStoreAdapterTests.cs | 213 +++++++++++++++++- 1 file changed, 209 insertions(+), 4 deletions(-) diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index cee7c10b..fbb067e4 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -653,6 +653,203 @@ public async Task RoleCatalogStore_GetCatalog_NoActor_ReturnsEmpty() catalog.Roles.Should().BeEmpty(); } + // ════════════════════════════════════════════════════════════ + // ChatHistoryStore: GetMessagesAsync read mapping + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChatHistoryStore_GetMessages_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new ChatConversationState(); + state.Messages.Add(new StoredChatMessageProto + { + Id = "msg-1", + Role = "user", + Content = "Hello", + Timestamp = 1700000000000, + Status = "sent", + Error = "", + Thinking = "reasoning...", + }); + state.Messages.Add(new StoredChatMessageProto + { + Id = "msg-2", + Role = "assistant", + Content = "Hi there!", + Timestamp = 1700000001000, + Status = "sent", + }); + runtime.RegisterActor("chat-scope-1-conv-1", + new FakeAgent("chat-scope-1-conv-1", state)); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var messages = await store.GetMessagesAsync("scope-1", "conv-1"); + + messages.Should().HaveCount(2); + messages[0].Id.Should().Be("msg-1"); + messages[0].Role.Should().Be("user"); + messages[0].Content.Should().Be("Hello"); + messages[0].Thinking.Should().Be("reasoning..."); + messages[1].Id.Should().Be("msg-2"); + messages[1].Role.Should().Be("assistant"); + messages[1].Error.Should().BeNull("empty proto string maps to null"); + } + + [Fact] + public async Task ChatHistoryStore_GetMessages_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var messages = await store.GetMessagesAsync("scope-1", "conv-1"); + + messages.Should().BeEmpty(); + } + + // ════════════════════════════════════════════════════════════ + // RoleCatalogStore: SaveDraft workspace sync + read mapping + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task RoleCatalogStore_SaveDraft_SyncsToWorkspace() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var draft = new StoredRoleDraft( + HomeDirectory: "test", + FilePath: "test/draft", + FileExists: true, + UpdatedAtUtc: DateTimeOffset.UtcNow, + Draft: new StoredRoleDefinition("r1", "My Role", "prompt", + "anthropic", "claude-opus", [])); + + await store.SaveRoleDraftAsync(draft); + + workspaceStore.LastSavedRoleDraft.Should().NotBeNull(); + workspaceStore.LastSavedRoleDraft!.Draft!.Name.Should().Be("My Role"); + } + + [Fact] + public async Task RoleCatalogStore_DeleteDraft_SendsEventAndSyncsWorkspace() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + await store.DeleteRoleDraftAsync(); + + var actorId = "role-catalog-scope-1"; + runtime.Actors.Should().ContainKey(actorId); + var actor = runtime.Actors[actorId]; + actor.ReceivedEnvelopes.Should().HaveCount(1); + actor.ReceivedEnvelopes[0].Payload.Is(RoleDraftDeletedEvent.Descriptor).Should().BeTrue(); + workspaceStore.RoleDraftDeleted.Should().BeTrue(); + } + + [Fact] + public async Task RoleCatalogStore_GetCatalog_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new RoleCatalogState(); + state.Roles.Add(new RoleDefinitionEntry + { + Id = "role-1", + Name = "Assistant", + SystemPrompt = "You are helpful", + Provider = "anthropic", + Model = "claude-opus", + }); + runtime.RegisterActor("role-catalog-scope-1", + new FakeAgent("role-catalog-scope-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = await store.GetRoleCatalogAsync(); + + catalog.FileExists.Should().BeTrue(); + catalog.Roles.Should().HaveCount(1); + catalog.Roles[0].Id.Should().Be("role-1"); + catalog.Roles[0].Name.Should().Be("Assistant"); + catalog.Roles[0].Provider.Should().Be("anthropic"); + } + + // ════════════════════════════════════════════════════════════ + // ConnectorCatalogStore: read mapping + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task ConnectorCatalogStore_GetCatalog_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var state = new ConnectorCatalogState(); + state.Connectors.Add(new ConnectorDefinitionEntry + { + Name = "web-search", + Type = "http", + Enabled = true, + TimeoutMs = 30000, + Retry = 3, + Http = new HttpConnectorConfigEntry + { + BaseUrl = "https://api.search.example.com", + }, + }); + runtime.RegisterActor("connector-catalog-scope-1", + new FakeAgent("connector-catalog-scope-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = await store.GetConnectorCatalogAsync(); + + catalog.FileExists.Should().BeTrue(); + catalog.Connectors.Should().HaveCount(1); + catalog.Connectors[0].Name.Should().Be("web-search"); + catalog.Connectors[0].Type.Should().Be("http"); + catalog.Connectors[0].Http.BaseUrl.Should().Be("https://api.search.example.com"); + catalog.Connectors[0].TimeoutMs.Should().Be(30000); + } + + // ════════════════════════════════════════════════════════════ + // Scope isolation: two scopes don't share actors + // ════════════════════════════════════════════════════════════ + + [Fact] + public async Task GAgentActorStore_DifferentScopes_UseDifferentActors() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + + var scopeA = new FakeScopeResolver { ScopeIdToReturn = "scope-a" }; + var storeA = new ActorBackedGAgentActorStore(runtime, scopeA, logger); + await storeA.AddActorAsync("MyAgent", "actor-1"); + + var scopeB = new FakeScopeResolver { ScopeIdToReturn = "scope-b" }; + var storeB = new ActorBackedGAgentActorStore(runtime, scopeB, logger); + await storeB.AddActorAsync("MyAgent", "actor-2"); + + runtime.Actors.Should().ContainKey("gagent-registry-scope-a"); + runtime.Actors.Should().ContainKey("gagent-registry-scope-b"); + runtime.Actors["gagent-registry-scope-a"].ReceivedEnvelopes.Should().HaveCount(1); + runtime.Actors["gagent-registry-scope-b"].ReceivedEnvelopes.Should().HaveCount(1); + } + // ════════════════════════════════════════════════════════════ // Helper: stub IStudioWorkspaceStore for catalog tests // ════════════════════════════════════════════════════════════ @@ -661,6 +858,8 @@ private sealed class StubWorkspaceStore : IStudioWorkspaceStore { public bool RoleDraftDeleted { get; private set; } public bool ConnectorDraftDeleted { get; private set; } + public StoredRoleDraft? LastSavedRoleDraft { get; private set; } + public StoredConnectorDraft? LastSavedConnectorDraft { get; private set; } public Task GetSettingsAsync(CancellationToken ct = default) => Task.FromResult(new StudioWorkspaceSettings("", [], "", "")); @@ -684,8 +883,11 @@ public Task SaveConnectorCatalogAsync(StoredConnectorCat Task.FromResult(c); public Task GetConnectorDraftAsync(CancellationToken ct = default) => Task.FromResult(new StoredConnectorDraft("", "", false, null, null)); - public Task SaveConnectorDraftAsync(StoredConnectorDraft d, CancellationToken ct = default) => - Task.FromResult(d); + public Task SaveConnectorDraftAsync(StoredConnectorDraft d, CancellationToken ct = default) + { + LastSavedConnectorDraft = d; + return Task.FromResult(d); + } public Task DeleteConnectorDraftAsync(CancellationToken ct = default) { ConnectorDraftDeleted = true; @@ -697,8 +899,11 @@ public Task SaveRoleCatalogAsync(StoredRoleCatalog c, Cancell Task.FromResult(c); public Task GetRoleDraftAsync(CancellationToken ct = default) => Task.FromResult(new StoredRoleDraft("", "", false, null, null)); - public Task SaveRoleDraftAsync(StoredRoleDraft d, CancellationToken ct = default) => - Task.FromResult(d); + public Task SaveRoleDraftAsync(StoredRoleDraft d, CancellationToken ct = default) + { + LastSavedRoleDraft = d; + return Task.FromResult(d); + } public Task DeleteRoleDraftAsync(CancellationToken ct = default) { RoleDraftDeleted = true; From 97a70ac54332e69c71d6d4ee7b2bf9ee2f566e67 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 14 Apr 2026 21:49:11 +0800 Subject: [PATCH 17/21] Fix streaming proxy endpoint consistency --- .../StreamingProxyEndpoints.cs | 76 +++- .../StreamingProxyEndpointsCoverageTests.cs | 343 ++++++++++++++++++ 2 files changed, 402 insertions(+), 17 deletions(-) create mode 100644 test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs index 886f1096..175d8d68 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs @@ -56,29 +56,41 @@ private static async Task HandleCreateRoomAsync( roomName = "Group Chat"; var roomId = StreamingProxyDefaults.GenerateRoomId(); - - // Create the actor and initialize it - var actor = await actorRuntime.CreateAsync(roomId, ct); - - var initEvent = new GroupChatRoomInitializedEvent { RoomName = roomName }; - var envelope = new EventEnvelope + try { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), - Payload = Any.Pack(initEvent), - Route = new EnvelopeRoute { Direct = new DirectRoute { TargetActorId = actor.Id } }, - }; - await actor.HandleEventAsync(envelope, ct); + await actorStore.AddActorAsync(StreamingProxyDefaults.GAgentTypeName, roomId, ct); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + logger.LogError(ex, "Failed to register room {RoomId} before activation", roomId); + return Results.Json( + new { error = "Failed to create room" }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } try { - await actorStore.AddActorAsync(StreamingProxyDefaults.GAgentTypeName, roomId, ct); + var actor = await actorRuntime.CreateAsync(roomId, ct); + + var initEvent = new GroupChatRoomInitializedEvent { RoomName = roomName }; + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(initEvent), + Route = new EnvelopeRoute { Direct = new DirectRoute { TargetActorId = actor.Id } }, + }; + await actor.HandleEventAsync(envelope, ct); } catch (OperationCanceledException) { throw; } catch (Exception ex) { - // chrono-storage unavailable (timeout/403/network) — actor still usable via runtime - logger.LogWarning(ex, "Failed to persist room {RoomId} to actor store; room is usable via runtime", roomId); + logger.LogError(ex, "Failed to activate room {RoomId}; rolling back registration", roomId); + await TryRollbackRoomCreationAsync(roomId, actorStore, actorRuntime, logger); + return Results.Json( + new { error = "Failed to create room" }, + statusCode: StatusCodes.Status500InternalServerError); } return Results.Ok(new { roomId, roomName }); @@ -326,8 +338,10 @@ private static async Task HandleListParticipantsAsync( catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogWarning(ex, "Failed to list participants for room {RoomId}", roomId); - return Results.Ok(Array.Empty()); + logger.LogError(ex, "Failed to list participants for room {RoomId}", roomId); + return Results.Json( + new { error = "Failed to list participants" }, + statusCode: StatusCodes.Status500InternalServerError); } } @@ -410,6 +424,34 @@ private static async ValueTask MapAndWriteEventAsync(EventEnvelope envelope, Str } } + private static async Task TryRollbackRoomCreationAsync( + string roomId, + IGAgentActorStore actorStore, + IActorRuntime actorRuntime, + ILogger logger) + { + try + { + await actorRuntime.DestroyAsync(roomId, CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to destroy room actor {RoomId} during rollback", roomId); + } + + try + { + await actorStore.RemoveActorAsync( + StreamingProxyDefaults.GAgentTypeName, + roomId, + CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to remove room {RoomId} from actor store during rollback", roomId); + } + } + // ─── Request DTOs ─── public sealed record CreateRoomRequest(string? RoomName); diff --git a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs new file mode 100644 index 00000000..163447ea --- /dev/null +++ b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs @@ -0,0 +1,343 @@ +using System.Reflection; +using System.Text; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.StreamingProxy; +using Aevatar.Studio.Application.Studio.Abstractions; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using AppStreamingProxyParticipant = Aevatar.Studio.Application.Studio.Abstractions.StreamingProxyParticipant; + +namespace Aevatar.AI.Tests; + +public sealed class StreamingProxyEndpointsCoverageTests +{ + private static readonly MethodInfo HandleCreateRoomAsyncMethod = typeof(StreamingProxyEndpoints) + .GetMethod("HandleCreateRoomAsync", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("HandleCreateRoomAsync not found."); + + private static readonly MethodInfo HandleListParticipantsAsyncMethod = typeof(StreamingProxyEndpoints) + .GetMethod("HandleListParticipantsAsync", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("HandleListParticipantsAsync not found."); + + [Fact] + public async Task HandleCreateRoomAsync_ShouldRegisterAndInitializeRoomOnSuccess() + { + var operations = new List(); + var actor = new RecordingActor("created-room"); + var actorStore = new RecordingGAgentActorStore(operations); + var runtime = new RecordingActorRuntime(operations, actor); + var loggerFactory = LoggerFactory.Create(_ => { }); + + var result = await InvokeHandleCreateRoomAsync( + new DefaultHttpContext(), + "scope-a", + new StreamingProxyEndpoints.CreateRoomRequest(" Daily Standup "), + actorStore, + runtime, + loggerFactory, + CancellationToken.None); + + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status200OK); + actorStore.AddedActors.Should().ContainSingle(); + var registeredActorId = actorStore.AddedActors[0].ActorId; + operations.Should().ContainInOrder( + $"store:add:{registeredActorId}", + $"runtime:create:{registeredActorId}"); + actor.ReceivedEnvelopes.Should().ContainSingle(); + actor.ReceivedEnvelopes[0].Payload.Unpack().RoomName.Should().Be("Daily Standup"); + body.Should().Contain(registeredActorId); + body.Should().Contain("Daily Standup"); + } + + [Fact] + public async Task HandleCreateRoomAsync_ShouldRollbackRegistration_WhenActivationFails() + { + var operations = new List(); + var actorStore = new RecordingGAgentActorStore(operations); + var runtime = new RecordingActorRuntime( + operations, + new RecordingActor("created-room")) + { + ThrowOnCreate = new InvalidOperationException("boom"), + }; + var loggerFactory = LoggerFactory.Create(_ => { }); + + var result = await InvokeHandleCreateRoomAsync( + new DefaultHttpContext(), + "scope-a", + new StreamingProxyEndpoints.CreateRoomRequest("Incident Room"), + actorStore, + runtime, + loggerFactory, + CancellationToken.None); + + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status500InternalServerError); + actorStore.AddedActors.Should().ContainSingle(); + actorStore.RemovedActors.Should().ContainSingle(); + actorStore.RemovedActors[0].ActorId.Should().Be(actorStore.AddedActors[0].ActorId); + runtime.DestroyedActorIds.Should().ContainSingle(actorStore.AddedActors[0].ActorId); + operations.Should().ContainInOrder( + $"store:add:{actorStore.AddedActors[0].ActorId}", + $"runtime:create:{actorStore.AddedActors[0].ActorId}", + $"runtime:destroy:{actorStore.AddedActors[0].ActorId}", + $"store:remove:{actorStore.AddedActors[0].ActorId}"); + body.Should().Contain("Failed to create room"); + } + + [Fact] + public async Task HandleListParticipantsAsync_ShouldReturnStoredParticipants() + { + var participantStore = new RecordingParticipantStore + { + Participants = + [ + new AppStreamingProxyParticipant("agent-1", "Bot", DateTimeOffset.Parse("2026-04-14T10:00:00+08:00")), + ], + }; + var loggerFactory = LoggerFactory.Create(_ => { }); + + var result = await InvokeHandleListParticipantsAsync( + new DefaultHttpContext(), + "scope-a", + "room-1", + participantStore, + loggerFactory, + CancellationToken.None); + + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status200OK); + body.Should().Contain("agent-1"); + body.Should().Contain("Bot"); + } + + [Fact] + public async Task HandleListParticipantsAsync_ShouldReturnServerError_WhenStoreThrows() + { + var participantStore = new RecordingParticipantStore + { + ThrowOnList = new InvalidOperationException("list failed"), + }; + var loggerFactory = LoggerFactory.Create(_ => { }); + + var result = await InvokeHandleListParticipantsAsync( + new DefaultHttpContext(), + "scope-a", + "room-1", + participantStore, + loggerFactory, + CancellationToken.None); + + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status500InternalServerError); + body.Should().Contain("Failed to list participants"); + } + + private static async Task InvokeHandleCreateRoomAsync( + HttpContext context, + string scopeId, + StreamingProxyEndpoints.CreateRoomRequest? request, + IGAgentActorStore actorStore, + IActorRuntime actorRuntime, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + return await (Task)HandleCreateRoomAsyncMethod.Invoke( + null, + [context, scopeId, request, actorStore, actorRuntime, loggerFactory, ct])!; + } + + private static async Task InvokeHandleListParticipantsAsync( + HttpContext context, + string scopeId, + string roomId, + IStreamingProxyParticipantStore participantStore, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + return await (Task)HandleListParticipantsAsyncMethod.Invoke( + null, + [context, scopeId, roomId, participantStore, loggerFactory, ct])!; + } + + private static async Task<(int StatusCode, string Body)> ExecuteResultAsync(IResult result) + { + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + context.RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider(); + + await result.ExecuteAsync(context); + + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body, Encoding.UTF8, leaveOpen: true); + return (context.Response.StatusCode, await reader.ReadToEndAsync()); + } + + private sealed class RecordingGAgentActorStore(List operations) : IGAgentActorStore + { + public List<(string GAgentType, string ActorId)> AddedActors { get; } = []; + public List<(string GAgentType, string ActorId)> RemovedActors { get; } = []; + + public Task> GetAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public Task AddActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) + { + operations.Add($"store:add:{actorId}"); + AddedActors.Add((gagentType, actorId)); + return Task.CompletedTask; + } + + public Task RemoveActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) + { + operations.Add($"store:remove:{actorId}"); + RemovedActors.Add((gagentType, actorId)); + return Task.CompletedTask; + } + } + + private sealed class RecordingParticipantStore : IStreamingProxyParticipantStore + { + public Exception? ThrowOnList { get; init; } + public IReadOnlyList Participants { get; init; } = []; + + public Task> ListAsync( + string roomId, + CancellationToken cancellationToken = default) + { + _ = roomId; + if (ThrowOnList is not null) + throw ThrowOnList; + + return Task.FromResult(Participants); + } + + public Task AddAsync( + string roomId, + string agentId, + string displayName, + CancellationToken cancellationToken = default) + { + _ = roomId; + _ = agentId; + _ = displayName; + return Task.CompletedTask; + } + + public Task RemoveRoomAsync(string roomId, CancellationToken cancellationToken = default) + { + _ = roomId; + return Task.CompletedTask; + } + } + + private sealed class RecordingActorRuntime(List operations, IActor actor) : IActorRuntime + { + public Exception? ThrowOnCreate { get; init; } + public List DestroyedActorIds { get; } = []; + + public Task CreateAsync(string? id = null, CancellationToken ct = default) + where TAgent : IAgent => + CreateAsync(typeof(TAgent), id, ct); + + public Task CreateAsync(System.Type agentType, string? id = null, CancellationToken ct = default) + { + _ = agentType; + ct.ThrowIfCancellationRequested(); + + var actorId = id ?? throw new InvalidOperationException("Actor id is required for this test."); + operations.Add($"runtime:create:{actorId}"); + if (ThrowOnCreate is not null) + throw ThrowOnCreate; + + return Task.FromResult(actor); + } + + public Task DestroyAsync(string id, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + operations.Add($"runtime:destroy:{id}"); + DestroyedActorIds.Add(id); + return Task.CompletedTask; + } + + public Task GetAsync(string id) + { + _ = id; + return Task.FromResult(null); + } + + public Task ExistsAsync(string id) + { + _ = id; + return Task.FromResult(false); + } + + public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) + { + _ = parentId; + _ = childId; + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task UnlinkAsync(string childId, CancellationToken ct = default) + { + _ = childId; + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } + + private sealed class RecordingActor(string id) : IActor + { + public List ReceivedEnvelopes { get; } = []; + + public string Id { get; } = id; + + public IAgent Agent { get; } = new StubAgent(id); + + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) + { + ReceivedEnvelopes.Add(envelope); + return Task.CompletedTask; + } + + public Task GetParentIdAsync() => Task.FromResult(null); + + public Task> GetChildrenIdsAsync() => Task.FromResult>([]); + } + + private sealed class StubAgent(string id) : IAgent + { + public string Id { get; } = id; + + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) + { + _ = envelope; + return Task.CompletedTask; + } + + public Task GetDescriptionAsync() => Task.FromResult(string.Empty); + + public Task> GetSubscribedEventTypesAsync() => + Task.FromResult>([]); + + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + } +} From 5c99e1e9343b988906c182de28c1c18c6321f625 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Tue, 14 Apr 2026 22:25:00 +0800 Subject: [PATCH 18/21] Fix Codecov patch coverage upload --- .github/workflows/ci.yml | 2 +- tools/ci/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ec0dbee..fa36b723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -299,7 +299,7 @@ jobs: if: ${{ success() && env.CODECOV_TOKEN != '' }} uses: codecov/codecov-action@v5 with: - files: ./artifacts/coverage/**/report/Cobertura.xml + files: ./artifacts/coverage/**/raw/**/coverage.cobertura.xml disable_search: true fail_ci_if_error: true flags: ci diff --git a/tools/ci/README.md b/tools/ci/README.md index c78500ea..4f43cdd4 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -47,6 +47,6 @@ This directory keeps CI gate scripts and smoke tests. - Job `coverage-quality` - Runs restore/build + `tools/ci/coverage_quality_guard.sh`. - Uploads `artifacts/coverage/**` as CI artifacts (`coverage-quality-report`). - - Uploads the filtered `artifacts/coverage/**/report/Cobertura.xml` to Codecov when `CODECOV_TOKEN` is available. + - Uploads the raw `artifacts/coverage/**/raw/**/coverage.cobertura.xml` files to Codecov when `CODECOV_TOKEN` is available, while the filtered report remains the local quality-gate input. - Triggered on `main/dev` pushes, nightly schedule, or manual dispatch. - Job `distributed-3node-smoke` -> `tools/ci/distributed_3node_smoke.sh` From 9b629624b228a28e2420c3ce44af672b2ea2467a Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 15 Apr 2026 17:09:27 +0800 Subject: [PATCH 19/21] Improve studio patch coverage --- .../Controllers/UserConfigController.cs | 6 +- .../ActorBackedStoreAdapterTests.cs | 612 +++++++++++++++++- .../Aevatar.Tools.Cli.Tests.csproj | 1 + .../UserConfigProjectionAndControllerTests.cs | 452 +++++++++++++ 4 files changed, 1063 insertions(+), 8 deletions(-) create mode 100644 test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs diff --git a/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs b/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs index 0591495a..42526d63 100644 --- a/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs +++ b/src/Aevatar.Studio.Hosting/Controllers/UserConfigController.cs @@ -71,7 +71,8 @@ public async Task> Save( PreferredLlmRoute: request.PreferredLlmRoute is null ? current.PreferredLlmRoute : UserConfigLlmRoute.Normalize(request.PreferredLlmRoute), RuntimeMode: request.RuntimeMode is null ? current.RuntimeMode : request.RuntimeMode.Trim(), LocalRuntimeBaseUrl: request.LocalRuntimeBaseUrl is null ? current.LocalRuntimeBaseUrl : request.LocalRuntimeBaseUrl.Trim(), - RemoteRuntimeBaseUrl: request.RemoteRuntimeBaseUrl is null ? current.RemoteRuntimeBaseUrl : request.RemoteRuntimeBaseUrl.Trim()); + RemoteRuntimeBaseUrl: request.RemoteRuntimeBaseUrl is null ? current.RemoteRuntimeBaseUrl : request.RemoteRuntimeBaseUrl.Trim(), + MaxToolRounds: request.MaxToolRounds ?? current.MaxToolRounds); await _commandService.SaveAsync(merged, cancellationToken); return Ok(merged); } @@ -91,7 +92,8 @@ public sealed record SaveUserConfigRequest( [property: JsonPropertyName("preferredLlmRoute")] string? PreferredLlmRoute = null, [property: JsonPropertyName("runtimeMode")] string? RuntimeMode = null, [property: JsonPropertyName("localRuntimeBaseUrl")] string? LocalRuntimeBaseUrl = null, - [property: JsonPropertyName("remoteRuntimeBaseUrl")] string? RemoteRuntimeBaseUrl = null); + [property: JsonPropertyName("remoteRuntimeBaseUrl")] string? RemoteRuntimeBaseUrl = null, + [property: JsonPropertyName("maxToolRounds")] int? MaxToolRounds = null); [HttpGet("models")] public async Task> GetModels(CancellationToken cancellationToken) diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index 0b9c77ae..2034aa42 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -208,6 +208,30 @@ public async Task GAgentActorStore_GetAsync_NoActor_ReturnsEmptyList() groups.Should().BeEmpty(); } + [Fact] + public async Task GAgentActorStore_GetAsync_MapsRegistryState() + { + var runtime = new FakeActorRuntime(); + var state = new GAgentRegistryState(); + state.Groups.Add(new GAgentRegistryEntry + { + GagentType = "RoleGAgent", + ActorIds = { "actor-a", "actor-b" }, + }); + runtime.RegisterActor( + "gagent-registry-scope-1", + new FakeAgent("gagent-registry-scope-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedGAgentActorStore(runtime, scopeResolver, logger); + + var groups = await store.GetAsync(); + + groups.Should().ContainSingle(); + groups[0].GAgentType.Should().Be("RoleGAgent"); + groups[0].ActorIds.Should().Equal("actor-a", "actor-b"); + } + // ════════════════════════════════════════════════════════════ // GAgentActorStore: AddActorAsync command construction // ════════════════════════════════════════════════════════════ @@ -280,7 +304,7 @@ public Task GetAsync(CancellationToken ct = default) => // ════════════════════════════════════════════════════════════ [Fact] - public async Task ChatHistoryStore_SaveMessages_SendsMessagesReplacedEvent() + public async Task ChatHistoryStore_SaveMessages_MapsOptionalMetadataAndFields() { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; @@ -310,7 +334,7 @@ public async Task ChatHistoryStore_SaveMessages_SendsMessagesReplacedEvent() } [Fact] - public async Task ChatHistoryStore_DeleteConversation_SendsConversationDeletedEvent() + public async Task ChatHistoryStore_DeleteConversation_UsesConversationActorId() { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; @@ -530,6 +554,153 @@ public async Task UserMemoryStore_GetAsync_MapsStateCorrectly() doc.Entries[0].Category.Should().Be("context"); } + [Fact] + public async Task UserMemoryStore_GetAsync_FiltersInvalidEntries() + { + var runtime = new FakeActorRuntime(); + var state = new UserMemoryState(); + state.Entries.Add(new UserMemoryEntryProto + { + Id = "mem-1", + Category = "context", + Content = "keep", + }); + state.Entries.Add(new UserMemoryEntryProto + { + Id = string.Empty, + Category = "context", + Content = "drop-no-id", + }); + state.Entries.Add(new UserMemoryEntryProto + { + Id = "mem-3", + Category = "context", + Content = string.Empty, + }); + runtime.RegisterActor("user-memory-user-1", + new FakeAgent("user-memory-user-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var doc = await store.GetAsync(); + + doc.Entries.Should().ContainSingle(); + doc.Entries[0].Id.Should().Be("mem-1"); + } + + [Fact] + public async Task UserMemoryStore_SaveAsync_ReconcilesMissingAndStaleEntries() + { + var runtime = new FakeActorRuntime(); + var state = new UserMemoryState(); + state.Entries.Add(new UserMemoryEntryProto + { + Id = "keep", + Category = "preference", + Content = "Keep me", + Source = "explicit", + }); + state.Entries.Add(new UserMemoryEntryProto + { + Id = "remove", + Category = "context", + Content = "Remove me", + Source = "inferred", + }); + runtime.RegisterActor("user-memory-user-1", + new FakeAgent("user-memory-user-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + await store.SaveAsync(new UserMemoryDocument( + 1, + [ + new UserMemoryEntry("keep", "preference", "Keep me", "explicit", 1, 1), + new UserMemoryEntry("add", "instruction", "Add me", "explicit", 2, 2), + ])); + + var actor = runtime.Actors["user-memory-user-1"]; + actor.ReceivedEnvelopes.Should().HaveCount(2); + actor.ReceivedEnvelopes[0].Payload.Is(MemoryEntryRemovedEvent.Descriptor).Should().BeTrue(); + actor.ReceivedEnvelopes[0].Payload.Unpack().EntryId.Should().Be("remove"); + actor.ReceivedEnvelopes[1].Payload.Is(MemoryEntryAddedEvent.Descriptor).Should().BeTrue(); + actor.ReceivedEnvelopes[1].Payload.Unpack().Entry.Id.Should().Be("add"); + } + + [Fact] + public async Task UserMemoryStore_RemoveEntryAsync_MissingEntry_ReturnsFalse() + { + var runtime = new FakeActorRuntime(); + var state = new UserMemoryState(); + state.Entries.Add(new UserMemoryEntryProto + { + Id = "present", + Category = "context", + Content = "present", + }); + runtime.RegisterActor("user-memory-user-1", + new FakeAgent("user-memory-user-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var removed = await store.RemoveEntryAsync("missing"); + + removed.Should().BeFalse(); + runtime.Actors["user-memory-user-1"].ReceivedEnvelopes.Should().BeEmpty(); + } + + [Fact] + public async Task UserMemoryStore_BuildPromptSectionAsync_FormatsGroupsAndTruncates() + { + var runtime = new FakeActorRuntime(); + var state = new UserMemoryState(); + state.Entries.Add(new UserMemoryEntryProto + { + Id = "ctx", + Category = UserMemoryCategories.Context, + Content = "Project context that is long enough to require truncation.", + Source = "inferred", + UpdatedAt = 1, + }); + state.Entries.Add(new UserMemoryEntryProto + { + Id = "pref", + Category = UserMemoryCategories.Preference, + Content = "Prefers concise answers", + Source = "explicit", + UpdatedAt = 2, + }); + runtime.RegisterActor("user-memory-user-1", + new FakeAgent("user-memory-user-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var prompt = await store.BuildPromptSectionAsync(70); + + prompt.Should().StartWith(""); + prompt.Should().Contain("## Preferences"); + prompt.Should().Contain("- Prefers concise answers"); + prompt.Should().Contain(""); + prompt.Length.Should().BeLessThanOrEqualTo(85); + } + + [Fact] + public async Task UserMemoryStore_BuildPromptSectionAsync_WhenReadFails_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver(); + var logger = NullLogger.Instance; + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + + var prompt = await store.BuildPromptSectionAsync(); + + prompt.Should().BeEmpty(); + } + [Fact] public async Task UserMemoryStore_NoScope_Throws() { @@ -604,6 +775,163 @@ public async Task ConnectorCatalogStore_GetCatalog_NoActor_ReturnsEmpty() catalog.Connectors.Should().BeEmpty(); } + [Fact] + public async Task ConnectorCatalogStore_ImportLocalCatalog_NoFile_Throws() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var act = () => store.ImportLocalCatalogAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ConnectorCatalogStore_ImportLocalCatalog_SendsCatalogAndReturnsImportedCatalog() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore + { + ConnectorCatalogToReturn = new StoredConnectorCatalog( + "workspace", + "/tmp/connectors.json", + true, + [ + new StoredConnectorDefinition( + "imported", "cli", true, 1000, 1, + new StoredHttpConnectorConfig("", [], [], [], new Dictionary(), new StoredConnectorAuthConfig("", "", "", "", "")), + new StoredCliConnectorConfig("uvx", ["tool"], ["run"], ["query"], "/tmp", new Dictionary { ["MODE"] = "test" }), + new StoredMcpConnectorConfig("", "", "", [], new Dictionary(), new Dictionary(), new StoredConnectorAuthConfig("", "", "", "", ""), "", [], [])) + ]) + }; + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var imported = await store.ImportLocalCatalogAsync(); + + imported.SourceFileExists.Should().BeTrue(); + imported.SourceFilePath.Should().Be("/tmp/connectors.json"); + imported.Catalog.FileExists.Should().BeTrue(); + var evt = runtime.Actors["connector-catalog-scope-1"].ReceivedEnvelopes[0].Payload.Unpack(); + evt.Connectors.Should().ContainSingle(); + evt.Connectors[0].Cli.Command.Should().Be("uvx"); + evt.Connectors[0].Cli.Environment["MODE"].Should().Be("test"); + } + + [Fact] + public async Task ConnectorCatalogStore_GetDraft_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var draft = await store.GetConnectorDraftAsync(); + + draft.FileExists.Should().BeFalse(); + draft.Draft.Should().BeNull(); + } + + [Fact] + public async Task ConnectorCatalogStore_GetDraft_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var updatedAt = DateTimeOffset.UtcNow; + var state = new ConnectorCatalogState + { + Draft = new ConnectorDraftEntry + { + UpdatedAtUtc = Timestamp.FromDateTimeOffset(updatedAt), + Draft = new ConnectorDefinitionEntry + { + Name = "draft-conn", + Type = "mcp", + Enabled = true, + Mcp = new McpConnectorConfigEntry + { + ServerName = "server-a", + DefaultTool = "tool-a", + }, + }, + }, + }; + runtime.RegisterActor( + "connector-catalog-scope-1", + new FakeAgent("connector-catalog-scope-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var draft = await store.GetConnectorDraftAsync(); + + draft.FileExists.Should().BeTrue(); + draft.UpdatedAtUtc.Should().Be(updatedAt); + draft.Draft.Should().NotBeNull(); + draft.Draft!.Name.Should().Be("draft-conn"); + draft.Draft.Mcp.ServerName.Should().Be("server-a"); + draft.Draft.Mcp.DefaultTool.Should().Be("tool-a"); + } + + [Fact] + public async Task ConnectorCatalogStore_SaveDraft_SendsEventAndSyncsWorkspace() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + var updatedAt = DateTimeOffset.UtcNow; + var draft = new StoredConnectorDraft( + HomeDirectory: "test", + FilePath: "test/draft", + FileExists: true, + UpdatedAtUtc: updatedAt, + Draft: new StoredConnectorDefinition( + "draft-conn", "http", true, 2000, 2, + new StoredHttpConnectorConfig("https://api.example.com", ["GET"], ["/search"], ["q"], new Dictionary { ["X-Test"] = "1" }, new StoredConnectorAuthConfig("oauth", "https://auth", "client", "secret", "scope")), + new StoredCliConnectorConfig("", [], [], [], "", new Dictionary()), + new StoredMcpConnectorConfig("", "", "", [], new Dictionary(), new Dictionary(), new StoredConnectorAuthConfig("", "", "", "", ""), "", [], []))); + + var saved = await store.SaveConnectorDraftAsync(draft); + + saved.FileExists.Should().BeTrue(); + workspaceStore.LastSavedConnectorDraft.Should().BeEquivalentTo(draft); + var evt = runtime.Actors["connector-catalog-scope-1"].ReceivedEnvelopes[0].Payload.Unpack(); + evt.Draft.Name.Should().Be("draft-conn"); + evt.Draft.Http.Auth.Type.Should().Be("oauth"); + evt.Draft.Http.DefaultHeaders["X-Test"].Should().Be("1"); + evt.UpdatedAtUtc.ToDateTimeOffset().Should().Be(updatedAt); + } + + [Fact] + public async Task ConnectorCatalogStore_DeleteDraft_SendsEventAndSyncsWorkspace() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + await store.DeleteConnectorDraftAsync(); + + var actor = runtime.Actors["connector-catalog-scope-1"]; + actor.ReceivedEnvelopes.Should().ContainSingle(); + actor.ReceivedEnvelopes[0].Payload.Is(ConnectorDraftDeletedEvent.Descriptor).Should().BeTrue(); + workspaceStore.ConnectorDraftDeleted.Should().BeTrue(); + } + // ════════════════════════════════════════════════════════════ // RoleCatalogStore: command dispatch + workspace sync // ════════════════════════════════════════════════════════════ @@ -669,6 +997,51 @@ public async Task RoleCatalogStore_GetCatalog_NoActor_ReturnsEmpty() catalog.Roles.Should().BeEmpty(); } + [Fact] + public async Task RoleCatalogStore_ImportLocalCatalog_NoFile_Throws() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var act = () => store.ImportLocalCatalogAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RoleCatalogStore_ImportLocalCatalog_SendsCatalogAndReturnsImportedCatalog() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore + { + RoleCatalogToReturn = new StoredRoleCatalog( + "workspace", + "/tmp/roles.json", + true, + [ + new StoredRoleDefinition("role-imported", "Imported Role", "prompt", "anthropic", "claude-opus", ["connector-a"]) + ]) + }; + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var imported = await store.ImportLocalCatalogAsync(); + + imported.SourceFileExists.Should().BeTrue(); + imported.SourceFilePath.Should().Be("/tmp/roles.json"); + imported.Catalog.FileExists.Should().BeTrue(); + var evt = runtime.Actors["role-catalog-scope-1"].ReceivedEnvelopes[0].Payload.Unpack(); + evt.Roles.Should().ContainSingle(); + evt.Roles[0].Name.Should().Be("Imported Role"); + evt.Roles[0].Connectors.Should().Equal("connector-a"); + } + // ════════════════════════════════════════════════════════════ // ChatHistoryStore: GetMessagesAsync read mapping // ════════════════════════════════════════════════════════════ @@ -725,6 +1098,95 @@ public async Task ChatHistoryStore_GetMessages_NoActor_ReturnsEmpty() messages.Should().BeEmpty(); } + [Fact] + public async Task ChatHistoryStore_GetIndex_MapsAndOrdersState() + { + var runtime = new FakeActorRuntime(); + var state = new ChatHistoryIndexState(); + state.Conversations.Add(new ConversationMetaProto + { + Id = "older", + Title = "Older", + UpdatedAtMs = 1000, + }); + state.Conversations.Add(new ConversationMetaProto + { + Id = "newer", + Title = "Newer", + UpdatedAtMs = 2000, + LlmRoute = string.Empty, + LlmModel = string.Empty, + }); + runtime.RegisterActor( + "chat-index-scope-1", + new FakeAgent("chat-index-scope-1", state)); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + var index = await store.GetIndexAsync("scope-1"); + + index.Conversations.Select(static c => c.Id).Should().Equal("newer", "older"); + index.Conversations[0].LlmRoute.Should().BeNull(); + index.Conversations[0].LlmModel.Should().BeNull(); + } + + [Fact] + public async Task ChatHistoryStore_SaveMessages_SendsMessagesReplacedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + await store.SaveMessagesAsync( + "scope-1", + "conv-1", + new ConversationMeta( + Id: "ignored", + Title: "Assistant", + ServiceId: "svc-1", + ServiceKind: "chat", + CreatedAt: DateTimeOffset.FromUnixTimeMilliseconds(1000), + UpdatedAt: DateTimeOffset.FromUnixTimeMilliseconds(2000), + MessageCount: 1, + LlmRoute: null, + LlmModel: "gpt-4.1"), + [ + new StoredChatMessage( + Id: "msg-1", + Role: "assistant", + Content: "hello", + Timestamp: 1700000000000, + Status: "sent", + Error: "boom", + Thinking: "reasoning") + ]); + + var actor = runtime.Actors["chat-scope-1-conv-1"]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.ScopeId.Should().Be("scope-1"); + evt.Meta.Id.Should().Be("conv-1"); + evt.Meta.LlmRoute.Should().BeEmpty(); + evt.Meta.LlmModel.Should().Be("gpt-4.1"); + evt.Messages.Should().ContainSingle(); + evt.Messages[0].Error.Should().Be("boom"); + evt.Messages[0].Thinking.Should().Be("reasoning"); + } + + [Fact] + public async Task ChatHistoryStore_DeleteConversation_SendsConversationDeletedEvent() + { + var runtime = new FakeActorRuntime(); + var logger = NullLogger.Instance; + var store = new ActorBackedChatHistoryStore(runtime, logger); + + await store.DeleteConversationAsync("scope-1", "conv-1"); + + var actor = runtime.Actors["chat-scope-1-conv-1"]; + var evt = actor.ReceivedEnvelopes[0].Payload.Unpack(); + evt.ScopeId.Should().Be("scope-1"); + evt.ConversationId.Should().Be("conv-1"); + } + // ════════════════════════════════════════════════════════════ // RoleCatalogStore: SaveDraft workspace sync + read mapping // ════════════════════════════════════════════════════════════ @@ -751,6 +1213,62 @@ public async Task RoleCatalogStore_SaveDraft_SyncsToWorkspace() workspaceStore.LastSavedRoleDraft.Should().NotBeNull(); workspaceStore.LastSavedRoleDraft!.Draft!.Name.Should().Be("My Role"); + var evt = runtime.Actors["role-catalog-scope-1"].ReceivedEnvelopes[0].Payload.Unpack(); + evt.Draft.Name.Should().Be("My Role"); + } + + [Fact] + public async Task RoleCatalogStore_GetDraft_NoActor_ReturnsEmpty() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var draft = await store.GetRoleDraftAsync(); + + draft.FileExists.Should().BeFalse(); + draft.Draft.Should().BeNull(); + } + + [Fact] + public async Task RoleCatalogStore_GetDraft_MapsStateCorrectly() + { + var runtime = new FakeActorRuntime(); + var updatedAt = DateTimeOffset.UtcNow; + var state = new RoleCatalogState + { + Draft = new RoleDraftEntry + { + UpdatedAtUtc = Timestamp.FromDateTimeOffset(updatedAt), + Draft = new RoleDefinitionEntry + { + Id = "draft-1", + Name = "Draft Role", + SystemPrompt = "prompt", + Provider = "anthropic", + Model = "claude-opus", + }, + }, + }; + runtime.RegisterActor( + "role-catalog-scope-1", + new FakeAgent("role-catalog-scope-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedRoleCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var draft = await store.GetRoleDraftAsync(); + + draft.FileExists.Should().BeTrue(); + draft.UpdatedAtUtc.Should().Be(updatedAt); + draft.Draft.Should().NotBeNull(); + draft.Draft!.Name.Should().Be("Draft Role"); + draft.Draft.Provider.Should().Be("anthropic"); } [Fact] @@ -842,6 +1360,80 @@ public async Task ConnectorCatalogStore_GetCatalog_MapsStateCorrectly() catalog.Connectors[0].TimeoutMs.Should().Be(30000); } + [Fact] + public async Task ConnectorCatalogStore_GetCatalog_MapsAllConnectorConfigShapes() + { + var runtime = new FakeActorRuntime(); + var state = new ConnectorCatalogState(); + state.Connectors.Add(new ConnectorDefinitionEntry + { + Name = "full-connector", + Type = "mcp", + Enabled = true, + TimeoutMs = 30000, + Retry = 3, + Http = new HttpConnectorConfigEntry + { + BaseUrl = "https://api.example.com", + AllowedMethods = { "GET" }, + AllowedPaths = { "/search" }, + AllowedInputKeys = { "q" }, + Auth = new ConnectorAuthEntry + { + Type = "oauth", + TokenUrl = "https://auth.example.com", + ClientId = "client", + ClientSecret = "secret", + Scope = "read", + }, + }, + Cli = new CliConnectorConfigEntry + { + Command = "uvx", + FixedArguments = { "mcp-server" }, + AllowedOperations = { "run" }, + AllowedInputKeys = { "query" }, + WorkingDirectory = "/tmp", + }, + Mcp = new McpConnectorConfigEntry + { + ServerName = "server-a", + Command = "uvx", + Url = "http://localhost:3000", + Arguments = { "--stdio" }, + DefaultTool = "tool-a", + AllowedTools = { "tool-a" }, + AllowedInputKeys = { "input" }, + Auth = new ConnectorAuthEntry { Type = "bearer" }, + }, + }); + state.Connectors[0].Http.DefaultHeaders["X-Test"] = "1"; + state.Connectors[0].Cli.Environment["MODE"] = "test"; + state.Connectors[0].Mcp.Environment["TOKEN"] = "abc"; + state.Connectors[0].Mcp.AdditionalHeaders["X-Trace"] = "trace"; + runtime.RegisterActor( + "connector-catalog-scope-1", + new FakeAgent("connector-catalog-scope-1", state)); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; + var workspaceStore = new StubWorkspaceStore(); + var logger = NullLogger.Instance; + var store = new ActorBackedConnectorCatalogStore( + runtime, scopeResolver, workspaceStore, logger); + + var catalog = await store.GetConnectorCatalogAsync(); + + catalog.Connectors.Should().ContainSingle(); + var connector = catalog.Connectors[0]; + connector.Http.Auth.TokenUrl.Should().Be("https://auth.example.com"); + connector.Http.DefaultHeaders["X-Test"].Should().Be("1"); + connector.Cli.Command.Should().Be("uvx"); + connector.Cli.Environment["MODE"].Should().Be("test"); + connector.Mcp.ServerName.Should().Be("server-a"); + connector.Mcp.Environment["TOKEN"].Should().Be("abc"); + connector.Mcp.AdditionalHeaders["X-Trace"].Should().Be("trace"); + connector.Mcp.Auth.Type.Should().Be("bearer"); + } + // ════════════════════════════════════════════════════════════ // Scope isolation: two scopes don't share actors // ════════════════════════════════════════════════════════════ @@ -876,6 +1468,14 @@ private sealed class StubWorkspaceStore : IStudioWorkspaceStore public bool ConnectorDraftDeleted { get; private set; } public StoredRoleDraft? LastSavedRoleDraft { get; private set; } public StoredConnectorDraft? LastSavedConnectorDraft { get; private set; } + public StoredConnectorCatalog ConnectorCatalogToReturn { get; set; } = + new("", "", false, []); + public StoredRoleCatalog RoleCatalogToReturn { get; set; } = + new("", "", false, []); + public StoredConnectorDraft ConnectorDraftToReturn { get; set; } = + new("", "", false, null, null); + public StoredRoleDraft RoleDraftToReturn { get; set; } = + new("", "", false, null, null); public Task GetSettingsAsync(CancellationToken ct = default) => Task.FromResult(new StudioWorkspaceSettings("", [], "", "")); @@ -894,11 +1494,11 @@ public Task> ListExecutionsAsync(Cancellati public Task SaveExecutionAsync(StoredExecutionRecord r, CancellationToken ct = default) => Task.FromResult(r); public Task GetConnectorCatalogAsync(CancellationToken ct = default) => - Task.FromResult(new StoredConnectorCatalog("", "", false, [])); + Task.FromResult(ConnectorCatalogToReturn); public Task SaveConnectorCatalogAsync(StoredConnectorCatalog c, CancellationToken ct = default) => Task.FromResult(c); public Task GetConnectorDraftAsync(CancellationToken ct = default) => - Task.FromResult(new StoredConnectorDraft("", "", false, null, null)); + Task.FromResult(ConnectorDraftToReturn); public Task SaveConnectorDraftAsync(StoredConnectorDraft d, CancellationToken ct = default) { LastSavedConnectorDraft = d; @@ -910,11 +1510,11 @@ public Task DeleteConnectorDraftAsync(CancellationToken ct = default) return Task.CompletedTask; } public Task GetRoleCatalogAsync(CancellationToken ct = default) => - Task.FromResult(new StoredRoleCatalog("", "", false, [])); + Task.FromResult(RoleCatalogToReturn); public Task SaveRoleCatalogAsync(StoredRoleCatalog c, CancellationToken ct = default) => Task.FromResult(c); public Task GetRoleDraftAsync(CancellationToken ct = default) => - Task.FromResult(new StoredRoleDraft("", "", false, null, null)); + Task.FromResult(RoleDraftToReturn); public Task SaveRoleDraftAsync(StoredRoleDraft d, CancellationToken ct = default) { LastSavedRoleDraft = d; diff --git a/test/Aevatar.Tools.Cli.Tests/Aevatar.Tools.Cli.Tests.csproj b/test/Aevatar.Tools.Cli.Tests/Aevatar.Tools.Cli.Tests.csproj index 6f90f162..66ca986f 100644 --- a/test/Aevatar.Tools.Cli.Tests/Aevatar.Tools.Cli.Tests.csproj +++ b/test/Aevatar.Tools.Cli.Tests/Aevatar.Tools.Cli.Tests.csproj @@ -26,5 +26,6 @@ + diff --git a/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs b/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs new file mode 100644 index 00000000..e9745a20 --- /dev/null +++ b/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs @@ -0,0 +1,452 @@ +using System.Net.Http; +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.UserConfig; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Hosting.Controllers; +using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Projection.DependencyInjection; +using Aevatar.Studio.Projection.Metadata; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.Projectors; +using Aevatar.Studio.Projection.QueryPorts; +using Aevatar.Studio.Projection.ReadModels; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Tools.Cli.Tests; + +public sealed class UserConfigProjectionAndControllerTests +{ + [Fact] + public async Task AddStudioProjectionComponents_RegistersPortsAndDispatchesNormalizedEvent() + { + var services = new ServiceCollection(); + var dispatchPort = new RecordingActorDispatchPort(); + var scopeResolver = new StubScopeResolver { ScopeIdToReturn = "scope-1" }; + services.AddSingleton(dispatchPort); + services.AddSingleton(scopeResolver); + services.AddSingleton>( + new StubUserConfigDocumentReader()); + services.AddStudioProjectionComponents(); + + await using var provider = services.BuildServiceProvider(); + var commandService = provider.GetRequiredService(); + var queryPort = provider.GetRequiredService(); + var metadataProvider = provider.GetRequiredService>(); + + commandService.Should().NotBeNull(); + queryPort.Should().NotBeNull(); + metadataProvider.Should().BeOfType(); + + await commandService.SaveAsync(new UserConfig( + DefaultModel: "claude-opus", + PreferredLlmRoute: "gateway", + RuntimeMode: "REMOTE", + LocalRuntimeBaseUrl: "http://127.0.0.1:5080/", + RemoteRuntimeBaseUrl: "https://runtime.example.com/", + MaxToolRounds: 9)); + + dispatchPort.ActorId.Should().Be("user-config-scope-1"); + dispatchPort.Envelope.Should().NotBeNull(); + var evt = dispatchPort.Envelope!.Payload.Unpack(); + evt.DefaultModel.Should().Be("claude-opus"); + evt.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); + evt.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.RemoteMode); + evt.LocalRuntimeBaseUrl.Should().Be("http://127.0.0.1:5080"); + evt.RemoteRuntimeBaseUrl.Should().Be("https://runtime.example.com"); + evt.MaxToolRounds.Should().Be(9); + } + + [Fact] + public async Task ProjectionUserConfigQueryPort_GetAsync_ReturnsDefaultsWhenDocumentMissing() + { + var reader = new StubUserConfigDocumentReader(); + var scopeResolver = new StubScopeResolver(); + var port = new ProjectionUserConfigQueryPort(reader, scopeResolver); + + var result = await port.GetAsync(); + + reader.LastKey.Should().Be("user-config-default"); + result.DefaultModel.Should().BeEmpty(); + result.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); + result.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.LocalMode); + result.LocalRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.LocalRuntimeBaseUrl); + result.RemoteRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl); + result.MaxToolRounds.Should().Be(0); + } + + [Fact] + public async Task ProjectionUserConfigQueryPort_GetAsync_MapsDocumentAndNormalizesEmptyStrings() + { + var reader = new StubUserConfigDocumentReader + { + Document = new UserConfigCurrentStateDocument + { + Id = "user-config-scope-2", + ActorId = "user-config-scope-2", + DefaultModel = "gpt-4.1", + PreferredLlmRoute = string.Empty, + RuntimeMode = string.Empty, + LocalRuntimeBaseUrl = string.Empty, + RemoteRuntimeBaseUrl = string.Empty, + MaxToolRounds = 7, + }, + }; + var scopeResolver = new StubScopeResolver { ScopeIdToReturn = "scope-2" }; + var port = new ProjectionUserConfigQueryPort(reader, scopeResolver); + + var result = await port.GetAsync(); + + reader.LastKey.Should().Be("user-config-scope-2"); + result.DefaultModel.Should().Be("gpt-4.1"); + result.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); + result.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.LocalMode); + result.LocalRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.LocalRuntimeBaseUrl); + result.RemoteRuntimeBaseUrl.Should().Be(UserConfigRuntimeDefaults.RemoteRuntimeBaseUrl); + result.MaxToolRounds.Should().Be(7); + } + + [Fact] + public async Task UserConfigCurrentStateProjector_ProjectAsync_UpsertsCommittedState() + { + var dispatcher = new RecordingWriteDispatcher(); + var observedAt = DateTimeOffset.Parse("2026-04-15T10:00:00+00:00"); + var projector = new UserConfigCurrentStateProjector(dispatcher, new FixedProjectionClock(DateTimeOffset.MinValue)); + var context = new StudioMaterializationContext + { + RootActorId = "user-config-scope-1", + ProjectionKind = "user-config", + }; + + await projector.ProjectAsync( + context, + BuildCommittedEnvelope( + new UserConfigUpdatedEvent { DefaultModel = "gpt-4.1" }, + "evt-1", + 4, + observedAt, + new UserConfigGAgentState + { + DefaultModel = "gpt-4.1", + PreferredLlmRoute = "/api/v1/proxy/s/custom", + RuntimeMode = UserConfigRuntimeDefaults.RemoteMode, + LocalRuntimeBaseUrl = "http://127.0.0.1:5080", + RemoteRuntimeBaseUrl = "https://runtime.example.com", + MaxToolRounds = 6, + })); + + dispatcher.LastUpsert.Should().NotBeNull(); + dispatcher.LastUpsert!.ActorId.Should().Be("user-config-scope-1"); + dispatcher.LastUpsert.StateVersion.Should().Be(4); + dispatcher.LastUpsert.LastEventId.Should().Be("evt-1"); + dispatcher.LastUpsert.UpdatedAt!.ToDateTimeOffset().Should().Be(observedAt); + dispatcher.LastUpsert.DefaultModel.Should().Be("gpt-4.1"); + dispatcher.LastUpsert.PreferredLlmRoute.Should().Be("/api/v1/proxy/s/custom"); + dispatcher.LastUpsert.RuntimeMode.Should().Be(UserConfigRuntimeDefaults.RemoteMode); + dispatcher.LastUpsert.MaxToolRounds.Should().Be(6); + } + + [Fact] + public async Task UserConfigCurrentStateProjector_ProjectAsync_IgnoresEnvelopeWithoutState() + { + var dispatcher = new RecordingWriteDispatcher(); + var projector = new UserConfigCurrentStateProjector(dispatcher, new FixedProjectionClock(DateTimeOffset.UtcNow)); + var context = new StudioMaterializationContext + { + RootActorId = "user-config-scope-1", + ProjectionKind = "user-config", + }; + + await projector.ProjectAsync( + context, + new EventEnvelope + { + Id = "outer", + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + Payload = Any.Pack(new CommittedStateEventPublished + { + StateEvent = new StateEvent + { + EventId = "evt-missing-state", + Version = 1, + }, + }), + }); + + dispatcher.LastUpsert.Should().BeNull(); + } + + [Fact] + public void UserConfigCurrentStateDocumentMetadataProvider_UsesStudioIndex() + { + var provider = new UserConfigCurrentStateDocumentMetadataProvider(); + + provider.Metadata.IndexName.Should().Be("studio-user-config"); + provider.Metadata.Mappings["dynamic"].Should().Be(true); + provider.Metadata.Settings.Should().BeEmpty(); + provider.Metadata.Aliases.Should().BeEmpty(); + } + + [Fact] + public async Task UserConfigController_Get_ReturnsQueriedConfig() + { + var queryPort = new StubUserConfigQueryPort + { + ConfigToReturn = new UserConfig("gpt-4.1", "/api/v1/proxy/s/custom", MaxToolRounds: 3), + }; + var controller = CreateController(queryPort, new RecordingUserConfigCommandService()); + + var response = await controller.Get(CancellationToken.None); + + var ok = response.Result.Should().BeOfType().Subject; + var payload = ok.Value.Should().BeOfType().Subject; + payload.DefaultModel.Should().Be("gpt-4.1"); + payload.MaxToolRounds.Should().Be(3); + } + + [Fact] + public async Task UserConfigController_Get_ReturnsBadRequestForInvalidOperation() + { + var queryPort = new StubUserConfigQueryPort + { + ExceptionToThrow = new InvalidOperationException("invalid"), + }; + var controller = CreateController(queryPort, new RecordingUserConfigCommandService()); + + var response = await controller.Get(CancellationToken.None); + + response.Result.Should().BeOfType(); + } + + [Fact] + public async Task UserConfigController_Get_Returns502ForUnexpectedFailure() + { + var queryPort = new StubUserConfigQueryPort + { + ExceptionToThrow = new Exception("boom"), + }; + var controller = CreateController(queryPort, new RecordingUserConfigCommandService()); + + var response = await controller.Get(CancellationToken.None); + + var result = response.Result.Should().BeOfType().Subject; + result.StatusCode.Should().Be(502); + } + + [Fact] + public async Task UserConfigController_Save_PreservesCurrentMaxToolRounds_WhenRequestOmitsIt() + { + var queryPort = new StubUserConfigQueryPort + { + ConfigToReturn = new UserConfig( + DefaultModel: "old-model", + PreferredLlmRoute: "/api/v1/proxy/s/old", + RuntimeMode: UserConfigRuntimeDefaults.LocalMode, + LocalRuntimeBaseUrl: "http://127.0.0.1:5080", + RemoteRuntimeBaseUrl: "https://remote.example.com", + MaxToolRounds: 7), + }; + var commandService = new RecordingUserConfigCommandService(); + var controller = CreateController(queryPort, commandService); + + var response = await controller.Save( + new UserConfigController.SaveUserConfigRequest( + DefaultModel: " gpt-4.1 ", + PreferredLlmRoute: "gateway", + RuntimeMode: "remote", + LocalRuntimeBaseUrl: "http://localhost:5080/"), + CancellationToken.None); + + var ok = response.Result.Should().BeOfType().Subject; + var payload = ok.Value.Should().BeOfType().Subject; + payload.DefaultModel.Should().Be("gpt-4.1"); + payload.PreferredLlmRoute.Should().Be(UserConfigLlmRouteDefaults.Gateway); + payload.RuntimeMode.Should().Be("remote"); + payload.LocalRuntimeBaseUrl.Should().Be("http://localhost:5080/"); + payload.RemoteRuntimeBaseUrl.Should().Be("https://remote.example.com"); + payload.MaxToolRounds.Should().Be(7); + commandService.SavedConfig.Should().BeEquivalentTo(payload); + } + + [Fact] + public async Task UserConfigController_Save_UsesRequestMaxToolRounds_WhenProvided() + { + var queryPort = new StubUserConfigQueryPort + { + ConfigToReturn = new UserConfig(DefaultModel: "old-model", MaxToolRounds: 1), + }; + var commandService = new RecordingUserConfigCommandService(); + var controller = CreateController(queryPort, commandService); + + await controller.Save( + new UserConfigController.SaveUserConfigRequest( + MaxToolRounds: 12), + CancellationToken.None); + + commandService.SavedConfig.Should().NotBeNull(); + commandService.SavedConfig!.MaxToolRounds.Should().Be(12); + } + + [Fact] + public async Task UserConfigController_Save_ReturnsBadRequestForInvalidOperation() + { + var queryPort = new StubUserConfigQueryPort + { + ExceptionToThrow = new InvalidOperationException("invalid"), + }; + var controller = CreateController(queryPort, new RecordingUserConfigCommandService()); + + var response = await controller.Save(new UserConfigController.SaveUserConfigRequest(), CancellationToken.None); + + response.Result.Should().BeOfType(); + } + + private static UserConfigController CreateController( + IUserConfigQueryPort queryPort, + IUserConfigCommandService commandService) + { + var controller = new UserConfigController( + queryPort, + commandService, + new StubHttpClientFactory(), + new ConfigurationBuilder().Build(), + NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext(), + }, + }; + + return controller; + } + + private static EventEnvelope BuildCommittedEnvelope( + IMessage payload, + string eventId, + long version, + DateTimeOffset observedAt, + UserConfigGAgentState state) => + new() + { + Id = $"outer-{eventId}", + Timestamp = Timestamp.FromDateTimeOffset(observedAt), + Route = EnvelopeRouteSemantics.CreateObserverPublication("projection-test"), + Payload = Any.Pack(new CommittedStateEventPublished + { + StateEvent = new StateEvent + { + EventId = eventId, + Version = version, + Timestamp = Timestamp.FromDateTimeOffset(observedAt), + EventData = Any.Pack(payload), + }, + StateRoot = Any.Pack(state), + }), + }; + + private sealed class StubScopeResolver : IAppScopeResolver + { + public string? ScopeIdToReturn { get; set; } + + public AppScopeContext? Resolve(HttpContext? httpContext = null) => + ScopeIdToReturn is null ? null : new AppScopeContext(ScopeIdToReturn, "test"); + } + + private sealed class RecordingActorDispatchPort : IActorDispatchPort + { + public string? ActorId { get; private set; } + public EventEnvelope? Envelope { get; private set; } + + public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) + { + ActorId = actorId; + Envelope = envelope.Clone(); + return Task.CompletedTask; + } + } + + private sealed class StubUserConfigDocumentReader + : IProjectionDocumentReader + { + public string? LastKey { get; private set; } + public UserConfigCurrentStateDocument? Document { get; set; } + + public Task GetAsync(string key, CancellationToken ct = default) + { + LastKey = key; + return Task.FromResult(Document); + } + + public Task> QueryAsync( + ProjectionDocumentQuery query, + CancellationToken ct = default) => + Task.FromResult(ProjectionDocumentQueryResult.Empty); + } + + private sealed class RecordingWriteDispatcher : IProjectionWriteDispatcher + { + public UserConfigCurrentStateDocument? LastUpsert { get; private set; } + + public Task UpsertAsync( + UserConfigCurrentStateDocument readModel, + CancellationToken ct = default) + { + LastUpsert = readModel.Clone(); + return Task.FromResult(ProjectionWriteResult.Applied()); + } + } + + private sealed class FixedProjectionClock(DateTimeOffset now) : IProjectionClock + { + public DateTimeOffset UtcNow => now; + } + + private sealed class StubUserConfigQueryPort : IUserConfigQueryPort + { + public UserConfig ConfigToReturn { get; set; } = new(string.Empty); + public Exception? ExceptionToThrow { get; set; } + + public Task GetAsync(CancellationToken ct = default) + { + if (ExceptionToThrow is not null) + throw ExceptionToThrow; + + return Task.FromResult(ConfigToReturn); + } + } + + private sealed class RecordingUserConfigCommandService : IUserConfigCommandService + { + public UserConfig? SavedConfig { get; private set; } + + public Task SaveAsync(UserConfig config, CancellationToken ct = default) + { + SavedConfig = config; + return Task.CompletedTask; + } + } + + private sealed class StubHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(new StaticHandler()); + } + + private sealed class StaticHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage()); + } +} From ed8d7f900669371123241e2bc12af37adec0a78d Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 15 Apr 2026 17:34:11 +0800 Subject: [PATCH 20/21] Fix NyxId chat endpoint coverage tests --- .../NyxIdChatEndpointsCoverageTests.cs | 75 +++++++++++++++---- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs index 08f9d57d..0da0765f 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs @@ -19,6 +19,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Hosting; +using Aevatar.Studio.Application.Studio.Abstractions; namespace Aevatar.AI.Tests; @@ -53,7 +54,7 @@ public void MapNyxIdChatEndpoints_ShouldRegisterExpectedRoutes() [Fact] public async Task HandleCreateConversationAsync_ShouldReturnCreatedConversation() { - var store = new NyxIdChatActorStore(); + var store = new StubGAgentActorStore(); var result = await InvokeResultAsync( "HandleCreateConversationAsync", new DefaultHttpContext(), @@ -65,16 +66,24 @@ public async Task HandleCreateConversationAsync_ShouldReturnCreatedConversation( response.StatusCode.Should().Be(StatusCodes.Status200OK); using var doc = JsonDocument.Parse(response.Body); doc.RootElement.TryGetProperty("actorId", out var actorId).Should().BeTrue(); - actorId.GetString().Should().NotBeNullOrWhiteSpace(); - doc.RootElement.TryGetProperty("createdAt", out _).Should().BeTrue(); - (await store.ListActorsAsync("scope-a")).Should().ContainSingle(); + var createdActorId = actorId.GetString(); + createdActorId.Should().NotBeNullOrWhiteSpace(); + store.AddedActors.Should().ContainSingle(entry => + entry.GAgentType == NyxIdChatServiceDefaults.GAgentTypeName && + entry.ActorId == createdActorId); } [Fact] public async Task HandleListConversationsAsync_ShouldReturnList() { - var store = new NyxIdChatActorStore(); - await store.CreateActorAsync("scope-a"); + var store = new StubGAgentActorStore + { + GroupsToReturn = + [ + new GAgentActorGroup(NyxIdChatServiceDefaults.GAgentTypeName, ["actor-1"]), + new GAgentActorGroup("other-agent", ["actor-2"]), + ], + }; var result = await InvokeResultAsync( "HandleListConversationsAsync", @@ -87,22 +96,26 @@ public async Task HandleListConversationsAsync_ShouldReturnList() response.StatusCode.Should().Be(StatusCodes.Status200OK); using var doc = JsonDocument.Parse(response.Body); doc.RootElement.GetArrayLength().Should().Be(1); + doc.RootElement[0].GetProperty("actorId").GetString().Should().Be("actor-1"); } [Fact] - public async Task HandleDeleteConversationAsync_ShouldReturnNotFound_WhenMissing() + public async Task HandleDeleteConversationAsync_ShouldReturnOk_AndRemoveActor() { - var store = new NyxIdChatActorStore(); + var store = new StubGAgentActorStore(); var result = await InvokeResultAsync( "HandleDeleteConversationAsync", new DefaultHttpContext(), "scope-a", - "missing-id", + "actor-1", store, CancellationToken.None); var response = await ExecuteResultAsync(result); - response.StatusCode.Should().Be(StatusCodes.Status404NotFound); + response.StatusCode.Should().Be(StatusCodes.Status200OK); + store.RemovedActors.Should().ContainSingle(entry => + entry.GAgentType == NyxIdChatServiceDefaults.GAgentTypeName && + entry.ActorId == "actor-1"); } [Fact] @@ -396,7 +409,7 @@ public async Task HandleRelayWebhookAsync_ShouldReturnParseError_ForInvalidJson( context, new StubActorRuntime(), new StubSubscriptionProvider(), - new NyxIdChatActorStore(), + new StubGAgentActorStore(), new NyxIdRelayOptions(), NullLoggerFactory.Instance, CancellationToken.None); @@ -418,7 +431,7 @@ public async Task HandleRelayWebhookAsync_ShouldRejectMissingText() context, new StubActorRuntime(), new StubSubscriptionProvider(), - new NyxIdChatActorStore(), + new StubGAgentActorStore(), new NyxIdRelayOptions(), NullLoggerFactory.Instance, CancellationToken.None); @@ -444,7 +457,7 @@ public async Task HandleRelayWebhookAsync_ShouldRejectWhenUserTokenMissing() context, new StubActorRuntime(), new StubSubscriptionProvider(), - new NyxIdChatActorStore(), + new StubGAgentActorStore(), new NyxIdRelayOptions(), NullLoggerFactory.Instance, CancellationToken.None); @@ -484,7 +497,7 @@ public async Task HandleRelayWebhookAsync_ShouldReturnPartialResponse_WhenTimedO new EventEnvelope { Payload = Any.Pack(new TextMessageContentEvent { Delta = "partial reply" }) }, }, }; - var store = new NyxIdChatActorStore(); + var store = new StubGAgentActorStore(); var result = await InvokeResultAsync( "HandleRelayWebhookAsync", @@ -499,7 +512,9 @@ public async Task HandleRelayWebhookAsync_ShouldReturnPartialResponse_WhenTimedO var response = await ExecuteResultAsync(result); response.StatusCode.Should().Be(StatusCodes.Status200OK); response.Body.Should().Contain("partial reply"); - (await store.ListActorsAsync("scope-a")).Should().ContainSingle("nyxid-relay-slack-room-1"); + store.AddedActors.Should().ContainSingle(entry => + entry.GAgentType == NyxIdChatServiceDefaults.GAgentTypeName && + entry.ActorId == "nyxid-relay-slack-room-1"); } [Fact] @@ -547,7 +562,7 @@ public async Task HandleRelayWebhookAsync_ShouldClassifyError_AndAppendDiagnosti }, }, }, - new NyxIdChatActorStore(), + new StubGAgentActorStore(), new NyxIdRelayOptions { ResponseTimeoutSeconds = 1, @@ -1025,6 +1040,34 @@ public Task SubscribeAsync( } } + private sealed class StubGAgentActorStore : IGAgentActorStore + { + public IReadOnlyList GroupsToReturn { get; init; } = []; + public List<(string GAgentType, string ActorId)> AddedActors { get; } = []; + public List<(string GAgentType, string ActorId)> RemovedActors { get; } = []; + + public Task> GetAsync(CancellationToken cancellationToken = default) => + Task.FromResult(GroupsToReturn); + + public Task AddActorAsync( + string gagentType, + string actorId, + CancellationToken cancellationToken = default) + { + AddedActors.Add((gagentType, actorId)); + return Task.CompletedTask; + } + + public Task RemoveActorAsync( + string gagentType, + string actorId, + CancellationToken cancellationToken = default) + { + RemovedActors.Add((gagentType, actorId)); + return Task.CompletedTask; + } + } + private sealed class ThrowingSubscriptionProvider(Exception exception) : IActorEventSubscriptionProvider { public Task SubscribeAsync( From eafc350270067f8e085f81240c4c73d1071b7ef7 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 15 Apr 2026 18:00:42 +0800 Subject: [PATCH 21/21] refactor(projection): enforce read-model-only reads for all actor-backed stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct actor state reads (ReadWriteActorStateAsync / IAgent.State) with projection document store reads in all 6 actor-backed stores, fixing the "查询始终走 readmodel" architectural constraint violation. Changes: - Add 7 current-state projection documents (proto + partial classes) for GAgentRegistry, ConnectorCatalog, RoleCatalog, UserMemory, StreamingProxyParticipant, ChatHistoryIndex, ChatConversation - Add 7 projectors that materialize committed actor state into projection document store via IProjectionWriteDispatcher - Add 7 metadata providers for Elasticsearch index configuration - Update all 6 stores to inject IProjectionDocumentReader and read from projection instead of casting actor runtime state - Move IAppScopeResolver to Application layer to break circular dependency between Projection and Infrastructure - Update DI registration with all new projectors and metadata providers - Update tests with FakeProjectionDocumentReader for projection reads Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Studio/Abstractions/IAppScopeResolver.cs | 10 + .../Controllers/EditorController.cs | 2 +- .../Controllers/ExecutionsController.cs | 2 +- .../Controllers/SettingsController.cs | 2 +- .../Controllers/WorkspaceController.cs | 2 +- .../Endpoints/ExplorerEndpoints.cs | 2 +- .../Endpoints/StudioEndpoints.cs | 1 - ...tudioHostingServiceCollectionExtensions.cs | 2 +- .../ActorBackedChatHistoryStore.cs | 45 ++- .../ActorBackedConnectorCatalogStore.cs | 24 +- .../ActorBackedGAgentActorStore.cs | 24 +- .../ActorBackedRoleCatalogStore.cs | 24 +- ...torBackedStreamingProxyParticipantStore.cs | 21 +- .../ActorBacked/ActorBackedUserMemoryStore.cs | 25 +- .../ActorBacked/AppScopeResolverExtensions.cs | 2 +- .../Aevatar.Studio.Infrastructure.csproj | 2 + .../ScopeResolution/AppScopeResolver.cs | 8 +- .../Storage/ChronoStorageCatalogBlobClient.cs | 2 +- .../Aevatar.Studio.Projection.csproj | 8 +- .../ActorDispatchUserConfigCommandService.cs | 1 - .../ServiceCollectionExtensions.cs | 62 ++++- ...ionCurrentStateDocumentMetadataProvider.cs | 17 ++ ...dexCurrentStateDocumentMetadataProvider.cs | 17 ++ ...logCurrentStateDocumentMetadataProvider.cs | 17 ++ ...tryCurrentStateDocumentMetadataProvider.cs | 17 ++ ...logCurrentStateDocumentMetadataProvider.cs | 17 ++ ...antCurrentStateDocumentMetadataProvider.cs | 17 ++ ...oryCurrentStateDocumentMetadataProvider.cs | 17 ++ .../ChatConversationCurrentStateProjector.cs | 63 +++++ .../ChatHistoryIndexCurrentStateProjector.cs | 63 +++++ .../ConnectorCatalogCurrentStateProjector.cs | 63 +++++ .../GAgentRegistryCurrentStateProjector.cs | 63 +++++ .../RoleCatalogCurrentStateProjector.cs | 63 +++++ ...ngProxyParticipantCurrentStateProjector.cs | 63 +++++ .../UserMemoryCurrentStateProjector.cs | 63 +++++ .../ProjectionUserConfigQueryPort.cs | 1 - ...onversationCurrentStateDocument.Partial.cs | 18 ++ ...istoryIndexCurrentStateDocument.Partial.cs | 18 ++ ...ctorCatalogCurrentStateDocument.Partial.cs | 18 ++ ...entRegistryCurrentStateDocument.Partial.cs | 18 ++ ...RoleCatalogCurrentStateDocument.Partial.cs | 18 ++ ...ParticipantCurrentStateDocument.Partial.cs | 18 ++ .../UserMemoryCurrentStateDocument.Partial.cs | 18 ++ .../studio_projection_readmodels.proto | 78 ++++++ .../ActorBackedStoreAdapterTests.cs | 256 ++++++++++++------ .../AppScopeResolverTests.cs | 1 + .../AppStudioScriptDraftRunEndpointTests.cs | 2 +- .../AppStudioScriptSaveEndpointTests.cs | 2 +- .../ExplorerEndpointsTests.cs | 2 +- .../UserConfigProjectionAndControllerTests.cs | 1 - tools/ci/architecture_guards.sh | 6 +- 51 files changed, 1122 insertions(+), 184 deletions(-) create mode 100644 src/Aevatar.Studio.Application/Studio/Abstractions/IAppScopeResolver.cs create mode 100644 src/Aevatar.Studio.Projection/Metadata/ChatConversationCurrentStateDocumentMetadataProvider.cs create mode 100644 src/Aevatar.Studio.Projection/Metadata/ChatHistoryIndexCurrentStateDocumentMetadataProvider.cs create mode 100644 src/Aevatar.Studio.Projection/Metadata/ConnectorCatalogCurrentStateDocumentMetadataProvider.cs create mode 100644 src/Aevatar.Studio.Projection/Metadata/GAgentRegistryCurrentStateDocumentMetadataProvider.cs create mode 100644 src/Aevatar.Studio.Projection/Metadata/RoleCatalogCurrentStateDocumentMetadataProvider.cs create mode 100644 src/Aevatar.Studio.Projection/Metadata/StreamingProxyParticipantCurrentStateDocumentMetadataProvider.cs create mode 100644 src/Aevatar.Studio.Projection/Metadata/UserMemoryCurrentStateDocumentMetadataProvider.cs create mode 100644 src/Aevatar.Studio.Projection/Projectors/ChatConversationCurrentStateProjector.cs create mode 100644 src/Aevatar.Studio.Projection/Projectors/ChatHistoryIndexCurrentStateProjector.cs create mode 100644 src/Aevatar.Studio.Projection/Projectors/ConnectorCatalogCurrentStateProjector.cs create mode 100644 src/Aevatar.Studio.Projection/Projectors/GAgentRegistryCurrentStateProjector.cs create mode 100644 src/Aevatar.Studio.Projection/Projectors/RoleCatalogCurrentStateProjector.cs create mode 100644 src/Aevatar.Studio.Projection/Projectors/StreamingProxyParticipantCurrentStateProjector.cs create mode 100644 src/Aevatar.Studio.Projection/Projectors/UserMemoryCurrentStateProjector.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/ChatConversationCurrentStateDocument.Partial.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/ChatHistoryIndexCurrentStateDocument.Partial.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/ConnectorCatalogCurrentStateDocument.Partial.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/GAgentRegistryCurrentStateDocument.Partial.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/RoleCatalogCurrentStateDocument.Partial.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/StreamingProxyParticipantCurrentStateDocument.Partial.cs create mode 100644 src/Aevatar.Studio.Projection/ReadModels/UserMemoryCurrentStateDocument.Partial.cs diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IAppScopeResolver.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IAppScopeResolver.cs new file mode 100644 index 00000000..6b55ce2f --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IAppScopeResolver.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; + +namespace Aevatar.Studio.Application.Studio.Abstractions; + +public sealed record AppScopeContext(string ScopeId, string Source); + +public interface IAppScopeResolver +{ + AppScopeContext? Resolve(HttpContext? httpContext = null); +} diff --git a/src/Aevatar.Studio.Hosting/Controllers/EditorController.cs b/src/Aevatar.Studio.Hosting/Controllers/EditorController.cs index b9abc8e2..56241075 100644 --- a/src/Aevatar.Studio.Hosting/Controllers/EditorController.cs +++ b/src/Aevatar.Studio.Hosting/Controllers/EditorController.cs @@ -1,5 +1,5 @@ using Aevatar.Studio.Application; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; using Aevatar.Studio.Application.Studio.Services; using Microsoft.AspNetCore.Mvc; diff --git a/src/Aevatar.Studio.Hosting/Controllers/ExecutionsController.cs b/src/Aevatar.Studio.Hosting/Controllers/ExecutionsController.cs index 1cc7c1c9..f6252866 100644 --- a/src/Aevatar.Studio.Hosting/Controllers/ExecutionsController.cs +++ b/src/Aevatar.Studio.Hosting/Controllers/ExecutionsController.cs @@ -1,5 +1,5 @@ using Aevatar.Studio.Application; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; using Aevatar.Studio.Application.Studio.Services; using Microsoft.AspNetCore.Mvc; diff --git a/src/Aevatar.Studio.Hosting/Controllers/SettingsController.cs b/src/Aevatar.Studio.Hosting/Controllers/SettingsController.cs index c81727f3..66768f79 100644 --- a/src/Aevatar.Studio.Hosting/Controllers/SettingsController.cs +++ b/src/Aevatar.Studio.Hosting/Controllers/SettingsController.cs @@ -1,5 +1,5 @@ using Aevatar.Studio.Application; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; using Aevatar.Studio.Application.Studio.Services; using Microsoft.AspNetCore.Mvc; diff --git a/src/Aevatar.Studio.Hosting/Controllers/WorkspaceController.cs b/src/Aevatar.Studio.Hosting/Controllers/WorkspaceController.cs index 407cab2c..514ab6bb 100644 --- a/src/Aevatar.Studio.Hosting/Controllers/WorkspaceController.cs +++ b/src/Aevatar.Studio.Hosting/Controllers/WorkspaceController.cs @@ -1,5 +1,5 @@ using Aevatar.Studio.Application; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; using Aevatar.Studio.Application.Studio.Services; using Aevatar.Studio.Hosting; diff --git a/src/Aevatar.Studio.Hosting/Endpoints/ExplorerEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/ExplorerEndpoints.cs index e62810b7..4a5c92f5 100644 --- a/src/Aevatar.Studio.Hosting/Endpoints/ExplorerEndpoints.cs +++ b/src/Aevatar.Studio.Hosting/Endpoints/ExplorerEndpoints.cs @@ -2,9 +2,9 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Hosting; using Aevatar.Studio.Infrastructure.Storage; -using Aevatar.Studio.Infrastructure.ScopeResolution; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; diff --git a/src/Aevatar.Studio.Hosting/Endpoints/StudioEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/StudioEndpoints.cs index 218dfb0c..9021f62a 100644 --- a/src/Aevatar.Studio.Hosting/Endpoints/StudioEndpoints.cs +++ b/src/Aevatar.Studio.Hosting/Endpoints/StudioEndpoints.cs @@ -7,7 +7,6 @@ using Aevatar.Studio.Application.Scripts.Contracts; using Aevatar.Studio.Application.Studio; using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; using Aevatar.Studio.Infrastructure.Storage; using Aevatar.Scripting.Hosting.CapabilityApi; using System.Security.Cryptography; diff --git a/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs b/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs index 53719749..6c674ccc 100644 --- a/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Hosting/StudioHostingServiceCollectionExtensions.cs @@ -5,7 +5,7 @@ using Aevatar.Studio.Hosting.Controllers; using Aevatar.Studio.Hosting.Endpoints; using Aevatar.Studio.Infrastructure.DependencyInjection; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Infrastructure.ScopeResolution; // DefaultAppScopeResolver using Aevatar.Studio.Projection.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs index 4980cfbd..8e803e85 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedChatHistoryStore.cs @@ -1,35 +1,46 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.ChatHistory; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Projection.ReadModels; using Microsoft.Extensions.Logging; namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Reads the write actors' state directly. +/// Reads from the projection document store (CQRS read model). /// Writes send commands only to /// (index updates are handled internally by the conversation actor). /// internal sealed class ActorBackedChatHistoryStore : IChatHistoryStore { private readonly IActorRuntime _runtime; + private readonly IProjectionDocumentReader _indexDocumentReader; + private readonly IProjectionDocumentReader _conversationDocumentReader; private readonly ILogger _logger; public ActorBackedChatHistoryStore( IActorRuntime runtime, + IProjectionDocumentReader indexDocumentReader, + IProjectionDocumentReader conversationDocumentReader, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _indexDocumentReader = indexDocumentReader ?? throw new ArgumentNullException(nameof(indexDocumentReader)); + _conversationDocumentReader = conversationDocumentReader ?? throw new ArgumentNullException(nameof(conversationDocumentReader)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetIndexAsync(string scopeId, CancellationToken ct = default) { - var state = await ReadIndexActorStateAsync(scopeId, ct); - if (state is null) + var actorId = IndexActorId(scopeId); + var document = await _indexDocumentReader.GetAsync(actorId, ct); + if (document?.StateRoot == null || + !document.StateRoot.Is(ChatHistoryIndexState.Descriptor)) return new ChatHistoryIndex([]); + var state = document.StateRoot.Unpack(); return new ChatHistoryIndex(state.Conversations .Select(ToConversationMeta) .OrderByDescending(static c => c.UpdatedAt) @@ -41,8 +52,14 @@ public async Task GetIndexAsync(string scopeId, CancellationTo public async Task> GetMessagesAsync( string scopeId, string conversationId, CancellationToken ct = default) { - var state = await ReadConversationActorStateAsync(scopeId, conversationId, ct); - if (state is null || state.Messages.Count == 0) + var actorId = ConversationActorId(scopeId, conversationId); + var document = await _conversationDocumentReader.GetAsync(actorId, ct); + if (document?.StateRoot == null || + !document.StateRoot.Is(ChatConversationState.Descriptor)) + return []; + + var state = document.StateRoot.Unpack(); + if (state.Messages.Count == 0) return []; return state.Messages @@ -78,24 +95,6 @@ public async Task DeleteConversationAsync( await ActorCommandDispatcher.SendAsync(conversationActor, deleteEvt, ct); } - // ── Read write actor state directly ─── - - private async Task ReadIndexActorStateAsync( - string scopeId, CancellationToken ct) - { - var actorId = IndexActorId(scopeId); - var actor = await _runtime.GetAsync(actorId); - return (actor?.Agent as IAgent)?.State; - } - - private async Task ReadConversationActorStateAsync( - string scopeId, string conversationId, CancellationToken ct) - { - var actorId = ConversationActorId(scopeId, conversationId); - var actor = await _runtime.GetAsync(actorId); - return (actor?.Agent as IAgent)?.State; - } - // ── Actor resolution ─────────────────────────────────────── private async Task EnsureConversationActorAsync( diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs index 857bc490..bef404fb 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedConnectorCatalogStore.cs @@ -1,7 +1,8 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.ConnectorCatalog; using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Projection.ReadModels; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -9,7 +10,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Reads the write actor's state directly. +/// Reads from the projection document store (CQRS read model). /// Writes send commands to the Write GAgent. /// Local workspace operations (import, draft backup) delegate to . /// Per-scope isolation: each scope gets its own connector-catalog-{scopeId} actor. @@ -23,24 +24,27 @@ internal sealed class ActorBackedConnectorCatalogStore : IConnectorCatalogStore private readonly IActorRuntime _runtime; private readonly IAppScopeResolver _scopeResolver; private readonly IStudioWorkspaceStore _workspaceStore; + private readonly IProjectionDocumentReader _documentReader; private readonly ILogger _logger; public ActorBackedConnectorCatalogStore( IActorRuntime runtime, IAppScopeResolver scopeResolver, IStudioWorkspaceStore workspaceStore, + IProjectionDocumentReader documentReader, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _workspaceStore = workspaceStore ?? throw new ArgumentNullException(nameof(workspaceStore)); + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetConnectorCatalogAsync( CancellationToken cancellationToken = default) { - var state = await ReadWriteActorStateAsync(cancellationToken); + var state = await ReadProjectedStateAsync(cancellationToken); if (state is null) { return new StoredConnectorCatalog( @@ -105,7 +109,7 @@ public async Task ImportLocalCatalogAsync( public async Task GetConnectorDraftAsync( CancellationToken cancellationToken = default) { - var state = await ReadWriteActorStateAsync(cancellationToken); + var state = await ReadProjectedStateAsync(cancellationToken); var draftEntry = state?.Draft; if (draftEntry is null) { @@ -157,13 +161,17 @@ public async Task DeleteConnectorDraftAsync(CancellationToken cancellationToken await _workspaceStore.DeleteConnectorDraftAsync(cancellationToken); } - // ── Read write actor state directly ── + // ── Read from projection ── - private async Task ReadWriteActorStateAsync(CancellationToken ct) + private async Task ReadProjectedStateAsync(CancellationToken ct) { var actorId = ResolveWriteActorId(); - var actor = await _runtime.GetAsync(actorId); - return (actor?.Agent as IAgent)?.State; + var document = await _documentReader.GetAsync(actorId, ct); + if (document?.StateRoot == null || + !document.StateRoot.Is(ConnectorCatalogState.Descriptor)) + return null; + + return document.StateRoot.Unpack(); } // ── Actor resolution ── diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs index be24518b..244f13cd 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs @@ -1,14 +1,15 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Registry; using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Projection.ReadModels; using Microsoft.Extensions.Logging; namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Reads the write actor's state directly. +/// Reads from the projection document store (CQRS read model). /// Writes send commands to the Write GAgent. /// internal sealed class ActorBackedGAgentActorStore : IGAgentActorStore @@ -17,25 +18,31 @@ internal sealed class ActorBackedGAgentActorStore : IGAgentActorStore private readonly IActorRuntime _runtime; private readonly IAppScopeResolver _scopeResolver; + private readonly IProjectionDocumentReader _documentReader; private readonly ILogger _logger; public ActorBackedGAgentActorStore( IActorRuntime runtime, IAppScopeResolver scopeResolver, + IProjectionDocumentReader documentReader, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task> GetAsync( CancellationToken cancellationToken = default) { - var state = await ReadWriteActorStateAsync(cancellationToken); - if (state is null) + var actorId = ResolveWriteActorId(); + var document = await _documentReader.GetAsync(actorId, cancellationToken); + if (document?.StateRoot == null || + !document.StateRoot.Is(GAgentRegistryState.Descriptor)) return []; + var state = document.StateRoot.Unpack(); return state.Groups .Select(g => new GAgentActorGroup( g.GagentType, @@ -68,15 +75,6 @@ public async Task RemoveActorAsync( }, cancellationToken); } - // ── Read write actor state directly ── - - private async Task ReadWriteActorStateAsync(CancellationToken ct) - { - var actorId = ResolveWriteActorId(); - var actor = await _runtime.GetAsync(actorId); - return (actor?.Agent as IAgent)?.State; - } - // ── Actor resolution ── private string ResolveWriteActorId() => WriteActorIdPrefix + _scopeResolver.ResolveScopeIdOrDefault(); diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs index d78937d4..0724d7a1 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedRoleCatalogStore.cs @@ -1,7 +1,8 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.RoleCatalog; using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Projection.ReadModels; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -9,7 +10,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Reads the write actor's state directly. +/// Reads from the projection document store (CQRS read model). /// Writes send commands to the Write GAgent. /// Local workspace operations (import, draft backup) delegate to /// . @@ -24,23 +25,26 @@ internal sealed class ActorBackedRoleCatalogStore : IRoleCatalogStore private readonly IActorRuntime _runtime; private readonly IAppScopeResolver _scopeResolver; private readonly IStudioWorkspaceStore _localWorkspaceStore; + private readonly IProjectionDocumentReader _documentReader; private readonly ILogger _logger; public ActorBackedRoleCatalogStore( IActorRuntime runtime, IAppScopeResolver scopeResolver, IStudioWorkspaceStore localWorkspaceStore, + IProjectionDocumentReader documentReader, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _localWorkspaceStore = localWorkspaceStore ?? throw new ArgumentNullException(nameof(localWorkspaceStore)); + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetRoleCatalogAsync(CancellationToken cancellationToken = default) { - var state = await ReadWriteActorStateAsync(cancellationToken); + var state = await ReadProjectedStateAsync(cancellationToken); var roles = state?.Roles .Select(ToStoredRoleDefinition) .ToList() @@ -94,7 +98,7 @@ public async Task ImportLocalCatalogAsync(CancellationToken public async Task GetRoleDraftAsync(CancellationToken cancellationToken = default) { - var state = await ReadWriteActorStateAsync(cancellationToken); + var state = await ReadProjectedStateAsync(cancellationToken); var draftEntry = state?.Draft; if (draftEntry is null) { @@ -145,13 +149,17 @@ public async Task DeleteRoleDraftAsync(CancellationToken cancellationToken = def await _localWorkspaceStore.DeleteRoleDraftAsync(cancellationToken); } - // ── Read write actor state directly ── + // ── Read from projection ── - private async Task ReadWriteActorStateAsync(CancellationToken ct) + private async Task ReadProjectedStateAsync(CancellationToken ct) { var actorId = ResolveWriteActorId(); - var actor = await _runtime.GetAsync(actorId); - return (actor?.Agent as IAgent)?.State; + var document = await _documentReader.GetAsync(actorId, ct); + if (document?.StateRoot == null || + !document.StateRoot.Is(RoleCatalogState.Descriptor)) + return null; + + return document.StateRoot.Unpack(); } // ── Actor resolution ── diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs index a76fde4d..dc3657be 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedStreamingProxyParticipantStore.cs @@ -1,6 +1,8 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.StreamingProxyParticipant; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Projection.ReadModels; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -8,7 +10,7 @@ namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Reads the write actor's state directly. +/// Reads from the projection document store (CQRS read model). /// Writes send commands to the Write GAgent. /// internal sealed class ActorBackedStreamingProxyParticipantStore @@ -17,23 +19,28 @@ internal sealed class ActorBackedStreamingProxyParticipantStore private const string WriteActorId = "streaming-proxy-participants"; private readonly IActorRuntime _runtime; + private readonly IProjectionDocumentReader _documentReader; private readonly ILogger _logger; public ActorBackedStreamingProxyParticipantStore( IActorRuntime runtime, + IProjectionDocumentReader documentReader, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task> ListAsync( string roomId, CancellationToken cancellationToken = default) { - var state = await ReadWriteActorStateAsync(cancellationToken); - if (state is null) + var document = await _documentReader.GetAsync(WriteActorId, cancellationToken); + if (document?.StateRoot == null || + !document.StateRoot.Is(StreamingProxyParticipantGAgentState.Descriptor)) return []; + var state = document.StateRoot.Unpack(); if (!state.Rooms.TryGetValue(roomId, out var list)) return []; @@ -84,14 +91,6 @@ public async Task RemoveRoomAsync( await ActorCommandDispatcher.SendAsync(actor, evt, cancellationToken); } - // ── Read write actor state directly ── - - private async Task ReadWriteActorStateAsync(CancellationToken ct) - { - var actor = await _runtime.GetAsync(WriteActorId); - return (actor?.Agent as IAgent)?.State; - } - // ── Actor resolution ── private async Task EnsureWriteActorAsync(CancellationToken ct) diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs index 4302d091..f6406a64 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedUserMemoryStore.cs @@ -1,16 +1,18 @@ using System.Security.Cryptography; using System.Text; using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.UserMemory; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Projection.ReadModels; using Microsoft.Extensions.Logging; namespace Aevatar.Studio.Infrastructure.ActorBacked; /// /// Actor-backed implementation of . -/// Reads the write actor's state directly. +/// Reads from the projection document store (CQRS read model). /// Writes send commands to the Write GAgent. /// internal sealed class ActorBackedUserMemoryStore : IUserMemoryStore @@ -19,21 +21,24 @@ internal sealed class ActorBackedUserMemoryStore : IUserMemoryStore private readonly IActorRuntime _runtime; private readonly IAppScopeResolver _scopeResolver; + private readonly IProjectionDocumentReader _documentReader; private readonly ILogger _logger; public ActorBackedUserMemoryStore( IActorRuntime runtime, IAppScopeResolver scopeResolver, + IProjectionDocumentReader documentReader, ILogger logger) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetAsync(CancellationToken ct = default) { - var state = await ReadWriteActorStateAsync(ct); + var state = await ReadProjectedStateAsync(ct); if (state is null) return UserMemoryDocument.Empty; @@ -117,7 +122,7 @@ public async Task AddEntryAsync( public async Task RemoveEntryAsync(string id, CancellationToken ct = default) { - var state = await ReadWriteActorStateAsync(ct); + var state = await ReadProjectedStateAsync(ct); if (state is null || !state.Entries.Any(e => string.Equals(e.Id, id, StringComparison.Ordinal))) return false; @@ -186,13 +191,17 @@ public async Task BuildPromptSectionAsync(int maxChars = 2000, Cancellat : truncated; } - // ── Read write actor state directly ── + // ── Read from projection ── - private async Task ReadWriteActorStateAsync(CancellationToken ct) + private async Task ReadProjectedStateAsync(CancellationToken ct) { var actorId = ResolveWriteActorId(); - var actor = await _runtime.GetAsync(actorId); - return (actor?.Agent as IAgent)?.State; + var document = await _documentReader.GetAsync(actorId, ct); + if (document?.StateRoot == null || + !document.StateRoot.Is(UserMemoryState.Descriptor)) + return null; + + return document.StateRoot.Unpack(); } // ── Actor resolution ── diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs index c3a8d5cf..2cfd96cf 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/AppScopeResolverExtensions.cs @@ -1,4 +1,4 @@ -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Application.Studio.Abstractions; namespace Aevatar.Studio.Infrastructure.ActorBacked; diff --git a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj index 62f6a017..005d7074 100644 --- a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj +++ b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj @@ -18,6 +18,8 @@ + + diff --git a/src/Aevatar.Studio.Infrastructure/ScopeResolution/AppScopeResolver.cs b/src/Aevatar.Studio.Infrastructure/ScopeResolution/AppScopeResolver.cs index 662251a1..f5086510 100644 --- a/src/Aevatar.Studio.Infrastructure/ScopeResolution/AppScopeResolver.cs +++ b/src/Aevatar.Studio.Infrastructure/ScopeResolution/AppScopeResolver.cs @@ -1,17 +1,11 @@ using System.Security.Claims; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.Storage; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; namespace Aevatar.Studio.Infrastructure.ScopeResolution; -public sealed record AppScopeContext(string ScopeId, string Source); - -public interface IAppScopeResolver -{ - AppScopeContext? Resolve(HttpContext? httpContext = null); -} - public sealed class DefaultAppScopeResolver : IAppScopeResolver { private static readonly HashSet IgnoredGenericIdClaimTypes = new(StringComparer.OrdinalIgnoreCase) diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageCatalogBlobClient.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageCatalogBlobClient.cs index 7fdca405..a2f8ca6f 100644 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageCatalogBlobClient.cs +++ b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageCatalogBlobClient.cs @@ -3,7 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Application.Studio.Abstractions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; diff --git a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj index eae0e9f0..f297c57d 100644 --- a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj +++ b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj @@ -14,8 +14,14 @@ - + + + + + + + diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs index 1eb16bfc..3bbf2ba7 100644 --- a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchUserConfigCommandService.cs @@ -1,7 +1,6 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.UserConfig; using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs index af843b8b..7436d5ae 100644 --- a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -43,16 +43,74 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl }, context => new StudioMaterializationRuntimeLease(context)); - // UserConfig projector + // ── Projectors ── + services.AddCurrentStateProjectionMaterializer< StudioMaterializationContext, UserConfigCurrentStateProjector>(); - // Document metadata providers (for index creation in Elasticsearch) + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + GAgentRegistryCurrentStateProjector>(); + + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + ConnectorCatalogCurrentStateProjector>(); + + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + RoleCatalogCurrentStateProjector>(); + + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + UserMemoryCurrentStateProjector>(); + + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + StreamingProxyParticipantCurrentStateProjector>(); + + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + ChatHistoryIndexCurrentStateProjector>(); + + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + ChatConversationCurrentStateProjector>(); + + // ── Document metadata providers (for index creation in Elasticsearch) ── + services.TryAddSingleton< IProjectionDocumentMetadataProvider, UserConfigCurrentStateDocumentMetadataProvider>(); + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + GAgentRegistryCurrentStateDocumentMetadataProvider>(); + + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + ConnectorCatalogCurrentStateDocumentMetadataProvider>(); + + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + RoleCatalogCurrentStateDocumentMetadataProvider>(); + + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + UserMemoryCurrentStateDocumentMetadataProvider>(); + + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + StreamingProxyParticipantCurrentStateDocumentMetadataProvider>(); + + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + ChatHistoryIndexCurrentStateDocumentMetadataProvider>(); + + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + ChatConversationCurrentStateDocumentMetadataProvider>(); + // Query ports (read side) services.TryAddSingleton(); diff --git a/src/Aevatar.Studio.Projection/Metadata/ChatConversationCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/ChatConversationCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 00000000..7159a247 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/ChatConversationCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class ChatConversationCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-chat-conversation", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Metadata/ChatHistoryIndexCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/ChatHistoryIndexCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 00000000..2d194919 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/ChatHistoryIndexCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class ChatHistoryIndexCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-chat-history-index", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Metadata/ConnectorCatalogCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/ConnectorCatalogCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 00000000..bdc18e45 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/ConnectorCatalogCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class ConnectorCatalogCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-connector-catalog", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Metadata/GAgentRegistryCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/GAgentRegistryCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 00000000..67ca180c --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/GAgentRegistryCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class GAgentRegistryCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-gagent-registry", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Metadata/RoleCatalogCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/RoleCatalogCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 00000000..0b360dd7 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/RoleCatalogCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class RoleCatalogCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-role-catalog", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Metadata/StreamingProxyParticipantCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/StreamingProxyParticipantCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 00000000..49900f97 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/StreamingProxyParticipantCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class StreamingProxyParticipantCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-streaming-proxy-participant", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Metadata/UserMemoryCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/UserMemoryCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 00000000..4a52486f --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/UserMemoryCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class UserMemoryCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-user-memory", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Projectors/ChatConversationCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/ChatConversationCurrentStateProjector.cs new file mode 100644 index 00000000..4fdb4afd --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/ChatConversationCurrentStateProjector.cs @@ -0,0 +1,63 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.ChatHistory; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// in the projection document store. +/// +public sealed class ChatConversationCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public ChatConversationCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new ChatConversationCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + StateRoot = Any.Pack(state), + }; + + await _writeDispatcher.UpsertAsync(document, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/Projectors/ChatHistoryIndexCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/ChatHistoryIndexCurrentStateProjector.cs new file mode 100644 index 00000000..16b1c151 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/ChatHistoryIndexCurrentStateProjector.cs @@ -0,0 +1,63 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.ChatHistory; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// in the projection document store. +/// +public sealed class ChatHistoryIndexCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public ChatHistoryIndexCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new ChatHistoryIndexCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + StateRoot = Any.Pack(state), + }; + + await _writeDispatcher.UpsertAsync(document, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/Projectors/ConnectorCatalogCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/ConnectorCatalogCurrentStateProjector.cs new file mode 100644 index 00000000..edacbac2 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/ConnectorCatalogCurrentStateProjector.cs @@ -0,0 +1,63 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.ConnectorCatalog; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// in the projection document store. +/// +public sealed class ConnectorCatalogCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public ConnectorCatalogCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new ConnectorCatalogCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + StateRoot = Any.Pack(state), + }; + + await _writeDispatcher.UpsertAsync(document, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/Projectors/GAgentRegistryCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/GAgentRegistryCurrentStateProjector.cs new file mode 100644 index 00000000..8fdf0814 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/GAgentRegistryCurrentStateProjector.cs @@ -0,0 +1,63 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Registry; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// in the projection document store. +/// +public sealed class GAgentRegistryCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public GAgentRegistryCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new GAgentRegistryCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + StateRoot = Any.Pack(state), + }; + + await _writeDispatcher.UpsertAsync(document, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/Projectors/RoleCatalogCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/RoleCatalogCurrentStateProjector.cs new file mode 100644 index 00000000..7e05f52c --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/RoleCatalogCurrentStateProjector.cs @@ -0,0 +1,63 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.RoleCatalog; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// in the projection document store. +/// +public sealed class RoleCatalogCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public RoleCatalogCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new RoleCatalogCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + StateRoot = Any.Pack(state), + }; + + await _writeDispatcher.UpsertAsync(document, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/Projectors/StreamingProxyParticipantCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/StreamingProxyParticipantCurrentStateProjector.cs new file mode 100644 index 00000000..88181d4a --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/StreamingProxyParticipantCurrentStateProjector.cs @@ -0,0 +1,63 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.StreamingProxyParticipant; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// in the projection document store. +/// +public sealed class StreamingProxyParticipantCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public StreamingProxyParticipantCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new StreamingProxyParticipantCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + StateRoot = Any.Pack(state), + }; + + await _writeDispatcher.UpsertAsync(document, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/Projectors/UserMemoryCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/UserMemoryCurrentStateProjector.cs new file mode 100644 index 00000000..149be70c --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/UserMemoryCurrentStateProjector.cs @@ -0,0 +1,63 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.UserMemory; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// in the projection document store. +/// +public sealed class UserMemoryCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public UserMemoryCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new UserMemoryCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + StateRoot = Any.Pack(state), + }; + + await _writeDispatcher.UpsertAsync(document, ct); + } +} diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs index 5c0019d5..23fd7600 100644 --- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionUserConfigQueryPort.cs @@ -1,6 +1,5 @@ using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Studio.Application.Studio.Abstractions; -using Aevatar.Studio.Infrastructure.ScopeResolution; using Aevatar.Studio.Projection.ReadModels; namespace Aevatar.Studio.Projection.QueryPorts; diff --git a/src/Aevatar.Studio.Projection/ReadModels/ChatConversationCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/ChatConversationCurrentStateDocument.Partial.cs new file mode 100644 index 00000000..c9b7dac1 --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/ChatConversationCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class ChatConversationCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/ChatHistoryIndexCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/ChatHistoryIndexCurrentStateDocument.Partial.cs new file mode 100644 index 00000000..5ddace40 --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/ChatHistoryIndexCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class ChatHistoryIndexCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/ConnectorCatalogCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/ConnectorCatalogCurrentStateDocument.Partial.cs new file mode 100644 index 00000000..81f732de --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/ConnectorCatalogCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class ConnectorCatalogCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/GAgentRegistryCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/GAgentRegistryCurrentStateDocument.Partial.cs new file mode 100644 index 00000000..4ff3fb88 --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/GAgentRegistryCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class GAgentRegistryCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/RoleCatalogCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/RoleCatalogCurrentStateDocument.Partial.cs new file mode 100644 index 00000000..176bbbc7 --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/RoleCatalogCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class RoleCatalogCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/StreamingProxyParticipantCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/StreamingProxyParticipantCurrentStateDocument.Partial.cs new file mode 100644 index 00000000..66e116ef --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/StreamingProxyParticipantCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class StreamingProxyParticipantCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/UserMemoryCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/UserMemoryCurrentStateDocument.Partial.cs new file mode 100644 index 00000000..16d9a18a --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/UserMemoryCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class UserMemoryCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto index 9f9ef2bf..128ce5d6 100644 --- a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto +++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package aevatar.studio.projection; option csharp_namespace = "Aevatar.Studio.Projection.ReadModels"; +import "google/protobuf/any.proto"; import "google/protobuf/timestamp.proto"; // ─── UserConfig Current State ReadModel ─── @@ -21,3 +22,80 @@ message UserConfigCurrentStateDocument { string remote_runtime_base_url = 14; int32 max_tool_rounds = 15; } + +// ─── GAgentRegistry Current State ReadModel ─── + +message GAgentRegistryCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + google.protobuf.Any state_root = 10; +} + +// ─── ConnectorCatalog Current State ReadModel ─── + +message ConnectorCatalogCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + google.protobuf.Any state_root = 10; +} + +// ─── RoleCatalog Current State ReadModel ─── + +message RoleCatalogCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + google.protobuf.Any state_root = 10; +} + +// ─── UserMemory Current State ReadModel ─── + +message UserMemoryCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + google.protobuf.Any state_root = 10; +} + +// ─── StreamingProxyParticipant Current State ReadModel ─── + +message StreamingProxyParticipantCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + google.protobuf.Any state_root = 10; +} + +// ─── ChatHistoryIndex Current State ReadModel ─── + +message ChatHistoryIndexCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + google.protobuf.Any state_root = 10; +} + +// ─── ChatConversation Current State ReadModel ─── + +message ChatConversationCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + google.protobuf.Any state_root = 10; +} diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index 2034aa42..b5ede6dc 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -1,4 +1,5 @@ using Aevatar.AI.Abstractions.LLMProviders; +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.ChatHistory; using Aevatar.GAgents.ConnectorCatalog; @@ -9,7 +10,7 @@ using Aevatar.GAgents.UserMemory; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ActorBacked; -using Aevatar.Studio.Infrastructure.ScopeResolution; +using Aevatar.Studio.Projection.ReadModels; using FluentAssertions; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; @@ -134,6 +135,89 @@ ScopeIdToReturn is not null : null; } + /// + /// Fake projection document reader that stores documents by key + /// and packs actor state into state_root via Any.Pack. + /// + private sealed class FakeProjectionDocumentReader + : IProjectionDocumentReader + where TDoc : class, IProjectionReadModel + { + private readonly Dictionary _docs = new(StringComparer.Ordinal); + + public void Set(string key, TDoc document) => _docs[key] = document; + + public Task GetAsync(string key, CancellationToken ct = default) + => Task.FromResult(_docs.GetValueOrDefault(key)); + + public Task> QueryAsync( + ProjectionDocumentQuery query, CancellationToken ct = default) + => Task.FromResult(new ProjectionDocumentQueryResult + { + Items = _docs.Values.ToList(), + TotalCount = _docs.Count, + }); + } + + private static FakeProjectionDocumentReader EmptyReader() + where TDoc : class, IProjectionReadModel + => new(); + + private static FakeProjectionDocumentReader PackedReader( + string actorId, UserMemoryState state) + { + var reader = new FakeProjectionDocumentReader(); + reader.Set(actorId, new UserMemoryCurrentStateDocument + { + Id = actorId, ActorId = actorId, StateRoot = Any.Pack(state), + }); + return reader; + } + + private static FakeProjectionDocumentReader PackedReader( + string actorId, ConnectorCatalogState state) + { + var reader = new FakeProjectionDocumentReader(); + reader.Set(actorId, new ConnectorCatalogCurrentStateDocument + { + Id = actorId, ActorId = actorId, StateRoot = Any.Pack(state), + }); + return reader; + } + + private static FakeProjectionDocumentReader PackedReader( + string actorId, RoleCatalogState state) + { + var reader = new FakeProjectionDocumentReader(); + reader.Set(actorId, new RoleCatalogCurrentStateDocument + { + Id = actorId, ActorId = actorId, StateRoot = Any.Pack(state), + }); + return reader; + } + + private static FakeProjectionDocumentReader PackedReader( + string actorId, ChatHistoryIndexState state) + { + var reader = new FakeProjectionDocumentReader(); + reader.Set(actorId, new ChatHistoryIndexCurrentStateDocument + { + Id = actorId, ActorId = actorId, StateRoot = Any.Pack(state), + }); + return reader; + } + + private static FakeProjectionDocumentReader PackedReader( + string actorId, ChatConversationState state) + { + var reader = new FakeProjectionDocumentReader(); + reader.Set(actorId, new ChatConversationCurrentStateDocument + { + Id = actorId, ActorId = actorId, StateRoot = Any.Pack(state), + }); + return reader; + } + // UserConfigStore tests removed — ActorBackedUserConfigStore replaced by // IUserConfigQueryPort (projection) + IUserConfigCommandService (dispatch). // See ActorDispatchUserConfigCommandService tests in projection test project. @@ -201,7 +285,7 @@ public async Task GAgentActorStore_GetAsync_NoActor_ReturnsEmptyList() var logger = NullLogger.Instance; var store = new ActorBackedGAgentActorStore( - runtime, scopeResolver, logger); + runtime, scopeResolver, EmptyReader(), logger); var groups = await store.GetAsync(); @@ -218,12 +302,16 @@ public async Task GAgentActorStore_GetAsync_MapsRegistryState() GagentType = "RoleGAgent", ActorIds = { "actor-a", "actor-b" }, }); - runtime.RegisterActor( - "gagent-registry-scope-1", - new FakeAgent("gagent-registry-scope-1", state)); + var reader = new FakeProjectionDocumentReader(); + reader.Set("gagent-registry-scope-1", new GAgentRegistryCurrentStateDocument + { + Id = "gagent-registry-scope-1", + ActorId = "gagent-registry-scope-1", + StateRoot = Any.Pack(state), + }); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedGAgentActorStore(runtime, scopeResolver, logger); + var store = new ActorBackedGAgentActorStore(runtime, scopeResolver, reader, logger); var groups = await store.GetAsync(); @@ -244,7 +332,7 @@ public async Task GAgentActorStore_AddActorAsync_SendsActorRegisteredEvent() var logger = NullLogger.Instance; var store = new ActorBackedGAgentActorStore( - runtime, scopeResolver, logger); + runtime, scopeResolver, EmptyReader(), logger); await store.AddActorAsync("MyGAgent", "actor-123"); @@ -271,7 +359,7 @@ public async Task GAgentActorStore_RemoveActorAsync_SendsActorUnregisteredEvent( var logger = NullLogger.Instance; var store = new ActorBackedGAgentActorStore( - runtime, scopeResolver, logger); + runtime, scopeResolver, EmptyReader(), logger); await store.RemoveActorAsync("MyGAgent", "actor-456"); @@ -308,7 +396,7 @@ public async Task ChatHistoryStore_SaveMessages_MapsOptionalMetadataAndFields() { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedChatHistoryStore(runtime, logger); + var store = new ActorBackedChatHistoryStore(runtime, EmptyReader(), EmptyReader(), logger); var meta = new ConversationMeta( Id: "conv-1", Title: "Test", ServiceId: "svc", @@ -338,7 +426,7 @@ public async Task ChatHistoryStore_DeleteConversation_UsesConversationActorId() { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedChatHistoryStore(runtime, logger); + var store = new ActorBackedChatHistoryStore(runtime, EmptyReader(), EmptyReader(), logger); await store.DeleteConversationAsync("scope-1", "conv-1"); @@ -355,7 +443,7 @@ public async Task ChatHistoryStore_GetIndex_NoActor_ReturnsEmpty() { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedChatHistoryStore(runtime, logger); + var store = new ActorBackedChatHistoryStore(runtime, EmptyReader(), EmptyReader(), logger); var index = await store.GetIndexAsync("scope-1"); @@ -379,10 +467,15 @@ public async Task ChatHistoryStore_GetIndex_MapsStateCorrectly() LlmRoute = "/api/proxy", LlmModel = "claude-opus", }); - runtime.RegisterActor("chat-index-scope-1", - new FakeAgent("chat-index-scope-1", state)); + var indexReader = new FakeProjectionDocumentReader(); + indexReader.Set("chat-index-scope-1", new ChatHistoryIndexCurrentStateDocument + { + Id = "chat-index-scope-1", + ActorId = "chat-index-scope-1", + StateRoot = Any.Pack(state), + }); var logger = NullLogger.Instance; - var store = new ActorBackedChatHistoryStore(runtime, logger); + var store = new ActorBackedChatHistoryStore(runtime, indexReader, EmptyReader(), logger); var index = await store.GetIndexAsync("scope-1"); @@ -403,7 +496,7 @@ public async Task ParticipantStore_AddAsync_SendsParticipantAddedEvent() { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + var store = new ActorBackedStreamingProxyParticipantStore(runtime, EmptyReader(), logger); await store.AddAsync("room-1", "agent-abc", "Alice"); @@ -421,7 +514,7 @@ public async Task ParticipantStore_RemoveRoomAsync_SendsRoomParticipantsRemovedE { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + var store = new ActorBackedStreamingProxyParticipantStore(runtime, EmptyReader(), logger); await store.RemoveRoomAsync("room-1"); @@ -436,7 +529,7 @@ public async Task ParticipantStore_RemoveParticipantAsync_SendsParticipantRemove { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + var store = new ActorBackedStreamingProxyParticipantStore(runtime, EmptyReader(), logger); await store.RemoveParticipantAsync("room-1", "agent-abc"); @@ -452,7 +545,7 @@ public async Task ParticipantStore_ListAsync_NoActor_ReturnsEmpty() { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + var store = new ActorBackedStreamingProxyParticipantStore(runtime, EmptyReader(), logger); var participants = await store.ListAsync("room-1"); @@ -473,11 +566,15 @@ public async Task ParticipantStore_ListAsync_MapsStateCorrectly() JoinedAt = joinedAt, }); state.Rooms["room-1"] = room; - runtime.RegisterActor("streaming-proxy-participants", - new FakeAgent( - "streaming-proxy-participants", state)); + var reader = new FakeProjectionDocumentReader(); + reader.Set("streaming-proxy-participants", new StreamingProxyParticipantCurrentStateDocument + { + Id = "streaming-proxy-participants", + ActorId = "streaming-proxy-participants", + StateRoot = Any.Pack(state), + }); var logger = NullLogger.Instance; - var store = new ActorBackedStreamingProxyParticipantStore(runtime, logger); + var store = new ActorBackedStreamingProxyParticipantStore(runtime, reader, logger); var participants = await store.ListAsync("room-1"); @@ -496,7 +593,7 @@ public async Task UserMemoryStore_AddEntry_SendsMemoryEntryAddedEvent() var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, EmptyReader(), logger); var entry = await store.AddEntryAsync("preference", "Dark mode", "explicit"); @@ -519,7 +616,7 @@ public async Task UserMemoryStore_GetAsync_NoActor_ReturnsEmpty() var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, EmptyReader(), logger); var doc = await store.GetAsync(); @@ -540,11 +637,10 @@ public async Task UserMemoryStore_GetAsync_MapsStateCorrectly() CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }); - runtime.RegisterActor("user-memory-user-1", - new FakeAgent("user-memory-user-1", state)); + var reader = PackedReader("user-memory-user-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, reader, logger); var doc = await store.GetAsync(); @@ -577,11 +673,10 @@ public async Task UserMemoryStore_GetAsync_FiltersInvalidEntries() Category = "context", Content = string.Empty, }); - runtime.RegisterActor("user-memory-user-1", - new FakeAgent("user-memory-user-1", state)); + var reader = PackedReader("user-memory-user-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, reader, logger); var doc = await store.GetAsync(); @@ -608,11 +703,10 @@ public async Task UserMemoryStore_SaveAsync_ReconcilesMissingAndStaleEntries() Content = "Remove me", Source = "inferred", }); - runtime.RegisterActor("user-memory-user-1", - new FakeAgent("user-memory-user-1", state)); + var reader = PackedReader("user-memory-user-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, reader, logger); await store.SaveAsync(new UserMemoryDocument( 1, @@ -640,16 +734,16 @@ public async Task UserMemoryStore_RemoveEntryAsync_MissingEntry_ReturnsFalse() Category = "context", Content = "present", }); - runtime.RegisterActor("user-memory-user-1", - new FakeAgent("user-memory-user-1", state)); + var reader = PackedReader("user-memory-user-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, reader, logger); var removed = await store.RemoveEntryAsync("missing"); removed.Should().BeFalse(); - runtime.Actors["user-memory-user-1"].ReceivedEnvelopes.Should().BeEmpty(); + runtime.Actors.Should().NotContainKey("user-memory-user-1", + "no actor should be created when entry is missing"); } [Fact] @@ -673,11 +767,10 @@ public async Task UserMemoryStore_BuildPromptSectionAsync_FormatsGroupsAndTrunca Source = "explicit", UpdatedAt = 2, }); - runtime.RegisterActor("user-memory-user-1", - new FakeAgent("user-memory-user-1", state)); + var reader = PackedReader("user-memory-user-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "user-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, reader, logger); var prompt = await store.BuildPromptSectionAsync(70); @@ -694,7 +787,7 @@ public async Task UserMemoryStore_BuildPromptSectionAsync_WhenReadFails_ReturnsE var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver(); var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, EmptyReader(), logger); var prompt = await store.BuildPromptSectionAsync(); @@ -707,7 +800,7 @@ public async Task UserMemoryStore_NoScope_Throws() var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = null }; var logger = NullLogger.Instance; - var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, logger); + var store = new ActorBackedUserMemoryStore(runtime, scopeResolver, EmptyReader(), logger); var act = () => store.AddEntryAsync("preference", "test", "explicit"); @@ -726,7 +819,7 @@ public async Task ConnectorCatalogStore_SaveCatalog_SendsCatalogSavedEvent() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var catalog = new StoredConnectorCatalog( HomeDirectory: "test", @@ -767,7 +860,7 @@ public async Task ConnectorCatalogStore_GetCatalog_NoActor_ReturnsEmpty() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var catalog = await store.GetConnectorCatalogAsync(); @@ -783,7 +876,7 @@ public async Task ConnectorCatalogStore_ImportLocalCatalog_NoFile_Throws() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var act = () => store.ImportLocalCatalogAsync(); @@ -811,7 +904,7 @@ public async Task ConnectorCatalogStore_ImportLocalCatalog_SendsCatalogAndReturn }; var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var imported = await store.ImportLocalCatalogAsync(); @@ -832,7 +925,7 @@ public async Task ConnectorCatalogStore_GetDraft_NoActor_ReturnsEmpty() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var draft = await store.GetConnectorDraftAsync(); @@ -863,14 +956,12 @@ public async Task ConnectorCatalogStore_GetDraft_MapsStateCorrectly() }, }, }; - runtime.RegisterActor( - "connector-catalog-scope-1", - new FakeAgent("connector-catalog-scope-1", state)); + var connReader = PackedReader("connector-catalog-scope-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, connReader, logger); var draft = await store.GetConnectorDraftAsync(); @@ -890,7 +981,7 @@ public async Task ConnectorCatalogStore_SaveDraft_SendsEventAndSyncsWorkspace() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var updatedAt = DateTimeOffset.UtcNow; var draft = new StoredConnectorDraft( HomeDirectory: "test", @@ -922,7 +1013,7 @@ public async Task ConnectorCatalogStore_DeleteDraft_SendsEventAndSyncsWorkspace( var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); await store.DeleteConnectorDraftAsync(); @@ -944,7 +1035,7 @@ public async Task RoleCatalogStore_SaveCatalog_SendsCatalogSavedEvent() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var catalog = new StoredRoleCatalog( HomeDirectory: "test", @@ -974,7 +1065,7 @@ public async Task RoleCatalogStore_DeleteDraft_SyncsToWorkspace() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); await store.DeleteRoleDraftAsync(); @@ -989,7 +1080,7 @@ public async Task RoleCatalogStore_GetCatalog_NoActor_ReturnsEmpty() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var catalog = await store.GetRoleCatalogAsync(); @@ -1005,7 +1096,7 @@ public async Task RoleCatalogStore_ImportLocalCatalog_NoFile_Throws() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var act = () => store.ImportLocalCatalogAsync(); @@ -1029,7 +1120,7 @@ public async Task RoleCatalogStore_ImportLocalCatalog_SendsCatalogAndReturnsImpo }; var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var imported = await store.ImportLocalCatalogAsync(); @@ -1069,10 +1160,9 @@ public async Task ChatHistoryStore_GetMessages_MapsStateCorrectly() Timestamp = 1700000001000, Status = "sent", }); - runtime.RegisterActor("chat-scope-1-conv-1", - new FakeAgent("chat-scope-1-conv-1", state)); + var convReader = PackedReader("chat-scope-1-conv-1", state); var logger = NullLogger.Instance; - var store = new ActorBackedChatHistoryStore(runtime, logger); + var store = new ActorBackedChatHistoryStore(runtime, EmptyReader(), convReader, logger); var messages = await store.GetMessagesAsync("scope-1", "conv-1"); @@ -1091,7 +1181,7 @@ public async Task ChatHistoryStore_GetMessages_NoActor_ReturnsEmpty() { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedChatHistoryStore(runtime, logger); + var store = new ActorBackedChatHistoryStore(runtime, EmptyReader(), EmptyReader(), logger); var messages = await store.GetMessagesAsync("scope-1", "conv-1"); @@ -1117,11 +1207,9 @@ public async Task ChatHistoryStore_GetIndex_MapsAndOrdersState() LlmRoute = string.Empty, LlmModel = string.Empty, }); - runtime.RegisterActor( - "chat-index-scope-1", - new FakeAgent("chat-index-scope-1", state)); + var indexReader = PackedReader("chat-index-scope-1", state); var logger = NullLogger.Instance; - var store = new ActorBackedChatHistoryStore(runtime, logger); + var store = new ActorBackedChatHistoryStore(runtime, indexReader, EmptyReader(), logger); var index = await store.GetIndexAsync("scope-1"); @@ -1135,7 +1223,7 @@ public async Task ChatHistoryStore_SaveMessages_SendsMessagesReplacedEvent() { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedChatHistoryStore(runtime, logger); + var store = new ActorBackedChatHistoryStore(runtime, EmptyReader(), EmptyReader(), logger); await store.SaveMessagesAsync( "scope-1", @@ -1177,7 +1265,7 @@ public async Task ChatHistoryStore_DeleteConversation_SendsConversationDeletedEv { var runtime = new FakeActorRuntime(); var logger = NullLogger.Instance; - var store = new ActorBackedChatHistoryStore(runtime, logger); + var store = new ActorBackedChatHistoryStore(runtime, EmptyReader(), EmptyReader(), logger); await store.DeleteConversationAsync("scope-1", "conv-1"); @@ -1199,7 +1287,7 @@ public async Task RoleCatalogStore_SaveDraft_SyncsToWorkspace() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var draft = new StoredRoleDraft( HomeDirectory: "test", @@ -1225,7 +1313,7 @@ public async Task RoleCatalogStore_GetDraft_NoActor_ReturnsEmpty() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); var draft = await store.GetRoleDraftAsync(); @@ -1253,14 +1341,12 @@ public async Task RoleCatalogStore_GetDraft_MapsStateCorrectly() }, }, }; - runtime.RegisterActor( - "role-catalog-scope-1", - new FakeAgent("role-catalog-scope-1", state)); + var roleReader = PackedReader("role-catalog-scope-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, roleReader, logger); var draft = await store.GetRoleDraftAsync(); @@ -1279,7 +1365,7 @@ public async Task RoleCatalogStore_DeleteDraft_SendsEventAndSyncsWorkspace() var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, EmptyReader(), logger); await store.DeleteRoleDraftAsync(); @@ -1304,13 +1390,12 @@ public async Task RoleCatalogStore_GetCatalog_MapsStateCorrectly() Provider = "anthropic", Model = "claude-opus", }); - runtime.RegisterActor("role-catalog-scope-1", - new FakeAgent("role-catalog-scope-1", state)); + var roleReader = PackedReader("role-catalog-scope-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedRoleCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, roleReader, logger); var catalog = await store.GetRoleCatalogAsync(); @@ -1342,13 +1427,12 @@ public async Task ConnectorCatalogStore_GetCatalog_MapsStateCorrectly() BaseUrl = "https://api.search.example.com", }, }); - runtime.RegisterActor("connector-catalog-scope-1", - new FakeAgent("connector-catalog-scope-1", state)); + var connReader = PackedReader("connector-catalog-scope-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, connReader, logger); var catalog = await store.GetConnectorCatalogAsync(); @@ -1411,14 +1495,12 @@ public async Task ConnectorCatalogStore_GetCatalog_MapsAllConnectorConfigShapes( state.Connectors[0].Cli.Environment["MODE"] = "test"; state.Connectors[0].Mcp.Environment["TOKEN"] = "abc"; state.Connectors[0].Mcp.AdditionalHeaders["X-Trace"] = "trace"; - runtime.RegisterActor( - "connector-catalog-scope-1", - new FakeAgent("connector-catalog-scope-1", state)); + var connReader = PackedReader("connector-catalog-scope-1", state); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; var workspaceStore = new StubWorkspaceStore(); var logger = NullLogger.Instance; var store = new ActorBackedConnectorCatalogStore( - runtime, scopeResolver, workspaceStore, logger); + runtime, scopeResolver, workspaceStore, connReader, logger); var catalog = await store.GetConnectorCatalogAsync(); @@ -1445,11 +1527,11 @@ public async Task GAgentActorStore_DifferentScopes_UseDifferentActors() var logger = NullLogger.Instance; var scopeA = new FakeScopeResolver { ScopeIdToReturn = "scope-a" }; - var storeA = new ActorBackedGAgentActorStore(runtime, scopeA, logger); + var storeA = new ActorBackedGAgentActorStore(runtime, scopeA, EmptyReader(), logger); await storeA.AddActorAsync("MyAgent", "actor-1"); var scopeB = new FakeScopeResolver { ScopeIdToReturn = "scope-b" }; - var storeB = new ActorBackedGAgentActorStore(runtime, scopeB, logger); + var storeB = new ActorBackedGAgentActorStore(runtime, scopeB, EmptyReader(), logger); await storeB.AddActorAsync("MyAgent", "actor-2"); runtime.Actors.Should().ContainKey("gagent-registry-scope-a"); diff --git a/test/Aevatar.Tools.Cli.Tests/AppScopeResolverTests.cs b/test/Aevatar.Tools.Cli.Tests/AppScopeResolverTests.cs index 65d3e606..bdfb5476 100644 --- a/test/Aevatar.Tools.Cli.Tests/AppScopeResolverTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/AppScopeResolverTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Infrastructure.ScopeResolution; using FluentAssertions; using Microsoft.AspNetCore.Http; diff --git a/test/Aevatar.Tools.Cli.Tests/AppStudioScriptDraftRunEndpointTests.cs b/test/Aevatar.Tools.Cli.Tests/AppStudioScriptDraftRunEndpointTests.cs index 0988091f..21b20812 100644 --- a/test/Aevatar.Tools.Cli.Tests/AppStudioScriptDraftRunEndpointTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/AppStudioScriptDraftRunEndpointTests.cs @@ -6,8 +6,8 @@ using Aevatar.Scripting.Core.Ports; using Aevatar.Hosting; using Aevatar.Studio.Application.Scripts.Contracts; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Hosting.Endpoints; -using Aevatar.Studio.Infrastructure.ScopeResolution; using FluentAssertions; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Builder; diff --git a/test/Aevatar.Tools.Cli.Tests/AppStudioScriptSaveEndpointTests.cs b/test/Aevatar.Tools.Cli.Tests/AppStudioScriptSaveEndpointTests.cs index 58716e69..e58bc667 100644 --- a/test/Aevatar.Tools.Cli.Tests/AppStudioScriptSaveEndpointTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/AppStudioScriptSaveEndpointTests.cs @@ -4,8 +4,8 @@ using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.Hosting; using Aevatar.Studio.Application; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Hosting.Endpoints; -using Aevatar.Studio.Infrastructure.ScopeResolution; using FluentAssertions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; diff --git a/test/Aevatar.Tools.Cli.Tests/ExplorerEndpointsTests.cs b/test/Aevatar.Tools.Cli.Tests/ExplorerEndpointsTests.cs index c8aee475..22da1831 100644 --- a/test/Aevatar.Tools.Cli.Tests/ExplorerEndpointsTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ExplorerEndpointsTests.cs @@ -2,8 +2,8 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Hosting.Endpoints; -using Aevatar.Studio.Infrastructure.ScopeResolution; using Aevatar.Studio.Infrastructure.Storage; using FluentAssertions; using Microsoft.AspNetCore.Builder; diff --git a/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs b/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs index e9745a20..dae587cc 100644 --- a/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/UserConfigProjectionAndControllerTests.cs @@ -7,7 +7,6 @@ using Aevatar.GAgents.UserConfig; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Hosting.Controllers; -using Aevatar.Studio.Infrastructure.ScopeResolution; using Aevatar.Studio.Projection.DependencyInjection; using Aevatar.Studio.Projection.Metadata; using Aevatar.Studio.Projection.Orchestration; diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index 2677e322..95470d20 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -124,9 +124,13 @@ if rg -n "Projection:ReadModel:Bindings" src test; then fi set +e +# Check for reader.ListAsync() calls (dot-prefixed) in files that use IProjectionDocumentReader. +# Business-domain ListAsync methods (e.g., IStreamingProxyParticipantStore.ListAsync) are excluded +# by requiring the call to be on a reader/document field (dot prefix pattern). projection_document_reader_list_report="$( rg -l "IProjectionDocumentReader<" src test demos \ - | xargs -r rg -n "ListAsync\(" + | xargs -r rg -n "\.ListAsync\(" \ + | rg -i "(reader|document|projection).*\.ListAsync" )" projection_document_reader_list_status=$? set -e