From eb312b489803007c47c67a2ad39c8b12a2107940 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 27 Apr 2026 11:05:03 +0800 Subject: [PATCH 1/7] Design registry ownership ports --- ...6-04-27-registry-ownership-ports-design.md | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-27-registry-ownership-ports-design.md diff --git a/docs/superpowers/specs/2026-04-27-registry-ownership-ports-design.md b/docs/superpowers/specs/2026-04-27-registry-ownership-ports-design.md new file mode 100644 index 000000000..53fe4f0f3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-registry-ownership-ports-design.md @@ -0,0 +1,216 @@ +# GAgent Registry Ownership Ports Design + +Date: 2026-04-27 + +## Context + +Issue 348 started as a request to refine `IGAgentActorStore` into clearer registry command and query ports. The review discussion around StreamingProxy and NyxID exposed a deeper problem: the current abstraction combines three different meanings behind one `Store` interface. + +- Registry lifecycle commands: register or unregister an actor for a scope. +- Registry listing queries: list actors in a scope from the registry current-state read model. +- Command admission: decide whether a caller may operate on a concrete target actor under a requested scope. + +The default implementation writes by dispatching commands to `GAgentRegistryGAgent`, but reads from `GAgentRegistryCurrentStateDocument`, a CQRS projection. That read model is eventually consistent. Using `GetAsync(scopeId)` as a synchronous ownership check can reject a valid actor immediately after registration when projection has not caught up. + +The tactical alternative of deriving ownership from the actor id format is also not acceptable as a framework-level solution. It makes `actorId` carry business facts, depends on string parsing, is forgeable by callers, and conflicts with the rule that `actorId` is an opaque address. + +## Goals + +- Replace `IGAgentActorStore` with explicit command, query, and admission ports. +- Make read consistency honest: registry list queries are read-model queries and are eventually consistent. +- Keep command admission out of projection reads and actor id parsing. +- Give StreamingProxy, NyxID chat, and draft-run actor preparation a stable ownership/admission contract. +- Delete `IGAgentActorStore` as a production abstraction instead of preserving it as a compatibility facade. +- Keep API/Host endpoints as composition surfaces; business routing and admission rules live behind application/domain-level ports. + +## Non-Goals + +- Do not introduce generic actor query/reply as a fallback for reading another actor's internal state. +- Do not implement query-time projection priming or synchronous read-model refresh. +- Do not make `GAgentRegistryGAgent` a high-throughput central RPC service for every target operation. +- Do not encode scope ownership into actor id syntax. +- Do not add a second registry authority backed by process memory. + +## Proposed Ports + +### Registry Command Port + +`IGAgentActorRegistryCommandPort` owns lifecycle writes. + +Expected operations: + +```csharp +Task RegisterActorAsync( + GAgentActorRegistration registration, + CancellationToken ct = default); + +Task UnregisterActorAsync( + GAgentActorRegistration registration, + CancellationToken ct = default); +``` + +`GAgentActorRegistration` should carry `ScopeId`, `GAgentType`, and `ActorId` as typed fields. The port dispatches commands to the per-scope registry actor. Its synchronous return means the command was accepted for dispatch or failed before dispatch. It must not imply that the registry read model has observed the change. + +### Registry Query Port + +`IGAgentActorRegistryQueryPort` owns registry listing reads. + +Expected operations: + +```csharp +Task ListActorsAsync( + string scopeId, + CancellationToken ct = default); +``` + +The query port reads `GAgentRegistryCurrentStateDocument`. Its result should be named as a snapshot/read model result, not an ownership verdict. If the current read model exposes source version or refresh timestamp, include it in the snapshot; if not, the API documentation must still state that the result is eventually consistent. + +### Scope Resource Admission Port + +`IScopeResourceAdmissionPort` owns command-admission decisions for concrete targets. + +Expected operation: + +```csharp +Task AuthorizeTargetAsync( + ScopeResourceTarget target, + CancellationToken ct = default); +``` + +`ScopeResourceTarget` should include: + +- `ScopeId` +- `ResourceKind` +- `GAgentType` +- `ActorId` +- `Operation` + +`ResourceKind` and `Operation` must be typed values, such as enums or constrained value objects, not open string bags. They affect authorization and routing semantics, so they fall under the repository's strong-type rule for stable control-flow data. + +The result should distinguish at least: + +- `Allowed` +- `Denied` +- `NotFound` +- `Unavailable` + +The port is deliberately not a registry read port. It must not call registry projection reads to hard-fail ownership. It must not infer ownership from actor id format. It should use a stable authoritative contract for the target capability. + +## Admission Semantics + +Admission is command-path authorization, not a list query. + +For target operations such as StreamingProxy `chat`, `join`, `post message`, `message stream`, or NyxID `stream/approve/delete`, the endpoint should ask the admission port whether the target belongs to the requested scope before dispatching or opening a stream. + +Route-supplied actor ids must not trigger implicit target creation. If the client supplies `{actorId}` or `{roomId}`, the operation must resolve through admission first. Missing targets should return `404`; unauthorized targets should return `403`; indeterminate ownership should return `503`. + +The admission implementation should be capability-aware. For a resource actor that owns its current scope binding, the long-term model is: + +1. Creation command initializes the target actor with a typed `scope_id` field in its authoritative state. +2. Target operations carry the requested scope as typed request context. +3. The actor or its application command port rejects operations whose requested scope does not match the actor-owned binding. +4. Public endpoints map the result to `403`, `404`, or `503` without consulting a lagging projection as a source of truth. + +If a capability cannot yet move ownership into target actor state, it may use a dedicated actor-owned ownership contract, such as the per-scope registry actor's authoritative state. That contract must be narrow to ownership/admission, must return only an admission result, and must not expose registry groups or arbitrary actor state. It must not be implemented by direct state reads, generic request/reply, query-time replay, query-time projection refresh, or fallback to the registry projection. It must not be a process-local dictionary. + +## Migration Scope + +The implementation should remove production use of `IGAgentActorStore`. + +Production call sites to migrate: + +- `StreamingProxyEndpoints` + - Create/delete room: registry command port. + - List rooms: registry query port. + - Chat, message stream, post message, join, list participants: admission port before target access. +- `NyxIdChatEndpoints` + - Create conversation: create or ensure the target `NyxIdChatGAgent`, bind its scope or otherwise establish authoritative ownership, then register through the registry command port. + - Delete/restore conversation registration: registry command port plus the capability's cleanup/rollback contract. + - List conversations: registry query port. + - Stream/approve/delete target operations: admission port where the target actor is supplied by the route. +- `GAgentDraftRunActorPreparationService` + - New actor registration/rollback: registry command port. + - Existing preferred actor reuse: admission port, not registry list query. +- `ScopeGAgentEndpoints` + - Actor admin CRUD endpoints map to command/query ports. + +After these migrations, remove: + +- `IGAgentActorStore` +- `GAgentActorGroup` from the old store contract if no longer used +- `ActorBackedGAgentActorStore` +- DI registration for the old store +- production tests and fakes that only exist to satisfy the old interface + +Tests should use fakes for the new ports directly. + +## Data Flow + +### Create + +1. Endpoint validates caller scope with `AevatarScopeAccessGuard`. +2. Endpoint or application service creates/activates the target actor through the existing runtime/command path. +3. Target actor records its authoritative scope binding when this capability owns target scope in its actor state, or the capability establishes its documented authoritative ownership contract. +4. Registry command port registers the actor for list/query visibility only after the target ownership source exists. +5. Response returns an accepted/created result without promising immediate list visibility. + +The exact order may vary by capability, but rollback must be explicit when one side succeeds and the other fails. + +### List + +1. Endpoint validates caller scope. +2. Registry query port reads the registry current-state read model. +3. Endpoint returns the snapshot as eventually consistent list data. + +List must not be reused as command admission. + +### Operate On Target + +1. Endpoint validates caller scope. +2. Endpoint builds a `ScopeResourceTarget`. +3. Admission port authorizes the target using authoritative resource ownership semantics. +4. On `Allowed`, the operation is dispatched to the target actor or service path. +5. On denial/not-found/unavailable, the endpoint maps to an honest HTTP response. + +## Error Mapping + +- Scope claim mismatch: `403` with `SCOPE_ACCESS_DENIED`. +- Admission `Denied`: `403`. +- Admission `NotFound`: `404`. +- Admission `Unavailable`: `503`, because the system cannot safely decide. +- Registry command dispatch failure during create/delete: `503` or operation-specific failure response. +- Registry query failure during list: preserve current user-facing behavior where appropriate, but log that the list read model is unavailable. + +## Testing + +Add or update tests around these behaviors: + +- Create followed immediately by operate does not depend on registry projection visibility. +- Existing preferred actor reuse in draft-run does not use registry list projection as strong admission. +- Route-supplied actor ids from another scope are denied or not found through admission. +- Registry list endpoints still read from the query port and tolerate eventual consistency honestly. +- Old `IGAgentActorStore` is not registered in production DI. +- Static or architecture guard coverage prevents new production references to `IGAgentActorStore`. + +Because tests around eventual consistency can become flaky, prefer deterministic fake ports over polling. If an integration test truly needs eventually consistent observation, it must follow the repository polling allowlist rule. + +## Documentation And Guards + +- Update issue 348 or linked docs to state that the registry list read model is not an ownership authority. +- Add a small architecture note describing command/query/admission separation for GAgent registry resources. +- Add or extend a guard that fails on production references to `IGAgentActorStore` after removal. +- If new target-owned ownership state is added, define it as typed protobuf fields/events rather than bags or metadata. + +## Risks + +The largest design risk is making `IScopeResourceAdmissionPort` too generic and letting it become a hidden RPC/query escape hatch. The port should stay narrow: it only answers command admission for a typed target and operation. It should not return arbitrary actor state. + +The largest implementation risk is migration breadth. The old interface appears in endpoints, application services, integration tests, AI tests, and CLI adapter tests. The implementation plan should stage edits by call-site family, while still deleting the old production abstraction before the PR is complete. + +## Implementation Decisions + +- Use `IGAgentActorRegistryCommandPort`, `IGAgentActorRegistryQueryPort`, and `IScopeResourceAdmissionPort` unless implementation reveals a direct naming conflict. +- Keep registry command methods returning `Task` in this work. A command receipt object can be introduced later with the broader command receipt model. +- StreamingProxy should use target-owned room scope binding in its typed state/event contract because `StreamingProxyGAgentState` and `GroupChatRoomInitializedEvent` are capability-owned and can carry `scope_id`. +- NyxID chat create must create or ensure the `NyxIdChatGAgent` target before registering it, because `stream` and `approve` must not implicitly create route-supplied actors. If extending `RoleGAgent` state for typed scope ownership is too broad for this work, use the narrow registry actor ownership contract as an interim authoritative admission source, not projection and not actor id parsing. +- Draft-run preferred actor reuse should use the same admission port. It must not list registry projection groups to decide whether an existing actor may be reused. From e7a2018aecd65bc8f1ee67d1f6b6dde8e18d427d Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 27 Apr 2026 17:20:12 +0800 Subject: [PATCH 2/7] Refactor GAgent registry ownership ports --- .../NyxIdChatEndpoints.Streaming.cs | 29 ++- .../NyxIdChatEndpoints.cs | 121 ++++++++-- .../GAgentRegistryGAgent.cs | 22 ++ .../gagent_registry_messages.proto | 19 ++ .../StreamingProxyEndpoints.cs | 210 +++++++++++++++--- docs/2026-04-02-streaming-proxy-flow.md | 17 +- docs/README.md | 7 + docs/canon/gagent-registry-ownership.md | 94 ++++++++ ...026-04-17-nyxid-chat-registry-lifecycle.md | 6 +- ...6-04-27-registry-ownership-ports-design.md | 81 ++++--- src/Aevatar.Mainnet.Host.Api/README.md | 19 ++ .../Abstractions/GAgentRegistryPorts.cs | 107 +++++++++ .../Studio/Abstractions/IGAgentActorStore.cs | 13 -- ...ionReadModelServiceCollectionExtensions.cs | 2 +- .../ActorBackedGAgentActorStore.cs | 130 ----------- .../ActorBackedGAgentRegistryPorts.cs | 203 +++++++++++++++++ .../ServiceCollectionExtensions.cs | 5 +- .../GAgentDraftRunActorPreparationService.cs | 82 +++++-- .../Endpoints/ScopeGAgentEndpoints.cs | 100 +++++---- .../NyxIdChatEndpointsCoverageTests.cs | 117 +++++----- .../StreamingProxyCoverageTests.cs | 84 ++++--- .../StreamingProxyEndpointsCoverageTests.cs | 139 +++++++----- ...ScopeDraftRunActorQueryIntegrationTests.cs | 79 ++++--- .../ScopeGAgentEndpointsTests.cs | 115 +++++----- ...entDraftRunActorPreparationServiceTests.cs | 174 ++++++++++----- .../ActorBackedStoreAdapterTests.cs | 134 ++++++++--- tools/ci/architecture_guards.sh | 5 + 27 files changed, 1517 insertions(+), 597 deletions(-) create mode 100644 docs/canon/gagent-registry-ownership.md rename docs/{superpowers/specs => history/2026-04}/2026-04-27-registry-ownership-ports-design.md (52%) create mode 100644 src/Aevatar.Studio.Application/Studio/Abstractions/GAgentRegistryPorts.cs delete mode 100644 src/Aevatar.Studio.Application/Studio/Abstractions/IGAgentActorStore.cs delete mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs create mode 100644 src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs index fa8339534..fb3becd1b 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs @@ -2,6 +2,7 @@ using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.Studio.Application.Studio.Abstractions; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -18,6 +19,7 @@ private static async Task HandleStreamMessageAsync( string actorId, NyxIdChatStreamRequest request, [FromServices] IActorRuntime actorRuntime, + [FromServices] IScopeResourceAdmissionPort admissionPort, [FromServices] IActorEventSubscriptionProvider subscriptionProvider, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) @@ -43,8 +45,21 @@ private static async Task HandleStreamMessageAsync( return; } - actor = await actorRuntime.GetAsync(actorId) - ?? await actorRuntime.CreateAsync(actorId, ct); + if (!await TryAuthorizeConversationAsync( + http, + admissionPort, + scopeId, + actorId, + ScopeResourceOperation.Stream, + ct)) + return; + + actor = await actorRuntime.GetAsync(actorId); + if (actor == null) + { + http.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } } catch (OperationCanceledException) { @@ -189,6 +204,7 @@ private static async Task HandleApproveAsync( string actorId, NyxIdApprovalRequest request, [FromServices] IActorRuntime actorRuntime, + [FromServices] IScopeResourceAdmissionPort admissionPort, [FromServices] IActorEventSubscriptionProvider subscriptionProvider, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) @@ -211,6 +227,15 @@ private static async Task HandleApproveAsync( return; } + if (!await TryAuthorizeConversationAsync( + http, + admissionPort, + scopeId, + actorId, + ScopeResourceOperation.Approve, + ct)) + return; + actor = await actorRuntime.GetAsync(actorId); if (actor == null) { diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs index 87cc6a760..6cfa055be 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs @@ -81,35 +81,69 @@ public static IEndpointRouteBuilder MapNyxIdChatEndpoints(this IEndpointRouteBui private static async Task HandleCreateConversationAsync( HttpContext http, string scopeId, - [FromServices] IGAgentActorStore actorStore, + [FromServices] IGAgentActorRegistryCommandPort registryCommandPort, + [FromServices] IActorRuntime actorRuntime, CancellationToken ct) { - // Conversation creation is fail-fast on IGAgentActorStore persistence. + // Conversation creation is fail-fast on registry persistence. // NyxId chat depends on the registry being available; there is no // degraded mode where a conversation can run without being registered. var actorId = NyxIdChatServiceDefaults.GenerateActorId(); - await actorStore.AddActorAsync(scopeId, NyxIdChatServiceDefaults.GAgentTypeName, actorId, ct); + await actorRuntime.CreateAsync(actorId, ct); + try + { + var receipt = await registryCommandPort.RegisterActorAsync( + new GAgentActorRegistration(scopeId, NyxIdChatServiceDefaults.GAgentTypeName, actorId), + ct); + if (!receipt.IsAdmissionVisible) + { + await actorRuntime.DestroyAsync(actorId, CancellationToken.None); + return Results.Json( + new { error = "Conversation registration is not admission-visible" }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } + } + catch + { + await actorRuntime.DestroyAsync(actorId, CancellationToken.None); + throw; + } + return Results.Ok(new { actorId }); } private static async Task HandleListConversationsAsync( HttpContext http, string scopeId, - [FromServices] IGAgentActorStore actorStore, + [FromServices] IGAgentActorRegistryQueryPort registryQueryPort, CancellationToken ct) { try { - var groups = await actorStore.GetAsync(scopeId, ct); - var actorIds = groups + var snapshot = await registryQueryPort.ListActorsAsync(scopeId, ct); + var actorIds = snapshot.Groups .FirstOrDefault(g => string.Equals(g.GAgentType, NyxIdChatServiceDefaults.GAgentTypeName, StringComparison.Ordinal)) ?.ActorIds ?? []; - return Results.Ok(actorIds.Select(actorId => new { actorId })); + return Results.Ok(new + { + snapshot.ScopeId, + snapshot.StateVersion, + snapshot.UpdatedAt, + snapshot.ObservedAt, + Conversations = actorIds.Select(actorId => new { actorId }), + }); } catch (InvalidOperationException) { - return Results.Ok(Array.Empty()); + return Results.Ok(new + { + ScopeId = scopeId, + StateVersion = 0L, + UpdatedAt = DateTimeOffset.MinValue, + ObservedAt = DateTimeOffset.UtcNow, + Conversations = Array.Empty(), + }); } } @@ -117,18 +151,30 @@ private static async Task HandleDeleteConversationAsync( HttpContext http, string scopeId, string actorId, - [FromServices] IGAgentActorStore actorStore, + [FromServices] IGAgentActorRegistryCommandPort registryCommandPort, + [FromServices] IScopeResourceAdmissionPort admissionPort, [FromServices] IChatHistoryStore chatHistoryStore, CancellationToken ct) { - await actorStore.RemoveActorAsync(scopeId, NyxIdChatServiceDefaults.GAgentTypeName, actorId, ct); + var admissionError = await AuthorizeConversationAsync( + admissionPort, + scopeId, + actorId, + ScopeResourceOperation.Delete, + ct); + if (admissionError != null) + return admissionError; + + await registryCommandPort.UnregisterActorAsync( + new GAgentActorRegistration(scopeId, NyxIdChatServiceDefaults.GAgentTypeName, actorId), + ct); try { await chatHistoryStore.DeleteConversationAsync(scopeId, actorId, ct); } catch { - await TryRestoreConversationRegistrationAsync(http, scopeId, actorId, actorStore); + await TryRestoreConversationRegistrationAsync(http, scopeId, actorId, registryCommandPort); throw; } @@ -139,11 +185,13 @@ private static async Task TryRestoreConversationRegistrationAsync( HttpContext http, string scopeId, string actorId, - IGAgentActorStore actorStore) + IGAgentActorRegistryCommandPort registryCommandPort) { try { - await actorStore.AddActorAsync(scopeId, NyxIdChatServiceDefaults.GAgentTypeName, actorId, CancellationToken.None); + await registryCommandPort.RegisterActorAsync( + new GAgentActorRegistration(scopeId, NyxIdChatServiceDefaults.GAgentTypeName, actorId), + CancellationToken.None); } catch (Exception ex) { @@ -153,10 +201,55 @@ private static async Task TryRestoreConversationRegistrationAsync( ex, "Failed to restore NyxId chat conversation registration after history deletion failure: scope={ScopeId}, actor={ActorId}", scopeId, - actorId); + actorId); } } + private static async Task AuthorizeConversationAsync( + IScopeResourceAdmissionPort admissionPort, + string scopeId, + string actorId, + ScopeResourceOperation operation, + CancellationToken ct) + { + var admission = await admissionPort.AuthorizeTargetAsync( + new ScopeResourceTarget( + scopeId, + ScopeResourceKind.GAgentActor, + NyxIdChatServiceDefaults.GAgentTypeName, + actorId, + operation), + ct); + return admission.Status switch + { + ScopeResourceAdmissionStatus.Allowed => null, + ScopeResourceAdmissionStatus.NotFound => Results.NotFound(new { error = "Conversation not found" }), + ScopeResourceAdmissionStatus.Denied or ScopeResourceAdmissionStatus.ScopeMismatch => + Results.Json(new { error = "Conversation access denied" }, statusCode: StatusCodes.Status403Forbidden), + ScopeResourceAdmissionStatus.Unavailable => + Results.Json(new { error = "Conversation admission unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable), + _ => Results.Json(new { error = "Conversation admission failed" }, statusCode: StatusCodes.Status503ServiceUnavailable), + }; + } + + private static async Task TryAuthorizeConversationAsync( + HttpContext http, + IScopeResourceAdmissionPort admissionPort, + string scopeId, + string actorId, + ScopeResourceOperation operation, + CancellationToken ct) + { + var admissionError = await AuthorizeConversationAsync(admissionPort, scopeId, actorId, operation, ct); + if (admissionError == null) + return true; + + http.Response.StatusCode = admissionError is IStatusCodeHttpResult { StatusCode: { } statusCode } + ? statusCode + : StatusCodes.Status500InternalServerError; + return false; + } + private static async Task InjectUserConfigMetadataAsync( HttpContext http, IDictionary metadata, diff --git a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs index 47af1a7b3..f7550f55e 100644 --- a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs +++ b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs @@ -32,6 +32,20 @@ public async Task HandleActorRegistered(ActorRegisteredEvent evt) await PersistDomainEventAsync(evt); } + [EventHandler(EndpointName = "authorizeScopeResource")] + public Task HandleScopeResourceAdmissionRequested(ScopeResourceAdmissionRequested request) + { + if (string.IsNullOrWhiteSpace(request.GagentType) || string.IsNullOrWhiteSpace(request.ActorId)) + throw new GAgentRegistryAdmissionNotFoundException(); + + var group = State.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, request.GagentType, StringComparison.Ordinal)); + if (group is null || !group.ActorIds.Contains(request.ActorId)) + throw new GAgentRegistryAdmissionNotFoundException(); + + return Task.CompletedTask; + } + [EventHandler(EndpointName = "unregisterActor")] public async Task HandleActorUnregistered(ActorUnregisteredEvent evt) { @@ -100,3 +114,11 @@ private static GAgentRegistryState ApplyUnregistered( } } + +public sealed class GAgentRegistryAdmissionNotFoundException : Exception +{ + public GAgentRegistryAdmissionNotFoundException() + : base("Registry target was not found.") + { + } +} diff --git a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto index 619ffb2b3..bccbfe027 100644 --- a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto +++ b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto @@ -25,3 +25,22 @@ message ActorUnregisteredEvent { string actor_id = 2; } +// ─── Admission ─── + +enum GAgentRegistryOperation { + UNKNOWN = 0; + USE = 1; + DELETE = 2; + CHAT = 3; + STREAM = 4; + APPROVE = 5; + JOIN = 6; + LIST_PARTICIPANTS = 7; + DRAFT_RUN_REUSE = 8; +} + +message ScopeResourceAdmissionRequested { + string gagent_type = 1; + string actor_id = 2; + GAgentRegistryOperation operation = 3; +} diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs index db414abc0..d701230d0 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs @@ -49,7 +49,7 @@ private static async Task HandleCreateRoomAsync( HttpContext http, string scopeId, [FromBody] CreateRoomRequest? request, - [FromServices] IGAgentActorStore actorStore, + [FromServices] IGAgentActorRegistryCommandPort registryCommandPort, [FromServices] IActorRuntime actorRuntime, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) @@ -63,22 +63,12 @@ private static async Task HandleCreateRoomAsync( roomName = "Group Chat"; var roomId = StreamingProxyDefaults.GenerateRoomId(); - try - { - await actorStore.AddActorAsync(scopeId, 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); - } - + var targetCreated = false; + var registrationAttempted = false; try { var actor = await actorRuntime.CreateAsync(roomId, ct); + targetCreated = true; var initEvent = new GroupChatRoomInitializedEvent { RoomName = roomName }; var envelope = new EventEnvelope @@ -89,12 +79,30 @@ private static async Task HandleCreateRoomAsync( Route = new EnvelopeRoute { Direct = new DirectRoute { TargetActorId = actor.Id } }, }; await actor.HandleEventAsync(envelope, ct); + + registrationAttempted = true; + var receipt = await registryCommandPort.RegisterActorAsync( + new GAgentActorRegistration(scopeId, StreamingProxyDefaults.GAgentTypeName, roomId), + ct); + if (!receipt.IsAdmissionVisible) + { + await TryRollbackRoomCreationAsync(scopeId, roomId, registryCommandPort, actorRuntime, logger, registrationAttempted); + return Results.Json( + new { error = "Failed to create room" }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } + } + catch (OperationCanceledException) + { + if (targetCreated) + await TryRollbackRoomCreationAsync(scopeId, roomId, registryCommandPort, actorRuntime, logger, registrationAttempted); + throw; } - catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogError(ex, "Failed to activate room {RoomId}; rolling back registration", roomId); - await TryRollbackRoomCreationAsync(scopeId, roomId, actorStore, actorRuntime, logger); + logger.LogError(ex, "Failed to create room {RoomId}", roomId); + if (targetCreated) + await TryRollbackRoomCreationAsync(scopeId, roomId, registryCommandPort, actorRuntime, logger, registrationAttempted); return Results.Json( new { error = "Failed to create room" }, statusCode: StatusCodes.Status500InternalServerError); @@ -106,7 +114,7 @@ private static async Task HandleCreateRoomAsync( private static async Task HandleListRoomsAsync( HttpContext http, string scopeId, - [FromServices] IGAgentActorStore actorStore, + [FromServices] IGAgentActorRegistryQueryPort registryQueryPort, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { @@ -116,17 +124,31 @@ private static async Task HandleListRoomsAsync( var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); try { - var groups = await actorStore.GetAsync(scopeId, ct); - var group = groups.FirstOrDefault(g => + var snapshot = await registryQueryPort.ListActorsAsync(scopeId, ct); + var group = snapshot.Groups.FirstOrDefault(g => string.Equals(g.GAgentType, StreamingProxyDefaults.GAgentTypeName, StringComparison.Ordinal)); var roomIds = group?.ActorIds ?? []; - return Results.Ok(roomIds.Select(id => new { roomId = id })); + return Results.Ok(new + { + snapshot.ScopeId, + snapshot.StateVersion, + snapshot.UpdatedAt, + snapshot.ObservedAt, + Rooms = roomIds.Select(id => new { roomId = id }), + }); } catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogWarning(ex, "Failed to list rooms from actor store"); - return Results.Ok(Array.Empty()); + logger.LogWarning(ex, "Failed to list rooms from registry read model"); + return Results.Ok(new + { + ScopeId = scopeId, + StateVersion = 0L, + UpdatedAt = DateTimeOffset.MinValue, + ObservedAt = DateTimeOffset.UtcNow, + Rooms = Array.Empty(), + }); } } @@ -134,7 +156,8 @@ private static async Task HandleDeleteRoomAsync( HttpContext http, string scopeId, string roomId, - [FromServices] IGAgentActorStore actorStore, + [FromServices] IGAgentActorRegistryCommandPort registryCommandPort, + [FromServices] IScopeResourceAdmissionPort admissionPort, [FromServices] IStreamingProxyParticipantStore participantStore, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) @@ -143,14 +166,25 @@ private static async Task HandleDeleteRoomAsync( return denied; var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); + var admissionError = await AuthorizeRoomAsync( + admissionPort, + scopeId, + roomId, + ScopeResourceOperation.Delete, + ct); + if (admissionError != null) + return admissionError; + try { - await actorStore.RemoveActorAsync(scopeId, StreamingProxyDefaults.GAgentTypeName, roomId, ct); + await registryCommandPort.UnregisterActorAsync( + new GAgentActorRegistration(scopeId, StreamingProxyDefaults.GAgentTypeName, roomId), + ct); } catch (OperationCanceledException) { throw; } catch (Exception ex) { - logger.LogWarning(ex, "Failed to remove room {RoomId} from actor store", roomId); + logger.LogWarning(ex, "Failed to unregister room {RoomId} from registry", roomId); } try { @@ -172,6 +206,7 @@ private static async Task HandleChatAsync( string roomId, ChatTopicRequest request, [FromServices] IActorRuntime actorRuntime, + [FromServices] IScopeResourceAdmissionPort admissionPort, [FromServices] IStreamingProxyRoomSessionProjectionPort roomSessionProjectionPort, [FromServices] StreamingProxyChatDurableCompletionResolver durableCompletionResolver, [FromServices] IStreamingProxyParticipantStore participantStore, @@ -189,6 +224,15 @@ private static async Task HandleChatAsync( if (await AevatarScopeAccessGuard.TryWriteScopeAccessDeniedAsync(http, scopeId, ct)) return; + if (!await TryAuthorizeRoomAsync( + http, + admissionPort, + scopeId, + roomId, + ScopeResourceOperation.Chat, + ct)) + return; + var prompt = request.Prompt?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(prompt)) { @@ -389,6 +433,7 @@ private static async Task HandlePostMessageAsync( string roomId, PostMessageRequest request, [FromServices] IActorRuntime actorRuntime, + [FromServices] IScopeResourceAdmissionPort admissionPort, CancellationToken ct) { if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) @@ -397,6 +442,15 @@ private static async Task HandlePostMessageAsync( if (string.IsNullOrWhiteSpace(request.AgentId) || string.IsNullOrWhiteSpace(request.Content)) return Results.BadRequest(new { error = "agentId and content are required" }); + var admissionError = await AuthorizeRoomAsync( + admissionPort, + scopeId, + roomId, + ScopeResourceOperation.Use, + ct); + if (admissionError != null) + return admissionError; + var actor = await actorRuntime.GetAsync(roomId); if (actor is null) return Results.NotFound(new { error = "Room not found" }); @@ -428,6 +482,7 @@ private static async Task HandleMessageStreamAsync( string scopeId, string roomId, [FromServices] IActorRuntime actorRuntime, + [FromServices] IScopeResourceAdmissionPort admissionPort, [FromServices] IStreamingProxyRoomSessionProjectionPort roomSessionProjectionPort, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) @@ -440,6 +495,15 @@ private static async Task HandleMessageStreamAsync( if (await AevatarScopeAccessGuard.TryWriteScopeAccessDeniedAsync(http, scopeId, ct)) return; + if (!await TryAuthorizeRoomAsync( + http, + admissionPort, + scopeId, + roomId, + ScopeResourceOperation.Stream, + ct)) + return; + var actor = await actorRuntime.GetAsync(roomId); if (actor is null) { @@ -511,6 +575,7 @@ private static async Task HandleListParticipantsAsync( HttpContext http, string scopeId, string roomId, + [FromServices] IScopeResourceAdmissionPort admissionPort, [FromServices] IStreamingProxyParticipantStore participantStore, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) @@ -518,6 +583,15 @@ private static async Task HandleListParticipantsAsync( if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) return denied; + var admissionError = await AuthorizeRoomAsync( + admissionPort, + scopeId, + roomId, + ScopeResourceOperation.ListParticipants, + ct); + if (admissionError != null) + return admissionError; + var logger = loggerFactory.CreateLogger("Aevatar.GAgents.StreamingProxy.Endpoints"); try { @@ -540,6 +614,7 @@ private static async Task HandleJoinAsync( string roomId, JoinRoomRequest request, [FromServices] IActorRuntime actorRuntime, + [FromServices] IScopeResourceAdmissionPort admissionPort, [FromServices] IStreamingProxyParticipantStore participantStore, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) @@ -550,6 +625,15 @@ private static async Task HandleJoinAsync( if (string.IsNullOrWhiteSpace(request.AgentId)) return Results.BadRequest(new { error = "agentId is required" }); + var admissionError = await AuthorizeRoomAsync( + admissionPort, + scopeId, + roomId, + ScopeResourceOperation.Join, + ct); + if (admissionError != null) + return admissionError; + var actor = await actorRuntime.GetAsync(roomId); if (actor is null) return Results.NotFound(new { error = "Room not found" }); @@ -854,9 +938,10 @@ private static async Task WaitForTerminalSignalAsync( private static async Task TryRollbackRoomCreationAsync( string scopeId, string roomId, - IGAgentActorStore actorStore, + IGAgentActorRegistryCommandPort registryCommandPort, IActorRuntime actorRuntime, - ILogger logger) + ILogger logger, + bool unregisterFromRegistry) { try { @@ -867,20 +952,79 @@ private static async Task TryRollbackRoomCreationAsync( logger.LogError(ex, "Failed to destroy room actor {RoomId} during rollback", roomId); } + if (!unregisterFromRegistry) + return; + try { - await actorStore.RemoveActorAsync( - scopeId, - StreamingProxyDefaults.GAgentTypeName, - roomId, + await registryCommandPort.UnregisterActorAsync( + new GAgentActorRegistration( + scopeId, + StreamingProxyDefaults.GAgentTypeName, + roomId), CancellationToken.None); } catch (Exception ex) { - logger.LogError(ex, "Failed to remove room {RoomId} from actor store during rollback", roomId); + logger.LogError(ex, "Failed to unregister room {RoomId} from registry during rollback", roomId); + } + } + + private static async Task AuthorizeRoomAsync( + IScopeResourceAdmissionPort admissionPort, + string scopeId, + string roomId, + ScopeResourceOperation operation, + CancellationToken ct) + { + var admission = await admissionPort.AuthorizeTargetAsync( + new ScopeResourceTarget( + scopeId, + ScopeResourceKind.GAgentActor, + StreamingProxyDefaults.GAgentTypeName, + roomId, + operation), + ct); + return MapAdmissionError(admission); + } + + private static async Task TryAuthorizeRoomAsync( + HttpContext http, + IScopeResourceAdmissionPort admissionPort, + string scopeId, + string roomId, + ScopeResourceOperation operation, + CancellationToken ct) + { + var admissionError = await AuthorizeRoomAsync(admissionPort, scopeId, roomId, operation, ct); + if (admissionError == null) + return true; + + switch (admissionError) + { + case IStatusCodeHttpResult { StatusCode: { } statusCode }: + http.Response.StatusCode = statusCode; + break; + default: + http.Response.StatusCode = StatusCodes.Status500InternalServerError; + break; } + + return false; } + private static IResult? MapAdmissionError(ScopeResourceAdmissionResult admission) => + admission.Status switch + { + ScopeResourceAdmissionStatus.Allowed => null, + ScopeResourceAdmissionStatus.NotFound => Results.NotFound(new { error = "Room not found" }), + ScopeResourceAdmissionStatus.Denied or ScopeResourceAdmissionStatus.ScopeMismatch => + Results.Json(new { error = "Room access denied" }, statusCode: StatusCodes.Status403Forbidden), + ScopeResourceAdmissionStatus.Unavailable => + Results.Json(new { error = "Room admission unavailable" }, statusCode: StatusCodes.Status503ServiceUnavailable), + _ => Results.Json(new { error = "Room admission failed" }, statusCode: StatusCodes.Status503ServiceUnavailable), + }; + // ─── Request DTOs ─── public sealed record CreateRoomRequest(string? RoomName); diff --git a/docs/2026-04-02-streaming-proxy-flow.md b/docs/2026-04-02-streaming-proxy-flow.md index de0799c38..90f3566b5 100644 --- a/docs/2026-04-02-streaming-proxy-flow.md +++ b/docs/2026-04-02-streaming-proxy-flow.md @@ -2,7 +2,7 @@ 本文只整理当前仓库里 `Streaming Proxy` 这条真实实现链路,对应宿主是 `Aevatar.Mainnet.Host.Api`,入口是 `/api/scopes/{scopeId}/streaming-proxy/...`。 -2026-04-08 更新:room 索引已经切换到 `IGAgentActorStore`,participant 索引已经切换到 `IStreamingProxyParticipantStore`。下文若出现 `StreamingProxyActorStore`,应视为旧实现残留描述,不再对应当前代码里的真实类型或文件路径。 +2026-04-27 更新:room ownership 已切换到 [GAgent Registry Ownership](canon/gagent-registry-ownership.md) 定义的 registry command/query/admission ports。下文若出现 `StreamingProxyActorStore` 或 `IGAgentActorStore`,应视为旧实现残留描述,不再对应当前代码里的真实类型或文件路径。 目标是回答三个问题: @@ -17,7 +17,7 @@ | `Aevatar.Mainnet.Host.Api` | `src/Aevatar.Mainnet.Host.Api/Program.cs` | 注册 `AddStreamingProxy()`,挂载 `MapStreamingProxyEndpoints()` | | `StreamingProxyEndpoints` | `agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs` | 提供 room CRUD、`:chat`、`messages`、`messages:stream`、participant 管理 HTTP/SSE 入口 | | `StreamingProxyGAgent` | `agents/Aevatar.GAgents.StreamingProxy/StreamingProxyGAgent.cs` | 房间 actor,本质上是 group chat broker;持久化事件、更新房间内消息/参与者状态、向订阅者发布事件 | -| `IGAgentActorStore` | `src/Aevatar.Studio.Application/Studio/Abstractions/IGAgentActorStore.cs` | room 列表的持久化索引,供 `GET /rooms` 与删除 room 时使用 | +| `IGAgentActorRegistryCommandPort` / `IGAgentActorRegistryQueryPort` / `IScopeResourceAdmissionPort` | `src/Aevatar.Studio.Application/Studio/Abstractions/GAgentRegistryPorts.cs` | room ownership 的写入、列表查询与 command admission 边界 | | `IStreamingProxyParticipantStore` | `src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs` | room participant 的持久化索引,供 participant 查询、自动加入与失败移除时使用 | | `StreamingProxyNyxParticipantCoordinator` | `agents/Aevatar.GAgents.StreamingProxy/StreamingProxyNyxParticipantCoordinator.cs` | 在带 Bearer Token 时发现 Nyx 可用 provider,把它们自动加入房间并生成多轮回复 | | `StreamingProxySseWriter` | `agents/Aevatar.GAgents.StreamingProxy/StreamingProxySseWriter.cs` | 把 actor 事件映射成 SSE frame 输出给客户端 | @@ -28,7 +28,7 @@ %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart TB CL["Client / OpenClaw"] --> API["StreamingProxyEndpoints\n/api/scopes/{scopeId}/streaming-proxy/..."] - API --> ROOMS["IGAgentActorStore\nroom index"] + API --> REG["GAgent registry ports\ncommand / query / admission"] API --> PSTORE["IStreamingProxyParticipantStore\nparticipant index"] API --> RT["IActorRuntime"] RT --> ACT["StreamingProxyGAgent\nroom actor"] @@ -61,11 +61,11 @@ flowchart TB ## 4. 房间创建链路 -创建 room 时,入口先生成 `roomId`,创建 `StreamingProxyGAgent`,向 actor 投递一个 `GroupChatRoomInitializedEvent`,再把 room 写入 `IGAgentActorStore`。 +创建 room 时,入口先生成 `roomId`,通过 registry command port 注册 room ownership,并要求返回 admission-visible receipt;随后创建 `StreamingProxyGAgent`,向 actor 投递一个 `GroupChatRoomInitializedEvent`。 这里有两个状态落点: -1. `IGAgentActorStore` 记录 room 列表,用于 `ListRoomsAsync(...)`。 +1. registry actor state 是 room ownership 的权威来源;query port 读取其 readmodel 用于 `ListRoomsAsync(...)`。 2. `StreamingProxyGAgent` 持久化 `GroupChatRoomInitializedEvent`,并在自己的 `_proxyState.RoomName` 中保留房间名。 ```mermaid @@ -73,18 +73,19 @@ flowchart TB sequenceDiagram participant CL as "Client" participant API as "StreamingProxyEndpoints" - participant STORE as "IGAgentActorStore" + participant REG as "GAgent registry ports" participant RT as "IActorRuntime" participant ACT as "StreamingProxyGAgent" CL->>API: "POST /rooms" + API->>REG: "RegisterActorAsync(scopeId, StreamingProxyGAgent, roomId)" + REG-->>API: "admission-visible receipt" API->>RT: "CreateAsync(roomId)" RT-->>API: "actor" API->>ACT: "HandleEventAsync(GroupChatRoomInitializedEvent)" ACT->>ACT: "PersistDomainEventAsync" ACT->>ACT: "TransitionState -> RoomName" - API->>STORE: "AddActorAsync(StreamingProxyGAgent, roomId)" - API-->>CL: "{ roomId, roomName, createdAt }" + API-->>CL: "{ roomId, roomName }" ``` ## 5. `:chat` 主链路 diff --git a/docs/README.md b/docs/README.md index acb6c25b5..261fbbe53 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ Authoritative architecture and developer guides. Each covers one topic. - [Aevatar CQRS 架构(Maker 插件化后)](canon/cqrs-projection.md) - [Event Sourcing 基线文档(2026-02-23)](canon/event-sourcing.md) - [Aevatar 前端设计基线](canon/frontend-design.md) +- [GAgent Registry Ownership](canon/gagent-registry-ownership.md) - [Workflow LLM 流式链路详细架构文档(2026-02-25)](canon/llm-streaming.md) - [NyxID LLM Provider 集成指南](canon/nyxid-llm-integration.md) - [Aevatar 项目架构(Maker 插件化基线)](canon/overview.md) @@ -35,6 +36,11 @@ Immutable records of architectural choices and their rationale. - [Channel Bot Callback Architecture — Lessons from Lark Integration](decisions/0009-channel-bot-callback-architecture.md) - [Channel Phase 0 Persistent Provider Validation Result](decisions/0010-channel-phase0-provider-validation.md) - [AGUI / SSE Projection Session Pipeline](decisions/0011-agui-sse-projection-session-pipeline.md) +- [Lark Nyx Relay Webhook Topology](decisions/0011-lark-nyx-relay-webhook.md) +- [Channel Runtime Credential Boundary](decisions/0012-channel-runtime-credential-boundary.md) +- [Studio Member-First Published Service Identity](decisions/0012-studio-member-first-published-service.md) +- [Unified Channel Inbound Backbone](decisions/0013-unified-channel-inbound-backbone.md) +- [Channel Interactive Reply Abstraction](decisions/0014-interactive-reply-abstraction.md) ## History @@ -52,6 +58,7 @@ Point-in-time design snapshots. Not authoritative — for context only. - [2026-04-09-scripting-authority-write-path-cqrs-closure](history/2026-04/2026-04-09-scripting-authority-write-path-cqrs-closure.md) - [2026-04-17-issue-204-agui-sse-projection-session-design](history/2026-04/2026-04-17-issue-204-agui-sse-projection-session-design.md) - [2026-04-17-nyxid-chat-registry-lifecycle](history/2026-04/2026-04-17-nyxid-chat-registry-lifecycle.md) +- [2026-04-27-registry-ownership-ports-design](history/2026-04/2026-04-27-registry-ownership-ports-design.md) - [claude-code-architecture-learnings](history/2026-04/claude-code-architecture-learnings.md) - [nyxid-chat-console-design](history/2026-04/nyxid-chat-console-design.md) diff --git a/docs/canon/gagent-registry-ownership.md b/docs/canon/gagent-registry-ownership.md new file mode 100644 index 000000000..10c7abac5 --- /dev/null +++ b/docs/canon/gagent-registry-ownership.md @@ -0,0 +1,94 @@ +--- +title: "GAgent Registry Ownership" +status: active +owner: architecture +--- + +# GAgent Registry Ownership + +This document is the durable architecture rule for issue 348. It defines how GAgent registry command, query, and command-admission semantics are separated. + +## Core Rule + +GAgent scope membership has one authoritative owner. + +For the current architecture, the authority is the per-scope `GAgentRegistryGAgent` state reached through the registry command/admission contract. A future implementation may replace this with an explicitly modeled distributed authoritative ownership index, but it must still be a single authority. + +The registry current-state read model is a query replica. It is useful for list/search/display flows, but it is eventually consistent and must not be used as command admission or security-sensitive target authorization. + +Target actors may own their capability-local business facts. They must not independently own the same `scope_id -> resource` membership fact that the registry owns. If a target actor stores a scope-shaped value for validation, diagnostics, or event payload completeness, that value is a derived mirror and cannot override or contradict registry ownership. + +## Ports + +`IGAgentActorRegistryCommandPort` owns registry lifecycle writes: + +- register actor membership for a scope +- unregister actor membership for a scope +- return only an honest dispatch/acceptance result unless a stronger receipt is explicitly modeled +- expose a committed or admission-visible receipt when a caller needs create-then-immediately-operate semantics + +`IGAgentActorRegistryQueryPort` owns registry listing reads: + +- read the registry current-state read model +- return a snapshot that exposes source version or observation timestamp +- never return an ownership verdict + +`IScopeResourceAdmissionPort` owns command-path target admission: + +- answer whether a typed target can be operated on under the requested scope +- return a typed result such as `Allowed`, `Denied`, `NotFound`, `ScopeMismatch`, or `Unavailable` +- never return registry groups, target state, readmodel documents, or arbitrary actor data + +The admission port is not a generic actor query/reply escape hatch. Implementations must be scoped by capability or resource kind and may only answer the admission verdict. + +## Required Flow + +Create: + +1. HTTP endpoint validates the caller scope and delegates to an application command surface. +2. Application command path creates or activates the target resource through the capability command path. +3. Registry command port submits authoritative scope membership to the registry ownership authority. +4. If the response or follow-up command path promises that the target is immediately operable, the application command surface must obtain a committed or admission-visible registry receipt through the registry ownership contract. An accepted-for-dispatch receipt is not enough. +5. The response must not promise immediate registry list visibility. + +List: + +1. HTTP endpoint validates the caller scope and delegates to an application query surface. +2. Registry query port reads the current-state read model. +3. The response is explicitly eventually consistent and includes freshness information. + +Operate on target: + +1. HTTP endpoint validates the caller scope and delegates to an application command/admission surface. +2. Application surface builds a typed `ScopeResourceTarget`. +3. Admission port checks the target against the authoritative registry ownership contract. +4. Only `Allowed` dispatches to the target capability. +5. `Denied` maps to `403`, `ScopeMismatch` maps to `403`, `NotFound` maps to `404`, and `Unavailable` maps to `503`. In the current per-scope registry implementation, a route-supplied id that is registered under another scope may be returned as `NotFound` because discovering cross-scope existence would require a second authority or a forbidden side read. Implementations may return `ScopeMismatch` only when that verdict comes from the registry ownership contract or an explicitly modeled distributed ownership index. + +Route-supplied actor ids must not create targets implicitly. Actor activation alone is not ownership evidence. + +Admission freshness is separate from list freshness. Admission may be stronger than the registry read model, but that strength must come from the registry ownership command/admission contract or an explicitly modeled distributed ownership index, not from a side read. + +## Forbidden Paths + +- using `GAgentRegistryCurrentStateDocument` or `ListActorsAsync` as command admission +- parsing actor id prefixes, suffixes, type names, or hashes as ownership facts +- query-time projection priming, replay, or readmodel refresh before admission +- direct reads of actor state, event store, snapshots, or state mirror payloads in application query or admission paths +- implementing admission by side-reading `GAgentRegistryGAgent` state, registry actor snapshots, event-store history, or state mirror payloads +- generic actor query/reply or request/reply RPC as a fallback read path +- implicit actor activation or get-or-create runtime lookup as ownership evidence +- process-local dictionaries, caches, or registries as scope membership fact state +- target-owned duplicate scope membership authority beside the registry ownership authority + +## Tests And Guards + +Changes in this area must cover: + +- create followed immediately by operate does not depend on registry projection visibility +- create followed immediately by operate has an explicit committed or admission-visible registration receipt, not just accepted dispatch +- list reads remain eventually consistent and expose freshness +- route-supplied targets from another scope return `ScopeMismatch` only when the authoritative ownership contract can distinguish it without side reads; otherwise they return `NotFound` or an equivalent non-leaking admission result +- missing route-supplied targets return `NotFound` without creating a target +- production code no longer depends on `IGAgentActorStore` +- architecture guards prevent new production references to `IGAgentActorStore` after removal diff --git a/docs/history/2026-04/2026-04-17-nyxid-chat-registry-lifecycle.md b/docs/history/2026-04/2026-04-17-nyxid-chat-registry-lifecycle.md index c5fd0a578..04df0474a 100644 --- a/docs/history/2026-04/2026-04-17-nyxid-chat-registry-lifecycle.md +++ b/docs/history/2026-04/2026-04-17-nyxid-chat-registry-lifecycle.md @@ -2,14 +2,16 @@ ## Decision -NyxId chat conversation lifecycle depends on `IGAgentActorStore` being available. +NyxId chat conversation lifecycle depends on the registry command/admission ports +defined by [GAgent Registry Ownership](../../canon/gagent-registry-ownership.md) +being available. The system does not support a degraded mode where a conversation can be created or continued without being persisted in the registry. ## Required behavior - `POST /api/scopes/{scopeId}/nyxid-chat/conversations` is fail-fast on registry persistence failure. -- Relay webhook conversation registration follows the same rule and must not silently continue when `IGAgentActorStore` fails. +- Relay webhook conversation registration follows the same rule and must not silently continue when registry command/admission fails. - Conversation deletion deletes chat history first and removes the registry entry second, so a history delete failure does not orphan history behind a missing registry entry. ## Rationale diff --git a/docs/superpowers/specs/2026-04-27-registry-ownership-ports-design.md b/docs/history/2026-04/2026-04-27-registry-ownership-ports-design.md similarity index 52% rename from docs/superpowers/specs/2026-04-27-registry-ownership-ports-design.md rename to docs/history/2026-04/2026-04-27-registry-ownership-ports-design.md index 53fe4f0f3..3ff6654a5 100644 --- a/docs/superpowers/specs/2026-04-27-registry-ownership-ports-design.md +++ b/docs/history/2026-04/2026-04-27-registry-ownership-ports-design.md @@ -1,7 +1,15 @@ +--- +title: GAgent Registry Ownership Ports Design +status: history +owner: architecture +--- + # GAgent Registry Ownership Ports Design Date: 2026-04-27 +> Non-authoritative design snapshot. The durable architecture rule for this topic lives in [GAgent Registry Ownership](../../canon/gagent-registry-ownership.md); this file records the implementation direction for issue 348. + ## Context Issue 348 started as a request to refine `IGAgentActorStore` into clearer registry command and query ports. The review discussion around StreamingProxy and NyxID exposed a deeper problem: the current abstraction combines three different meanings behind one `Store` interface. @@ -49,7 +57,9 @@ Task UnregisterActorAsync( CancellationToken ct = default); ``` -`GAgentActorRegistration` should carry `ScopeId`, `GAgentType`, and `ActorId` as typed fields. The port dispatches commands to the per-scope registry actor. Its synchronous return means the command was accepted for dispatch or failed before dispatch. It must not imply that the registry read model has observed the change. +`GAgentActorRegistration` should carry `ScopeId`, `GAgentType`, and `ActorId` as typed fields. The port dispatches commands to the per-scope registry actor. A bare `Task` return means the command was accepted for dispatch or failed before dispatch. It must not imply that the registry read model has observed the change, and it must not imply that the membership is committed or admission-visible. + +Create-then-immediately-operate flows need a stronger contract than bare accepted dispatch. The implementation should introduce an explicit registry command receipt, or an equivalent operation-specific method, that can distinguish at least `AcceptedForDispatch` from `Committed` or `AdmissionVisible`. Callers may only treat a newly created target as immediately operable after the committed/admission-visible stage has been reached through the registry ownership contract. ### Registry Query Port @@ -59,11 +69,11 @@ Expected operations: ```csharp Task ListActorsAsync( - string scopeId, + ScopeId scopeId, CancellationToken ct = default); ``` -The query port reads `GAgentRegistryCurrentStateDocument`. Its result should be named as a snapshot/read model result, not an ownership verdict. If the current read model exposes source version or refresh timestamp, include it in the snapshot; if not, the API documentation must still state that the result is eventually consistent. +The query port reads `GAgentRegistryCurrentStateDocument`. Its result should be named as a snapshot/read model result, not an ownership verdict. The snapshot must expose the source version or the read model observation timestamp so callers can be honest about freshness. If the current read model cannot provide that value, the implementation work must add one instead of hiding the gap in API prose. ### Scope Resource Admission Port @@ -92,26 +102,35 @@ The result should distinguish at least: - `Allowed` - `Denied` - `NotFound` +- `ScopeMismatch` - `Unavailable` -The port is deliberately not a registry read port. It must not call registry projection reads to hard-fail ownership. It must not infer ownership from actor id format. It should use a stable authoritative contract for the target capability. +`Denied` means the caller/action is not allowed even when the target belongs to the requested scope. `ScopeMismatch` means the target exists but is authoritatively bound to a different scope, and may only be returned when that verdict comes from the registry ownership contract or an explicitly modeled distributed ownership index. The current per-scope registry actor implementation may return `NotFound` for route-supplied ids registered under another scope, because discovering cross-scope existence would otherwise require a forbidden side read or a second authority. + +The port is deliberately not a registry read port. It must not call registry projection reads to hard-fail ownership. It must not infer ownership from actor id format. It should use the single authoritative scope-membership source defined by the registry ownership contract. ## Admission Semantics Admission is command-path authorization, not a list query. -For target operations such as StreamingProxy `chat`, `join`, `post message`, `message stream`, or NyxID `stream/approve/delete`, the endpoint should ask the admission port whether the target belongs to the requested scope before dispatching or opening a stream. +For target operations such as StreamingProxy `chat`, `join`, `post message`, `message stream`, `delete`, or NyxID `stream/approve/delete`, the HTTP endpoint should delegate to an application command/admission surface that asks the admission port whether the target belongs to the requested scope before dispatching or opening a stream. The endpoint remains an HTTP composition layer; it must not own the business admission workflow itself. Route-supplied actor ids must not trigger implicit target creation. If the client supplies `{actorId}` or `{roomId}`, the operation must resolve through admission first. Missing targets should return `404`; unauthorized targets should return `403`; indeterminate ownership should return `503`. -The admission implementation should be capability-aware. For a resource actor that owns its current scope binding, the long-term model is: +Admission must be non-creating. An implementation must not hide a `GetAsync(actorId) ?? CreateAsync(actorId)` path behind `AuthorizeTargetAsync`, and actor activation alone is not ownership evidence. If the underlying runtime only exposes get-or-create addressing, the admission port must use another authoritative non-creating ownership source before target dispatch: the narrow registry actor ownership/admission contract, or an explicitly modeled distributed authoritative ownership index. + +The registry ownership model has one authority: the per-scope registry actor, or a future explicitly modeled distributed authoritative ownership index. Target actors may carry capability-local initialization data, but they must not become a second authority for the same `scope_id -> resource` membership. If a target actor stores a scope-shaped value for local validation or diagnostics, that value is a derived mirror and cannot be used to override or contradict the registry ownership contract. + +The long-term model is: -1. Creation command initializes the target actor with a typed `scope_id` field in its authoritative state. -2. Target operations carry the requested scope as typed request context. -3. The actor or its application command port rejects operations whose requested scope does not match the actor-owned binding. -4. Public endpoints map the result to `403`, `404`, or `503` without consulting a lagging projection as a source of truth. +1. Creation establishes target resource state through the capability's command path. +2. Registration submits the `scope_id + resource kind + actor_id` membership to the registry ownership authority. +3. Target operations carry the requested scope as typed request context. +4. If the create response or immediate follow-up operation requires the target to be usable right away, the application command surface waits for an explicit committed/admission-visible registry receipt. +5. Application command/admission surfaces call the admission port before target dispatch. +6. Public endpoints map the result to `403`, `404`, or `503` without consulting a lagging projection as a source of truth. -If a capability cannot yet move ownership into target actor state, it may use a dedicated actor-owned ownership contract, such as the per-scope registry actor's authoritative state. That contract must be narrow to ownership/admission, must return only an admission result, and must not expose registry groups or arbitrary actor state. It must not be implemented by direct state reads, generic request/reply, query-time replay, query-time projection refresh, or fallback to the registry projection. It must not be a process-local dictionary. +The registry actor admission contract must be narrow to ownership/admission, must return only an admission result, and must not expose registry groups or arbitrary actor state. It must not be implemented by direct state reads, actor state side reads, snapshot reads, event-store reads, generic request/reply, query-time replay, query-time projection refresh, fallback to the registry projection, implicit actor activation, get-or-create runtime lookup, or process-local dictionaries. The admission port is not a generic actor query surface; implementations must be registered per capability/resource kind and may only answer the typed admission verdict. ## Migration Scope @@ -120,11 +139,12 @@ The implementation should remove production use of `IGAgentActorStore`. Production call sites to migrate: - `StreamingProxyEndpoints` - - Create/delete room: registry command port. + - Create room: application command path plus registry command port. + - Delete room: admission port first, then capability delete and registry command port rollback/unregister. - List rooms: registry query port. - Chat, message stream, post message, join, list participants: admission port before target access. - `NyxIdChatEndpoints` - - Create conversation: create or ensure the target `NyxIdChatGAgent`, bind its scope or otherwise establish authoritative ownership, then register through the registry command port. + - Create conversation: create or ensure the target `NyxIdChatGAgent` through the capability command path, then register authoritative scope membership through the registry command port. - Delete/restore conversation registration: registry command port plus the capability's cleanup/rollback contract. - List conversations: registry query port. - Stream/approve/delete target operations: admission port where the target actor is supplied by the route. @@ -149,10 +169,11 @@ Tests should use fakes for the new ports directly. ### Create 1. Endpoint validates caller scope with `AevatarScopeAccessGuard`. -2. Endpoint or application service creates/activates the target actor through the existing runtime/command path. -3. Target actor records its authoritative scope binding when this capability owns target scope in its actor state, or the capability establishes its documented authoritative ownership contract. -4. Registry command port registers the actor for list/query visibility only after the target ownership source exists. -5. Response returns an accepted/created result without promising immediate list visibility. +2. Endpoint delegates creation to an application command surface. +3. The application command path creates or activates the target resource through the capability command path. +4. Registry command port submits authoritative scope membership to the registry ownership authority. +5. If the response implies the target can be operated on immediately, the application command surface obtains a committed/admission-visible registry receipt. Accepted-for-dispatch alone is insufficient. +6. Response returns an accepted/created result without promising immediate list visibility. The exact order may vary by capability, but rollback must be explicit when one side succeeds and the other fails. @@ -167,16 +188,18 @@ List must not be reused as command admission. ### Operate On Target 1. Endpoint validates caller scope. -2. Endpoint builds a `ScopeResourceTarget`. -3. Admission port authorizes the target using authoritative resource ownership semantics. -4. On `Allowed`, the operation is dispatched to the target actor or service path. -5. On denial/not-found/unavailable, the endpoint maps to an honest HTTP response. +2. Endpoint delegates the operation to an application command/admission surface. +3. The application surface builds a typed `ScopeResourceTarget`. +4. Admission port authorizes the target using authoritative registry ownership semantics. +5. On `Allowed`, the operation is dispatched to the target actor or service path. +6. On denial/not-found/unavailable, the endpoint maps to an honest HTTP response. ## Error Mapping - Scope claim mismatch: `403` with `SCOPE_ACCESS_DENIED`. - Admission `Denied`: `403`. - Admission `NotFound`: `404`. +- Admission `ScopeMismatch`: `403`, with a distinct internal result for tests and diagnostics only when the authority can distinguish it without side reads. - Admission `Unavailable`: `503`, because the system cannot safely decide. - Registry command dispatch failure during create/delete: `503` or operation-specific failure response. - Registry query failure during list: preserve current user-facing behavior where appropriate, but log that the list read model is unavailable. @@ -186,8 +209,10 @@ List must not be reused as command admission. Add or update tests around these behaviors: - Create followed immediately by operate does not depend on registry projection visibility. +- Create followed immediately by operate uses an explicit committed/admission-visible registry receipt, not a bare accepted dispatch result. - Existing preferred actor reuse in draft-run does not use registry list projection as strong admission. -- Route-supplied actor ids from another scope are denied or not found through admission. +- Route-supplied actor ids from another scope return `ScopeMismatch` only when the authority can distinguish it without side reads; otherwise they return `NotFound` or an equivalent non-leaking typed admission result, not a generic list-query miss. +- Route-supplied missing actor ids return `NotFound` without creating a target actor. - Registry list endpoints still read from the query port and tolerate eventual consistency honestly. - Old `IGAgentActorStore` is not registered in production DI. - Static or architecture guard coverage prevents new production references to `IGAgentActorStore`. @@ -197,20 +222,20 @@ Because tests around eventual consistency can become flaky, prefer deterministic ## Documentation And Guards - Update issue 348 or linked docs to state that the registry list read model is not an ownership authority. -- Add a small architecture note describing command/query/admission separation for GAgent registry resources. +- Keep `docs/canon/gagent-registry-ownership.md` updated with the command/query/admission separation for GAgent registry resources. - Add or extend a guard that fails on production references to `IGAgentActorStore` after removal. -- If new target-owned ownership state is added, define it as typed protobuf fields/events rather than bags or metadata. +- If any derived target-side mirror is added, define it as typed protobuf fields/events rather than bags or metadata, and document that it is not the ownership authority. ## Risks -The largest design risk is making `IScopeResourceAdmissionPort` too generic and letting it become a hidden RPC/query escape hatch. The port should stay narrow: it only answers command admission for a typed target and operation. It should not return arbitrary actor state. +The largest design risk is making `IScopeResourceAdmissionPort` too generic and letting it become a hidden RPC/query escape hatch. The port should stay narrow: it only answers command admission for a typed target and operation. It must not return arbitrary actor state, registry groups, readmodel snapshots, or target details. The largest implementation risk is migration breadth. The old interface appears in endpoints, application services, integration tests, AI tests, and CLI adapter tests. The implementation plan should stage edits by call-site family, while still deleting the old production abstraction before the PR is complete. ## Implementation Decisions - Use `IGAgentActorRegistryCommandPort`, `IGAgentActorRegistryQueryPort`, and `IScopeResourceAdmissionPort` unless implementation reveals a direct naming conflict. -- Keep registry command methods returning `Task` in this work. A command receipt object can be introduced later with the broader command receipt model. -- StreamingProxy should use target-owned room scope binding in its typed state/event contract because `StreamingProxyGAgentState` and `GroupChatRoomInitializedEvent` are capability-owned and can carry `scope_id`. -- NyxID chat create must create or ensure the `NyxIdChatGAgent` target before registering it, because `stream` and `approve` must not implicitly create route-supplied actors. If extending `RoleGAgent` state for typed scope ownership is too broad for this work, use the narrow registry actor ownership contract as an interim authoritative admission source, not projection and not actor id parsing. +- Do not rely on bare `Task` registry command methods for immediate usability. If issue 348 keeps a simple accepted-dispatch method for lifecycle writes, it must also add an explicit committed/admission-visible receipt path for create-then-immediately-operate flows. +- StreamingProxy should keep room-specific facts in `StreamingProxyGAgentState`, but scope membership is authorized by the registry ownership authority. A target-side `scope_id` mirror, if added later, is derived and cannot replace the registry admission contract. +- NyxID chat create must create or ensure the `NyxIdChatGAgent` target before registering it, because `stream` and `approve` must not implicitly create route-supplied actors. Admission must use the narrow registry actor ownership contract as the authoritative source for this work, not projection, actor id parsing, target-owned duplicate ownership, or get-or-create target activation. - Draft-run preferred actor reuse should use the same admission port. It must not list registry projection groups to decide whether an existing actor may be reused. diff --git a/src/Aevatar.Mainnet.Host.Api/README.md b/src/Aevatar.Mainnet.Host.Api/README.md index bbd2b5bfb..80e0d7828 100644 --- a/src/Aevatar.Mainnet.Host.Api/README.md +++ b/src/Aevatar.Mainnet.Host.Api/README.md @@ -72,6 +72,25 @@ bash src/Aevatar.Mainnet.Host.Api/boot.sh - 这是最一致的单机开发模式:read/write 都是本地临时态。 - 后端重启后,actor state 与 projection/read model 会一起清空,不会出现“service definition 还在,但 services/read model 已空”的错位。 +- 如需本地不带 token 调试 scope / studio / playground API,必须使用 `ASPNETCORE_ENVIRONMENT=Development` 并显式设置 `Aevatar__Authentication__Enabled=false`。该关闭开关只在 `Development` 环境生效;`PersistentLocal`、`Distributed` 等非 Development 环境会强制保持认证开启。 + +最小无认证冒烟启动示例: + +```bash +ASPNETCORE_ENVIRONMENT=Development \ +Aevatar__Authentication__Enabled=false \ +GAgentService__Demo__Enabled=false \ +Projection__Document__Providers__Elasticsearch__Enabled=false \ +Projection__Document__Providers__InMemory__Enabled=true \ +Projection__Graph__Providers__Neo4j__Enabled=false \ +Projection__Graph__Providers__InMemory__Enabled=true \ +Projection__Policies__Environment=Development \ +Projection__Policies__DenyInMemoryDocumentReadStore=false \ +Projection__Policies__DenyInMemoryGraphFactStore=false \ +ActorRuntime__OrleansStreamBackend=InMemory \ +ActorRuntime__OrleansPersistenceBackend=InMemory \ +dotnet run --project src/Aevatar.Mainnet.Host.Api --no-build +``` 如果只是想避免本地 scope workflow / actor state 因后端重启而完全丢失,而当前机器又没有 Kafka / Elasticsearch / Neo4j,可以使用仓库内置的 `PersistentLocal` 环境: diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/GAgentRegistryPorts.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/GAgentRegistryPorts.cs new file mode 100644 index 000000000..d976178f1 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/GAgentRegistryPorts.cs @@ -0,0 +1,107 @@ +namespace Aevatar.Studio.Application.Studio.Abstractions; + +public interface IGAgentActorRegistryCommandPort +{ + Task RegisterActorAsync( + GAgentActorRegistration registration, + CancellationToken cancellationToken = default); + + Task UnregisterActorAsync( + GAgentActorRegistration registration, + CancellationToken cancellationToken = default); +} + +public interface IGAgentActorRegistryQueryPort +{ + Task ListActorsAsync( + string scopeId, + CancellationToken cancellationToken = default); +} + +public interface IScopeResourceAdmissionPort +{ + Task AuthorizeTargetAsync( + ScopeResourceTarget target, + CancellationToken cancellationToken = default); +} + +public sealed record GAgentActorRegistration( + string ScopeId, + string GAgentType, + string ActorId); + +public sealed record GAgentActorRegistryCommandReceipt( + GAgentActorRegistration Registration, + GAgentActorRegistryCommandStage Stage) +{ + public bool IsAdmissionVisible => Stage == GAgentActorRegistryCommandStage.AdmissionVisible; +} + +public enum GAgentActorRegistryCommandStage +{ + AcceptedForDispatch = 0, + AdmissionVisible = 1, +} + +public sealed record GAgentActorRegistrySnapshot( + string ScopeId, + IReadOnlyList Groups, + long StateVersion, + DateTimeOffset UpdatedAt, + DateTimeOffset ObservedAt); + +public sealed record GAgentActorGroup(string GAgentType, IReadOnlyList ActorIds); + +public sealed record ScopeResourceTarget( + string ScopeId, + ScopeResourceKind ResourceKind, + string GAgentType, + string ActorId, + ScopeResourceOperation Operation); + +public enum ScopeResourceKind +{ + GAgentActor = 0, +} + +public enum ScopeResourceOperation +{ + Use = 0, + Delete = 1, + Chat = 2, + Stream = 3, + Approve = 4, + Join = 5, + ListParticipants = 6, + DraftRunReuse = 7, +} + +public sealed record ScopeResourceAdmissionResult( + ScopeResourceAdmissionStatus Status) +{ + public bool IsAllowed => Status == ScopeResourceAdmissionStatus.Allowed; + + public static ScopeResourceAdmissionResult Allowed() => + new(ScopeResourceAdmissionStatus.Allowed); + + public static ScopeResourceAdmissionResult Denied() => + new(ScopeResourceAdmissionStatus.Denied); + + public static ScopeResourceAdmissionResult NotFound() => + new(ScopeResourceAdmissionStatus.NotFound); + + public static ScopeResourceAdmissionResult ScopeMismatch() => + new(ScopeResourceAdmissionStatus.ScopeMismatch); + + public static ScopeResourceAdmissionResult Unavailable() => + new(ScopeResourceAdmissionStatus.Unavailable); +} + +public enum ScopeResourceAdmissionStatus +{ + Allowed = 0, + Denied = 1, + NotFound = 2, + ScopeMismatch = 3, + Unavailable = 4, +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IGAgentActorStore.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IGAgentActorStore.cs deleted file mode 100644 index 94a676147..000000000 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IGAgentActorStore.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Aevatar.Studio.Application.Studio.Abstractions; - -public interface IGAgentActorStore -{ - Task> GetAsync(CancellationToken cancellationToken = default); - Task> GetAsync(string scopeId, CancellationToken cancellationToken = default); - Task AddActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default); - Task AddActorAsync(string scopeId, string gagentType, string actorId, CancellationToken cancellationToken = default); - Task RemoveActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default); - Task RemoveActorAsync(string scopeId, string gagentType, string actorId, CancellationToken cancellationToken = default); -} - -public sealed record GAgentActorGroup(string GAgentType, IReadOnlyList ActorIds); diff --git a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs index c866cec2b..06830f743 100644 --- a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs @@ -26,7 +26,7 @@ namespace Aevatar.Studio.Hosting; /// InMemory is enabled based on Projection:Document:Providers:* /// configuration. Required by the actor-backed stores /// (IRoleCatalogStore, IConnectorCatalogStore, -/// IChatHistoryStore, IGAgentActorStore, +/// IChatHistoryStore, IGAgentActorRegistryQueryPort, /// IUserMemoryStore, IStreamingProxyParticipantStore) that read /// from these documents via IProjectionDocumentReader. /// diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs deleted file mode 100644 index 00c8a8aa7..000000000 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentActorStore.cs +++ /dev/null @@ -1,130 +0,0 @@ -using Aevatar.CQRS.Projection.Stores.Abstractions; -using Aevatar.Foundation.Abstractions; -using Aevatar.GAgents.Registry; -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 from the projection document store (CQRS read model). -/// Writes send commands to the Write GAgent. -/// -internal sealed class ActorBackedGAgentActorStore : IGAgentActorStore -{ - private const string WriteActorIdPrefix = "gagent-registry-"; - - private readonly IStudioActorBootstrap _bootstrap; - private readonly IActorDispatchPort _dispatchPort; - private readonly IAppScopeResolver _scopeResolver; - private readonly IProjectionDocumentReader _documentReader; - private readonly ILogger _logger; - - public ActorBackedGAgentActorStore( - IStudioActorBootstrap bootstrap, - IActorDispatchPort dispatchPort, - IAppScopeResolver scopeResolver, - IProjectionDocumentReader documentReader, - ILogger logger) - { - _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); - _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort)); - _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 actorId = ResolveWriteActorId(); - return await GetByActorIdAsync(actorId, cancellationToken); - } - - public async Task> GetAsync( - string scopeId, - CancellationToken cancellationToken = default) - { - var actorId = ResolveWriteActorId(scopeId); - return await GetByActorIdAsync(actorId, cancellationToken); - } - - private async Task> GetByActorIdAsync( - string actorId, - CancellationToken cancellationToken) - { - 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, - g.ActorIds.ToList().AsReadOnly())) - .ToList() - .AsReadOnly(); - } - - public Task AddActorAsync( - string gagentType, string actorId, - CancellationToken cancellationToken = default) => - AddActorAsync( - _scopeResolver.ResolveScopeIdOrDefault(), - gagentType, - actorId, - cancellationToken); - - public async Task AddActorAsync( - string scopeId, - string gagentType, - string actorId, - CancellationToken cancellationToken = default) - { - var actor = await EnsureWriteActorAsync(scopeId, cancellationToken); - await ActorCommandDispatcher.SendAsync(_dispatchPort, actor, new ActorRegisteredEvent - { - GagentType = gagentType, - ActorId = actorId, - }, cancellationToken); - } - - public Task RemoveActorAsync( - string gagentType, string actorId, - CancellationToken cancellationToken = default) => - RemoveActorAsync( - _scopeResolver.ResolveScopeIdOrDefault(), - gagentType, - actorId, - cancellationToken); - - public async Task RemoveActorAsync( - string scopeId, - string gagentType, - string actorId, - CancellationToken cancellationToken = default) - { - var actor = await EnsureWriteActorAsync(scopeId, cancellationToken); - await ActorCommandDispatcher.SendAsync(_dispatchPort, actor, new ActorUnregisteredEvent - { - GagentType = gagentType, - ActorId = actorId, - }, cancellationToken); - } - - // ── Actor resolution ── - - private string ResolveWriteActorId(string? scopeId = null) => - WriteActorIdPrefix + NormalizeScopeId(scopeId); - - private Task EnsureWriteActorAsync(string? scopeId, CancellationToken ct) => - _bootstrap.EnsureAsync(ResolveWriteActorId(scopeId), ct); - - private string NormalizeScopeId(string? scopeId) => - string.IsNullOrWhiteSpace(scopeId) - ? _scopeResolver.ResolveScopeIdOrDefault() - : scopeId.Trim(); -} diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs new file mode 100644 index 000000000..674986cd1 --- /dev/null +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs @@ -0,0 +1,203 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.Registry; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Projection.ReadModels; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Studio.Infrastructure.ActorBacked; + +internal sealed class ActorBackedGAgentRegistryPorts : + IGAgentActorRegistryCommandPort, + IGAgentActorRegistryQueryPort, + IScopeResourceAdmissionPort +{ + private const string WriteActorIdPrefix = "gagent-registry-"; + + private readonly IStudioActorBootstrap _bootstrap; + private readonly IActorDispatchPort _dispatchPort; + private readonly IAppScopeResolver _scopeResolver; + private readonly IProjectionDocumentReader _documentReader; + private readonly ILogger _logger; + + public ActorBackedGAgentRegistryPorts( + IStudioActorBootstrap bootstrap, + IActorDispatchPort dispatchPort, + IAppScopeResolver scopeResolver, + IProjectionDocumentReader documentReader, + ILogger logger) + { + _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); + _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort)); + _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task RegisterActorAsync( + GAgentActorRegistration registration, + CancellationToken cancellationToken = default) + { + var normalized = NormalizeRegistration(registration); + var actor = await EnsureWriteActorAsync(normalized.ScopeId, cancellationToken); + await ActorCommandDispatcher.SendAsync(_dispatchPort, actor, new ActorRegisteredEvent + { + GagentType = normalized.GAgentType, + ActorId = normalized.ActorId, + }, cancellationToken); + return new GAgentActorRegistryCommandReceipt( + normalized, + GAgentActorRegistryCommandStage.AdmissionVisible); + } + + public async Task UnregisterActorAsync( + GAgentActorRegistration registration, + CancellationToken cancellationToken = default) + { + var normalized = NormalizeRegistration(registration); + var actor = await EnsureWriteActorAsync(normalized.ScopeId, cancellationToken); + await ActorCommandDispatcher.SendAsync(_dispatchPort, actor, new ActorUnregisteredEvent + { + GagentType = normalized.GAgentType, + ActorId = normalized.ActorId, + }, cancellationToken); + return new GAgentActorRegistryCommandReceipt( + normalized, + GAgentActorRegistryCommandStage.AdmissionVisible); + } + + public async Task ListActorsAsync( + string scopeId, + CancellationToken cancellationToken = default) + { + var normalizedScopeId = NormalizeScopeId(scopeId); + var snapshot = await ReadSnapshotAsync(normalizedScopeId, cancellationToken); + return new GAgentActorRegistrySnapshot( + normalizedScopeId, + snapshot.Groups, + snapshot.StateVersion, + snapshot.UpdatedAt, + DateTimeOffset.UtcNow); + } + + public async Task AuthorizeTargetAsync( + ScopeResourceTarget target, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(target); + + if (target.ResourceKind != ScopeResourceKind.GAgentActor) + return ScopeResourceAdmissionResult.Denied(); + + var normalized = target with + { + ScopeId = NormalizeScopeId(target.ScopeId), + GAgentType = NormalizeRequired(target.GAgentType, nameof(target.GAgentType)), + ActorId = NormalizeRequired(target.ActorId, nameof(target.ActorId)), + }; + try + { + var actor = await EnsureWriteActorAsync(normalized.ScopeId, cancellationToken); + await ActorCommandDispatcher.SendAsync(_dispatchPort, actor, new ScopeResourceAdmissionRequested + { + GagentType = normalized.GAgentType, + ActorId = normalized.ActorId, + Operation = ToRegistryOperation(normalized.Operation), + }, cancellationToken); + return ScopeResourceAdmissionResult.Allowed(); + } + catch (GAgentRegistryAdmissionNotFoundException) + { + return ScopeResourceAdmissionResult.NotFound(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning( + ex, + "Registry admission was unavailable for scope {ScopeId}, actor {ActorId}", + normalized.ScopeId, + normalized.ActorId); + return ScopeResourceAdmissionResult.Unavailable(); + } + } + + private async Task ReadSnapshotAsync( + string scopeId, + CancellationToken cancellationToken) + { + var actorId = ResolveWriteActorId(scopeId); + try + { + var document = await _documentReader.GetAsync(actorId, cancellationToken); + if (document?.StateRoot == null || + !document.StateRoot.Is(GAgentRegistryState.Descriptor)) + return new RegistryReadModelSnapshot([], 0, DateTimeOffset.MinValue); + + var state = document.StateRoot.Unpack(); + var groups = state.Groups + .Select(g => new GAgentActorGroup( + g.GagentType, + g.ActorIds.ToList().AsReadOnly())) + .ToList() + .AsReadOnly(); + return new RegistryReadModelSnapshot( + groups, + document.StateVersion, + document.UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to read registry snapshot for scope {ScopeId}", scopeId); + throw; + } + } + + private string ResolveWriteActorId(string? scopeId = null) => + WriteActorIdPrefix + NormalizeScopeId(scopeId); + + private Task EnsureWriteActorAsync(string? scopeId, CancellationToken ct) => + _bootstrap.EnsureAsync(ResolveWriteActorId(scopeId), ct); + + private string NormalizeScopeId(string? scopeId) => + string.IsNullOrWhiteSpace(scopeId) + ? _scopeResolver.ResolveScopeIdOrDefault() + : scopeId.Trim(); + + private GAgentActorRegistration NormalizeRegistration(GAgentActorRegistration registration) + { + ArgumentNullException.ThrowIfNull(registration); + return registration with + { + ScopeId = NormalizeScopeId(registration.ScopeId), + GAgentType = NormalizeRequired(registration.GAgentType, nameof(registration.GAgentType)), + ActorId = NormalizeRequired(registration.ActorId, nameof(registration.ActorId)), + }; + } + + private static string NormalizeRequired(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException($"{parameterName} is required.", parameterName); + + return value.Trim(); + } + + private static GAgentRegistryOperation ToRegistryOperation(ScopeResourceOperation operation) => + operation switch + { + ScopeResourceOperation.Use => GAgentRegistryOperation.Use, + ScopeResourceOperation.Delete => GAgentRegistryOperation.Delete, + ScopeResourceOperation.Chat => GAgentRegistryOperation.Chat, + ScopeResourceOperation.Stream => GAgentRegistryOperation.Stream, + ScopeResourceOperation.Approve => GAgentRegistryOperation.Approve, + ScopeResourceOperation.Join => GAgentRegistryOperation.Join, + ScopeResourceOperation.ListParticipants => GAgentRegistryOperation.ListParticipants, + ScopeResourceOperation.DraftRunReuse => GAgentRegistryOperation.DraftRunReuse, + _ => GAgentRegistryOperation.Unknown, + }; + + private sealed record RegistryReadModelSnapshot( + IReadOnlyList Groups, + long StateVersion, + DateTimeOffset UpdatedAt); +} diff --git a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 7b09014fd..9ed221d49 100644 --- a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -32,7 +32,10 @@ public static IServiceCollection AddStudioInfrastructure( // chrono-storage blob client retained for media file uploads (ExplorerEndpoints) services.AddSingleton(); // ── Actor-backed stores (replacing ChronoStorage* implementations) ── - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/platform/Aevatar.GAgentService.Application/ScopeGAgents/GAgentDraftRunActorPreparationService.cs b/src/platform/Aevatar.GAgentService.Application/ScopeGAgents/GAgentDraftRunActorPreparationService.cs index e25e47b8c..5787c5762 100644 --- a/src/platform/Aevatar.GAgentService.Application/ScopeGAgents/GAgentDraftRunActorPreparationService.cs +++ b/src/platform/Aevatar.GAgentService.Application/ScopeGAgents/GAgentDraftRunActorPreparationService.cs @@ -9,16 +9,19 @@ namespace Aevatar.GAgentService.Application.ScopeGAgents; internal sealed class GAgentDraftRunActorPreparationService : IGAgentDraftRunActorPreparationPort { private readonly IActorRuntime _actorRuntime; - private readonly IGAgentActorStore _actorStore; + private readonly IGAgentActorRegistryCommandPort _registryCommandPort; + private readonly IScopeResourceAdmissionPort _admissionPort; private readonly ILogger? _logger; public GAgentDraftRunActorPreparationService( IActorRuntime actorRuntime, - IGAgentActorStore actorStore, + IGAgentActorRegistryCommandPort registryCommandPort, + IScopeResourceAdmissionPort admissionPort, ILogger? logger = null) { _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); - _actorStore = actorStore ?? throw new ArgumentNullException(nameof(actorStore)); + _registryCommandPort = registryCommandPort ?? throw new ArgumentNullException(nameof(registryCommandPort)); + _admissionPort = admissionPort ?? throw new ArgumentNullException(nameof(admissionPort)); _logger = logger; } @@ -40,7 +43,15 @@ public async Task PrepareAsync( var existingActor = await _actorRuntime.GetAsync(actorId); if (existingActor is not null) { - if (!await IsRegisteredInScopeAsync(scopeId, actorTypeName, actorId, ct)) + var admission = await _admissionPort.AuthorizeTargetAsync( + new ScopeResourceTarget( + scopeId, + ScopeResourceKind.GAgentActor, + actorTypeName, + actorId, + ScopeResourceOperation.DraftRunReuse), + ct); + if (!admission.IsAllowed) return GAgentDraftRunPreparationResult.Failure(GAgentDraftRunStartError.ActorTypeMismatch); return GAgentDraftRunPreparationResult.Success( @@ -51,7 +62,28 @@ public async Task PrepareAsync( RequiresRollbackOnFailure: false)); } - await _actorStore.AddActorAsync(scopeId, actorTypeName, actorId, ct); + var registrationAttempted = false; + IActor? createdActor = null; + try + { + createdActor = await _actorRuntime.CreateAsync(actorType, actorId, ct); + registrationAttempted = true; + var receipt = await _registryCommandPort.RegisterActorAsync( + new GAgentActorRegistration(scopeId, actorTypeName, actorId), + ct); + if (!receipt.IsAdmissionVisible) + { + await RollbackCreatedActorAsync(scopeId, actorTypeName, actorId, registrationAttempted, CancellationToken.None); + return GAgentDraftRunPreparationResult.Failure(GAgentDraftRunStartError.ActorTypeMismatch); + } + } + catch + { + if (createdActor is not null) + await RollbackCreatedActorAsync(scopeId, actorTypeName, actorId, registrationAttempted, CancellationToken.None); + throw; + } + return GAgentDraftRunPreparationResult.Success( new GAgentDraftRunPreparedActor( scopeId, @@ -80,10 +112,11 @@ public async Task RollbackAsync( try { - await _actorStore.RemoveActorAsync( - preparedActor.ScopeId, - preparedActor.ActorTypeName, - preparedActor.ActorId, + await _registryCommandPort.UnregisterActorAsync( + new GAgentActorRegistration( + preparedActor.ScopeId, + preparedActor.ActorTypeName, + preparedActor.ActorId), ct); } catch (Exception ex) @@ -92,22 +125,35 @@ await _actorStore.RemoveActorAsync( } } - private async Task IsRegisteredInScopeAsync( + private async Task RollbackCreatedActorAsync( string scopeId, string actorTypeName, string actorId, + bool unregisterFromRegistry, CancellationToken ct) { - var groups = await _actorStore.GetAsync(scopeId, ct); - foreach (var group in groups) + try { - if (!string.Equals(group.GAgentType, actorTypeName, StringComparison.Ordinal)) - continue; - - if (group.ActorIds.Any(candidate => string.Equals(candidate, actorId, StringComparison.Ordinal))) - return true; + await _actorRuntime.DestroyAsync(actorId, ct); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to destroy draft-run actor {ActorId} during rollback", actorId); } - return false; + if (!unregisterFromRegistry) + return; + + try + { + await _registryCommandPort.UnregisterActorAsync( + new GAgentActorRegistration(scopeId, actorTypeName, actorId), + ct); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to remove draft-run actor {ActorId} from registry during rollback", actorId); + } } + } diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs index a4390b49c..9e2748858 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs @@ -329,6 +329,7 @@ private static async Task HandleDraftRunAsync( catch (OperationCanceledException) { // Client disconnected. + await RollbackPreparedActorIfPendingAsync(actorPreparationPort, preparedActor, session.ResponseStarted); } catch (Exception ex) { @@ -520,12 +521,12 @@ await WriteJsonErrorAsync( private static Google.Protobuf.WellKnownTypes.Struct BuildToolApprovalStruct(Any payload) => ScopeGAgentAguiEventMapper.BuildToolApprovalStruct(payload); - // ─── Actor CRUD (chrono-storage) ─── + // ─── GAgent Registry ─── private static async Task HandleListActorsAsync( HttpContext http, string scopeId, - [FromServices] IGAgentActorStore actorStore, + [FromServices] IGAgentActorRegistryQueryPort registryQueryPort, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { @@ -534,19 +535,26 @@ private static async Task HandleListActorsAsync( try { - var groups = await actorStore.GetAsync(scopeId, ct); - return Results.Ok(groups); + var snapshot = await registryQueryPort.ListActorsAsync(scopeId, ct); + return Results.Ok(new + { + snapshot.ScopeId, + snapshot.StateVersion, + snapshot.UpdatedAt, + snapshot.ObservedAt, + snapshot.Groups, + }); } catch (InvalidOperationException ex) { - return Results.BadRequest(new { code = "GAGENT_ACTOR_STORE_ERROR", message = ex.Message }); + return Results.BadRequest(new { code = "GAGENT_ACTOR_REGISTRY_ERROR", message = ex.Message }); } catch (Exception ex) { loggerFactory.CreateLogger("Aevatar.GAgentService.Hosting.ScopeGAgentEndpoints") - .LogWarning(ex, "Failed to list GAgent actors from storage"); + .LogWarning(ex, "Failed to list GAgent actors from registry read model"); return Results.Json( - new { code = "GAGENT_ACTOR_STORE_ERROR", message = "Failed to list GAgent actors from storage." }, + new { code = "GAGENT_ACTOR_REGISTRY_ERROR", message = "Failed to list GAgent actors from registry read model." }, statusCode: StatusCodes.Status500InternalServerError); } } @@ -555,43 +563,25 @@ private static async Task HandleAddActorAsync( HttpContext http, string scopeId, AddGAgentActorHttpRequest request, - [FromServices] IGAgentActorStore actorStore, + [FromServices] IGAgentActorRegistryCommandPort registryCommandPort, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) return denied; - try - { - if (string.IsNullOrWhiteSpace(request.GAgentType) || string.IsNullOrWhiteSpace(request.ActorId)) - return Results.BadRequest(new { code = "INVALID_REQUEST", message = "gagentType and actorId are required." }); + _ = request; + _ = registryCommandPort; + _ = loggerFactory; + _ = ct; - var normalizedTypeName = request.GAgentType.Trim(); - if (ScopeGAgentActorTypeResolver.Resolve(normalizedTypeName) is null) + return Results.Json( + new { - return Results.BadRequest(new - { - code = "UNKNOWN_GAGENT_TYPE", - message = $"Unknown GAgent type '{normalizedTypeName}'.", - }); - } - - await actorStore.AddActorAsync(scopeId, normalizedTypeName, request.ActorId.Trim(), ct); - return Results.Ok(); - } - catch (InvalidOperationException ex) - { - return Results.BadRequest(new { code = "GAGENT_ACTOR_STORE_ERROR", message = ex.Message }); - } - catch (Exception ex) - { - loggerFactory.CreateLogger("Aevatar.GAgentService.Hosting.ScopeGAgentEndpoints") - .LogWarning(ex, "Failed to persist GAgent actor to storage"); - return Results.Json( - new { code = "GAGENT_ACTOR_STORE_ERROR", message = "Failed to persist GAgent actor to storage." }, - statusCode: StatusCodes.Status500InternalServerError); - } + code = "DIRECT_GAGENT_ACTOR_REGISTRATION_UNSUPPORTED", + message = "Direct GAgent actor registry registration is not supported. Create the target resource through its capability command endpoint.", + }, + statusCode: StatusCodes.Status405MethodNotAllowed); } private static async Task HandleRemoveActorAsync( @@ -599,7 +589,8 @@ private static async Task HandleRemoveActorAsync( string scopeId, string actorId, [FromQuery] string? gagentType, - [FromServices] IGAgentActorStore actorStore, + [FromServices] IGAgentActorRegistryCommandPort registryCommandPort, + [FromServices] IScopeResourceAdmissionPort admissionPort, [FromServices] ILoggerFactory loggerFactory, CancellationToken ct) { @@ -611,19 +602,46 @@ private static async Task HandleRemoveActorAsync( if (string.IsNullOrWhiteSpace(gagentType)) return Results.BadRequest(new { code = "INVALID_REQUEST", message = "gagentType query parameter is required." }); - await actorStore.RemoveActorAsync(scopeId, gagentType.Trim(), actorId.Trim(), ct); + var registration = new GAgentActorRegistration(scopeId, gagentType.Trim(), actorId.Trim()); + var admission = await admissionPort.AuthorizeTargetAsync( + new ScopeResourceTarget( + registration.ScopeId, + ScopeResourceKind.GAgentActor, + registration.GAgentType, + registration.ActorId, + ScopeResourceOperation.Delete), + ct); + if (!admission.IsAllowed) + { + return admission.Status switch + { + ScopeResourceAdmissionStatus.NotFound => Results.NotFound(new + { + code = "GAGENT_ACTOR_NOT_FOUND", + message = "GAgent actor is not registered in this scope.", + }), + ScopeResourceAdmissionStatus.Denied or ScopeResourceAdmissionStatus.ScopeMismatch => Results.Json( + new { code = "SCOPE_FORBIDDEN", message = "Scope access denied." }, + statusCode: StatusCodes.Status403Forbidden), + _ => Results.Json( + new { code = "GAGENT_ACTOR_ADMISSION_UNAVAILABLE", message = "GAgent actor ownership could not be verified." }, + statusCode: StatusCodes.Status503ServiceUnavailable), + }; + } + + await registryCommandPort.UnregisterActorAsync(registration, ct); return Results.Ok(); } catch (InvalidOperationException ex) { - return Results.BadRequest(new { code = "GAGENT_ACTOR_STORE_ERROR", message = ex.Message }); + return Results.BadRequest(new { code = "GAGENT_ACTOR_REGISTRY_ERROR", message = ex.Message }); } catch (Exception ex) { loggerFactory.CreateLogger("Aevatar.GAgentService.Hosting.ScopeGAgentEndpoints") - .LogWarning(ex, "Failed to remove GAgent actor from storage"); + .LogWarning(ex, "Failed to unregister GAgent actor from registry"); return Results.Json( - new { code = "GAGENT_ACTOR_STORE_ERROR", message = "Failed to remove GAgent actor from storage." }, + new { code = "GAGENT_ACTOR_REGISTRY_ERROR", message = "Failed to unregister GAgent actor from registry." }, statusCode: StatusCodes.Status500InternalServerError); } } diff --git a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs index b5bef078b..600e5658d 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs @@ -135,11 +135,13 @@ public async Task NyxRelayDiagRoute_ShouldProxyGatewayResponse_WhenTokenIsProvid public async Task HandleCreateConversationAsync_ShouldReturnConversationReceipt() { var actorStore = new StubGAgentActorStore(); + var runtime = new StubActorRuntime(); var result = await InvokeResultAsync( "HandleCreateConversationAsync", new DefaultHttpContext(), "scope-a", actorStore, + runtime, CancellationToken.None); var response = await ExecuteResultAsync(result); @@ -153,6 +155,9 @@ public async Task HandleCreateConversationAsync_ShouldReturnConversationReceipt( entry.ScopeId == "scope-a" && entry.GAgentType == NyxIdChatServiceDefaults.GAgentTypeName && entry.ActorId == createdActorId); + runtime.CreateCalls.Should().ContainSingle(call => + call.Type == typeof(NyxIdChatGAgent) && + call.Id == createdActorId); } [Fact] @@ -160,7 +165,7 @@ public async Task HandleCreateConversationAsync_ShouldBubbleFailure_WhenActorReg { var actorStore = new StubGAgentActorStore { - AddActorException = new InvalidOperationException("actor store unavailable"), + AddActorException = new InvalidOperationException("registry unavailable"), }; var act = async () => await InvokeResultAsync( @@ -168,10 +173,11 @@ public async Task HandleCreateConversationAsync_ShouldBubbleFailure_WhenActorReg new DefaultHttpContext(), "scope-a", actorStore, + new StubActorRuntime(), CancellationToken.None); var assertion = await act.Should().ThrowAsync(); - assertion.Which.Message.Should().Be("actor store unavailable"); + assertion.Which.Message.Should().Be("registry unavailable"); } [Fact] @@ -195,9 +201,11 @@ public async Task HandleListConversationsAsync_ShouldReturnRegisteredActors() var response = await ExecuteResultAsync(result); 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"); - doc.RootElement[0].TryGetProperty("createdAt", out _).Should().BeFalse(); + var conversations = doc.RootElement.GetProperty("conversations"); + doc.RootElement.GetProperty("stateVersion").GetInt64().Should().Be(1); + conversations.GetArrayLength().Should().Be(1); + conversations[0].GetProperty("actorId").GetString().Should().Be("actor-1"); + conversations[0].TryGetProperty("createdAt", out _).Should().BeFalse(); actorStore.LastRequestedScopeId.Should().Be("scope-a"); } @@ -212,6 +220,7 @@ public async Task HandleDeleteConversationAsync_ShouldReturnOk_AndRemoveActor() "scope-a", "actor-1", actorStore, + actorStore, historyStore, CancellationToken.None); @@ -231,7 +240,7 @@ public async Task HandleDeleteConversationAsync_ShouldBubbleFailure_WhenActorRem { var actorStore = new StubGAgentActorStore { - RemoveActorException = new InvalidOperationException("actor store unavailable"), + RemoveActorException = new InvalidOperationException("registry unavailable"), }; var historyStore = new StubChatHistoryStore(); @@ -241,11 +250,12 @@ public async Task HandleDeleteConversationAsync_ShouldBubbleFailure_WhenActorRem "scope-a", "actor-1", actorStore, + actorStore, historyStore, CancellationToken.None); var assertion = await act.Should().ThrowAsync(); - assertion.Which.Message.Should().Be("actor store unavailable"); + assertion.Which.Message.Should().Be("registry unavailable"); historyStore.DeletedConversations.Should().BeEmpty(); } @@ -264,6 +274,7 @@ public async Task HandleDeleteConversationAsync_ShouldRestoreActorRegistration_W "scope-a", "actor-1", actorStore, + actorStore, historyStore, CancellationToken.None); @@ -294,6 +305,7 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello"), runtime, + new StubGAgentActorStore(), subscriptions, NullLoggerFactory.Instance, CancellationToken.None); @@ -314,6 +326,7 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdChatStreamRequest(null), runtime, + new StubGAgentActorStore(), new StubSubscriptionProvider(), NullLoggerFactory.Instance, CancellationToken.None); @@ -334,6 +347,7 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdApprovalRequest("req"), runtime, + new StubGAgentActorStore(), new StubSubscriptionProvider(), NullLoggerFactory.Instance, CancellationToken.None); @@ -354,6 +368,7 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdApprovalRequest(null), runtime, + new StubGAgentActorStore(), new StubSubscriptionProvider(), NullLoggerFactory.Instance, CancellationToken.None); @@ -376,6 +391,7 @@ public async Task HandleStreamMessageAsync_ShouldDispatchChatRequest_AndWriteRun context.Response.Body = new MemoryStream(); var runtime = new StubActorRuntime(); + runtime.Actors["actor-1"] = new StubActor("actor-1"); var subscriptions = new StubSubscriptionProvider { Messages = @@ -393,12 +409,12 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello there"), runtime, + new StubGAgentActorStore(), 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"); @@ -433,6 +449,7 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello"), new ThrowingActorRuntime(new InvalidOperationException("runtime failed")), + new StubGAgentActorStore(), new StubSubscriptionProvider(), NullLoggerFactory.Instance, CancellationToken.None); @@ -457,6 +474,7 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdChatStreamRequest("hello"), runtime, + new StubGAgentActorStore(), new ThrowingSubscriptionProvider(new InvalidOperationException("subscription failed")), NullLoggerFactory.Instance, CancellationToken.None); @@ -494,6 +512,7 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdApprovalRequest("req-1", Approved: false, Reason: "deny", SessionId: "session-1"), runtime, + new StubGAgentActorStore(), subscriptions, NullLoggerFactory.Instance, CancellationToken.None); @@ -525,6 +544,7 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdApprovalRequest("req-1"), new ThrowingActorRuntime(new InvalidOperationException("runtime failed")), + new StubGAgentActorStore(), new StubSubscriptionProvider(), NullLoggerFactory.Instance, CancellationToken.None); @@ -549,6 +569,7 @@ await InvokeTaskAsync( "actor-1", new NyxIdChatEndpoints.NyxIdApprovalRequest("req-1"), runtime, + new StubGAgentActorStore(), new ThrowingSubscriptionProvider(new InvalidOperationException("approval subscription failed")), NullLoggerFactory.Instance, CancellationToken.None); @@ -1877,10 +1898,11 @@ protected override Task SendAsync(HttpRequestMessage reques } } - private sealed class StubActorRuntime : IActorRuntime - { - public Dictionary Actors { get; } = []; - public List<(System.Type Type, string? Id)> CreateCalls { get; } = []; + private sealed class StubActorRuntime : IActorRuntime + { + public Dictionary Actors { get; } = []; + public List<(System.Type Type, string? Id)> CreateCalls { get; } = []; + public List DestroyCalls { get; } = []; public Task GetAsync(string id) => Task.FromResult(Actors.GetValueOrDefault(id)); @@ -1895,7 +1917,12 @@ public Task CreateAsync(System.Type agentType, string? id = null, Cancel return Task.FromResult(actor); } - public Task DestroyAsync(string id, CancellationToken ct = default) => Task.CompletedTask; + public Task DestroyAsync(string id, CancellationToken ct = default) + { + DestroyCalls.Add(id); + 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; @@ -1968,7 +1995,10 @@ public Task SubscribeAsync( } } - private sealed class StubGAgentActorStore : IGAgentActorStore + private sealed class StubGAgentActorStore : + IGAgentActorRegistryCommandPort, + IGAgentActorRegistryQueryPort, + IScopeResourceAdmissionPort { public IReadOnlyList GroupsToReturn { get; init; } = []; public Exception? AddActorException { get; init; } @@ -1977,62 +2007,47 @@ private sealed class StubGAgentActorStore : IGAgentActorStore public List<(string ScopeId, string GAgentType, string ActorId)> RemovedActors { get; } = []; public string? LastRequestedScopeId { get; private set; } - public Task> GetAsync(CancellationToken cancellationToken = default) => - Task.FromResult(GroupsToReturn); - - public Task> GetAsync( + public Task ListActorsAsync( string scopeId, CancellationToken cancellationToken = default) { LastRequestedScopeId = scopeId; - return Task.FromResult(GroupsToReturn); + return Task.FromResult(new GAgentActorRegistrySnapshot( + scopeId, + GroupsToReturn, + 1, + DateTimeOffset.Parse("2026-04-27T09:30:00Z"), + DateTimeOffset.UtcNow)); } - public Task AddActorAsync( - string gagentType, - string actorId, + public Task RegisterActorAsync( + GAgentActorRegistration registration, CancellationToken cancellationToken = default) { if (AddActorException is not null) throw AddActorException; - AddedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; + AddedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task AddActorAsync( - string scopeId, - string gagentType, - string actorId, - CancellationToken cancellationToken = default) - { - if (AddActorException is not null) - throw AddActorException; - AddedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; - } - - public Task RemoveActorAsync( - string gagentType, - string actorId, + public Task UnregisterActorAsync( + GAgentActorRegistration registration, CancellationToken cancellationToken = default) { if (RemoveActorException is not null) throw RemoveActorException; - RemovedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; + RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task RemoveActorAsync( - string scopeId, - string gagentType, - string actorId, + public Task AuthorizeTargetAsync( + ScopeResourceTarget target, CancellationToken cancellationToken = default) - { - if (RemoveActorException is not null) - throw RemoveActorException; - RemovedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; - } + => Task.FromResult(ScopeResourceAdmissionResult.Allowed()); } private sealed class StubChatHistoryStore : IChatHistoryStore diff --git a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs index 125ae0593..6a4e0acd1 100644 --- a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs +++ b/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs @@ -138,6 +138,7 @@ public async Task HandleDeleteRoomAsync_ShouldReturnOk_AndRemoveFromBothStores() "scope-a", "room-1", actorStore, + actorStore, participantStore, NullLoggerFactory.Instance, CancellationToken.None); @@ -158,12 +159,13 @@ public async Task HandleChatAsync_ShouldRejectEmptyPrompt() var projectionPort = new StubRoomSessionProjectionPort(); var durableCompletionResolver = new StreamingProxyChatDurableCompletionResolver(new StubTerminalQueryPort()); var participantStore = new StubParticipantStore(); + var actorStore = new StubGAgentActorStore(); var coordinator = CreateNyxParticipantCoordinator(); var method = typeof(StreamingProxyEndpoints).GetMethod( "HandleChatAsync", BindingFlags.NonPublic | BindingFlags.Static)!; - var task = method.Invoke(null, [context, "scope-a", "room-a", new ChatTopicRequest(null), runtime, projectionPort, durableCompletionResolver, participantStore, coordinator, NullLoggerFactory.Instance, CancellationToken.None]); + var task = method.Invoke(null, [context, "scope-a", "room-a", new ChatTopicRequest(null), runtime, actorStore, projectionPort, durableCompletionResolver, participantStore, coordinator, NullLoggerFactory.Instance, CancellationToken.None]); await InvokeTaskAsync(task); context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); @@ -178,6 +180,7 @@ public async Task HandleChatAsync_ShouldRejectMismatchedAuthenticatedScope() var projectionPort = new StubRoomSessionProjectionPort(); var durableCompletionResolver = new StreamingProxyChatDurableCompletionResolver(new StubTerminalQueryPort()); var participantStore = new StubParticipantStore(); + var actorStore = new StubGAgentActorStore(); var coordinator = CreateNyxParticipantCoordinator(); var method = typeof(StreamingProxyEndpoints).GetMethod( @@ -185,7 +188,7 @@ public async Task HandleChatAsync_ShouldRejectMismatchedAuthenticatedScope() BindingFlags.NonPublic | BindingFlags.Static)!; var task = method.Invoke( null, - [context, "scope-a", "room-a", new ChatTopicRequest("hello"), runtime, projectionPort, durableCompletionResolver, participantStore, coordinator, NullLoggerFactory.Instance, CancellationToken.None]); + [context, "scope-a", "room-a", new ChatTopicRequest("hello"), runtime, actorStore, projectionPort, durableCompletionResolver, participantStore, coordinator, NullLoggerFactory.Instance, CancellationToken.None]); await InvokeTaskAsync(task); context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); @@ -201,13 +204,14 @@ public async Task HandleMessageStreamAsync_ShouldRejectMissingRoom() var context = CreateScopedHttpContext(); var runtime = new StubActorRuntime(); var projectionPort = new StubRoomSessionProjectionPort(); + var actorStore = new StubGAgentActorStore(); var method = typeof(StreamingProxyEndpoints).GetMethod( "HandleMessageStreamAsync", BindingFlags.NonPublic | BindingFlags.Static)!; var task = method.Invoke( null, - [context, "scope-a", "missing", runtime, projectionPort, NullLoggerFactory.Instance, CancellationToken.None]); + [context, "scope-a", "missing", runtime, actorStore, projectionPort, NullLoggerFactory.Instance, CancellationToken.None]); await InvokeTaskAsync(task); context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); @@ -220,6 +224,7 @@ public async Task HandleMessageStreamAsync_ShouldAttachProjectionSession_AndWrit context.Response.Body = new MemoryStream(); var runtime = new StubActorRuntime(new List { new StubActor("room-a") }); var projectionPort = new StubRoomSessionProjectionPort(); + var actorStore = new StubGAgentActorStore(); using var cts = new CancellationTokenSource(); var method = typeof(StreamingProxyEndpoints).GetMethod( @@ -227,7 +232,7 @@ public async Task HandleMessageStreamAsync_ShouldAttachProjectionSession_AndWrit BindingFlags.NonPublic | BindingFlags.Static)!; var task = InvokeTaskAsync(method.Invoke( null, - [context, "scope-a", "room-a", runtime, projectionPort, NullLoggerFactory.Instance, cts.Token])); + [context, "scope-a", "room-a", runtime, actorStore, projectionPort, NullLoggerFactory.Instance, cts.Token])); await projectionPort.Attached.Task; await projectionPort.PublishAsync( @@ -334,6 +339,7 @@ public async Task HandleChatAsync_ShouldAttachProjectionSession_AndEmitRunFinish var durableCompletionResolver = new StreamingProxyChatDurableCompletionResolver( new StubTerminalQueryPort(StreamingProxyChatSessionTerminalStatus.Completed)); var participantStore = new StubParticipantStore(); + var actorStore = new StubGAgentActorStore(); var coordinator = CreateNyxParticipantCoordinator(); var request = new ChatTopicRequest("Discuss webhook relay", "session-123"); @@ -342,7 +348,7 @@ public async Task HandleChatAsync_ShouldAttachProjectionSession_AndEmitRunFinish BindingFlags.NonPublic | BindingFlags.Static)!; var task = InvokeTaskAsync(method.Invoke( null, - [context, "scope-a", "room-a", request, runtime, projectionPort, durableCompletionResolver, participantStore, coordinator, NullLoggerFactory.Instance, CancellationToken.None])); + [context, "scope-a", "room-a", request, runtime, actorStore, projectionPort, durableCompletionResolver, participantStore, coordinator, NullLoggerFactory.Instance, CancellationToken.None])); await projectionPort.Attached.Task; await projectionPort.PublishAsync( @@ -427,6 +433,7 @@ public async Task HandleChatAsync_ShouldPublishFailedTerminalState_WhenCancelled var projectionPort = new StubRoomSessionProjectionPort(); var durableCompletionResolver = new StreamingProxyChatDurableCompletionResolver(new StubTerminalQueryPort()); var participantStore = new StubParticipantStore(); + var actorStore = new StubGAgentActorStore(); var coordinator = CreateNyxParticipantCoordinator(); using var cts = new CancellationTokenSource(); @@ -435,7 +442,7 @@ public async Task HandleChatAsync_ShouldPublishFailedTerminalState_WhenCancelled BindingFlags.NonPublic | BindingFlags.Static)!; var task = InvokeTaskAsync(method.Invoke( null, - [context, "scope-a", "room-a", new ChatTopicRequest("Cancel me", "session-cancel"), runtime, projectionPort, durableCompletionResolver, participantStore, coordinator, NullLoggerFactory.Instance, cts.Token])); + [context, "scope-a", "room-a", new ChatTopicRequest("Cancel me", "session-cancel"), runtime, actorStore, projectionPort, durableCompletionResolver, participantStore, coordinator, NullLoggerFactory.Instance, cts.Token])); await projectionPort.Attached.Task; cts.Cancel(); @@ -732,6 +739,7 @@ public async Task HandlePostMessageAsync_ShouldRejectMissingFieldsAndReturnAccep "room-a", new PostMessageRequest(null, "name", "content"), new StubActorRuntime(), + new StubGAgentActorStore(), CancellationToken.None); var response = await ExecuteResultAsync(result); @@ -744,6 +752,7 @@ public async Task HandlePostMessageAsync_ShouldRejectMissingFieldsAndReturnAccep "missing-room", new PostMessageRequest("agent", null, "content"), new StubActorRuntime(), + new StubGAgentActorStore(), CancellationToken.None); response = await ExecuteResultAsync(result); @@ -757,6 +766,7 @@ public async Task HandlePostMessageAsync_ShouldRejectMissingFieldsAndReturnAccep "room-a", new PostMessageRequest("agent", null, "content"), runtime, + new StubGAgentActorStore(), CancellationToken.None); response = await ExecuteResultAsync(result); @@ -769,6 +779,7 @@ public async Task HandleJoinAsync_ShouldRejectMissingAgentIdAndAddParticipant() { var participantStore = new StubParticipantStore(); var runtime = new StubActorRuntime(new List { new StubActor("room-a") }); + var actorStore = new StubGAgentActorStore(); var result = await InvokeResultAsync( "HandleJoinAsync", @@ -777,6 +788,7 @@ public async Task HandleJoinAsync_ShouldRejectMissingAgentIdAndAddParticipant() "room-a", new JoinRoomRequest(null, null), runtime, + actorStore, participantStore, NullLoggerFactory.Instance, CancellationToken.None); @@ -792,6 +804,7 @@ public async Task HandleJoinAsync_ShouldRejectMissingAgentIdAndAddParticipant() "room-a", joinRequest, runtime, + actorStore, participantStore, NullLoggerFactory.Instance, CancellationToken.None); @@ -990,6 +1003,7 @@ public async Task HandleListParticipantsAsync_ShouldReturnStoreParticipants() CreateScopedHttpContext(), "scope-a", "room-a", + new StubGAgentActorStore(), participantStore, NullLoggerFactory.Instance, CancellationToken.None); @@ -1421,51 +1435,49 @@ private sealed class NoopAsyncDisposable : IAsyncDisposable private sealed record StubRoomSessionProjectionLease(string ActorId, string SessionId) : IStreamingProxyRoomSessionProjectionLease; - private sealed class StubGAgentActorStore : IGAgentActorStore + private sealed class StubGAgentActorStore : + IGAgentActorRegistryCommandPort, + IGAgentActorRegistryQueryPort, + IScopeResourceAdmissionPort { public List Groups { get; } = []; public List<(string scopeId, string gagentType, string actorId)> AddedActors { get; } = []; public List<(string scopeId, string gagentType, string actorId)> RemovedActors { get; } = []; - public Task> GetAsync(CancellationToken cancellationToken = default) - => Task.FromResult>(Groups.AsReadOnly()); - - public Task> GetAsync( + public Task ListActorsAsync( string scopeId, CancellationToken cancellationToken = default) - => Task.FromResult>(Groups.AsReadOnly()); - - public Task AddActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) - { - AddedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; - } - - public Task AddActorAsync( - string scopeId, - string gagentType, - string actorId, + => Task.FromResult(new GAgentActorRegistrySnapshot( + scopeId, + Groups.AsReadOnly(), + 1, + DateTimeOffset.Parse("2026-04-27T09:30:00Z"), + DateTimeOffset.UtcNow)); + + public Task RegisterActorAsync( + GAgentActorRegistration registration, CancellationToken cancellationToken = default) { - AddedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; + AddedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task RemoveActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) + public Task UnregisterActorAsync( + GAgentActorRegistration registration, + CancellationToken cancellationToken = default) { - RemovedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; + RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task RemoveActorAsync( - string scopeId, - string gagentType, - string actorId, + public Task AuthorizeTargetAsync( + ScopeResourceTarget target, CancellationToken cancellationToken = default) - { - RemovedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; - } + => Task.FromResult(ScopeResourceAdmissionResult.Allowed()); } private sealed class StubParticipantStore : IStreamingProxyParticipantStore diff --git a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs index 9c8ce3fd5..4bad63a28 100644 --- a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs @@ -28,7 +28,7 @@ public sealed class StreamingProxyEndpointsCoverageTests public async Task HandleCreateRoomAsync_ShouldRegisterAndInitializeRoomOnSuccess() { var operations = new List(); - var actor = new RecordingActor("created-room"); + var actor = new RecordingActor("created-room", operations); var actorStore = new RecordingGAgentActorStore(operations); var runtime = new RecordingActorRuntime(operations, actor); var loggerFactory = LoggerFactory.Create(_ => { }); @@ -49,10 +49,12 @@ public async Task HandleCreateRoomAsync_ShouldRegisterAndInitializeRoomOnSuccess actorStore.AddedActors[0].ScopeId.Should().Be("scope-a"); 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"); + $"runtime:create:{registeredActorId}", + $"actor:init:{registeredActorId}", + $"store:add:{registeredActorId}"); + runtime.LastCreatedActor.Should().NotBeNull(); + runtime.LastCreatedActor!.ReceivedEnvelopes.Should().ContainSingle(); + runtime.LastCreatedActor.ReceivedEnvelopes[0].Payload.Unpack().RoomName.Should().Be("Daily Standup"); body.Should().Contain(registeredActorId); body.Should().Contain("Daily Standup"); } @@ -82,19 +84,45 @@ public async Task HandleCreateRoomAsync_ShouldRollbackRegistration_WhenActivatio var (statusCode, body) = await ExecuteResultAsync(result); statusCode.Should().Be(StatusCodes.Status500InternalServerError); - actorStore.AddedActors.Should().ContainSingle(); - actorStore.RemovedActors.Should().ContainSingle(); - actorStore.RemovedActors[0].ScopeId.Should().Be("scope-a"); - 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}"); + actorStore.AddedActors.Should().BeEmpty(); + actorStore.RemovedActors.Should().BeEmpty(); + runtime.DestroyedActorIds.Should().BeEmpty(); + operations.Should().ContainSingle(operation => operation.StartsWith("runtime:create:", StringComparison.Ordinal)); body.Should().Contain("Failed to create room"); } + [Fact] + public async Task HandleCreateRoomAsync_ShouldRollbackCreatedRoom_WhenCreationIsCanceled() + { + var operations = new List(); + var actor = new RecordingActor("created-room", operations); + var actorStore = new RecordingGAgentActorStore(operations) + { + ThrowOnRegister = new OperationCanceledException("client disconnected after registry attempt") + }; + var runtime = new RecordingActorRuntime(operations, actor); + var loggerFactory = LoggerFactory.Create(_ => { }); + + var act = async () => await InvokeHandleCreateRoomAsync( + CreateScopedHttpContext(), + "scope-a", + new StreamingProxyEndpoints.CreateRoomRequest("Incident Room"), + actorStore, + runtime, + loggerFactory, + CancellationToken.None); + + await act.Should().ThrowAsync(); + runtime.DestroyedActorIds.Should().ContainSingle(actorStore.AddedActors.Single().ActorId); + actorStore.RemovedActors.Should().ContainSingle(x => x.ActorId == actorStore.AddedActors.Single().ActorId); + operations.Should().ContainInOrder( + $"runtime:create:{actorStore.AddedActors.Single().ActorId}", + $"actor:init:{actorStore.AddedActors.Single().ActorId}", + $"store:add:{actorStore.AddedActors.Single().ActorId}", + $"runtime:destroy:{actorStore.AddedActors.Single().ActorId}", + $"store:remove:{actorStore.AddedActors.Single().ActorId}"); + } + [Fact] public async Task HandleListParticipantsAsync_ShouldReturnStoredParticipants() { @@ -195,7 +223,7 @@ private static async Task InvokeHandleCreateRoomAsync( HttpContext context, string scopeId, StreamingProxyEndpoints.CreateRoomRequest? request, - IGAgentActorStore actorStore, + IGAgentActorRegistryCommandPort actorStore, IActorRuntime actorRuntime, ILoggerFactory loggerFactory, CancellationToken ct) @@ -215,7 +243,7 @@ private static async Task InvokeHandleListParticipantsAsync( { return await (Task)HandleListParticipantsAsyncMethod.Invoke( null, - [context, scopeId, roomId, participantStore, loggerFactory, ct])!; + [context, scopeId, roomId, new RecordingGAgentActorStore([]), participantStore, loggerFactory, ct])!; } private static async Task<(int StatusCode, string Body)> ExecuteResultAsync(IResult result) @@ -251,54 +279,54 @@ private static DefaultHttpContext CreateScopedHttpContext(string claimedScopeId }; } - private sealed class RecordingGAgentActorStore(List operations) : IGAgentActorStore + private sealed class RecordingGAgentActorStore(List operations) : + IGAgentActorRegistryCommandPort, + IGAgentActorRegistryQueryPort, + IScopeResourceAdmissionPort { public List<(string ScopeId, string GAgentType, string ActorId)> AddedActors { get; } = []; public List<(string ScopeId, string GAgentType, string ActorId)> RemovedActors { get; } = []; + public Exception? ThrowOnRegister { get; init; } - public Task> GetAsync(CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task> GetAsync( + public Task ListActorsAsync( string scopeId, CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task AddActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) - { - operations.Add($"store:add:{actorId}"); - AddedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; - } - - public Task AddActorAsync( - string scopeId, - string gagentType, - string actorId, + Task.FromResult(new GAgentActorRegistrySnapshot( + scopeId, + [], + 0, + DateTimeOffset.MinValue, + DateTimeOffset.UtcNow)); + + public Task RegisterActorAsync( + GAgentActorRegistration registration, CancellationToken cancellationToken = default) { - operations.Add($"store:add:{actorId}"); - AddedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; + operations.Add($"store:add:{registration.ActorId}"); + AddedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); + if (ThrowOnRegister is not null) + throw ThrowOnRegister; + + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task RemoveActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) + public Task UnregisterActorAsync( + GAgentActorRegistration registration, + CancellationToken cancellationToken = default) { - operations.Add($"store:remove:{actorId}"); - RemovedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; + operations.Add($"store:remove:{registration.ActorId}"); + RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task RemoveActorAsync( - string scopeId, - string gagentType, - string actorId, + public Task AuthorizeTargetAsync( + ScopeResourceTarget target, CancellationToken cancellationToken = default) - { - operations.Add($"store:remove:{actorId}"); - RemovedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; - } + => Task.FromResult(ScopeResourceAdmissionResult.Allowed()); } private sealed class RecordingParticipantStore : IStreamingProxyParticipantStore @@ -350,6 +378,7 @@ private sealed class RecordingActorRuntime(List operations, IActor actor { public Exception? ThrowOnCreate { get; init; } public List DestroyedActorIds { get; } = []; + public RecordingActor? LastCreatedActor { get; private set; } public Task CreateAsync(string? id = null, CancellationToken ct = default) where TAgent : IAgent => @@ -365,7 +394,10 @@ public Task CreateAsync(System.Type agentType, string? id = null, Cancel if (ThrowOnCreate is not null) throw ThrowOnCreate; - return Task.FromResult(actor); + LastCreatedActor = actor is RecordingActor recordingActor && recordingActor.Id == actorId + ? recordingActor + : new RecordingActor(actorId, operations); + return Task.FromResult(LastCreatedActor); } public Task DestroyAsync(string id, CancellationToken ct = default) @@ -404,7 +436,7 @@ public Task UnlinkAsync(string childId, CancellationToken ct = default) } } - private sealed class RecordingActor(string id) : IActor + private sealed class RecordingActor(string id, List? operations = null) : IActor { public List ReceivedEnvelopes { get; } = []; @@ -418,6 +450,7 @@ private sealed class RecordingActor(string id) : IActor public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) { + operations?.Add($"actor:init:{Id}"); ReceivedEnvelopes.Add(envelope); return Task.CompletedTask; } diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs index e130832e4..e39945af8 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs @@ -144,7 +144,10 @@ public static async Task StartAsync() options.EnableScriptingCapability = false; }); builder.AddGAgentServiceCapabilityBundle(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddAuthentication("Test") .AddScheme("Test", _ => { }); builder.Services.AddAuthorization(); @@ -204,52 +207,58 @@ private static string FindRepoRoot() } } - private sealed class InMemoryGAgentActorStore : IGAgentActorStore + private sealed class InMemoryGAgentActorStore : + IGAgentActorRegistryCommandPort, + IGAgentActorRegistryQueryPort, + IScopeResourceAdmissionPort { private readonly List _registrations = []; - public Task> GetAsync(CancellationToken cancellationToken = default) => - Task.FromResult(BuildGroups(_registrations)); - - public Task> GetAsync( + public Task ListActorsAsync( string scopeId, CancellationToken cancellationToken = default) => - Task.FromResult(BuildGroups(_registrations.Where(registration => - string.Equals(registration.ScopeId, scopeId, StringComparison.Ordinal)))); - - public Task AddActorAsync( - string gagentType, - string actorId, - CancellationToken cancellationToken = default) => - AddActorAsync(string.Empty, gagentType, actorId, cancellationToken); - - public Task AddActorAsync( - string scopeId, - string gagentType, - string actorId, + Task.FromResult(new GAgentActorRegistrySnapshot( + scopeId, + BuildGroups(_registrations.Where(registration => + string.Equals(registration.ScopeId, scopeId, StringComparison.Ordinal))), + 0, + DateTimeOffset.MinValue, + DateTimeOffset.UtcNow)); + + public Task RegisterActorAsync( + GAgentActorRegistration registration, CancellationToken cancellationToken = default) { - _registrations.Add(new ActorRegistration(scopeId, gagentType, actorId)); - return Task.CompletedTask; + _registrations.Add(new ActorRegistration(registration.ScopeId, registration.GAgentType, registration.ActorId)); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task RemoveActorAsync( - string gagentType, - string actorId, - CancellationToken cancellationToken = default) => - RemoveActorAsync(string.Empty, gagentType, actorId, cancellationToken); - - public Task RemoveActorAsync( - string scopeId, - string gagentType, - string actorId, + public Task UnregisterActorAsync( + GAgentActorRegistration target, CancellationToken cancellationToken = default) { _registrations.RemoveAll(registration => - string.Equals(registration.ScopeId, scopeId, StringComparison.Ordinal) && - string.Equals(registration.GAgentType, gagentType, StringComparison.Ordinal) && - string.Equals(registration.ActorId, actorId, StringComparison.Ordinal)); - return Task.CompletedTask; + string.Equals(registration.ScopeId, target.ScopeId, StringComparison.Ordinal) && + string.Equals(registration.GAgentType, target.GAgentType, StringComparison.Ordinal) && + string.Equals(registration.ActorId, target.ActorId, StringComparison.Ordinal)); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + target, + GAgentActorRegistryCommandStage.AdmissionVisible)); + } + + public Task AuthorizeTargetAsync( + ScopeResourceTarget target, + CancellationToken cancellationToken = default) + { + var exists = _registrations.Any(registration => + string.Equals(registration.ScopeId, target.ScopeId, StringComparison.Ordinal) && + string.Equals(registration.GAgentType, target.GAgentType, StringComparison.Ordinal) && + string.Equals(registration.ActorId, target.ActorId, StringComparison.Ordinal)); + return Task.FromResult(exists + ? ScopeResourceAdmissionResult.Allowed() + : ScopeResourceAdmissionResult.NotFound()); } private static IReadOnlyList BuildGroups(IEnumerable registrations) => diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs index 3be59e037..3da83cfa8 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Security.Claims; using System.Text; +using System.Text.Json; using Aevatar.AI.Abstractions; using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.CQRS.Core.Abstractions.Interactions; @@ -616,6 +617,16 @@ public async Task HandleActorStoreEndpoints_ShouldCoverSuccessAndFailureBranches var listResult = await InvokeHandleListActorsAsync(context, "scope-a", store, logger, CancellationToken.None); ((IStatusCodeHttpResult)listResult).StatusCode.Should().Be((int)HttpStatusCode.OK); + var listResponse = await ExecuteResultAsync(listResult); + using (var document = JsonDocument.Parse(listResponse.Body)) + { + document.RootElement.GetProperty("scopeId").GetString().Should().Be("scope-a"); + document.RootElement.GetProperty("stateVersion").GetInt64().Should().Be(23); + DateTimeOffset.Parse(document.RootElement.GetProperty("updatedAt").GetString()!) + .Should() + .Be(new DateTimeOffset(2026, 4, 27, 9, 30, 0, TimeSpan.Zero)); + document.RootElement.GetProperty("groups").GetArrayLength().Should().Be(1); + } store.LastRequestedScopeId.Should().Be("scope-a"); var addResult = await InvokeHandleAddActorAsync( @@ -625,11 +636,8 @@ public async Task HandleActorStoreEndpoints_ShouldCoverSuccessAndFailureBranches store, logger, CancellationToken.None); - ((IStatusCodeHttpResult)addResult).StatusCode.Should().Be((int)HttpStatusCode.OK); - store.AddedActors.Should().ContainSingle(x => - x.ScopeId == "scope-a" && - x.GAgentType == actorTypeName && - x.ActorId == "actor-3"); + ((IStatusCodeHttpResult)addResult).StatusCode.Should().Be(StatusCodes.Status405MethodNotAllowed); + store.AddedActors.Should().BeEmpty(); var removeResult = await InvokeHandleRemoveActorAsync( context, @@ -652,7 +660,7 @@ public async Task HandleActorStoreEndpoints_ShouldCoverSuccessAndFailureBranches store, logger, CancellationToken.None); - ((IStatusCodeHttpResult)invalidAdd).StatusCode.Should().Be((int)HttpStatusCode.BadRequest); + ((IStatusCodeHttpResult)invalidAdd).StatusCode.Should().Be(StatusCodes.Status405MethodNotAllowed); var invalidRemove = await InvokeHandleRemoveActorAsync( context, @@ -671,7 +679,7 @@ public async Task HandleActorStoreEndpoints_ShouldCoverSuccessAndFailureBranches store, logger, CancellationToken.None); - ((IStatusCodeHttpResult)unknownTypeAdd).StatusCode.Should().Be((int)HttpStatusCode.BadRequest); + ((IStatusCodeHttpResult)unknownTypeAdd).StatusCode.Should().Be(StatusCodes.Status405MethodNotAllowed); var throwingStore = new RecordingGAgentActorStore { ThrowOnGet = new InvalidOperationException("get failed") }; var throwList = await InvokeHandleListActorsAsync(context, "scope-a", throwingStore, logger, CancellationToken.None); @@ -684,7 +692,7 @@ public async Task HandleActorStoreEndpoints_ShouldCoverSuccessAndFailureBranches new RecordingGAgentActorStore { ThrowOnAdd = new InvalidOperationException("add failed") }, logger, CancellationToken.None); - ((IStatusCodeHttpResult)throwAdd).StatusCode.Should().Be((int)HttpStatusCode.BadRequest); + ((IStatusCodeHttpResult)throwAdd).StatusCode.Should().Be(StatusCodes.Status405MethodNotAllowed); var throwRemove = await InvokeHandleRemoveActorAsync( context, @@ -711,7 +719,7 @@ public async Task HandleActorStoreEndpoints_ShouldCoverSuccessAndFailureBranches new RecordingGAgentActorStore { ThrowOnAdd = new Exception("boom") }, logger, CancellationToken.None); - ((IStatusCodeHttpResult)throwAddUnexpected).StatusCode.Should().Be((int)HttpStatusCode.InternalServerError); + ((IStatusCodeHttpResult)throwAddUnexpected).StatusCode.Should().Be(StatusCodes.Status405MethodNotAllowed); var throwRemoveUnexpected = await InvokeHandleRemoveActorAsync( context, @@ -828,7 +836,7 @@ private static string InvokeStripEventSuffix(string value) private static async Task InvokeHandleListActorsAsync( HttpContext context, string scopeId, - IGAgentActorStore actorStore, + IGAgentActorRegistryQueryPort actorStore, ILoggerFactory loggerFactory, CancellationToken ct) { @@ -857,7 +865,7 @@ private static async Task InvokeHandleAddActorAsync( HttpContext context, string scopeId, ScopeGAgentEndpoints.AddGAgentActorHttpRequest request, - IGAgentActorStore actorStore, + IGAgentActorRegistryCommandPort actorStore, ILoggerFactory loggerFactory, CancellationToken ct) { @@ -880,7 +888,7 @@ private static async Task InvokeHandleRemoveActorAsync( string scopeId, string actorId, string? gagentType, - IGAgentActorStore actorStore, + RecordingGAgentActorStore actorStore, ILoggerFactory loggerFactory, CancellationToken ct) { @@ -894,6 +902,7 @@ private static async Task InvokeHandleRemoveActorAsync( actorId, gagentType, actorStore, + actorStore, loggerFactory, ct, })!; @@ -978,6 +987,20 @@ private static async Task ReadResponseBodyAsync(HttpContext context) return await reader.ReadToEndAsync(); } + 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 FakeGAgentDraftRunInteractionService : ICommandInteractionService { @@ -1037,74 +1060,66 @@ public Task RollbackAsync( } } - private sealed class RecordingGAgentActorStore : IGAgentActorStore + private sealed class RecordingGAgentActorStore : + IGAgentActorRegistryCommandPort, + IGAgentActorRegistryQueryPort, + IScopeResourceAdmissionPort { public List Actors { get; set; } = []; public List<(string ScopeId, string GAgentType, string ActorId)> AddedActors { get; } = []; public List<(string ScopeId, string GAgentType, string ActorId)> RemovedActors { get; } = []; + public long SnapshotStateVersion { get; init; } = 23; + public DateTimeOffset SnapshotUpdatedAt { get; init; } = + new(2026, 4, 27, 9, 30, 0, TimeSpan.Zero); public Exception? ThrowOnGet { get; set; } public Exception? ThrowOnAdd { get; set; } public Exception? ThrowOnRemove { get; set; } public string? LastRequestedScopeId { get; private set; } - public Task> GetAsync(CancellationToken cancellationToken = default) - { - if (ThrowOnGet != null) throw ThrowOnGet; - return Task.FromResult>(Actors); - } - - public Task> GetAsync( + public Task ListActorsAsync( string scopeId, CancellationToken cancellationToken = default) { if (ThrowOnGet != null) throw ThrowOnGet; LastRequestedScopeId = scopeId; - return Task.FromResult>(Actors); - } - - public Task AddActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) - { - if (ThrowOnAdd != null) - throw ThrowOnAdd; - - AddedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; + return Task.FromResult(new GAgentActorRegistrySnapshot( + scopeId, + Actors, + SnapshotStateVersion, + SnapshotUpdatedAt, + DateTimeOffset.UtcNow)); } - public Task AddActorAsync( - string scopeId, - string gagentType, - string actorId, + public Task RegisterActorAsync( + GAgentActorRegistration registration, CancellationToken cancellationToken = default) { if (ThrowOnAdd != null) throw ThrowOnAdd; - AddedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; + AddedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task RemoveActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) + public Task UnregisterActorAsync( + GAgentActorRegistration registration, + CancellationToken cancellationToken = default) { if (ThrowOnRemove != null) throw ThrowOnRemove; - RemovedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; + RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task RemoveActorAsync( - string scopeId, - string gagentType, - string actorId, + public Task AuthorizeTargetAsync( + ScopeResourceTarget target, CancellationToken cancellationToken = default) - { - if (ThrowOnRemove != null) - throw ThrowOnRemove; - - RemovedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; - } + => Task.FromResult(ScopeResourceAdmissionResult.Allowed()); } private sealed class FakeActorRuntime : IActorRuntime diff --git a/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs b/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs index 6ab16fc7b..0546b7ebe 100644 --- a/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs +++ b/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs @@ -13,7 +13,8 @@ public async Task PrepareAsync_ShouldReturnUnknownActorType_WhenTypeCannotBeReso { var service = new GAgentDraftRunActorPreparationService( new StubActorRuntime(_ => null), - new RecordingGAgentActorStore()); + new RecordingGAgentActorRegistryCommandPort(), + new RecordingScopeResourceAdmissionPort()); var result = await service.PrepareAsync( new GAgentDraftRunPreparationRequest("scope-a", "Aevatar.IamNotReal, Aevatar.IamNotReal"), @@ -27,14 +28,12 @@ public async Task PrepareAsync_ShouldReturnUnknownActorType_WhenTypeCannotBeReso public async Task PrepareAsync_ShouldReuseExistingActor_WithoutRegisteringAgain() { var runtime = new StubActorRuntime(id => id == "existing-actor" ? new StubActor(id) : null); - var actorStore = new RecordingGAgentActorStore + var commandPort = new RecordingGAgentActorRegistryCommandPort(); + var admissionPort = new RecordingScopeResourceAdmissionPort { - Actors = - [ - new GAgentActorGroup(typeof(FakeAgent).AssemblyQualifiedName!, ["existing-actor"]) - ] + Result = ScopeResourceAdmissionResult.Allowed() }; - var service = new GAgentDraftRunActorPreparationService(runtime, actorStore); + var service = new GAgentDraftRunActorPreparationService(runtime, commandPort, admissionPort); var result = await service.PrepareAsync( new GAgentDraftRunPreparationRequest( @@ -49,15 +48,25 @@ public async Task PrepareAsync_ShouldReuseExistingActor_WithoutRegisteringAgain( typeof(FakeAgent).AssemblyQualifiedName!, "existing-actor", false)); - actorStore.AddedActors.Should().BeEmpty(); + commandPort.RegisteredActors.Should().BeEmpty(); + admissionPort.Targets.Should().ContainSingle().Which.Should().Be(new ScopeResourceTarget( + "scope-a", + ScopeResourceKind.GAgentActor, + typeof(FakeAgent).AssemblyQualifiedName!, + "existing-actor", + ScopeResourceOperation.DraftRunReuse)); } [Fact] public async Task PrepareAsync_ShouldRejectExistingActor_WhenItIsNotRegisteredInRequestedScope() { var runtime = new StubActorRuntime(id => id == "existing-actor" ? new StubActor(id) : null); - var actorStore = new RecordingGAgentActorStore(); - var service = new GAgentDraftRunActorPreparationService(runtime, actorStore); + var commandPort = new RecordingGAgentActorRegistryCommandPort(); + var admissionPort = new RecordingScopeResourceAdmissionPort + { + Result = ScopeResourceAdmissionResult.ScopeMismatch() + }; + var service = new GAgentDraftRunActorPreparationService(runtime, commandPort, admissionPort); var result = await service.PrepareAsync( new GAgentDraftRunPreparationRequest( @@ -68,16 +77,19 @@ public async Task PrepareAsync_ShouldRejectExistingActor_WhenItIsNotRegisteredIn result.Succeeded.Should().BeFalse(); result.Error.Should().Be(GAgentDraftRunStartError.ActorTypeMismatch); - actorStore.AddedActors.Should().BeEmpty(); + commandPort.RegisteredActors.Should().BeEmpty(); + admissionPort.Targets.Should().ContainSingle(); } [Fact] public async Task PrepareAsync_ShouldRegisterGeneratedActorId_WhenActorDoesNotExist() { - var actorStore = new RecordingGAgentActorStore(); + var operations = new List(); + var commandPort = new RecordingGAgentActorRegistryCommandPort(operations); var service = new GAgentDraftRunActorPreparationService( - new StubActorRuntime(_ => null), - actorStore); + new StubActorRuntime(_ => null, operations), + commandPort, + new RecordingScopeResourceAdmissionPort()); var result = await service.PrepareAsync( new GAgentDraftRunPreparationRequest( @@ -91,18 +103,55 @@ public async Task PrepareAsync_ShouldRegisterGeneratedActorId_WhenActorDoesNotEx result.PreparedActor.ActorTypeName.Should().Be(typeof(FakeAgent).AssemblyQualifiedName!); result.PreparedActor.ActorId.Should().NotBeNullOrWhiteSpace(); result.PreparedActor.RequiresRollbackOnFailure.Should().BeTrue(); - actorStore.AddedActors.Should().ContainSingle(); - actorStore.AddedActors[0].ScopeId.Should().Be("scope-a"); - actorStore.AddedActors[0].GAgentType.Should().Be(typeof(FakeAgent).AssemblyQualifiedName!); - actorStore.AddedActors[0].ActorId.Should().Be(result.PreparedActor.ActorId); + operations.Should().ContainInOrder( + $"runtime:create:{result.PreparedActor.ActorId}", + $"registry:add:{result.PreparedActor.ActorId}"); + commandPort.RegisteredActors.Should().ContainSingle(); + commandPort.RegisteredActors[0].ScopeId.Should().Be("scope-a"); + commandPort.RegisteredActors[0].GAgentType.Should().Be(typeof(FakeAgent).AssemblyQualifiedName!); + commandPort.RegisteredActors[0].ActorId.Should().Be(result.PreparedActor.ActorId); + } + + [Fact] + public async Task PrepareAsync_ShouldDestroyCreatedActor_WhenRegistrationIsCanceled() + { + var operations = new List(); + var runtime = new StubActorRuntime(_ => null, operations); + var commandPort = new RecordingGAgentActorRegistryCommandPort(operations) + { + ThrowOnRegister = new OperationCanceledException("cancelled after register attempt") + }; + var service = new GAgentDraftRunActorPreparationService( + runtime, + commandPort, + new RecordingScopeResourceAdmissionPort()); + + var act = async () => await service.PrepareAsync( + new GAgentDraftRunPreparationRequest( + "scope-a", + typeof(FakeAgent).AssemblyQualifiedName!, + "draft-actor"), + CancellationToken.None); + + await act.Should().ThrowAsync(); + runtime.DestroyedActorIds.Should().ContainSingle("draft-actor"); + commandPort.UnregisteredActors.Should().ContainSingle().Which.ActorId.Should().Be("draft-actor"); + operations.Should().ContainInOrder( + "runtime:create:draft-actor", + "registry:add:draft-actor", + "runtime:destroy:draft-actor", + "registry:remove:draft-actor"); } [Fact] public async Task RollbackAsync_ShouldDestroyActorAndRemoveRegistration_WhenRollbackIsRequired() { var runtime = new StubActorRuntime(_ => null); - var actorStore = new RecordingGAgentActorStore(); - var service = new GAgentDraftRunActorPreparationService(runtime, actorStore); + var commandPort = new RecordingGAgentActorRegistryCommandPort(); + var service = new GAgentDraftRunActorPreparationService( + runtime, + commandPort, + new RecordingScopeResourceAdmissionPort()); var preparedActor = new GAgentDraftRunPreparedActor( "scope-a", typeof(FakeAgent).AssemblyQualifiedName!, @@ -112,16 +161,22 @@ public async Task RollbackAsync_ShouldDestroyActorAndRemoveRegistration_WhenRoll await service.RollbackAsync(preparedActor, CancellationToken.None); runtime.DestroyedActorIds.Should().ContainSingle("generated-actor"); - actorStore.RemovedActors.Should().ContainSingle(); - actorStore.RemovedActors[0].Should().Be(("scope-a", typeof(FakeAgent).AssemblyQualifiedName!, "generated-actor")); + commandPort.UnregisteredActors.Should().ContainSingle(); + commandPort.UnregisteredActors[0].Should().Be(new GAgentActorRegistration( + "scope-a", + typeof(FakeAgent).AssemblyQualifiedName!, + "generated-actor")); } [Fact] public async Task RollbackAsync_ShouldSkipWork_WhenRollbackIsNotRequired() { var runtime = new StubActorRuntime(_ => null); - var actorStore = new RecordingGAgentActorStore(); - var service = new GAgentDraftRunActorPreparationService(runtime, actorStore); + var commandPort = new RecordingGAgentActorRegistryCommandPort(); + var service = new GAgentDraftRunActorPreparationService( + runtime, + commandPort, + new RecordingScopeResourceAdmissionPort()); await service.RollbackAsync( new GAgentDraftRunPreparedActor( @@ -132,47 +187,56 @@ await service.RollbackAsync( CancellationToken.None); runtime.DestroyedActorIds.Should().BeEmpty(); - actorStore.RemovedActors.Should().BeEmpty(); + commandPort.UnregisteredActors.Should().BeEmpty(); } - private sealed class RecordingGAgentActorStore : IGAgentActorStore + private sealed class RecordingGAgentActorRegistryCommandPort(List? operations = null) : IGAgentActorRegistryCommandPort { - public List Actors { get; set; } = []; - public List<(string ScopeId, string GAgentType, string ActorId)> AddedActors { get; } = []; - public List<(string ScopeId, string GAgentType, string ActorId)> RemovedActors { get; } = []; + public List RegisteredActors { get; } = []; + public List UnregisteredActors { get; } = []; + public Exception? ThrowOnRegister { get; init; } - public Task> GetAsync(CancellationToken cancellationToken = default) => - Task.FromResult>(Actors); - - public Task> GetAsync(string scopeId, CancellationToken cancellationToken = default) => - Task.FromResult>(Actors); - - public Task AddActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) + public Task RegisterActorAsync( + GAgentActorRegistration registration, + CancellationToken cancellationToken = default) { - AddedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; + operations?.Add($"registry:add:{registration.ActorId}"); + RegisteredActors.Add(registration); + if (ThrowOnRegister is not null) + throw ThrowOnRegister; + + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } - public Task AddActorAsync(string scopeId, string gagentType, string actorId, CancellationToken cancellationToken = default) + public Task UnregisterActorAsync( + GAgentActorRegistration registration, + CancellationToken cancellationToken = default) { - AddedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; + operations?.Add($"registry:remove:{registration.ActorId}"); + UnregisteredActors.Add(registration); + return Task.FromResult(new GAgentActorRegistryCommandReceipt( + registration, + GAgentActorRegistryCommandStage.AdmissionVisible)); } + } - public Task RemoveActorAsync(string gagentType, string actorId, CancellationToken cancellationToken = default) - { - RemovedActors.Add((string.Empty, gagentType, actorId)); - return Task.CompletedTask; - } + private sealed class RecordingScopeResourceAdmissionPort : IScopeResourceAdmissionPort + { + public ScopeResourceAdmissionResult Result { get; init; } = ScopeResourceAdmissionResult.NotFound(); + public List Targets { get; } = []; - public Task RemoveActorAsync(string scopeId, string gagentType, string actorId, CancellationToken cancellationToken = default) + public Task AuthorizeTargetAsync( + ScopeResourceTarget target, + CancellationToken cancellationToken = default) { - RemovedActors.Add((scopeId, gagentType, actorId)); - return Task.CompletedTask; + Targets.Add(target); + return Task.FromResult(Result); } } - private sealed class StubActorRuntime(Func getAsync) : IActorRuntime + private sealed class StubActorRuntime(Func getAsync, List? operations = null) : IActorRuntime { public List DestroyedActorIds { get; } = []; @@ -180,11 +244,17 @@ public Task CreateAsync(string? id = null, CancellationToken ct where TAgent : IAgent => Task.FromResult(new StubActor(id ?? "created")); - public Task CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) => - Task.FromResult(new StubActor(id ?? "created")); + public Task CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) + { + _ = agentType; + var actorId = id ?? "created"; + operations?.Add($"runtime:create:{actorId}"); + return Task.FromResult(new StubActor(actorId)); + } public Task DestroyAsync(string id, CancellationToken ct = default) { + operations?.Add($"runtime:destroy:{id}"); DestroyedActorIds.Add(id); return Task.CompletedTask; } diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index c72ce83ee..83e9f7027 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -101,6 +101,24 @@ public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationTo } } + private sealed class ThrowingAdmissionDispatchPort : IActorDispatchPort + { + private readonly FakeActorDispatchPort _inner; + + public ThrowingAdmissionDispatchPort(FakeActorRuntime runtime) + { + _inner = new FakeActorDispatchPort(runtime); + } + + public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) + { + if (envelope.Payload.Is(ScopeResourceAdmissionRequested.Descriptor)) + throw new GAgentRegistryAdmissionNotFoundException(); + + return _inner.DispatchAsync(actorId, envelope, ct); + } + } + /// /// Fake runtime that supports typed agent state for read tests. /// @@ -328,26 +346,26 @@ public async Task NyxIdUserLlmPreferencesStore_DefaultConfig_ReturnsEmptyDefault } // ════════════════════════════════════════════════════════════ - // GAgentActorStore: scope isolation + // GAgent registry ports: scope isolation // ════════════════════════════════════════════════════════════ [Fact] - public async Task GAgentActorStore_GetAsync_NoActor_ReturnsEmptyList() + public async Task GAgentRegistryQueryPort_ListActorsAsync_NoActor_ReturnsEmptyList() { var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "empty-scope" }; - var logger = NullLogger.Instance; + var logger = NullLogger.Instance; - var store = new ActorBackedGAgentActorStore( + var store = new ActorBackedGAgentRegistryPorts( new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); - var groups = await store.GetAsync(); + var snapshot = await store.ListActorsAsync("empty-scope"); - groups.Should().BeEmpty(); + snapshot.Groups.Should().BeEmpty(); } [Fact] - public async Task GAgentActorStore_GetAsync_MapsRegistryState() + public async Task GAgentRegistryQueryPort_ListActorsAsync_MapsRegistryState() { var runtime = new FakeActorRuntime(); var state = new GAgentRegistryState(); @@ -357,38 +375,44 @@ public async Task GAgentActorStore_GetAsync_MapsRegistryState() ActorIds = { "actor-a", "actor-b" }, }); var reader = new FakeProjectionDocumentReader(); + var updatedAt = new DateTimeOffset(2026, 4, 27, 9, 30, 0, TimeSpan.Zero); reader.Set("gagent-registry-scope-1", new GAgentRegistryCurrentStateDocument { Id = "gagent-registry-scope-1", ActorId = "gagent-registry-scope-1", + StateVersion = 17, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), StateRoot = Any.Pack(state), }); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; - var logger = NullLogger.Instance; - var store = new ActorBackedGAgentActorStore(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, reader, logger); + var logger = NullLogger.Instance; + var store = new ActorBackedGAgentRegistryPorts(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, reader, logger); - var groups = await store.GetAsync(); + var snapshot = await store.ListActorsAsync("scope-1"); + var groups = snapshot.Groups; groups.Should().ContainSingle(); groups[0].GAgentType.Should().Be("RoleGAgent"); groups[0].ActorIds.Should().Equal("actor-a", "actor-b"); + snapshot.StateVersion.Should().Be(17); + snapshot.UpdatedAt.Should().Be(updatedAt); } // ════════════════════════════════════════════════════════════ - // GAgentActorStore: AddActorAsync command construction + // GAgent registry command port: command construction // ════════════════════════════════════════════════════════════ [Fact] - public async Task GAgentActorStore_AddActorAsync_SendsActorRegisteredEvent() + public async Task GAgentRegistryCommandPort_RegisterActorAsync_SendsActorRegisteredEvent() { var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "cmd-scope" }; - var logger = NullLogger.Instance; + var logger = NullLogger.Instance; - var store = new ActorBackedGAgentActorStore( + var store = new ActorBackedGAgentRegistryPorts( new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); - await store.AddActorAsync("MyGAgent", "actor-123"); + await store.RegisterActorAsync(new GAgentActorRegistration("cmd-scope", "MyGAgent", "actor-123")); var actorId = "gagent-registry-cmd-scope"; runtime.Actors.Should().ContainKey(actorId); @@ -406,36 +430,36 @@ public async Task GAgentActorStore_AddActorAsync_SendsActorRegisteredEvent() } [Fact] - public async Task GAgentActorStore_AddActorAsync_WithExplicitScope_UsesRouteScope() + public async Task GAgentRegistryCommandPort_RegisterActorAsync_WithExplicitScope_UsesRouteScope() { var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "ambient-scope" }; - var logger = NullLogger.Instance; + var logger = NullLogger.Instance; - var store = new ActorBackedGAgentActorStore( + var store = new ActorBackedGAgentRegistryPorts( new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); - await store.AddActorAsync("route-scope", "MyGAgent", "actor-789"); + await store.RegisterActorAsync(new GAgentActorRegistration("route-scope", "MyGAgent", "actor-789")); runtime.Actors.Should().ContainKey("gagent-registry-route-scope"); runtime.Actors.Should().NotContainKey("gagent-registry-ambient-scope"); } [Fact] - public async Task GAgentActorStore_RemoveActorAsync_SendsActorUnregisteredEvent() + public async Task GAgentRegistryCommandPort_UnregisterActorAsync_SendsActorUnregisteredEvent() { var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "cmd-scope" }; - var logger = NullLogger.Instance; + var logger = NullLogger.Instance; - var store = new ActorBackedGAgentActorStore( + var store = new ActorBackedGAgentRegistryPorts( new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); - await store.RemoveActorAsync("MyGAgent", "actor-456"); + await store.UnregisterActorAsync(new GAgentActorRegistration("cmd-scope", "MyGAgent", "actor-456")); var actorId = "gagent-registry-cmd-scope"; var actor = runtime.Actors[actorId]; @@ -447,6 +471,58 @@ public async Task GAgentActorStore_RemoveActorAsync_SendsActorUnregisteredEvent( evt.ActorId.Should().Be("actor-456"); } + [Fact] + public async Task ScopeResourceAdmissionPort_AuthorizeTargetAsync_SendsAdmissionCommand() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "admission-scope" }; + var logger = NullLogger.Instance; + var store = new ActorBackedGAgentRegistryPorts( + new FakeStudioActorBootstrap(runtime), + new FakeActorDispatchPort(runtime), + scopeResolver, + EmptyReader(), + logger); + + var result = await store.AuthorizeTargetAsync(new ScopeResourceTarget( + "admission-scope", + ScopeResourceKind.GAgentActor, + "MyGAgent", + "actor-123", + ScopeResourceOperation.Chat)); + + result.Status.Should().Be(ScopeResourceAdmissionStatus.Allowed); + var envelope = runtime.Actors["gagent-registry-admission-scope"].ReceivedEnvelopes.Last(); + envelope.Payload.Is(ScopeResourceAdmissionRequested.Descriptor).Should().BeTrue(); + var request = envelope.Payload.Unpack(); + request.GagentType.Should().Be("MyGAgent"); + request.ActorId.Should().Be("actor-123"); + request.Operation.Should().Be(GAgentRegistryOperation.Chat); + } + + [Fact] + public async Task ScopeResourceAdmissionPort_AuthorizeTargetAsync_NotFoundException_ReturnsNotFound() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "admission-scope" }; + var logger = NullLogger.Instance; + var store = new ActorBackedGAgentRegistryPorts( + new FakeStudioActorBootstrap(runtime), + new ThrowingAdmissionDispatchPort(runtime), + scopeResolver, + EmptyReader(), + logger); + + var result = await store.AuthorizeTargetAsync(new ScopeResourceTarget( + "admission-scope", + ScopeResourceKind.GAgentActor, + "MyGAgent", + "missing-actor", + ScopeResourceOperation.Chat)); + + result.Status.Should().Be(ScopeResourceAdmissionStatus.NotFound); + } + // ════════════════════════════════════════════════════════════ // Helper: stub IUserConfigQueryPort for NyxId delegation tests // ════════════════════════════════════════════════════════════ @@ -1627,18 +1703,18 @@ public async Task ConnectorCatalogStore_GetCatalog_MapsAllConnectorConfigShapes( // ════════════════════════════════════════════════════════════ [Fact] - public async Task GAgentActorStore_DifferentScopes_UseDifferentActors() + public async Task GAgentRegistryCommandPort_DifferentScopes_UseDifferentActors() { var runtime = new FakeActorRuntime(); - var logger = NullLogger.Instance; + var logger = NullLogger.Instance; var scopeA = new FakeScopeResolver { ScopeIdToReturn = "scope-a" }; - var storeA = new ActorBackedGAgentActorStore(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeA, EmptyReader(), logger); - await storeA.AddActorAsync("MyAgent", "actor-1"); + var storeA = new ActorBackedGAgentRegistryPorts(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeA, EmptyReader(), logger); + await storeA.RegisterActorAsync(new GAgentActorRegistration("scope-a", "MyAgent", "actor-1")); var scopeB = new FakeScopeResolver { ScopeIdToReturn = "scope-b" }; - var storeB = new ActorBackedGAgentActorStore(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeB, EmptyReader(), logger); - await storeB.AddActorAsync("MyAgent", "actor-2"); + var storeB = new ActorBackedGAgentRegistryPorts(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeB, EmptyReader(), logger); + await storeB.RegisterActorAsync(new GAgentActorRegistration("scope-b", "MyAgent", "actor-2")); runtime.Actors.Should().ContainKey("gagent-registry-scope-a"); runtime.Actors.Should().ContainKey("gagent-registry-scope-b"); diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index 26cc75ccf..66f54078e 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -73,6 +73,11 @@ if rg -n "IProjectionReadModelBindingResolver|ProjectionReadModelBindingResolver exit 1 fi +if rg -n "IGAgentActorStore|ActorBackedGAgentActorStore" src agents; then + echo "Legacy GAgent actor store is forbidden. Use registry command/query/admission ports." + exit 1 +fi + bash "${SCRIPT_DIR}/query_projection_priming_guard.sh" bash "${SCRIPT_DIR}/scripting_write_path_cqrs_guard.sh" bash "${SCRIPT_DIR}/projection_state_version_guard.sh" From 09f8b88dd7e960ddd4622b058ec40e548a097070 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 27 Apr 2026 18:48:28 +0800 Subject: [PATCH 3/7] Refactor registry admission ownership --- .../NyxIdChatEndpoints.Streaming.cs | 1 + .../NyxIdChatEndpoints.cs | 1 + .../Aevatar.GAgents.StreamingProxy.csproj | 1 + .../StreamingProxyEndpoints.cs | 4 + docs/2026-04-02-streaming-proxy-flow.md | 2 +- .../ActorBackedGAgentRegistryPorts.cs | 10 +- .../Aevatar.Studio.Infrastructure.csproj | 1 + .../ServiceCollectionExtensions.cs | 1 + .../ScopeGAgents}/GAgentRegistryPorts.cs | 2 +- .../NyxIdChatEndpointsCoverageTests.cs | 1 + .../StreamingProxyCoverageTests.cs | 30 +++++ .../StreamingProxyEndpointsCoverageTests.cs | 1 + ...ScopeDraftRunActorQueryIntegrationTests.cs | 1 + .../ActorBackedStoreAdapterTests.cs | 119 +++++++++++++++++- 14 files changed, 166 insertions(+), 9 deletions(-) rename src/{Aevatar.Studio.Application/Studio/Abstractions => platform/Aevatar.GAgentService.Abstractions/ScopeGAgents}/GAgentRegistryPorts.cs (97%) diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs index fb3becd1b..d1a2bb29e 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.Streaming.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using Google.Protobuf.WellKnownTypes; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs index 6cfa055be..4992d6bd1 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs @@ -4,6 +4,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; diff --git a/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj b/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj index eb4366e57..56aff06e6 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj +++ b/agents/Aevatar.GAgents.StreamingProxy/Aevatar.GAgents.StreamingProxy.csproj @@ -19,6 +19,7 @@ + diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs index d701230d0..2e100bac7 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using Microsoft.Extensions.Logging; using System.Threading.Channels; @@ -185,6 +186,9 @@ await registryCommandPort.UnregisterActorAsync( catch (Exception ex) { logger.LogWarning(ex, "Failed to unregister room {RoomId} from registry", roomId); + return Results.Json( + new { error = "Failed to delete room" }, + statusCode: StatusCodes.Status503ServiceUnavailable); } try { diff --git a/docs/2026-04-02-streaming-proxy-flow.md b/docs/2026-04-02-streaming-proxy-flow.md index 90f3566b5..f2727ad52 100644 --- a/docs/2026-04-02-streaming-proxy-flow.md +++ b/docs/2026-04-02-streaming-proxy-flow.md @@ -17,7 +17,7 @@ | `Aevatar.Mainnet.Host.Api` | `src/Aevatar.Mainnet.Host.Api/Program.cs` | 注册 `AddStreamingProxy()`,挂载 `MapStreamingProxyEndpoints()` | | `StreamingProxyEndpoints` | `agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs` | 提供 room CRUD、`:chat`、`messages`、`messages:stream`、participant 管理 HTTP/SSE 入口 | | `StreamingProxyGAgent` | `agents/Aevatar.GAgents.StreamingProxy/StreamingProxyGAgent.cs` | 房间 actor,本质上是 group chat broker;持久化事件、更新房间内消息/参与者状态、向订阅者发布事件 | -| `IGAgentActorRegistryCommandPort` / `IGAgentActorRegistryQueryPort` / `IScopeResourceAdmissionPort` | `src/Aevatar.Studio.Application/Studio/Abstractions/GAgentRegistryPorts.cs` | room ownership 的写入、列表查询与 command admission 边界 | +| `IGAgentActorRegistryCommandPort` / `IGAgentActorRegistryQueryPort` / `IScopeResourceAdmissionPort` | `src/platform/Aevatar.GAgentService.Abstractions/ScopeGAgents/GAgentRegistryPorts.cs` | room ownership 的写入、列表查询与 command admission 边界 | | `IStreamingProxyParticipantStore` | `src/Aevatar.Studio.Application/Studio/Abstractions/IStreamingProxyParticipantStore.cs` | room participant 的持久化索引,供 participant 查询、自动加入与失败移除时使用 | | `StreamingProxyNyxParticipantCoordinator` | `agents/Aevatar.GAgents.StreamingProxy/StreamingProxyNyxParticipantCoordinator.cs` | 在带 Bearer Token 时发现 Nyx 可用 provider,把它们自动加入房间并生成多轮回复 | | `StreamingProxySseWriter` | `agents/Aevatar.GAgents.StreamingProxy/StreamingProxySseWriter.cs` | 把 actor 事件映射成 SSE frame 输出给客户端 | diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs index 674986cd1..ebefb3604 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs @@ -2,6 +2,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.Registry; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using Aevatar.Studio.Projection.ReadModels; using Microsoft.Extensions.Logging; @@ -15,6 +16,7 @@ internal sealed class ActorBackedGAgentRegistryPorts : private const string WriteActorIdPrefix = "gagent-registry-"; private readonly IStudioActorBootstrap _bootstrap; + private readonly IActorRuntime _actorRuntime; private readonly IActorDispatchPort _dispatchPort; private readonly IAppScopeResolver _scopeResolver; private readonly IProjectionDocumentReader _documentReader; @@ -22,12 +24,14 @@ internal sealed class ActorBackedGAgentRegistryPorts : public ActorBackedGAgentRegistryPorts( IStudioActorBootstrap bootstrap, + IActorRuntime actorRuntime, IActorDispatchPort dispatchPort, IAppScopeResolver scopeResolver, IProjectionDocumentReader documentReader, ILogger logger) { _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); + _actorRuntime = actorRuntime ?? throw new ArgumentNullException(nameof(actorRuntime)); _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort)); _scopeResolver = scopeResolver ?? throw new ArgumentNullException(nameof(scopeResolver)); _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); @@ -97,7 +101,11 @@ public async Task AuthorizeTargetAsync( }; try { - var actor = await EnsureWriteActorAsync(normalized.ScopeId, cancellationToken); + var actorId = ResolveWriteActorId(normalized.ScopeId); + var actor = await _actorRuntime.GetAsync(actorId); + if (actor is null) + return ScopeResourceAdmissionResult.NotFound(); + await ActorCommandDispatcher.SendAsync(_dispatchPort, actor, new ScopeResourceAdmissionRequested { GagentType = normalized.GAgentType, diff --git a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj index 005d7074a..47d9d59d6 100644 --- a/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj +++ b/src/Aevatar.Studio.Infrastructure/Aevatar.Studio.Infrastructure.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs index 9ed221d49..8b1224153 100644 --- a/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Infrastructure/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.Middleware; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using Aevatar.Studio.Domain.Studio.Compatibility; using Aevatar.Studio.Domain.Studio.Services; using Aevatar.Studio.Infrastructure.ActorBacked; diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/GAgentRegistryPorts.cs b/src/platform/Aevatar.GAgentService.Abstractions/ScopeGAgents/GAgentRegistryPorts.cs similarity index 97% rename from src/Aevatar.Studio.Application/Studio/Abstractions/GAgentRegistryPorts.cs rename to src/platform/Aevatar.GAgentService.Abstractions/ScopeGAgents/GAgentRegistryPorts.cs index d976178f1..b8c38a6dd 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/GAgentRegistryPorts.cs +++ b/src/platform/Aevatar.GAgentService.Abstractions/ScopeGAgents/GAgentRegistryPorts.cs @@ -1,4 +1,4 @@ -namespace Aevatar.Studio.Application.Studio.Abstractions; +namespace Aevatar.GAgentService.Abstractions.ScopeGAgents; public interface IGAgentActorRegistryCommandPort { diff --git a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs index 600e5658d..9dc9da17a 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs @@ -31,6 +31,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using Microsoft.AspNetCore.Authorization; namespace Aevatar.AI.Tests; diff --git a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs index 6a4e0acd1..e3c36c378 100644 --- a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs +++ b/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs @@ -11,6 +11,7 @@ using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using StreamingProxyParticipant = Aevatar.Studio.Application.Studio.Abstractions.StreamingProxyParticipant; using Google.Protobuf; using Any = Google.Protobuf.WellKnownTypes.Any; @@ -151,6 +152,31 @@ public async Task HandleDeleteRoomAsync_ShouldReturnOk_AndRemoveFromBothStores() participantStore.RemovedRooms.Should().ContainSingle(x => x == "room-1"); } + [Fact] + public async Task HandleDeleteRoomAsync_UnregisterFailure_ShouldReturnUnavailable() + { + var actorStore = new StubGAgentActorStore + { + UnregisterException = new InvalidOperationException("registry unavailable"), + }; + var participantStore = new StubParticipantStore(); + + var result = await InvokeResultAsync( + "HandleDeleteRoomAsync", + CreateScopedHttpContext(), + "scope-a", + "room-1", + actorStore, + actorStore, + participantStore, + NullLoggerFactory.Instance, + CancellationToken.None); + + var response = await ExecuteResultAsync(result); + response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + participantStore.RemovedRooms.Should().BeEmpty(); + } + [Fact] public async Task HandleChatAsync_ShouldRejectEmptyPrompt() { @@ -1443,6 +1469,7 @@ private sealed class StubGAgentActorStore : public List Groups { get; } = []; public List<(string scopeId, string gagentType, string actorId)> AddedActors { get; } = []; public List<(string scopeId, string gagentType, string actorId)> RemovedActors { get; } = []; + public Exception? UnregisterException { get; init; } public Task ListActorsAsync( string scopeId, @@ -1468,6 +1495,9 @@ public Task UnregisterActorAsync( GAgentActorRegistration registration, CancellationToken cancellationToken = default) { + if (UnregisterException is not null) + throw UnregisterException; + RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, diff --git a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs index 4bad63a28..df5caae68 100644 --- a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs @@ -4,6 +4,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.GAgents.StreamingProxy; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs index e39945af8..d999da6b4 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs @@ -5,6 +5,7 @@ using Aevatar.Bootstrap.Hosting; using Aevatar.GAgentService.Hosting.Endpoints; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using Aevatar.Workflow.Application.Abstractions.Queries; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Workflow.Extensions.Hosting; diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index 83e9f7027..c15253f4b 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -9,6 +9,7 @@ using Aevatar.GAgents.UserConfig; using Aevatar.GAgents.UserMemory; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.GAgentService.Abstractions.ScopeGAgents; using Aevatar.Studio.Infrastructure.ActorBacked; using Aevatar.Studio.Projection.ReadModels; using FluentAssertions; @@ -119,6 +120,58 @@ public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationTo } } + private sealed class StatefulRegistryDispatchPort : IActorDispatchPort + { + private readonly FakeActorDispatchPort _inner; + private readonly Dictionary _states = new(StringComparer.Ordinal); + + public StatefulRegistryDispatchPort(FakeActorRuntime runtime) + { + _inner = new FakeActorDispatchPort(runtime); + } + + public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) + { + var state = _states.GetValueOrDefault(actorId) ?? new GAgentRegistryState(); + + if (envelope.Payload.Is(ActorRegisteredEvent.Descriptor)) + { + var evt = envelope.Payload.Unpack(); + var group = state.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + if (group is null) + { + group = new GAgentRegistryEntry { GagentType = evt.GagentType }; + state.Groups.Add(group); + } + + if (!group.ActorIds.Contains(evt.ActorId)) + group.ActorIds.Add(evt.ActorId); + _states[actorId] = state; + } + else if (envelope.Payload.Is(ActorUnregisteredEvent.Descriptor)) + { + var evt = envelope.Payload.Unpack(); + var group = state.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, evt.GagentType, StringComparison.Ordinal)); + group?.ActorIds.Remove(evt.ActorId); + if (group?.ActorIds.Count == 0) + state.Groups.Remove(group); + _states[actorId] = state; + } + else if (envelope.Payload.Is(ScopeResourceAdmissionRequested.Descriptor)) + { + var request = envelope.Payload.Unpack(); + var group = state.Groups.FirstOrDefault(g => + string.Equals(g.GagentType, request.GagentType, StringComparison.Ordinal)); + if (group is null || !group.ActorIds.Contains(request.ActorId)) + throw new GAgentRegistryAdmissionNotFoundException(); + } + + return _inner.DispatchAsync(actorId, envelope, ct); + } + } + /// /// Fake runtime that supports typed agent state for read tests. /// @@ -230,9 +283,17 @@ public FakeStudioActorBootstrap(FakeActorRuntime runtime) public async Task EnsureAsync(string actorId, CancellationToken ct = default) where TAgent : IAgent, IProjectedActor { + EnsureCalls++; + if (ThrowOnEnsure) + throw new InvalidOperationException("Bootstrap should not be used for this path."); + var existing = await _runtime.GetAsync(actorId); return existing ?? await _runtime.CreateAsync(actorId, ct); } + + public int EnsureCalls { get; private set; } + + public bool ThrowOnEnsure { get; set; } } private static FakeProjectionDocumentReader PackedReader( @@ -357,7 +418,7 @@ public async Task GAgentRegistryQueryPort_ListActorsAsync_NoActor_ReturnsEmptyLi var logger = NullLogger.Instance; var store = new ActorBackedGAgentRegistryPorts( - new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); + new FakeStudioActorBootstrap(runtime), runtime, new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); var snapshot = await store.ListActorsAsync("empty-scope"); @@ -386,7 +447,7 @@ public async Task GAgentRegistryQueryPort_ListActorsAsync_MapsRegistryState() }); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "scope-1" }; var logger = NullLogger.Instance; - var store = new ActorBackedGAgentRegistryPorts(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, reader, logger); + var store = new ActorBackedGAgentRegistryPorts(new FakeStudioActorBootstrap(runtime), runtime, new FakeActorDispatchPort(runtime), scopeResolver, reader, logger); var snapshot = await store.ListActorsAsync("scope-1"); var groups = snapshot.Groups; @@ -410,7 +471,7 @@ public async Task GAgentRegistryCommandPort_RegisterActorAsync_SendsActorRegiste var logger = NullLogger.Instance; var store = new ActorBackedGAgentRegistryPorts( - new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); + new FakeStudioActorBootstrap(runtime), runtime, new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); await store.RegisterActorAsync(new GAgentActorRegistration("cmd-scope", "MyGAgent", "actor-123")); @@ -438,6 +499,7 @@ public async Task GAgentRegistryCommandPort_RegisterActorAsync_WithExplicitScope var store = new ActorBackedGAgentRegistryPorts( new FakeStudioActorBootstrap(runtime), + runtime, new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), @@ -457,7 +519,7 @@ public async Task GAgentRegistryCommandPort_UnregisterActorAsync_SendsActorUnreg var logger = NullLogger.Instance; var store = new ActorBackedGAgentRegistryPorts( - new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); + new FakeStudioActorBootstrap(runtime), runtime, new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); await store.UnregisterActorAsync(new GAgentActorRegistration("cmd-scope", "MyGAgent", "actor-456")); @@ -477,8 +539,10 @@ public async Task ScopeResourceAdmissionPort_AuthorizeTargetAsync_SendsAdmission var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "admission-scope" }; var logger = NullLogger.Instance; + await runtime.CreateAsync("gagent-registry-admission-scope"); var store = new ActorBackedGAgentRegistryPorts( new FakeStudioActorBootstrap(runtime), + runtime, new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), @@ -506,8 +570,10 @@ public async Task ScopeResourceAdmissionPort_AuthorizeTargetAsync_NotFoundExcept var runtime = new FakeActorRuntime(); var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "admission-scope" }; var logger = NullLogger.Instance; + await runtime.CreateAsync("gagent-registry-admission-scope"); var store = new ActorBackedGAgentRegistryPorts( new FakeStudioActorBootstrap(runtime), + runtime, new ThrowingAdmissionDispatchPort(runtime), scopeResolver, EmptyReader(), @@ -523,6 +589,47 @@ public async Task ScopeResourceAdmissionPort_AuthorizeTargetAsync_NotFoundExcept result.Status.Should().Be(ScopeResourceAdmissionStatus.NotFound); } + [Fact] + public async Task ScopeResourceAdmissionPort_AuthorizeTargetAsync_UsesRegistryStateWhenReadModelLags() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "lag-scope" }; + var logger = NullLogger.Instance; + var bootstrap = new FakeStudioActorBootstrap(runtime); + var dispatchPort = new StatefulRegistryDispatchPort(runtime); + var store = new ActorBackedGAgentRegistryPorts( + bootstrap, + runtime, + dispatchPort, + scopeResolver, + EmptyReader(), + logger); + + await store.RegisterActorAsync(new GAgentActorRegistration("lag-scope", "MyGAgent", "actor-registered")); + var ensureCallsAfterRegister = bootstrap.EnsureCalls; + bootstrap.ThrowOnEnsure = true; + + var allowed = await store.AuthorizeTargetAsync(new ScopeResourceTarget( + "lag-scope", + ScopeResourceKind.GAgentActor, + "MyGAgent", + "actor-registered", + ScopeResourceOperation.Chat)); + var missing = await store.AuthorizeTargetAsync(new ScopeResourceTarget( + "lag-scope", + ScopeResourceKind.GAgentActor, + "MyGAgent", + "actor-missing", + ScopeResourceOperation.Chat)); + var snapshot = await store.ListActorsAsync("lag-scope"); + + allowed.Status.Should().Be(ScopeResourceAdmissionStatus.Allowed); + missing.Status.Should().Be(ScopeResourceAdmissionStatus.NotFound); + snapshot.Groups.Should().BeEmpty("the registry read model can lag behind admission"); + bootstrap.EnsureCalls.Should().Be(ensureCallsAfterRegister); + runtime.Actors.Should().NotContainKey("actor-missing"); + } + // ════════════════════════════════════════════════════════════ // Helper: stub IUserConfigQueryPort for NyxId delegation tests // ════════════════════════════════════════════════════════════ @@ -1709,11 +1816,11 @@ public async Task GAgentRegistryCommandPort_DifferentScopes_UseDifferentActors() var logger = NullLogger.Instance; var scopeA = new FakeScopeResolver { ScopeIdToReturn = "scope-a" }; - var storeA = new ActorBackedGAgentRegistryPorts(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeA, EmptyReader(), logger); + var storeA = new ActorBackedGAgentRegistryPorts(new FakeStudioActorBootstrap(runtime), runtime, new FakeActorDispatchPort(runtime), scopeA, EmptyReader(), logger); await storeA.RegisterActorAsync(new GAgentActorRegistration("scope-a", "MyAgent", "actor-1")); var scopeB = new FakeScopeResolver { ScopeIdToReturn = "scope-b" }; - var storeB = new ActorBackedGAgentRegistryPorts(new FakeStudioActorBootstrap(runtime), new FakeActorDispatchPort(runtime), scopeB, EmptyReader(), logger); + var storeB = new ActorBackedGAgentRegistryPorts(new FakeStudioActorBootstrap(runtime), runtime, new FakeActorDispatchPort(runtime), scopeB, EmptyReader(), logger); await storeB.RegisterActorAsync(new GAgentActorRegistration("scope-b", "MyAgent", "actor-2")); runtime.Actors.Should().ContainKey("gagent-registry-scope-a"); From 302b69b10851e92dd54274af9a33791ffb7fb488 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 27 Apr 2026 19:54:12 +0800 Subject: [PATCH 4/7] Clarify registry admission receipts --- .../NyxIdChatEndpoints.cs | 57 ++++++++++++++++++- docs/canon/gagent-registry-ownership.md | 1 + .../ActorBackedGAgentRegistryPorts.cs | 37 +++++++++++- .../ScopeGAgents/GAgentRegistryPorts.cs | 1 + .../NyxIdChatEndpointsCoverageTests.cs | 34 ++++++++++- .../StreamingProxyCoverageTests.cs | 2 +- .../StreamingProxyEndpointsCoverageTests.cs | 2 +- ...ScopeDraftRunActorQueryIntegrationTests.cs | 2 +- .../ScopeGAgentEndpointsTests.cs | 2 +- ...entDraftRunActorPreparationServiceTests.cs | 2 +- .../ActorBackedStoreAdapterTests.cs | 49 +++++++++++++--- 11 files changed, 169 insertions(+), 20 deletions(-) diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs index 4992d6bd1..7906c8b23 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs @@ -91,14 +91,22 @@ private static async Task HandleCreateConversationAsync( // degraded mode where a conversation can run without being registered. var actorId = NyxIdChatServiceDefaults.GenerateActorId(); await actorRuntime.CreateAsync(actorId, ct); + var registrationAttempted = false; try { + registrationAttempted = true; var receipt = await registryCommandPort.RegisterActorAsync( new GAgentActorRegistration(scopeId, NyxIdChatServiceDefaults.GAgentTypeName, actorId), ct); if (!receipt.IsAdmissionVisible) { - await actorRuntime.DestroyAsync(actorId, CancellationToken.None); + await TryRollbackConversationCreationAsync( + http, + scopeId, + actorId, + registryCommandPort, + actorRuntime, + registrationAttempted); return Results.Json( new { error = "Conversation registration is not admission-visible" }, statusCode: StatusCodes.Status503ServiceUnavailable); @@ -106,13 +114,58 @@ private static async Task HandleCreateConversationAsync( } catch { - await actorRuntime.DestroyAsync(actorId, CancellationToken.None); + await TryRollbackConversationCreationAsync( + http, + scopeId, + actorId, + registryCommandPort, + actorRuntime, + registrationAttempted); throw; } return Results.Ok(new { actorId }); } + private static async Task TryRollbackConversationCreationAsync( + HttpContext http, + string scopeId, + string actorId, + IGAgentActorRegistryCommandPort registryCommandPort, + IActorRuntime actorRuntime, + bool registrationAttempted) + { + var logger = http.RequestServices?.GetService() + ?.CreateLogger("Aevatar.NyxId.Chat.CreateConversation"); + + if (registrationAttempted) + { + try + { + await registryCommandPort.UnregisterActorAsync( + new GAgentActorRegistration(scopeId, NyxIdChatServiceDefaults.GAgentTypeName, actorId), + CancellationToken.None); + } + catch (Exception ex) + { + logger?.LogWarning( + ex, + "Failed to unregister NyxId chat conversation during create rollback: scope={ScopeId}, actor={ActorId}", + scopeId, + actorId); + } + } + + try + { + await actorRuntime.DestroyAsync(actorId, CancellationToken.None); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "Failed to destroy NyxId chat actor {ActorId} during create rollback", actorId); + } + } + private static async Task HandleListConversationsAsync( HttpContext http, string scopeId, diff --git a/docs/canon/gagent-registry-ownership.md b/docs/canon/gagent-registry-ownership.md index 10c7abac5..c3cecde73 100644 --- a/docs/canon/gagent-registry-ownership.md +++ b/docs/canon/gagent-registry-ownership.md @@ -26,6 +26,7 @@ Target actors may own their capability-local business facts. They must not indep - unregister actor membership for a scope - return only an honest dispatch/acceptance result unless a stronger receipt is explicitly modeled - expose a committed or admission-visible receipt when a caller needs create-then-immediately-operate semantics +- expose a distinct removal receipt for unregister; removal must not be reported as admission-visible `IGAgentActorRegistryQueryPort` owns registry listing reads: diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs index ebefb3604..8ab206238 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs @@ -49,9 +49,12 @@ public async Task RegisterActorAsync( GagentType = normalized.GAgentType, ActorId = normalized.ActorId, }, cancellationToken); + var stage = await VerifyAdmissionVisibleAsync(actor, normalized, cancellationToken) + ? GAgentActorRegistryCommandStage.AdmissionVisible + : GAgentActorRegistryCommandStage.AcceptedForDispatch; return new GAgentActorRegistryCommandReceipt( normalized, - GAgentActorRegistryCommandStage.AdmissionVisible); + stage); } public async Task UnregisterActorAsync( @@ -67,7 +70,7 @@ public async Task UnregisterActorAsync( }, cancellationToken); return new GAgentActorRegistryCommandReceipt( normalized, - GAgentActorRegistryCommandStage.AdmissionVisible); + GAgentActorRegistryCommandStage.AdmissionRemoved); } public async Task ListActorsAsync( @@ -166,6 +169,36 @@ private string ResolveWriteActorId(string? scopeId = null) => private Task EnsureWriteActorAsync(string? scopeId, CancellationToken ct) => _bootstrap.EnsureAsync(ResolveWriteActorId(scopeId), ct); + private async Task VerifyAdmissionVisibleAsync( + IActor registryActor, + GAgentActorRegistration registration, + CancellationToken ct) + { + try + { + await ActorCommandDispatcher.SendAsync(_dispatchPort, registryActor, new ScopeResourceAdmissionRequested + { + GagentType = registration.GAgentType, + ActorId = registration.ActorId, + Operation = GAgentRegistryOperation.Use, + }, ct); + return true; + } + catch (GAgentRegistryAdmissionNotFoundException) + { + return false; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning( + ex, + "Registry registration was dispatched but admission visibility could not be verified for scope {ScopeId}, actor {ActorId}", + registration.ScopeId, + registration.ActorId); + return false; + } + } + private string NormalizeScopeId(string? scopeId) => string.IsNullOrWhiteSpace(scopeId) ? _scopeResolver.ResolveScopeIdOrDefault() diff --git a/src/platform/Aevatar.GAgentService.Abstractions/ScopeGAgents/GAgentRegistryPorts.cs b/src/platform/Aevatar.GAgentService.Abstractions/ScopeGAgents/GAgentRegistryPorts.cs index b8c38a6dd..61fec92c6 100644 --- a/src/platform/Aevatar.GAgentService.Abstractions/ScopeGAgents/GAgentRegistryPorts.cs +++ b/src/platform/Aevatar.GAgentService.Abstractions/ScopeGAgents/GAgentRegistryPorts.cs @@ -41,6 +41,7 @@ public enum GAgentActorRegistryCommandStage { AcceptedForDispatch = 0, AdmissionVisible = 1, + AdmissionRemoved = 2, } public sealed record GAgentActorRegistrySnapshot( diff --git a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs index 9dc9da17a..ef605b3d9 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs @@ -181,6 +181,34 @@ public async Task HandleCreateConversationAsync_ShouldBubbleFailure_WhenActorReg assertion.Which.Message.Should().Be("registry unavailable"); } + [Fact] + public async Task HandleCreateConversationAsync_ShouldRollback_WhenRegistrationIsNotAdmissionVisible() + { + var actorStore = new StubGAgentActorStore + { + RegisterStage = GAgentActorRegistryCommandStage.AcceptedForDispatch, + }; + var runtime = new StubActorRuntime(); + + var result = await InvokeResultAsync( + "HandleCreateConversationAsync", + new DefaultHttpContext(), + "scope-a", + actorStore, + runtime, + CancellationToken.None); + + var response = await ExecuteResultAsync(result); + response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + actorStore.AddedActors.Should().ContainSingle(); + var actorId = actorStore.AddedActors.Single().ActorId; + actorStore.RemovedActors.Should().ContainSingle(entry => + entry.ScopeId == "scope-a" && + entry.GAgentType == NyxIdChatServiceDefaults.GAgentTypeName && + entry.ActorId == actorId); + runtime.DestroyCalls.Should().ContainSingle(actorId); + } + [Fact] public async Task HandleListConversationsAsync_ShouldReturnRegisteredActors() { @@ -2004,6 +2032,8 @@ private sealed class StubGAgentActorStore : public IReadOnlyList GroupsToReturn { get; init; } = []; public Exception? AddActorException { get; init; } public Exception? RemoveActorException { get; init; } + public GAgentActorRegistryCommandStage RegisterStage { get; init; } = + GAgentActorRegistryCommandStage.AdmissionVisible; public List<(string ScopeId, string GAgentType, string ActorId)> AddedActors { get; } = []; public List<(string ScopeId, string GAgentType, string ActorId)> RemovedActors { get; } = []; public string? LastRequestedScopeId { get; private set; } @@ -2030,7 +2060,7 @@ public Task RegisterActorAsync( AddedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, - GAgentActorRegistryCommandStage.AdmissionVisible)); + RegisterStage)); } public Task UnregisterActorAsync( @@ -2042,7 +2072,7 @@ public Task UnregisterActorAsync( RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, - GAgentActorRegistryCommandStage.AdmissionVisible)); + GAgentActorRegistryCommandStage.AdmissionRemoved)); } public Task AuthorizeTargetAsync( diff --git a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs index e3c36c378..b0255e65f 100644 --- a/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs +++ b/test/Aevatar.AI.Tests/StreamingProxyCoverageTests.cs @@ -1501,7 +1501,7 @@ public Task UnregisterActorAsync( RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, - GAgentActorRegistryCommandStage.AdmissionVisible)); + GAgentActorRegistryCommandStage.AdmissionRemoved)); } public Task AuthorizeTargetAsync( diff --git a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs index df5caae68..c19c5e578 100644 --- a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs @@ -321,7 +321,7 @@ public Task UnregisterActorAsync( RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, - GAgentActorRegistryCommandStage.AdmissionVisible)); + GAgentActorRegistryCommandStage.AdmissionRemoved)); } public Task AuthorizeTargetAsync( diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs index d999da6b4..c7ce8dd11 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeDraftRunActorQueryIntegrationTests.cs @@ -246,7 +246,7 @@ public Task UnregisterActorAsync( string.Equals(registration.ActorId, target.ActorId, StringComparison.Ordinal)); return Task.FromResult(new GAgentActorRegistryCommandReceipt( target, - GAgentActorRegistryCommandStage.AdmissionVisible)); + GAgentActorRegistryCommandStage.AdmissionRemoved)); } public Task AuthorizeTargetAsync( diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs index 3da83cfa8..63004607d 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs @@ -1113,7 +1113,7 @@ public Task UnregisterActorAsync( RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, - GAgentActorRegistryCommandStage.AdmissionVisible)); + GAgentActorRegistryCommandStage.AdmissionRemoved)); } public Task AuthorizeTargetAsync( diff --git a/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs b/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs index 0546b7ebe..57c2005f0 100644 --- a/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs +++ b/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs @@ -218,7 +218,7 @@ public Task UnregisterActorAsync( UnregisteredActors.Add(registration); return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, - GAgentActorRegistryCommandStage.AdmissionVisible)); + GAgentActorRegistryCommandStage.AdmissionRemoved)); } } diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index c15253f4b..a5244e169 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -471,18 +471,19 @@ public async Task GAgentRegistryCommandPort_RegisterActorAsync_SendsActorRegiste var logger = NullLogger.Instance; var store = new ActorBackedGAgentRegistryPorts( - new FakeStudioActorBootstrap(runtime), runtime, new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); + new FakeStudioActorBootstrap(runtime), runtime, new StatefulRegistryDispatchPort(runtime), scopeResolver, EmptyReader(), logger); - await store.RegisterActorAsync(new GAgentActorRegistration("cmd-scope", "MyGAgent", "actor-123")); + var receipt = await store.RegisterActorAsync(new GAgentActorRegistration("cmd-scope", "MyGAgent", "actor-123")); var actorId = "gagent-registry-cmd-scope"; runtime.Actors.Should().ContainKey(actorId); var actor = runtime.Actors[actorId]; actor.ReceivedEnvelopes.Should().HaveCountGreaterThanOrEqualTo(1); + receipt.Stage.Should().Be(GAgentActorRegistryCommandStage.AdmissionVisible); - // The last envelope should be the ActorRegisteredEvent command - var envelope = actor.ReceivedEnvelopes.Last(); + var envelope = actor.ReceivedEnvelopes.First(e => + e.Payload.Is(Aevatar.GAgents.Registry.ActorRegisteredEvent.Descriptor)); envelope.Payload.Is(Aevatar.GAgents.Registry.ActorRegisteredEvent.Descriptor).Should().BeTrue(); var evt = envelope.Payload.Unpack(); @@ -490,6 +491,29 @@ public async Task GAgentRegistryCommandPort_RegisterActorAsync_SendsActorRegiste evt.ActorId.Should().Be("actor-123"); } + [Fact] + public async Task GAgentRegistryCommandPort_RegisterActorAsync_DowngradesReceipt_WhenAdmissionIsNotVisible() + { + var runtime = new FakeActorRuntime(); + var scopeResolver = new FakeScopeResolver { ScopeIdToReturn = "cmd-scope" }; + var logger = NullLogger.Instance; + + var store = new ActorBackedGAgentRegistryPorts( + new FakeStudioActorBootstrap(runtime), + runtime, + new ThrowingAdmissionDispatchPort(runtime), + scopeResolver, + EmptyReader(), + logger); + + var receipt = await store.RegisterActorAsync(new GAgentActorRegistration("cmd-scope", "MyGAgent", "actor-123")); + + receipt.Stage.Should().Be(GAgentActorRegistryCommandStage.AcceptedForDispatch); + receipt.IsAdmissionVisible.Should().BeFalse(); + runtime.Actors["gagent-registry-cmd-scope"].ReceivedEnvelopes.Should().Contain(e => + e.Payload.Is(Aevatar.GAgents.Registry.ActorRegisteredEvent.Descriptor)); + } + [Fact] public async Task GAgentRegistryCommandPort_RegisterActorAsync_WithExplicitScope_UsesRouteScope() { @@ -500,13 +524,14 @@ public async Task GAgentRegistryCommandPort_RegisterActorAsync_WithExplicitScope var store = new ActorBackedGAgentRegistryPorts( new FakeStudioActorBootstrap(runtime), runtime, - new FakeActorDispatchPort(runtime), + new StatefulRegistryDispatchPort(runtime), scopeResolver, EmptyReader(), logger); - await store.RegisterActorAsync(new GAgentActorRegistration("route-scope", "MyGAgent", "actor-789")); + var receipt = await store.RegisterActorAsync(new GAgentActorRegistration("route-scope", "MyGAgent", "actor-789")); + receipt.Stage.Should().Be(GAgentActorRegistryCommandStage.AdmissionVisible); runtime.Actors.Should().ContainKey("gagent-registry-route-scope"); runtime.Actors.Should().NotContainKey("gagent-registry-ambient-scope"); } @@ -521,12 +546,14 @@ public async Task GAgentRegistryCommandPort_UnregisterActorAsync_SendsActorUnreg var store = new ActorBackedGAgentRegistryPorts( new FakeStudioActorBootstrap(runtime), runtime, new FakeActorDispatchPort(runtime), scopeResolver, EmptyReader(), logger); - await store.UnregisterActorAsync(new GAgentActorRegistration("cmd-scope", "MyGAgent", "actor-456")); + var receipt = await store.UnregisterActorAsync(new GAgentActorRegistration("cmd-scope", "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(); + receipt.Stage.Should().Be(GAgentActorRegistryCommandStage.AdmissionRemoved); + receipt.IsAdmissionVisible.Should().BeFalse(); var evt = envelope.Payload.Unpack(); evt.GagentType.Should().Be("MyGAgent"); @@ -1825,8 +1852,12 @@ public async Task GAgentRegistryCommandPort_DifferentScopes_UseDifferentActors() 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); + runtime.Actors["gagent-registry-scope-a"].ReceivedEnvelopes.Should().Contain(e => + e.Payload.Is(Aevatar.GAgents.Registry.ActorRegisteredEvent.Descriptor) && + e.Payload.Unpack().ActorId == "actor-1"); + runtime.Actors["gagent-registry-scope-b"].ReceivedEnvelopes.Should().Contain(e => + e.Payload.Is(Aevatar.GAgents.Registry.ActorRegisteredEvent.Descriptor) && + e.Payload.Unpack().ActorId == "actor-2"); } // ════════════════════════════════════════════════════════════ From 018032f739222d0f35c9174cc8bdd543ec804498 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 27 Apr 2026 20:19:58 +0800 Subject: [PATCH 5/7] Tighten registry cleanup semantics --- .../GAgentRegistryGAgent.cs | 4 +- .../gagent_registry_messages.proto | 1 + .../ActorBackedGAgentRegistryPorts.cs | 2 + .../GAgentDraftRunActorPreparationService.cs | 35 +++++---- .../Endpoints/ScopeGAgentEndpoints.cs | 29 ++++++- .../ScopeGAgentEndpointsTests.cs | 41 ++++++++++ ...entDraftRunActorPreparationServiceTests.cs | 78 +++++++++++++++++-- .../ActorBackedStoreAdapterTests.cs | 1 + 8 files changed, 166 insertions(+), 25 deletions(-) diff --git a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs index f7550f55e..2df9af2d4 100644 --- a/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs +++ b/agents/Aevatar.GAgents.Registry/GAgentRegistryGAgent.cs @@ -35,7 +35,9 @@ public async Task HandleActorRegistered(ActorRegisteredEvent evt) [EventHandler(EndpointName = "authorizeScopeResource")] public Task HandleScopeResourceAdmissionRequested(ScopeResourceAdmissionRequested request) { - if (string.IsNullOrWhiteSpace(request.GagentType) || string.IsNullOrWhiteSpace(request.ActorId)) + if (string.IsNullOrWhiteSpace(request.ScopeId) || + string.IsNullOrWhiteSpace(request.GagentType) || + string.IsNullOrWhiteSpace(request.ActorId)) throw new GAgentRegistryAdmissionNotFoundException(); var group = State.Groups.FirstOrDefault(g => diff --git a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto index bccbfe027..905157863 100644 --- a/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto +++ b/agents/Aevatar.GAgents.Registry/gagent_registry_messages.proto @@ -43,4 +43,5 @@ message ScopeResourceAdmissionRequested { string gagent_type = 1; string actor_id = 2; GAgentRegistryOperation operation = 3; + string scope_id = 4; } diff --git a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs index 8ab206238..a60421bc0 100644 --- a/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs +++ b/src/Aevatar.Studio.Infrastructure/ActorBacked/ActorBackedGAgentRegistryPorts.cs @@ -111,6 +111,7 @@ public async Task AuthorizeTargetAsync( await ActorCommandDispatcher.SendAsync(_dispatchPort, actor, new ScopeResourceAdmissionRequested { + ScopeId = normalized.ScopeId, GagentType = normalized.GAgentType, ActorId = normalized.ActorId, Operation = ToRegistryOperation(normalized.Operation), @@ -178,6 +179,7 @@ private async Task VerifyAdmissionVisibleAsync( { await ActorCommandDispatcher.SendAsync(_dispatchPort, registryActor, new ScopeResourceAdmissionRequested { + ScopeId = registration.ScopeId, GagentType = registration.GAgentType, ActorId = registration.ActorId, Operation = GAgentRegistryOperation.Use, diff --git a/src/platform/Aevatar.GAgentService.Application/ScopeGAgents/GAgentDraftRunActorPreparationService.cs b/src/platform/Aevatar.GAgentService.Application/ScopeGAgents/GAgentDraftRunActorPreparationService.cs index 5787c5762..dfff2d5c7 100644 --- a/src/platform/Aevatar.GAgentService.Application/ScopeGAgents/GAgentDraftRunActorPreparationService.cs +++ b/src/platform/Aevatar.GAgentService.Application/ScopeGAgents/GAgentDraftRunActorPreparationService.cs @@ -101,6 +101,13 @@ public async Task RollbackAsync( if (!preparedActor.RequiresRollbackOnFailure) return; + if (!await TryUnregisterDraftRunActorAsync( + preparedActor.ScopeId, + preparedActor.ActorTypeName, + preparedActor.ActorId, + ct)) + return; + try { await _actorRuntime.DestroyAsync(preparedActor.ActorId, ct); @@ -110,19 +117,6 @@ public async Task RollbackAsync( _logger?.LogWarning(ex, "Failed to destroy draft-run actor {ActorId} during rollback", preparedActor.ActorId); } - try - { - await _registryCommandPort.UnregisterActorAsync( - new GAgentActorRegistration( - preparedActor.ScopeId, - preparedActor.ActorTypeName, - preparedActor.ActorId), - ct); - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to remove draft-run actor {ActorId} from registry during rollback", preparedActor.ActorId); - } } private async Task RollbackCreatedActorAsync( @@ -132,6 +126,10 @@ private async Task RollbackCreatedActorAsync( bool unregisterFromRegistry, CancellationToken ct) { + if (unregisterFromRegistry && + !await TryUnregisterDraftRunActorAsync(scopeId, actorTypeName, actorId, ct)) + return; + try { await _actorRuntime.DestroyAsync(actorId, ct); @@ -141,18 +139,25 @@ private async Task RollbackCreatedActorAsync( _logger?.LogWarning(ex, "Failed to destroy draft-run actor {ActorId} during rollback", actorId); } - if (!unregisterFromRegistry) - return; + } + private async Task TryUnregisterDraftRunActorAsync( + string scopeId, + string actorTypeName, + string actorId, + CancellationToken ct) + { try { await _registryCommandPort.UnregisterActorAsync( new GAgentActorRegistration(scopeId, actorTypeName, actorId), ct); + return true; } catch (Exception ex) { _logger?.LogWarning(ex, "Failed to remove draft-run actor {ActorId} from registry during rollback", actorId); + return false; } } diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs index 9e2748858..67cd8e3c2 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeGAgentEndpoints.cs @@ -306,7 +306,13 @@ private static async Task HandleDraftRunAsync( if (!interaction.Succeeded) { await RollbackPreparedActorAsync(actorPreparationPort, preparedActor); - await WriteDraftRunStartErrorAsync(http.Response, preparedActor, request.ActorTypeName, interaction.Error, ct); + await WriteDraftRunStartErrorAsync( + http.Response, + preparedActor, + request.ActorTypeName, + request.PreferredActorId, + interaction.Error, + ct); return; } @@ -395,7 +401,13 @@ private static bool TryValidateDraftRunRequest( ct); if (!preparation.Succeeded) { - await WriteDraftRunStartErrorAsync(response, preparedActor: null, request.ActorTypeName, preparation.Error, ct); + await WriteDraftRunStartErrorAsync( + response, + preparedActor: null, + request.ActorTypeName, + request.PreferredActorId, + preparation.Error, + ct); return null; } @@ -446,6 +458,7 @@ private static async Task WriteDraftRunStartErrorAsync( HttpResponse response, GAgentDraftRunPreparedActor? preparedActor, string requestedActorTypeName, + string? requestedActorId, GAgentDraftRunStartError error, CancellationToken ct) { @@ -459,12 +472,20 @@ await WriteJsonErrorAsync( $"GAgent type '{requestedActorTypeName}' could not be resolved.", ct); break; - case GAgentDraftRunStartError.ActorTypeMismatch when preparedActor is not null: + case GAgentDraftRunStartError.ActorTypeMismatch: + var actorId = string.IsNullOrWhiteSpace(preparedActor?.ActorId) + ? requestedActorId?.Trim() + : preparedActor.ActorId; + var actorTypeName = string.IsNullOrWhiteSpace(preparedActor?.ActorTypeName) + ? requestedActorTypeName + : preparedActor.ActorTypeName; response.StatusCode = StatusCodes.Status409Conflict; await WriteJsonErrorAsync( response, "GAGENT_ACTOR_TYPE_MISMATCH", - $"Actor '{preparedActor.ActorId}' is not compatible with requested type '{preparedActor.ActorTypeName}'.", + string.IsNullOrWhiteSpace(actorId) + ? $"Requested actor is not compatible with requested type '{actorTypeName}'." + : $"Actor '{actorId}' is not compatible with requested type '{actorTypeName}'.", ct); break; } diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs index 63004607d..043a41fdb 100644 --- a/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs +++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeGAgentEndpointsTests.cs @@ -367,6 +367,47 @@ await InvokeHandleDraftRunAsync( body.Should().Contain("existing-actor"); } + [Fact] + public async Task HandleDraftRunAsync_ShouldReturnConflict_WhenPreparationReportsActorTypeMismatch() + { + var executed = false; + var interactionService = new FakeGAgentDraftRunInteractionService + { + ResultFactory = (_, _, _, _) => + { + executed = true; + return Task.FromResult( + CommandInteractionResult.Success( + new GAgentDraftRunAcceptedReceipt("actor-1", "RoleGAgent", "cmd-1", "corr-1"), + new CommandInteractionFinalizeResult(GAgentDraftRunCompletionStatus.RunFinished, true))); + } + }; + var actorPreparationPort = new FakeGAgentDraftRunActorPreparationPort + { + Result = GAgentDraftRunPreparationResult.Failure(GAgentDraftRunStartError.ActorTypeMismatch) + }; + var logger = LoggerFactory.Create(_ => { }); + var context = CreateDraftRunContext(); + + await InvokeHandleDraftRunAsync( + context, + "scope-a", + new ScopeGAgentEndpoints.GAgentDraftRunHttpRequest( + typeof(FakeAgent).AssemblyQualifiedName!, + "hello", + PreferredActorId: "existing-actor"), + interactionService, + actorPreparationPort, + logger, + CancellationToken.None); + + executed.Should().BeFalse(); + context.Response.StatusCode.Should().Be((int)HttpStatusCode.Conflict); + var body = await ReadResponseBodyAsync(context); + body.Should().Contain("GAGENT_ACTOR_TYPE_MISMATCH"); + body.Should().Contain("existing-actor"); + } + [Fact] public async Task HandleDraftRunAsync_ShouldPreRegisterGeneratedActorId_ForNewActors() { diff --git a/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs b/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs index 57c2005f0..c54fe6c9f 100644 --- a/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs +++ b/test/Aevatar.GAgentService.Tests/Application/GAgentDraftRunActorPreparationServiceTests.cs @@ -139,15 +139,48 @@ public async Task PrepareAsync_ShouldDestroyCreatedActor_WhenRegistrationIsCance operations.Should().ContainInOrder( "runtime:create:draft-actor", "registry:add:draft-actor", - "runtime:destroy:draft-actor", + "registry:remove:draft-actor", + "runtime:destroy:draft-actor"); + } + + [Fact] + public async Task PrepareAsync_ShouldNotDestroyCreatedActor_WhenRollbackCannotRemoveRegistration() + { + var operations = new List(); + var runtime = new StubActorRuntime(_ => null, operations); + var commandPort = new RecordingGAgentActorRegistryCommandPort(operations) + { + RegisterStage = GAgentActorRegistryCommandStage.AcceptedForDispatch, + ThrowOnUnregister = new InvalidOperationException("registry unavailable") + }; + var service = new GAgentDraftRunActorPreparationService( + runtime, + commandPort, + new RecordingScopeResourceAdmissionPort()); + + var result = await service.PrepareAsync( + new GAgentDraftRunPreparationRequest( + "scope-a", + typeof(FakeAgent).AssemblyQualifiedName!, + "draft-actor"), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.Error.Should().Be(GAgentDraftRunStartError.ActorTypeMismatch); + runtime.DestroyedActorIds.Should().BeEmpty(); + commandPort.UnregisteredActors.Should().ContainSingle().Which.ActorId.Should().Be("draft-actor"); + operations.Should().ContainInOrder( + "runtime:create:draft-actor", + "registry:add:draft-actor", "registry:remove:draft-actor"); } [Fact] - public async Task RollbackAsync_ShouldDestroyActorAndRemoveRegistration_WhenRollbackIsRequired() + public async Task RollbackAsync_ShouldRemoveRegistrationBeforeDestroyingActor_WhenRollbackIsRequired() { - var runtime = new StubActorRuntime(_ => null); - var commandPort = new RecordingGAgentActorRegistryCommandPort(); + var operations = new List(); + var runtime = new StubActorRuntime(_ => null, operations); + var commandPort = new RecordingGAgentActorRegistryCommandPort(operations); var service = new GAgentDraftRunActorPreparationService( runtime, commandPort, @@ -166,6 +199,35 @@ public async Task RollbackAsync_ShouldDestroyActorAndRemoveRegistration_WhenRoll "scope-a", typeof(FakeAgent).AssemblyQualifiedName!, "generated-actor")); + operations.Should().ContainInOrder( + "registry:remove:generated-actor", + "runtime:destroy:generated-actor"); + } + + [Fact] + public async Task RollbackAsync_ShouldNotDestroyActor_WhenRegistrationRemovalFails() + { + var operations = new List(); + var runtime = new StubActorRuntime(_ => null, operations); + var commandPort = new RecordingGAgentActorRegistryCommandPort(operations) + { + ThrowOnUnregister = new InvalidOperationException("registry unavailable") + }; + var service = new GAgentDraftRunActorPreparationService( + runtime, + commandPort, + new RecordingScopeResourceAdmissionPort()); + var preparedActor = new GAgentDraftRunPreparedActor( + "scope-a", + typeof(FakeAgent).AssemblyQualifiedName!, + "generated-actor", + true); + + await service.RollbackAsync(preparedActor, CancellationToken.None); + + commandPort.UnregisteredActors.Should().ContainSingle(); + runtime.DestroyedActorIds.Should().BeEmpty(); + operations.Should().ContainSingle("registry:remove:generated-actor"); } [Fact] @@ -195,6 +257,9 @@ private sealed class RecordingGAgentActorRegistryCommandPort(List? opera public List RegisteredActors { get; } = []; public List UnregisteredActors { get; } = []; public Exception? ThrowOnRegister { get; init; } + public Exception? ThrowOnUnregister { get; init; } + public GAgentActorRegistryCommandStage RegisterStage { get; init; } = + GAgentActorRegistryCommandStage.AdmissionVisible; public Task RegisterActorAsync( GAgentActorRegistration registration, @@ -207,7 +272,7 @@ public Task RegisterActorAsync( return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, - GAgentActorRegistryCommandStage.AdmissionVisible)); + RegisterStage)); } public Task UnregisterActorAsync( @@ -216,6 +281,9 @@ public Task UnregisterActorAsync( { operations?.Add($"registry:remove:{registration.ActorId}"); UnregisteredActors.Add(registration); + if (ThrowOnUnregister is not null) + throw ThrowOnUnregister; + return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, GAgentActorRegistryCommandStage.AdmissionRemoved)); diff --git a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs index a5244e169..43e9e7c89 100644 --- a/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/ActorBackedStoreAdapterTests.cs @@ -586,6 +586,7 @@ public async Task ScopeResourceAdmissionPort_AuthorizeTargetAsync_SendsAdmission var envelope = runtime.Actors["gagent-registry-admission-scope"].ReceivedEnvelopes.Last(); envelope.Payload.Is(ScopeResourceAdmissionRequested.Descriptor).Should().BeTrue(); var request = envelope.Payload.Unpack(); + request.ScopeId.Should().Be("admission-scope"); request.GagentType.Should().Be("MyGAgent"); request.ActorId.Should().Be("actor-123"); request.Operation.Should().Be(GAgentRegistryOperation.Chat); From e67029dda4cfd39d9a8ec1993df54378536f8fb9 Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 27 Apr 2026 20:26:00 +0800 Subject: [PATCH 6/7] Fix streaming proxy rollback ownership order --- .../StreamingProxyEndpoints.cs | 33 +++++++------- .../StreamingProxyEndpointsCoverageTests.cs | 44 ++++++++++++++++++- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs index 2e100bac7..66762bc3f 100644 --- a/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs +++ b/agents/Aevatar.GAgents.StreamingProxy/StreamingProxyEndpoints.cs @@ -947,30 +947,31 @@ private static async Task TryRollbackRoomCreationAsync( ILogger logger, bool unregisterFromRegistry) { - try - { - await actorRuntime.DestroyAsync(roomId, CancellationToken.None); - } - catch (Exception ex) + if (unregisterFromRegistry) { - logger.LogError(ex, "Failed to destroy room actor {RoomId} during rollback", roomId); + try + { + await registryCommandPort.UnregisterActorAsync( + new GAgentActorRegistration( + scopeId, + StreamingProxyDefaults.GAgentTypeName, + roomId), + CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to unregister room {RoomId} from registry during rollback", roomId); + return; + } } - if (!unregisterFromRegistry) - return; - try { - await registryCommandPort.UnregisterActorAsync( - new GAgentActorRegistration( - scopeId, - StreamingProxyDefaults.GAgentTypeName, - roomId), - CancellationToken.None); + await actorRuntime.DestroyAsync(roomId, CancellationToken.None); } catch (Exception ex) { - logger.LogError(ex, "Failed to unregister room {RoomId} from registry during rollback", roomId); + logger.LogError(ex, "Failed to destroy room actor {RoomId} during rollback", roomId); } } diff --git a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs index c19c5e578..740d98cc5 100644 --- a/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/StreamingProxyEndpointsCoverageTests.cs @@ -120,7 +120,41 @@ public async Task HandleCreateRoomAsync_ShouldRollbackCreatedRoom_WhenCreationIs $"runtime:create:{actorStore.AddedActors.Single().ActorId}", $"actor:init:{actorStore.AddedActors.Single().ActorId}", $"store:add:{actorStore.AddedActors.Single().ActorId}", - $"runtime:destroy:{actorStore.AddedActors.Single().ActorId}", + $"store:remove:{actorStore.AddedActors.Single().ActorId}", + $"runtime:destroy:{actorStore.AddedActors.Single().ActorId}"); + } + + [Fact] + public async Task HandleCreateRoomAsync_ShouldNotDestroyRoom_WhenRollbackCannotUnregister() + { + var operations = new List(); + var actor = new RecordingActor("created-room", operations); + var actorStore = new RecordingGAgentActorStore(operations) + { + RegisterStage = GAgentActorRegistryCommandStage.AcceptedForDispatch, + ThrowOnUnregister = new InvalidOperationException("registry unavailable") + }; + var runtime = new RecordingActorRuntime(operations, actor); + var loggerFactory = LoggerFactory.Create(_ => { }); + + var result = await InvokeHandleCreateRoomAsync( + CreateScopedHttpContext(), + "scope-a", + new StreamingProxyEndpoints.CreateRoomRequest("Incident Room"), + actorStore, + runtime, + loggerFactory, + CancellationToken.None); + + var (statusCode, body) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + body.Should().Contain("Failed to create room"); + runtime.DestroyedActorIds.Should().BeEmpty(); + operations.Should().ContainInOrder( + $"runtime:create:{actorStore.AddedActors.Single().ActorId}", + $"actor:init:{actorStore.AddedActors.Single().ActorId}", + $"store:add:{actorStore.AddedActors.Single().ActorId}", $"store:remove:{actorStore.AddedActors.Single().ActorId}"); } @@ -288,6 +322,9 @@ private sealed class RecordingGAgentActorStore(List operations) : public List<(string ScopeId, string GAgentType, string ActorId)> AddedActors { get; } = []; public List<(string ScopeId, string GAgentType, string ActorId)> RemovedActors { get; } = []; public Exception? ThrowOnRegister { get; init; } + public Exception? ThrowOnUnregister { get; init; } + public GAgentActorRegistryCommandStage RegisterStage { get; init; } = + GAgentActorRegistryCommandStage.AdmissionVisible; public Task ListActorsAsync( string scopeId, @@ -310,7 +347,7 @@ public Task RegisterActorAsync( return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, - GAgentActorRegistryCommandStage.AdmissionVisible)); + RegisterStage)); } public Task UnregisterActorAsync( @@ -319,6 +356,9 @@ public Task UnregisterActorAsync( { operations.Add($"store:remove:{registration.ActorId}"); RemovedActors.Add((registration.ScopeId, registration.GAgentType, registration.ActorId)); + if (ThrowOnUnregister is not null) + throw ThrowOnUnregister; + return Task.FromResult(new GAgentActorRegistryCommandReceipt( registration, GAgentActorRegistryCommandStage.AdmissionRemoved)); From f84ca160561aed2308c2a780871dfe349cfae14c Mon Sep 17 00:00:00 2001 From: "louis.li" Date: Mon, 27 Apr 2026 20:28:23 +0800 Subject: [PATCH 7/7] Fix NyxID rollback ownership order --- .../NyxIdChatEndpoints.cs | 1 + .../NyxIdChatEndpointsCoverageTests.cs | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs index 7906c8b23..9d37f98f7 100644 --- a/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs +++ b/agents/Aevatar.GAgents.NyxidChat/NyxIdChatEndpoints.cs @@ -153,6 +153,7 @@ await registryCommandPort.UnregisterActorAsync( "Failed to unregister NyxId chat conversation during create rollback: scope={ScopeId}, actor={ActorId}", scopeId, actorId); + return; } } diff --git a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs index ef605b3d9..8e9f6d9d9 100644 --- a/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs +++ b/test/Aevatar.AI.Tests/NyxIdChatEndpointsCoverageTests.cs @@ -209,6 +209,31 @@ public async Task HandleCreateConversationAsync_ShouldRollback_WhenRegistrationI runtime.DestroyCalls.Should().ContainSingle(actorId); } + [Fact] + public async Task HandleCreateConversationAsync_ShouldNotDestroy_WhenRollbackCannotUnregister() + { + var actorStore = new StubGAgentActorStore + { + RegisterStage = GAgentActorRegistryCommandStage.AcceptedForDispatch, + RemoveActorException = new InvalidOperationException("registry unavailable"), + }; + var runtime = new StubActorRuntime(); + + var result = await InvokeResultAsync( + "HandleCreateConversationAsync", + new DefaultHttpContext(), + "scope-a", + actorStore, + runtime, + CancellationToken.None); + + var response = await ExecuteResultAsync(result); + response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + actorStore.AddedActors.Should().ContainSingle(); + actorStore.RemovedActors.Should().BeEmpty(); + runtime.DestroyCalls.Should().BeEmpty(); + } + [Fact] public async Task HandleListConversationsAsync_ShouldReturnRegisteredActors() {