From 4b79cbc30f7e1850799a98c4530e8e5fbe3839d7 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 18:06:11 +0800 Subject: [PATCH 1/3] feat(studio): member-first endpoint contract + revision lifecycle APIs (#454) Closes #454. Adds three member-first HTTP routes the Studio frontend needs to finish removing serviceId from its Bind/Invoke/Observe paths: - GET /api/scopes/{scopeId}/members/{memberId}/endpoints/{endpointId}/contract - POST /api/scopes/{scopeId}/members/{memberId}/binding/revisions/{revisionId}:activate - POST /api/scopes/{scopeId}/members/{memberId}/binding/revisions/{revisionId}:retire All three resolve the member-owned publishedServiceId internally through the existing StudioMember authority (IStudioMemberQueryPort), then call platform IServiceLifecycleQueryPort / IServiceCommandPort against the resolved identity. ServiceId is never accepted as a user-facing input. The contract response mirrors the legacy ScopeServiceEndpointContractHttpResponse shape so the frontend can keep its rendering, but exposes the member-first InvokePath (/api/scopes/.../members/.../invoke/...). Activation/retire responses include both memberId and publishedServiceId for parity with the legacy serviceId-keyed shape during migration. Why this routes through Studio (not platform's IMemberPublishedServiceResolver): the Studio bind path persists publishedServiceId = "member-{memberId}" via the StudioMember authority (StudioMemberConventions). Going through StudioMemberQueryPort guarantees these new routes read the exact identity Studio's bind wrote, regardless of the platform resolver's convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Abstractions/IStudioMemberService.cs | 46 ++ .../Studio/Contracts/MemberContracts.cs | 57 ++ .../Studio/Services/StudioMemberService.cs | 427 +++++++++++++- .../Endpoints/StudioMemberEndpoints.cs | 97 ++++ .../StudioMemberEndpointsTests.cs | 273 +++++++++ .../StudioMemberPRReviewFixesTests.cs | 9 + .../StudioMemberServiceBindingTests.cs | 86 ++- ...ioMemberServiceContractAndRevisionTests.cs | 529 ++++++++++++++++++ 8 files changed, 1517 insertions(+), 7 deletions(-) create mode 100644 test/Aevatar.Studio.Tests/StudioMemberServiceContractAndRevisionTests.cs diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs index c8e3ea03c..587585e98 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs @@ -58,4 +58,50 @@ Task BindAsync( string scopeId, string memberId, CancellationToken ct = default); + + /// + /// Returns the request/response contract for a single endpoint on the + /// member-owned published service. Resolves the member's + /// publishedServiceId internally so callers never pass a serviceId. + /// Returns null when the member is bound but no matching endpoint + /// exists; throws when the + /// member itself does not exist; throws + /// when the member exists but has + /// not been bound yet (no published service to read a contract from). + /// + Task GetEndpointContractAsync( + string scopeId, + string memberId, + string endpointId, + CancellationToken ct = default); + + /// + /// Activates a binding revision on the member's published service: + /// sets the revision as default-serving and marks it as the active + /// service revision. Resolves the member-owned + /// publishedServiceId internally; callers never pass a serviceId. + /// Throws when the member + /// is missing, and when the + /// member exists but has not been bound, or when the requested revision + /// is missing/retired. + /// + Task ActivateBindingRevisionAsync( + string scopeId, + string memberId, + string revisionId, + CancellationToken ct = default); + + /// + /// Retires a binding revision on the member's published service. + /// Resolves the member-owned publishedServiceId internally; + /// callers never pass a serviceId. + /// Throws when the member + /// is missing, and when the + /// member is unbound or the revision does not exist. + /// + Task RetireBindingRevisionAsync( + string scopeId, + string memberId, + string revisionId, + CancellationToken ct = default); } diff --git a/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs index 411f88230..b8d12aca9 100644 --- a/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs +++ b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs @@ -134,3 +134,60 @@ public sealed record StudioMemberBindingResponse( string ImplementationKind, string ScopeId, string ExpectedActorId); + +/// +/// Member-first endpoint contract. Mirrors the existing scope-default +/// ScopeServiceEndpointContractHttpResponse shape so the frontend can +/// keep its rendering, while pinning and exposing the +/// member-first . is +/// included for parity with the legacy serviceId-based payload, not as a +/// required field for the caller. +/// +public sealed record StudioMemberEndpointContractResponse( + string ScopeId, + string MemberId, + string PublishedServiceId, + string EndpointId, + string InvokePath, + string Method, + string RequestContentType, + string ResponseContentType, + string RequestTypeUrl, + string ResponseTypeUrl, + bool SupportsSse, + bool SupportsWebSocket, + bool SupportsAguiFrames, + string? StreamFrameFormat, + bool SmokeTestSupported, + string DefaultSmokeInputMode, + string? DefaultSmokePrompt, + string? SampleRequestJson, + string DeploymentStatus, + string RevisionId, + string? CurlExample = null, + string? FetchExample = null); + +/// +/// Activation result for a member's binding revision. Carries +/// as the stable identity; +/// is included so the frontend can fall back to the legacy serviceId-keyed +/// store while it migrates, but no caller should require it. +/// +public sealed record StudioMemberBindingActivationResponse( + string ScopeId, + string MemberId, + string PublishedServiceId, + string DisplayName, + string RevisionId); + +/// +/// Generic member-first revision lifecycle action result, mirroring the +/// legacy ScopeServiceRevisionActionHttpResponse. +/// is the lowercase verb (e.g. retired) the legacy payload uses. +/// +public sealed record StudioMemberBindingRevisionActionResponse( + string ScopeId, + string MemberId, + string PublishedServiceId, + string RevisionId, + string Status); diff --git a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs index 790d381d8..b2b7dd7e7 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs @@ -1,5 +1,8 @@ +using System.Text.Json; using Aevatar.GAgentService.Abstractions; +using Aevatar.GAgentService.Abstractions.Commands; using Aevatar.GAgentService.Abstractions.Ports; +using Aevatar.GAgentService.Abstractions.Queries; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; @@ -25,19 +28,44 @@ namespace Aevatar.Studio.Application.Studio.Services; /// public sealed class StudioMemberService : IStudioMemberService { + // Mirrors the values pinned in ScopeWorkflowCapabilityOptions + // (FixedServiceAppId / FixedServiceNamespace). Adding a runtime options + // dependency here would force every Studio.Application consumer to wire + // the platform Application layer just to read two const strings; copying + // the constants keeps the layer boundary clean and matches what + // AppScopedWorkflowService already does for the same reason. + private const string ServiceAppId = "default"; + private const string ServiceNamespace = "default"; + private const string DefaultSmokePrompt = "Hello from Studio Bind."; + private const string StreamFrameFormatWorkflow = "workflow-run-event"; + private const string StreamFrameFormatAgui = "agui"; + + private static readonly JsonSerializerOptions PrettyJsonSerializerOptions = new() + { + WriteIndented = true, + }; + private readonly IStudioMemberCommandPort _memberCommandPort; private readonly IStudioMemberQueryPort _memberQueryPort; private readonly IScopeBindingCommandPort _scopeBindingCommandPort; + private readonly IServiceLifecycleQueryPort _serviceLifecycleQueryPort; + private readonly IServiceCommandPort _serviceCommandPort; public StudioMemberService( IStudioMemberCommandPort memberCommandPort, IStudioMemberQueryPort memberQueryPort, - IScopeBindingCommandPort scopeBindingCommandPort) + IScopeBindingCommandPort scopeBindingCommandPort, + IServiceLifecycleQueryPort serviceLifecycleQueryPort, + IServiceCommandPort serviceCommandPort) { _memberCommandPort = memberCommandPort ?? throw new ArgumentNullException(nameof(memberCommandPort)); _memberQueryPort = memberQueryPort ?? throw new ArgumentNullException(nameof(memberQueryPort)); _scopeBindingCommandPort = scopeBindingCommandPort ?? throw new ArgumentNullException(nameof(scopeBindingCommandPort)); + _serviceLifecycleQueryPort = serviceLifecycleQueryPort + ?? throw new ArgumentNullException(nameof(serviceLifecycleQueryPort)); + _serviceCommandPort = serviceCommandPort + ?? throw new ArgumentNullException(nameof(serviceCommandPort)); } public Task CreateAsync( @@ -150,6 +178,403 @@ await _memberCommandPort.RecordBindingAsync( return detail.LastBinding; } + public async Task GetEndpointContractAsync( + string scopeId, + string memberId, + string endpointId, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(endpointId)) + throw new InvalidOperationException("endpointId is required."); + + var (publishedServiceId, identity) = + await ResolveMemberServiceIdentityAsync(scopeId, memberId, ct); + + // The published service surfaces only after the member is bound — a + // pre-bind read returns 404 from the platform query port. We surface + // that as the same "not bound" 400 the activate/retire paths use, so + // the frontend can branch on a single cause rather than two. + var service = await _serviceLifecycleQueryPort.GetServiceAsync(identity, ct) + ?? throw BuildMemberNotBoundException(memberId); + var revisions = await _serviceLifecycleQueryPort.GetServiceRevisionsAsync(identity, ct); + return BuildMemberEndpointContractResponse( + scopeId, + memberId, + publishedServiceId, + endpointId, + service, + revisions); + } + + public async Task ActivateBindingRevisionAsync( + string scopeId, + string memberId, + string revisionId, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(revisionId)) + throw new InvalidOperationException("revisionId is required."); + + var normalizedRevisionId = revisionId.Trim(); + var (publishedServiceId, identity) = + await ResolveMemberServiceIdentityAsync(scopeId, memberId, ct); + + var service = await _serviceLifecycleQueryPort.GetServiceAsync(identity, ct) + ?? throw BuildMemberNotBoundException(memberId); + + var revision = await ResolveRevisionAsync(identity, normalizedRevisionId, ct); + if (string.Equals( + revision.Status, + ServiceRevisionStatus.Retired.ToString(), + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Revision '{normalizedRevisionId}' is retired and cannot be activated."); + } + + await _serviceCommandPort.SetDefaultServingRevisionAsync( + new SetDefaultServingRevisionCommand + { + Identity = identity.Clone(), + RevisionId = normalizedRevisionId, + }, + ct); + await _serviceCommandPort.ActivateServiceRevisionAsync( + new ActivateServiceRevisionCommand + { + Identity = identity.Clone(), + RevisionId = normalizedRevisionId, + }, + ct); + + return new StudioMemberBindingActivationResponse( + ScopeId: identity.TenantId, + MemberId: memberId, + PublishedServiceId: publishedServiceId, + DisplayName: service.DisplayName, + RevisionId: normalizedRevisionId); + } + + public async Task RetireBindingRevisionAsync( + string scopeId, + string memberId, + string revisionId, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(revisionId)) + throw new InvalidOperationException("revisionId is required."); + + var normalizedRevisionId = revisionId.Trim(); + var (publishedServiceId, identity) = + await ResolveMemberServiceIdentityAsync(scopeId, memberId, ct); + + // Retire is idempotent on the read-side guard: we still verify the + // revision exists so frontend gets a deterministic 400 for typos + // rather than a silent success that the projection later contradicts. + _ = await _serviceLifecycleQueryPort.GetServiceAsync(identity, ct) + ?? throw BuildMemberNotBoundException(memberId); + _ = await ResolveRevisionAsync(identity, normalizedRevisionId, ct); + + await _serviceCommandPort.RetireRevisionAsync( + new RetireServiceRevisionCommand + { + Identity = identity.Clone(), + RevisionId = normalizedRevisionId, + }, + ct); + + return new StudioMemberBindingRevisionActionResponse( + ScopeId: identity.TenantId, + MemberId: memberId, + PublishedServiceId: publishedServiceId, + RevisionId: normalizedRevisionId, + Status: "retired"); + } + + private async Task<(string PublishedServiceId, ServiceIdentity Identity)> ResolveMemberServiceIdentityAsync( + string scopeId, + string memberId, + CancellationToken ct) + { + var detail = await _memberQueryPort.GetAsync(scopeId, memberId, ct) + ?? throw new StudioMemberNotFoundException(scopeId, memberId); + + var publishedServiceId = detail.Summary.PublishedServiceId; + if (string.IsNullOrWhiteSpace(publishedServiceId)) + { + throw new InvalidOperationException( + $"member '{memberId}' has no publishedServiceId; this is a backend invariant violation."); + } + + var normalizedScopeId = (scopeId ?? string.Empty).Trim(); + if (normalizedScopeId.Length == 0) + throw new InvalidOperationException("scopeId is required."); + + var identity = new ServiceIdentity + { + TenantId = normalizedScopeId, + AppId = ServiceAppId, + Namespace = ServiceNamespace, + ServiceId = publishedServiceId, + }; + return (publishedServiceId, identity); + } + + private async Task ResolveRevisionAsync( + ServiceIdentity identity, + string revisionId, + CancellationToken ct) + { + var revisions = await _serviceLifecycleQueryPort.GetServiceRevisionsAsync(identity, ct); + var revision = revisions?.Revisions.FirstOrDefault(x => + string.Equals(x.RevisionId, revisionId, StringComparison.Ordinal)); + if (revision == null) + { + throw new InvalidOperationException( + $"Revision '{revisionId}' was not found on the member's published service."); + } + + return revision; + } + + private static InvalidOperationException BuildMemberNotBoundException(string memberId) => + new($"member '{memberId}' has no published service yet; bind the member before reading or mutating its revisions."); + + private static StudioMemberEndpointContractResponse? BuildMemberEndpointContractResponse( + string scopeId, + string memberId, + string publishedServiceId, + string endpointId, + ServiceCatalogSnapshot service, + ServiceRevisionCatalogSnapshot? revisions) + { + var normalizedEndpointId = endpointId.Trim(); + var currentRevision = ResolveCurrentContractRevision(service, revisions, normalizedEndpointId); + var endpoint = currentRevision?.Endpoints.FirstOrDefault(x => + string.Equals(x.EndpointId, normalizedEndpointId, StringComparison.Ordinal)) + ?? service.Endpoints.FirstOrDefault(x => + string.Equals(x.EndpointId, normalizedEndpointId, StringComparison.Ordinal)); + if (endpoint == null) + return null; + + var implementationKind = NullIfEmpty(currentRevision?.ImplementationKind); + var supportsSse = IsChatEndpoint(endpoint.Kind); + var streamFrameFormat = ResolveStreamFrameFormat(supportsSse, implementationKind); + var supportsAguiFrames = string.Equals( + streamFrameFormat, + StreamFrameFormatAgui, + StringComparison.Ordinal); + var invokePath = supportsSse + ? BuildMemberStreamInvokePath(scopeId, memberId, normalizedEndpointId) + : BuildMemberInvokePath(scopeId, memberId, normalizedEndpointId); + var responseContentType = supportsSse ? "text/event-stream" : "application/json"; + var defaultSmokeInputMode = supportsSse ? "prompt" : "typed-payload"; + var defaultSmokePrompt = supportsSse ? DefaultSmokePrompt : null; + var sampleRequestJson = supportsSse + ? null + : BuildTypedInvokeRequestExampleBody(endpoint.RequestTypeUrl, prettyPrinted: true); + var smokeTestSupported = supportsSse || sampleRequestJson != null; + + return new StudioMemberEndpointContractResponse( + ScopeId: scopeId, + MemberId: memberId, + PublishedServiceId: publishedServiceId, + EndpointId: normalizedEndpointId, + InvokePath: invokePath, + Method: "POST", + RequestContentType: "application/json", + ResponseContentType: responseContentType, + RequestTypeUrl: endpoint.RequestTypeUrl, + ResponseTypeUrl: endpoint.ResponseTypeUrl, + SupportsSse: supportsSse, + SupportsWebSocket: false, + SupportsAguiFrames: supportsAguiFrames, + StreamFrameFormat: streamFrameFormat, + SmokeTestSupported: smokeTestSupported, + DefaultSmokeInputMode: defaultSmokeInputMode, + DefaultSmokePrompt: defaultSmokePrompt, + SampleRequestJson: sampleRequestJson, + DeploymentStatus: service.DeploymentStatus, + RevisionId: currentRevision?.RevisionId + ?? NullIfEmpty(service.DefaultServingRevisionId) + ?? NullIfEmpty(service.ActiveServingRevisionId) + ?? string.Empty, + CurlExample: smokeTestSupported + ? BuildCurlExample(invokePath, supportsSse, endpoint.RequestTypeUrl) + : null, + FetchExample: smokeTestSupported + ? BuildFetchExample(invokePath, supportsSse, endpoint.RequestTypeUrl) + : null); + } + + private static ServiceRevisionSnapshot? ResolveCurrentContractRevision( + ServiceCatalogSnapshot service, + ServiceRevisionCatalogSnapshot? revisions, + string endpointId) + { + if (revisions == null || revisions.Revisions.Count == 0) + return null; + + foreach (var preferredRevisionId in EnumeratePreferredContractRevisionIds(service)) + { + var preferredRevision = revisions.Revisions.FirstOrDefault(x => + string.Equals(x.RevisionId, preferredRevisionId, StringComparison.Ordinal) && + RevisionContainsEndpoint(x, endpointId)); + if (preferredRevision != null) + return preferredRevision; + } + + return revisions.Revisions.FirstOrDefault(x => + RevisionContainsEndpoint(x, endpointId)) + ?? revisions.Revisions[0]; + } + + private static IEnumerable EnumeratePreferredContractRevisionIds(ServiceCatalogSnapshot service) + { + var defaultRevisionId = NullIfEmpty(service.DefaultServingRevisionId); + if (!string.IsNullOrWhiteSpace(defaultRevisionId)) + yield return defaultRevisionId; + + var activeRevisionId = NullIfEmpty(service.ActiveServingRevisionId); + if (!string.IsNullOrWhiteSpace(activeRevisionId) && + !string.Equals(activeRevisionId, defaultRevisionId, StringComparison.Ordinal)) + { + yield return activeRevisionId; + } + } + + private static bool RevisionContainsEndpoint(ServiceRevisionSnapshot revision, string endpointId) => + revision.Endpoints.Any(endpoint => + string.Equals(endpoint.EndpointId, endpointId, StringComparison.Ordinal)); + + private static bool IsChatEndpoint(string? endpointKind) => + string.Equals(endpointKind?.Trim(), "chat", StringComparison.OrdinalIgnoreCase); + + private static string? ResolveStreamFrameFormat(bool supportsSse, string? implementationKind) + { + if (!supportsSse) + return null; + + if (string.Equals( + implementationKind, + ServiceImplementationKind.Workflow.ToString(), + StringComparison.OrdinalIgnoreCase)) + { + return StreamFrameFormatWorkflow; + } + + if (string.Equals( + implementationKind, + ServiceImplementationKind.Static.ToString(), + StringComparison.OrdinalIgnoreCase) || + string.Equals( + implementationKind, + ServiceImplementationKind.Scripting.ToString(), + StringComparison.OrdinalIgnoreCase)) + { + return StreamFrameFormatAgui; + } + + return null; + } + + private static string BuildMemberInvokePath(string scopeId, string memberId, string endpointId) => + $"/api/scopes/{Uri.EscapeDataString(scopeId)}/members/{Uri.EscapeDataString(memberId)}/invoke/{Uri.EscapeDataString(endpointId)}"; + + private static string BuildMemberStreamInvokePath(string scopeId, string memberId, string endpointId) => + $"{BuildMemberInvokePath(scopeId, memberId, endpointId)}:stream"; + + private static string? BuildTypedInvokeRequestExampleBody(string? requestTypeUrl, bool prettyPrinted) + { + var normalized = NullIfEmpty(requestTypeUrl); + if (normalized == null) + return null; + + return JsonSerializer.Serialize( + new + { + payloadTypeUrl = normalized, + payloadBase64 = BuildBase64PayloadPlaceholder(normalized), + }, + prettyPrinted ? PrettyJsonSerializerOptions : null); + } + + private static string BuildBase64PayloadPlaceholder(string requestTypeUrl) + { + var typeName = requestTypeUrl + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault(); + return string.IsNullOrWhiteSpace(typeName) + ? "" + : $""; + } + + private static string BuildCurlExample(string invokePath, bool supportsSse, string? requestTypeUrl) + { + if (supportsSse) + { + var requestBody = JsonSerializer.Serialize(new { prompt = DefaultSmokePrompt }); + return $""" +curl -N -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -H "Authorization: Bearer " \ + "{invokePath}" \ + -d '{requestBody}' +"""; + } + + var typedBody = BuildTypedInvokeRequestExampleBody(requestTypeUrl, prettyPrinted: false) ?? "{}"; + return $""" +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + "{invokePath}" \ + -d '{typedBody}' +"""; + } + + private static string BuildFetchExample(string invokePath, bool supportsSse, string? requestTypeUrl) + { + if (supportsSse) + { + return $$""" +const response = await fetch("{{invokePath}}", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "text/event-stream", + "Authorization": "Bearer ", + }, + body: JSON.stringify({ + prompt: "{{DefaultSmokePrompt}}", + }), +}); + +// Consume response.body as an SSE stream. +"""; + } + + var normalizedRequestTypeUrl = NullIfEmpty(requestTypeUrl) ?? ""; + var payloadBase64 = BuildBase64PayloadPlaceholder(normalizedRequestTypeUrl); + return $$""" +const response = await fetch("{{invokePath}}", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer ", + }, + body: JSON.stringify({ + payloadTypeUrl: "{{normalizedRequestTypeUrl}}", + payloadBase64: "{{payloadBase64}}", + }), +}); +"""; + } + + private static string? NullIfEmpty(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static ScopeBindingUpsertRequest BuildScopeBindingRequest( string scopeId, string memberId, diff --git a/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs index c3506f146..f6b2333a6 100644 --- a/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs +++ b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs @@ -35,6 +35,18 @@ public static void Map(IEndpointRouteBuilder app) .WithTags("StudioMembers"); app.MapGet("/api/scopes/{scopeId}/members/{memberId}/binding", HandleGetBindingAsync) .WithTags("StudioMembers"); + app.MapGet( + "/api/scopes/{scopeId}/members/{memberId}/endpoints/{endpointId}/contract", + HandleGetEndpointContractAsync) + .WithTags("StudioMembers"); + app.MapPost( + "/api/scopes/{scopeId}/members/{memberId}/binding/revisions/{revisionId}:activate", + HandleActivateBindingRevisionAsync) + .WithTags("StudioMembers"); + app.MapPost( + "/api/scopes/{scopeId}/members/{memberId}/binding/revisions/{revisionId}:retire", + HandleRetireBindingRevisionAsync) + .WithTags("StudioMembers"); } internal static async Task HandleCreateAsync( @@ -163,6 +175,91 @@ internal static async Task HandleGetBindingAsync( } } + internal static async Task HandleGetEndpointContractAsync( + HttpContext http, + string scopeId, + string memberId, + string endpointId, + IStudioMemberService memberService, + CancellationToken ct) + { + if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) + return denied; + + try + { + var contract = await memberService.GetEndpointContractAsync(scopeId, memberId, endpointId, ct); + if (contract == null) + { + return Results.NotFound(new + { + code = "STUDIO_MEMBER_ENDPOINT_CONTRACT_NOT_FOUND", + message = $"Endpoint '{endpointId}' was not found on member '{memberId}' in scope '{scopeId}'.", + }); + } + + return Results.Ok(contract); + } + catch (StudioMemberNotFoundException ex) + { + return NotFound(ex); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_STUDIO_MEMBER_ENDPOINT_CONTRACT_REQUEST", ex.Message); + } + } + + internal static async Task HandleActivateBindingRevisionAsync( + HttpContext http, + string scopeId, + string memberId, + string revisionId, + IStudioMemberService memberService, + CancellationToken ct) + { + if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) + return denied; + + try + { + return Results.Ok(await memberService.ActivateBindingRevisionAsync(scopeId, memberId, revisionId, ct)); + } + catch (StudioMemberNotFoundException ex) + { + return NotFound(ex); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_STUDIO_MEMBER_BINDING_ACTIVATION_REQUEST", ex.Message); + } + } + + internal static async Task HandleRetireBindingRevisionAsync( + HttpContext http, + string scopeId, + string memberId, + string revisionId, + IStudioMemberService memberService, + CancellationToken ct) + { + if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) + return denied; + + try + { + return Results.Ok(await memberService.RetireBindingRevisionAsync(scopeId, memberId, revisionId, ct)); + } + catch (StudioMemberNotFoundException ex) + { + return NotFound(ex); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_STUDIO_MEMBER_BINDING_REVISION_REQUEST", ex.Message); + } + } + private static IResult BadRequest(string code, string message) => Results.BadRequest(new { code, message }); diff --git a/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs b/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs index a64f21091..160994aa6 100644 --- a/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs +++ b/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs @@ -254,6 +254,252 @@ public async Task HandleGetBindingAsync_ShouldReturnOk_WhenServiceReturnsBinding .Which.Value!.LastBinding.Should().BeSameAs(contract); } + [Fact] + public async Task HandleGetEndpointContractAsync_ShouldReturnOk_OnSuccess() + { + var contract = NewContract(); + var service = new RecordingMemberService { EndpointContractResponse = contract }; + + var result = await InvokeHandle( + "HandleGetEndpointContractAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + "chat", + service, + CancellationToken.None); + + result.Should().BeOfType>() + .Which.Value.Should().BeSameAs(contract); + } + + [Fact] + public async Task HandleGetEndpointContractAsync_ShouldReturnNotFound_WhenServiceReturnsNull() + { + // Service returns null for "exists, but no such endpoint on the + // member's published service" — the endpoint maps that to a typed + // 404 the frontend can switch on, distinct from the 404 for a + // missing member itself. + var service = new RecordingMemberService { EndpointContractResponse = null }; + + var result = await InvokeHandle( + "HandleGetEndpointContractAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + "no-such-ep", + service, + CancellationToken.None); + + var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?; + statusCode.Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public async Task HandleGetEndpointContractAsync_ShouldReturnTyped404_WhenMemberMissing() + { + var service = new RecordingMemberService + { + EndpointContractException = new StudioMemberNotFoundException(ScopeId, "m-missing"), + }; + + var result = await InvokeHandle( + "HandleGetEndpointContractAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-missing", + "chat", + service, + CancellationToken.None); + + var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?; + statusCode.Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public async Task HandleGetEndpointContractAsync_ShouldReturnBadRequest_OnDomainError() + { + var service = new RecordingMemberService + { + EndpointContractException = new InvalidOperationException("member 'm-1' has no published service yet"), + }; + + var result = await InvokeHandle( + "HandleGetEndpointContractAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + "chat", + service, + CancellationToken.None); + + result.GetType().Name.Should().StartWith("BadRequest"); + } + + [Fact] + public async Task HandleActivateBindingRevisionAsync_ShouldReturnOk_OnSuccess() + { + var activation = new StudioMemberBindingActivationResponse( + ScopeId, "m-1", "member-m-1", "Alpha", "rev-1"); + var service = new RecordingMemberService { ActivateResponse = activation }; + + var result = await InvokeHandle( + "HandleActivateBindingRevisionAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + "rev-1", + service, + CancellationToken.None); + + result.Should().BeOfType>() + .Which.Value.Should().BeSameAs(activation); + } + + [Fact] + public async Task HandleActivateBindingRevisionAsync_ShouldReturnTyped404_WhenMemberMissing() + { + var service = new RecordingMemberService + { + ActivateException = new StudioMemberNotFoundException(ScopeId, "m-missing"), + }; + + var result = await InvokeHandle( + "HandleActivateBindingRevisionAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-missing", + "rev-1", + service, + CancellationToken.None); + + var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?; + statusCode.Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public async Task HandleActivateBindingRevisionAsync_ShouldReturnBadRequest_OnDomainError() + { + // E.g. revision is retired — service throws InvalidOperationException. + var service = new RecordingMemberService + { + ActivateException = new InvalidOperationException("Revision 'rev-x' is retired and cannot be activated."), + }; + + var result = await InvokeHandle( + "HandleActivateBindingRevisionAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + "rev-x", + service, + CancellationToken.None); + + result.GetType().Name.Should().StartWith("BadRequest"); + } + + [Fact] + public async Task HandleActivateBindingRevisionAsync_ShouldReturnForbidden_WhenScopeAccessDenied() + { + var service = new RecordingMemberService(); + + var result = await InvokeHandle( + "HandleActivateBindingRevisionAsync", + CreateAuthenticatedContext("other-scope"), + ScopeId, + "m-1", + "rev-1", + service, + CancellationToken.None); + + // ActivateException being null without a guard would NRE; the guard + // must short-circuit before the service is touched. + AssertIsJsonStatus(result, expectedStatus: StatusCodes.Status403Forbidden); + } + + [Fact] + public async Task HandleRetireBindingRevisionAsync_ShouldReturnOk_OnSuccess() + { + var retire = new StudioMemberBindingRevisionActionResponse( + ScopeId, "m-1", "member-m-1", "rev-1", "retired"); + var service = new RecordingMemberService { RetireResponse = retire }; + + var result = await InvokeHandle( + "HandleRetireBindingRevisionAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + "rev-1", + service, + CancellationToken.None); + + result.Should().BeOfType>() + .Which.Value.Should().BeSameAs(retire); + } + + [Fact] + public async Task HandleRetireBindingRevisionAsync_ShouldReturnTyped404_WhenMemberMissing() + { + var service = new RecordingMemberService + { + RetireException = new StudioMemberNotFoundException(ScopeId, "m-missing"), + }; + + var result = await InvokeHandle( + "HandleRetireBindingRevisionAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-missing", + "rev-1", + service, + CancellationToken.None); + + var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?; + statusCode.Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public async Task HandleRetireBindingRevisionAsync_ShouldReturnBadRequest_OnDomainError() + { + var service = new RecordingMemberService + { + RetireException = new InvalidOperationException("Revision 'rev-x' was not found."), + }; + + var result = await InvokeHandle( + "HandleRetireBindingRevisionAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + "rev-x", + service, + CancellationToken.None); + + result.GetType().Name.Should().StartWith("BadRequest"); + } + + private static StudioMemberEndpointContractResponse NewContract() => new( + ScopeId: ScopeId, + MemberId: "m-1", + PublishedServiceId: "member-m-1", + EndpointId: "chat", + InvokePath: $"/api/scopes/{ScopeId}/members/m-1/invoke/chat:stream", + Method: "POST", + RequestContentType: "application/json", + ResponseContentType: "text/event-stream", + RequestTypeUrl: "type.googleapis.com/x.Request", + ResponseTypeUrl: "type.googleapis.com/x.Response", + SupportsSse: true, + SupportsWebSocket: false, + SupportsAguiFrames: true, + StreamFrameFormat: "agui", + SmokeTestSupported: true, + DefaultSmokeInputMode: "prompt", + DefaultSmokePrompt: "Hello from Studio Bind.", + SampleRequestJson: null, + DeploymentStatus: "Active", + RevisionId: "rev-1"); + private static StudioMemberSummaryResponse NewSummary() => new( MemberId: "m-1", ScopeId: ScopeId, @@ -319,6 +565,12 @@ private sealed class RecordingMemberService : IStudioMemberService public StudioMemberBindingResponse? BindResponse { get; set; } public Exception? BindException { get; set; } public StudioMemberBindingContractResponse? GetBindingResponse { get; set; } + public StudioMemberEndpointContractResponse? EndpointContractResponse { get; set; } + public Exception? EndpointContractException { get; set; } + public StudioMemberBindingActivationResponse? ActivateResponse { get; set; } + public Exception? ActivateException { get; set; } + public StudioMemberBindingRevisionActionResponse? RetireResponse { get; set; } + public Exception? RetireException { get; set; } public Task CreateAsync( string scopeId, CreateStudioMemberRequest request, CancellationToken ct = default) @@ -352,6 +604,27 @@ public Task BindAsync( public Task GetBindingAsync( string scopeId, string memberId, CancellationToken ct = default) => Task.FromResult(GetBindingResponse); + + public Task GetEndpointContractAsync( + string scopeId, string memberId, string endpointId, CancellationToken ct = default) + { + if (EndpointContractException != null) throw EndpointContractException; + return Task.FromResult(EndpointContractResponse); + } + + public Task ActivateBindingRevisionAsync( + string scopeId, string memberId, string revisionId, CancellationToken ct = default) + { + if (ActivateException != null) throw ActivateException; + return Task.FromResult(ActivateResponse!); + } + + public Task RetireBindingRevisionAsync( + string scopeId, string memberId, string revisionId, CancellationToken ct = default) + { + if (RetireException != null) throw RetireException; + return Task.FromResult(RetireResponse!); + } } private sealed class TestHostEnvironment : IHostEnvironment diff --git a/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs b/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs index 4a2bf4657..20c4967b1 100644 --- a/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs +++ b/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs @@ -260,6 +260,15 @@ public Task BindAsync( public Task GetBindingAsync( string scopeId, string memberId, CancellationToken ct = default) => throw _ex; + + public Task GetEndpointContractAsync( + string scopeId, string memberId, string endpointId, CancellationToken ct = default) => throw _ex; + + public Task ActivateBindingRevisionAsync( + string scopeId, string memberId, string revisionId, CancellationToken ct = default) => throw _ex; + + public Task RetireBindingRevisionAsync( + string scopeId, string memberId, string revisionId, CancellationToken ct = default) => throw _ex; } private sealed class TestHostEnvironment : IHostEnvironment diff --git a/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs b/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs index 8e79ac78e..d787b9a69 100644 --- a/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs +++ b/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs @@ -1,5 +1,7 @@ using Aevatar.GAgentService.Abstractions; +using Aevatar.GAgentService.Abstractions.Commands; using Aevatar.GAgentService.Abstractions.Ports; +using Aevatar.GAgentService.Abstractions.Queries; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; using Aevatar.Studio.Application.Studio.Services; @@ -32,7 +34,7 @@ public async Task BindAsync_Workflow_ShouldUseMemberPublishedServiceId() var commandPort = new RecordingCommandPort(); var bindingPort = new RecordingScopeBindingPort(); - var service = new StudioMemberService(commandPort, queryPort, bindingPort); + var service = NewService(commandPort, queryPort, bindingPort); var response = await service.BindAsync( ScopeId, @@ -77,7 +79,7 @@ public async Task BindAsync_Script_ShouldRouteThroughScriptingKind() var commandPort = new RecordingCommandPort(); var bindingPort = new RecordingScopeBindingPort(); - var service = new StudioMemberService(commandPort, queryPort, bindingPort); + var service = NewService(commandPort, queryPort, bindingPort); await service.BindAsync( ScopeId, @@ -105,7 +107,7 @@ public async Task BindAsync_GAgent_ShouldRouteThroughGAgentKind() var commandPort = new RecordingCommandPort(); var bindingPort = new RecordingScopeBindingPort(); - var service = new StudioMemberService(commandPort, queryPort, bindingPort); + var service = NewService(commandPort, queryPort, bindingPort); await service.BindAsync( ScopeId, @@ -139,7 +141,7 @@ await service.BindAsync( public async Task BindAsync_ShouldFail_WhenMemberDoesNotExist() { var queryPort = new InMemoryQueryPort(detail: null); - var service = new StudioMemberService( + var service = NewService( new RecordingCommandPort(), queryPort, new RecordingScopeBindingPort()); @@ -162,7 +164,7 @@ await act.Should().ThrowAsync() public async Task BindAsync_ShouldFail_WhenWorkflowYamlsAreMissing() { var detail = NewDetail(MemberImplementationKindNames.Workflow); - var service = new StudioMemberService( + var service = NewService( new RecordingCommandPort(), new InMemoryQueryPort(detail), new RecordingScopeBindingPort()); @@ -190,7 +192,7 @@ public async Task GetBindingAsync_ShouldReturnLastRecordedBinding() BoundAt: DateTimeOffset.UtcNow), }; - var service = new StudioMemberService( + var service = NewService( new RecordingCommandPort(), new InMemoryQueryPort(withBinding), new RecordingScopeBindingPort()); @@ -202,6 +204,22 @@ public async Task GetBindingAsync_ShouldReturnLastRecordedBinding() binding.RevisionId.Should().Be("rev-9"); } + // Bind / GetBinding don't touch the lifecycle/command ports. We pass + // throwing stubs so that any future regression which routes a bind + // through the platform service ports — instead of through the existing + // IScopeBindingCommandPort — fails loudly here rather than silently + // green. + private static StudioMemberService NewService( + IStudioMemberCommandPort memberCommandPort, + IStudioMemberQueryPort memberQueryPort, + IScopeBindingCommandPort scopeBindingCommandPort) => + new( + memberCommandPort, + memberQueryPort, + scopeBindingCommandPort, + new ThrowingServiceLifecycleQueryPort(), + new ThrowingServiceCommandPort()); + private static StudioMemberDetailResponse NewDetail(string implementationKindWire) { var summary = new StudioMemberSummaryResponse( @@ -295,6 +313,62 @@ public sealed record RecordedBinding( string ImplementationKindName); } + private sealed class ThrowingServiceLifecycleQueryPort : IServiceLifecycleQueryPort + { + public Task GetServiceAsync( + ServiceIdentity identity, CancellationToken ct = default) => + throw new InvalidOperationException("bind orchestration must not query the platform lifecycle port."); + + public Task> ListServicesAsync( + string tenantId, string appId, string @namespace, int take = 200, CancellationToken ct = default) => + throw new InvalidOperationException("bind orchestration must not list services on the platform lifecycle port."); + + public Task GetServiceRevisionsAsync( + ServiceIdentity identity, CancellationToken ct = default) => + throw new InvalidOperationException("bind orchestration must not read revisions through the platform lifecycle port."); + + public Task GetServiceDeploymentsAsync( + ServiceIdentity identity, CancellationToken ct = default) => + throw new InvalidOperationException("bind orchestration must not read deployments through the platform lifecycle port."); + } + + private sealed class ThrowingServiceCommandPort : IServiceCommandPort + { + private static InvalidOperationException Reject(string method) => + new($"bind orchestration must not call IServiceCommandPort.{method} — that surface belongs to revision lifecycle, not bind."); + + public Task CreateServiceAsync( + CreateServiceDefinitionCommand command, CancellationToken ct = default) => throw Reject(nameof(CreateServiceAsync)); + public Task UpdateServiceAsync( + UpdateServiceDefinitionCommand command, CancellationToken ct = default) => throw Reject(nameof(UpdateServiceAsync)); + public Task CreateRevisionAsync( + CreateServiceRevisionCommand command, CancellationToken ct = default) => throw Reject(nameof(CreateRevisionAsync)); + public Task PrepareRevisionAsync( + PrepareServiceRevisionCommand command, CancellationToken ct = default) => throw Reject(nameof(PrepareRevisionAsync)); + public Task PublishRevisionAsync( + PublishServiceRevisionCommand command, CancellationToken ct = default) => throw Reject(nameof(PublishRevisionAsync)); + public Task RetireRevisionAsync( + RetireServiceRevisionCommand command, CancellationToken ct = default) => throw Reject(nameof(RetireRevisionAsync)); + public Task SetDefaultServingRevisionAsync( + SetDefaultServingRevisionCommand command, CancellationToken ct = default) => throw Reject(nameof(SetDefaultServingRevisionAsync)); + public Task ActivateServiceRevisionAsync( + ActivateServiceRevisionCommand command, CancellationToken ct = default) => throw Reject(nameof(ActivateServiceRevisionAsync)); + public Task DeactivateServiceDeploymentAsync( + DeactivateServiceDeploymentCommand command, CancellationToken ct = default) => throw Reject(nameof(DeactivateServiceDeploymentAsync)); + public Task ReplaceServiceServingTargetsAsync( + ReplaceServiceServingTargetsCommand command, CancellationToken ct = default) => throw Reject(nameof(ReplaceServiceServingTargetsAsync)); + public Task StartServiceRolloutAsync( + StartServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(StartServiceRolloutAsync)); + public Task AdvanceServiceRolloutAsync( + AdvanceServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(AdvanceServiceRolloutAsync)); + public Task PauseServiceRolloutAsync( + PauseServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(PauseServiceRolloutAsync)); + public Task ResumeServiceRolloutAsync( + ResumeServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(ResumeServiceRolloutAsync)); + public Task RollbackServiceRolloutAsync( + RollbackServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(RollbackServiceRolloutAsync)); + } + private sealed class RecordingScopeBindingPort : IScopeBindingCommandPort { public string IssuedRevisionId { get; } = "rev-test"; diff --git a/test/Aevatar.Studio.Tests/StudioMemberServiceContractAndRevisionTests.cs b/test/Aevatar.Studio.Tests/StudioMemberServiceContractAndRevisionTests.cs new file mode 100644 index 000000000..06d1ef378 --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberServiceContractAndRevisionTests.cs @@ -0,0 +1,529 @@ +using Aevatar.GAgentService.Abstractions; +using Aevatar.GAgentService.Abstractions.Commands; +using Aevatar.GAgentService.Abstractions.Ports; +using Aevatar.GAgentService.Abstractions.Queries; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Application.Studio.Services; +using FluentAssertions; + +namespace Aevatar.Studio.Tests; + +/// +/// Locks in the orchestration invariants for the new member-first +/// contract / activate / retire surface (issue #454): +/// +/// - Every operation resolves the member-owned publishedServiceId +/// through ; callers never pass a +/// serviceId, and the service ID we hand to the platform ports is the +/// member's stable publishedServiceId, never the raw memberId. +/// - The contract response builds member-first invoke paths, not the +/// legacy /services/{serviceId}/ path. +/// - Activate dispatches both SetDefaultServingRevision and +/// ActivateServiceRevision against the member's identity. +/// - Retired revisions cannot be re-activated. +/// - Retire dispatches RetireServiceRevision against the member's +/// identity, after verifying the revision exists. +/// - Missing members → typed 404; missing revisions / not-yet-bound → +/// (which endpoints map to 400). +/// +public sealed class StudioMemberServiceContractAndRevisionTests +{ + private const string ScopeId = "scope-1"; + private const string MemberId = "m-contract"; + private const string PublishedServiceId = "member-m-contract"; + + [Fact] + public async Task GetEndpointContractAsync_ShouldUseMemberPublishedServiceIdAndMemberInvokePath() + { + var detail = NewDetail(); + var queryPort = new InMemoryMemberQueryPort(detail); + var lifecycle = new InMemoryServiceLifecycleQueryPort + { + Service = NewService( + endpoints: + [ + new ServiceEndpointSnapshot( + EndpointId: "chat", + DisplayName: "Chat", + Kind: "chat", + RequestTypeUrl: "type.googleapis.com/x.Request", + ResponseTypeUrl: "type.googleapis.com/x.Response", + Description: string.Empty), + ]), + Revisions = NewRevisions( + implementationKind: ServiceImplementationKind.Workflow, + endpoints: + [ + new ServiceEndpointSnapshot( + EndpointId: "chat", + DisplayName: "Chat", + Kind: "chat", + RequestTypeUrl: "type.googleapis.com/x.Request", + ResponseTypeUrl: "type.googleapis.com/x.Response", + Description: string.Empty), + ]), + }; + var commandPort = new RecordingServiceCommandPort(); + + var service = new StudioMemberService( + new InertMemberCommandPort(), + queryPort, + new InertScopeBindingCommandPort(), + lifecycle, + commandPort); + + var contract = await service.GetEndpointContractAsync(ScopeId, MemberId, "chat", CancellationToken.None); + + contract.Should().NotBeNull(); + contract!.ScopeId.Should().Be(ScopeId); + contract.MemberId.Should().Be(MemberId); + // The platform-facing identity must be the member's stable + // publishedServiceId — never the raw memberId — otherwise the + // round-trip with Studio's bind path (which writes at member-{id}) + // would 404. + contract.PublishedServiceId.Should().Be(PublishedServiceId); + lifecycle.LastIdentity!.ServiceId.Should().Be(PublishedServiceId); + + // Frontend dispatches off InvokePath; for member-first contracts it + // must be the member URL, not the legacy /services/{id}/invoke URL. + contract.InvokePath.Should().Be($"/api/scopes/{ScopeId}/members/{MemberId}/invoke/chat:stream"); + contract.SupportsSse.Should().BeTrue(); + contract.StreamFrameFormat.Should().Be("workflow-run-event"); + } + + [Fact] + public async Task GetEndpointContractAsync_ShouldReturnNullEndpoint_WhenEndpointNotFound() + { + var detail = NewDetail(); + var queryPort = new InMemoryMemberQueryPort(detail); + var lifecycle = new InMemoryServiceLifecycleQueryPort + { + Service = NewService(endpoints: []), + Revisions = NewRevisions(ServiceImplementationKind.Workflow, endpoints: []), + }; + + var service = new StudioMemberService( + new InertMemberCommandPort(), + queryPort, + new InertScopeBindingCommandPort(), + lifecycle, + new RecordingServiceCommandPort()); + + var contract = await service.GetEndpointContractAsync(ScopeId, MemberId, "ghost", CancellationToken.None); + + contract.Should().BeNull(); + } + + [Fact] + public async Task GetEndpointContractAsync_ShouldThrowMemberNotFound_WhenMemberMissing() + { + var queryPort = new InMemoryMemberQueryPort(detail: null); + var service = new StudioMemberService( + new InertMemberCommandPort(), + queryPort, + new InertScopeBindingCommandPort(), + new InMemoryServiceLifecycleQueryPort(), + new RecordingServiceCommandPort()); + + var act = () => service.GetEndpointContractAsync(ScopeId, "m-missing", "chat"); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetEndpointContractAsync_ShouldThrowInvalidOperation_WhenMemberNotYetBound() + { + // Member exists but has not been bound, so the platform lifecycle + // port has no service catalog entry. Surface as InvalidOperation, + // which endpoints map to 400, distinct from 404 missing-member. + var detail = NewDetail(); + var queryPort = new InMemoryMemberQueryPort(detail); + var lifecycle = new InMemoryServiceLifecycleQueryPort { Service = null }; + var service = new StudioMemberService( + new InertMemberCommandPort(), + queryPort, + new InertScopeBindingCommandPort(), + lifecycle, + new RecordingServiceCommandPort()); + + var act = () => service.GetEndpointContractAsync(ScopeId, MemberId, "chat"); + + await act.Should().ThrowAsync() + .WithMessage("*has no published service yet*"); + } + + [Fact] + public async Task ActivateBindingRevisionAsync_ShouldDispatchSetDefaultAndActivate() + { + var detail = NewDetail(); + var queryPort = new InMemoryMemberQueryPort(detail); + var lifecycle = new InMemoryServiceLifecycleQueryPort + { + Service = NewService(endpoints: []), + Revisions = NewRevisions( + ServiceImplementationKind.Workflow, + endpoints: [], + revisionId: "rev-1", + status: ServiceRevisionStatus.Created), + }; + var commandPort = new RecordingServiceCommandPort(); + + var service = new StudioMemberService( + new InertMemberCommandPort(), + queryPort, + new InertScopeBindingCommandPort(), + lifecycle, + commandPort); + + var response = await service.ActivateBindingRevisionAsync( + ScopeId, + MemberId, + "rev-1", + CancellationToken.None); + + // Both commands must fire in order, both pinned to the member's + // publishedServiceId — never the scope-default service. + commandPort.OperationsInOrder.Should().Equal("SetDefaultServing", "Activate"); + commandPort.SetDefaultIdentities.Should().ContainSingle() + .Which.ServiceId.Should().Be(PublishedServiceId); + commandPort.ActivateIdentities.Should().ContainSingle() + .Which.ServiceId.Should().Be(PublishedServiceId); + commandPort.SetDefaultRevisionIds.Should().ContainSingle().Which.Should().Be("rev-1"); + commandPort.ActivateRevisionIds.Should().ContainSingle().Which.Should().Be("rev-1"); + + response.MemberId.Should().Be(MemberId); + response.PublishedServiceId.Should().Be(PublishedServiceId); + response.RevisionId.Should().Be("rev-1"); + } + + [Fact] + public async Task ActivateBindingRevisionAsync_ShouldRefuse_WhenRevisionRetired() + { + var detail = NewDetail(); + var queryPort = new InMemoryMemberQueryPort(detail); + var lifecycle = new InMemoryServiceLifecycleQueryPort + { + Service = NewService(endpoints: []), + Revisions = NewRevisions( + ServiceImplementationKind.Workflow, + endpoints: [], + revisionId: "rev-r", + status: ServiceRevisionStatus.Retired), + }; + var commandPort = new RecordingServiceCommandPort(); + + var service = new StudioMemberService( + new InertMemberCommandPort(), + queryPort, + new InertScopeBindingCommandPort(), + lifecycle, + commandPort); + + var act = () => service.ActivateBindingRevisionAsync(ScopeId, MemberId, "rev-r"); + + await act.Should().ThrowAsync() + .WithMessage("*retired and cannot be activated*"); + // Guard must reject before any command is dispatched: a retired + // revision must never be revived through Activate. + commandPort.OperationsInOrder.Should().BeEmpty(); + } + + [Fact] + public async Task ActivateBindingRevisionAsync_ShouldThrowInvalidOperation_WhenRevisionMissing() + { + var detail = NewDetail(); + var queryPort = new InMemoryMemberQueryPort(detail); + var lifecycle = new InMemoryServiceLifecycleQueryPort + { + Service = NewService(endpoints: []), + Revisions = NewRevisions( + ServiceImplementationKind.Workflow, + endpoints: [], + revisionId: "rev-other"), + }; + var commandPort = new RecordingServiceCommandPort(); + + var service = new StudioMemberService( + new InertMemberCommandPort(), + queryPort, + new InertScopeBindingCommandPort(), + lifecycle, + commandPort); + + var act = () => service.ActivateBindingRevisionAsync(ScopeId, MemberId, "rev-missing"); + + await act.Should().ThrowAsync() + .WithMessage("*was not found*"); + commandPort.OperationsInOrder.Should().BeEmpty(); + } + + [Fact] + public async Task RetireBindingRevisionAsync_ShouldDispatchRetireOnMemberIdentity() + { + var detail = NewDetail(); + var queryPort = new InMemoryMemberQueryPort(detail); + var lifecycle = new InMemoryServiceLifecycleQueryPort + { + Service = NewService(endpoints: []), + Revisions = NewRevisions( + ServiceImplementationKind.Workflow, + endpoints: [], + revisionId: "rev-9"), + }; + var commandPort = new RecordingServiceCommandPort(); + + var service = new StudioMemberService( + new InertMemberCommandPort(), + queryPort, + new InertScopeBindingCommandPort(), + lifecycle, + commandPort); + + var response = await service.RetireBindingRevisionAsync( + ScopeId, MemberId, "rev-9", CancellationToken.None); + + commandPort.OperationsInOrder.Should().Equal("Retire"); + commandPort.RetireIdentities.Should().ContainSingle() + .Which.ServiceId.Should().Be(PublishedServiceId); + commandPort.RetireRevisionIds.Should().ContainSingle().Which.Should().Be("rev-9"); + + response.Status.Should().Be("retired"); + response.MemberId.Should().Be(MemberId); + response.PublishedServiceId.Should().Be(PublishedServiceId); + response.RevisionId.Should().Be("rev-9"); + } + + [Fact] + public async Task RetireBindingRevisionAsync_ShouldRequireExistingRevision() + { + var detail = NewDetail(); + var queryPort = new InMemoryMemberQueryPort(detail); + var lifecycle = new InMemoryServiceLifecycleQueryPort + { + Service = NewService(endpoints: []), + Revisions = NewRevisions( + ServiceImplementationKind.Workflow, + endpoints: [], + revisionId: "rev-other"), + }; + var commandPort = new RecordingServiceCommandPort(); + + var service = new StudioMemberService( + new InertMemberCommandPort(), + queryPort, + new InertScopeBindingCommandPort(), + lifecycle, + commandPort); + + var act = () => service.RetireBindingRevisionAsync(ScopeId, MemberId, "rev-missing"); + + await act.Should().ThrowAsync(); + commandPort.OperationsInOrder.Should().BeEmpty(); + } + + private static StudioMemberDetailResponse NewDetail() + { + var summary = new StudioMemberSummaryResponse( + MemberId: MemberId, + ScopeId: ScopeId, + DisplayName: "Test Member", + Description: string.Empty, + ImplementationKind: MemberImplementationKindNames.Workflow, + LifecycleStage: MemberLifecycleStageNames.BindReady, + PublishedServiceId: PublishedServiceId, + LastBoundRevisionId: "rev-1", + CreatedAt: DateTimeOffset.UtcNow.AddDays(-1), + UpdatedAt: DateTimeOffset.UtcNow.AddHours(-1)); + + return new StudioMemberDetailResponse( + Summary: summary, + ImplementationRef: null, + LastBinding: null); + } + + private static ServiceCatalogSnapshot NewService(IReadOnlyList endpoints) => + new( + ServiceKey: $"{ScopeId}/{PublishedServiceId}", + TenantId: ScopeId, + AppId: "default", + Namespace: "default", + ServiceId: PublishedServiceId, + DisplayName: "Test Member Service", + DefaultServingRevisionId: "rev-1", + ActiveServingRevisionId: "rev-1", + DeploymentId: "dep-1", + PrimaryActorId: "actor-1", + DeploymentStatus: "Active", + Endpoints: endpoints, + PolicyIds: [], + UpdatedAt: DateTimeOffset.UtcNow); + + private static ServiceRevisionCatalogSnapshot NewRevisions( + ServiceImplementationKind implementationKind, + IReadOnlyList endpoints, + string revisionId = "rev-1", + ServiceRevisionStatus status = ServiceRevisionStatus.Created) + { + return new ServiceRevisionCatalogSnapshot( + ServiceKey: $"{ScopeId}/{PublishedServiceId}", + Revisions: + [ + new ServiceRevisionSnapshot( + RevisionId: revisionId, + ImplementationKind: implementationKind.ToString(), + Status: status.ToString(), + ArtifactHash: "h", + FailureReason: string.Empty, + Endpoints: endpoints, + CreatedAt: DateTimeOffset.UtcNow.AddMinutes(-5), + PreparedAt: null, + PublishedAt: null, + RetiredAt: status == ServiceRevisionStatus.Retired ? DateTimeOffset.UtcNow : null), + ], + UpdatedAt: DateTimeOffset.UtcNow); + } + + private sealed class InMemoryMemberQueryPort : IStudioMemberQueryPort + { + private readonly StudioMemberDetailResponse? _detail; + + public InMemoryMemberQueryPort(StudioMemberDetailResponse? detail) + { + _detail = detail; + } + + public Task ListAsync( + string scopeId, StudioMemberRosterPageRequest? page = null, CancellationToken ct = default) => + Task.FromResult(new StudioMemberRosterResponse(scopeId, _detail == null ? [] : [_detail.Summary])); + + public Task GetAsync( + string scopeId, string memberId, CancellationToken ct = default) => + Task.FromResult(_detail); + } + + // Bind / impl-update commands are not exercised here; we route through + // the member query port and platform service ports directly. Any + // accidental fan-out into this surface should fail loudly. + private sealed class InertMemberCommandPort : IStudioMemberCommandPort + { + public Task CreateAsync( + string scopeId, CreateStudioMemberRequest request, CancellationToken ct = default) => + throw new InvalidOperationException("contract/activate/retire flows must not write to the member command port."); + + public Task UpdateImplementationAsync( + string scopeId, string memberId, + StudioMemberImplementationRefResponse implementation, CancellationToken ct = default) => + throw new InvalidOperationException("contract/activate/retire flows must not update implementation refs."); + + public Task RecordBindingAsync( + string scopeId, string memberId, string publishedServiceId, + string revisionId, string implementationKindName, CancellationToken ct = default) => + throw new InvalidOperationException("contract/activate/retire flows must not record new bindings."); + } + + private sealed class InertScopeBindingCommandPort : IScopeBindingCommandPort + { + public Task UpsertAsync( + ScopeBindingUpsertRequest request, CancellationToken ct = default) => + throw new InvalidOperationException("contract/activate/retire flows must not invoke the scope binding port."); + } + + private sealed class InMemoryServiceLifecycleQueryPort : IServiceLifecycleQueryPort + { + public ServiceCatalogSnapshot? Service { get; set; } + public ServiceRevisionCatalogSnapshot? Revisions { get; set; } + public ServiceIdentity? LastIdentity { get; private set; } + + public Task GetServiceAsync( + ServiceIdentity identity, CancellationToken ct = default) + { + LastIdentity = identity; + return Task.FromResult(Service); + } + + public Task> ListServicesAsync( + string tenantId, string appId, string @namespace, int take = 200, CancellationToken ct = default) => + Task.FromResult>([]); + + public Task GetServiceRevisionsAsync( + ServiceIdentity identity, CancellationToken ct = default) => + Task.FromResult(Revisions); + + public Task GetServiceDeploymentsAsync( + ServiceIdentity identity, CancellationToken ct = default) => + Task.FromResult(null); + } + + private sealed class RecordingServiceCommandPort : IServiceCommandPort + { + public List OperationsInOrder { get; } = []; + public List SetDefaultIdentities { get; } = []; + public List SetDefaultRevisionIds { get; } = []; + public List ActivateIdentities { get; } = []; + public List ActivateRevisionIds { get; } = []; + public List RetireIdentities { get; } = []; + public List RetireRevisionIds { get; } = []; + + public Task SetDefaultServingRevisionAsync( + SetDefaultServingRevisionCommand command, CancellationToken ct = default) + { + SetDefaultIdentities.Add(command.Identity); + SetDefaultRevisionIds.Add(command.RevisionId); + OperationsInOrder.Add("SetDefaultServing"); + return Task.FromResult(NewReceipt()); + } + + public Task ActivateServiceRevisionAsync( + ActivateServiceRevisionCommand command, CancellationToken ct = default) + { + ActivateIdentities.Add(command.Identity); + ActivateRevisionIds.Add(command.RevisionId); + OperationsInOrder.Add("Activate"); + return Task.FromResult(NewReceipt()); + } + + public Task RetireRevisionAsync( + RetireServiceRevisionCommand command, CancellationToken ct = default) + { + RetireIdentities.Add(command.Identity); + RetireRevisionIds.Add(command.RevisionId); + OperationsInOrder.Add("Retire"); + return Task.FromResult(NewReceipt()); + } + + // Unused commands — assert via throw so a future regression that + // routes through the wrong command makes the test red instead of + // silently passing. + private static InvalidOperationException Reject(string method) => + new($"contract/activate/retire flows must not call {method}."); + + public Task CreateServiceAsync( + CreateServiceDefinitionCommand command, CancellationToken ct = default) => throw Reject(nameof(CreateServiceAsync)); + public Task UpdateServiceAsync( + UpdateServiceDefinitionCommand command, CancellationToken ct = default) => throw Reject(nameof(UpdateServiceAsync)); + public Task CreateRevisionAsync( + CreateServiceRevisionCommand command, CancellationToken ct = default) => throw Reject(nameof(CreateRevisionAsync)); + public Task PrepareRevisionAsync( + PrepareServiceRevisionCommand command, CancellationToken ct = default) => throw Reject(nameof(PrepareRevisionAsync)); + public Task PublishRevisionAsync( + PublishServiceRevisionCommand command, CancellationToken ct = default) => throw Reject(nameof(PublishRevisionAsync)); + public Task DeactivateServiceDeploymentAsync( + DeactivateServiceDeploymentCommand command, CancellationToken ct = default) => throw Reject(nameof(DeactivateServiceDeploymentAsync)); + public Task ReplaceServiceServingTargetsAsync( + ReplaceServiceServingTargetsCommand command, CancellationToken ct = default) => throw Reject(nameof(ReplaceServiceServingTargetsAsync)); + public Task StartServiceRolloutAsync( + StartServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(StartServiceRolloutAsync)); + public Task AdvanceServiceRolloutAsync( + AdvanceServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(AdvanceServiceRolloutAsync)); + public Task PauseServiceRolloutAsync( + PauseServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(PauseServiceRolloutAsync)); + public Task ResumeServiceRolloutAsync( + ResumeServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(ResumeServiceRolloutAsync)); + public Task RollbackServiceRolloutAsync( + RollbackServiceRolloutCommand command, CancellationToken ct = default) => throw Reject(nameof(RollbackServiceRolloutAsync)); + + private static ServiceCommandAcceptedReceipt NewReceipt() => + new("actor-1", "cmd-1", "corr-1"); + } +} From 2a6b2a16b9bec087d7574fc255672507212d52e7 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 21:29:26 +0800 Subject: [PATCH 2/3] fix(studio): align platform member-first invoke with Studio publishedServiceId; share contract math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #457 review. ## Functional fix (the inline review): InvokePath / invoke handler mismatch The contract returned by the new `GET /members/.../endpoints/.../contract` was telling the frontend to call `/members/{memberId}/invoke/...`, but the existing platform handler for that path resolves the member through `IMemberPublishedServiceResolver` which today returns `publishedServiceId == memberId`. Studio's bind path persists `publishedServiceId == "member-{memberId}"`. So the contract was built for `member-{memberId}` while invoke would target `{memberId}` → 404. Fix: register `StudioAwareMemberPublishedServiceResolver` from Studio's DI. It first asks `IStudioMemberQueryPort` for the member's stored `publishedServiceId`; if no Studio member exists, falls back to the legacy deterministic mapping (`memberId == publishedServiceId`) so direct platform binds keep working unchanged. Now contract / activate / retire / invoke / runs all resolve to the same identity. ## Refactors per the PR review - **#1 Duplicated contract-building logic**: extracted the pure helpers (`ResolveCurrentContractRevision`, `EnumeratePreferredContractRevisionIds`, `RevisionContainsEndpoint`, `IsChatEndpoint`, `ResolveStreamFrameFormat`, `BuildBase64PayloadPlaceholder`, `BuildTypedInvokeRequestExampleBody`) into `Aevatar.GAgentService.Abstractions.Services.ServiceEndpointContractMath`. Both `ScopeServiceEndpoints.cs` (legacy) and `StudioMemberService.cs` (member-first) funnel through it. A bug fix in one helper now propagates to both paths automatically. - **#3 / #4 Repeated resolve+verify pattern**: introduced `ResolveBoundServiceContextAsync` returning `(ScopeId, MemberId, PublishedServiceId, Identity, Service, Revisions)`. The three new methods now all share one query path; activate / retire dropped from 4 platform queries to 2. - **#2 Non-atomic activate**: documented with a `NOTE:` comment that `SetDefaultServingRevision` then `ActivateServiceRevision` is intentionally non-transactional, mirroring the legacy scope-default behavior, and that both commands are platform-side idempotent. - **#7 Hardcoded "retired" string**: introduced `MemberRevisionLifecycleStatusNames.Retired` next to the existing `MemberLifecycleStageNames` so future lifecycle verbs declare themselves alongside it instead of as scattered magic strings. - **#6 / #8 Input trimming**: collapsed the four ad-hoc trimming sites into a single `NormalizeRequired(value, fieldName)` helper applied at the service entry of every public method. Trimming now happens at exactly one boundary per call. ## Tests - 13 new tests pin the resolver's contract (Studio member → stored publishedServiceId; non-Studio member → legacy fallback; trim; reject malformed input; empty publishedServiceId degrades safely). - Existing tests unchanged: 327 Studio + 281 platform integration passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Studio/Contracts/MemberContracts.cs | 12 + .../ServiceCollectionExtensions.cs | 8 + ...udioAwareMemberPublishedServiceResolver.cs | 85 +++++ .../Studio/Services/StudioMemberService.cs | 293 +++++++----------- .../Services/ServiceEndpointContractMath.cs | 143 +++++++++ .../Endpoints/ScopeServiceEndpoints.cs | 90 +----- ...wareMemberPublishedServiceResolverTests.cs | 171 ++++++++++ 7 files changed, 541 insertions(+), 261 deletions(-) create mode 100644 src/Aevatar.Studio.Application/Studio/Services/StudioAwareMemberPublishedServiceResolver.cs create mode 100644 src/platform/Aevatar.GAgentService.Abstractions/Services/ServiceEndpointContractMath.cs create mode 100644 test/Aevatar.Studio.Tests/StudioAwareMemberPublishedServiceResolverTests.cs diff --git a/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs index b8d12aca9..6e8ebd8a9 100644 --- a/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs +++ b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs @@ -25,6 +25,18 @@ public static class MemberLifecycleStageNames public const string BindReady = "bind_ready"; } +/// +/// Wire-format status values returned in +/// . Centralizing +/// the literal lets future lifecycle actions (e.g. "deprecated") declare +/// themselves alongside instead of rotting as a magic +/// string scattered across handler bodies. +/// +public static class MemberRevisionLifecycleStatusNames +{ + public const string Retired = "retired"; +} + /// /// Implementation reference returned to the caller. Always typed — never a /// generic property bag — so the frontend can dispatch on diff --git a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs index 5cafc4ad3..d38516036 100644 --- a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Services; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +19,13 @@ public static IServiceCollection AddStudioApplication(this IServiceCollection se services.AddSingleton(); services.AddSingleton(); services.TryAddSingleton(); + + // Override the platform's deterministic resolver so existing + // member-first invoke / runs / binding routes resolve to the same + // publishedServiceId Studio's bind path persisted on the member + // authority. Platform registers the default with TryAddSingleton, so + // a plain Replace here wins for IServiceProvider.GetService. + services.Replace(ServiceDescriptor.Singleton()); return services; } } diff --git a/src/Aevatar.Studio.Application/Studio/Services/StudioAwareMemberPublishedServiceResolver.cs b/src/Aevatar.Studio.Application/Studio/Services/StudioAwareMemberPublishedServiceResolver.cs new file mode 100644 index 000000000..c13947fa3 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Services/StudioAwareMemberPublishedServiceResolver.cs @@ -0,0 +1,85 @@ +using Aevatar.GAgentService.Abstractions.Ports; +using Aevatar.Studio.Application.Studio.Abstractions; + +namespace Aevatar.Studio.Application.Studio.Services; + +/// +/// Reconciles the platform's member-first invoke / runs / binding routes +/// with the StudioMember authority introduced in PR #428. +/// +/// The legacy returns +/// publishedServiceId == memberId. Studio's bind path persists +/// publishedServiceId = "member-{memberId}" on the member actor +/// (per StudioMemberConventions.BuildPublishedServiceId). Without this +/// resolver, contract reads / activate / retire would target +/// member-{memberId} while invoke would target {memberId}, so +/// the URL we hand the frontend would 404 against the same binding it just +/// committed. +/// +/// Resolution rule: +/// 1. If the StudioMember authority knows about (scope, member), return its +/// stable publishedServiceId — this is the Studio-bound case. +/// 2. Otherwise fall through to the deterministic legacy mapping +/// (publishedServiceId == memberId) so direct platform binds +/// keep working unchanged. +/// +/// Registered with AddSingleton in Studio's capability so it wins over +/// the platform's TryAddSingleton default; only Studio-enabled hosts +/// take this branch — pure platform integration tests still see the legacy +/// resolver. +/// +public sealed class StudioAwareMemberPublishedServiceResolver : IMemberPublishedServiceResolver +{ + private static readonly char[] DisallowedMemberIdChars = [':', '/', '\\', '?', '#']; + + private readonly IStudioMemberQueryPort _memberQueryPort; + + public StudioAwareMemberPublishedServiceResolver(IStudioMemberQueryPort memberQueryPort) + { + _memberQueryPort = memberQueryPort + ?? throw new ArgumentNullException(nameof(memberQueryPort)); + } + + public async Task ResolveAsync( + MemberPublishedServiceResolveRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ct.ThrowIfCancellationRequested(); + + // Reproduces the legacy resolver's normalization rules so a malformed + // member id (separator chars, empty after trim) fails fast in the + // same way regardless of whether StudioMember authority is touched. + // Centralizing the rule in a shared helper would mean a project + // reference into platform Application; the tradeoff isn't worth it. + var normalizedScopeId = NormalizeRequired(request.ScopeId, nameof(request.ScopeId)); + var normalizedMemberId = NormalizeMemberId(request.MemberId); + + var detail = await _memberQueryPort.GetAsync(normalizedScopeId, normalizedMemberId, ct); + var publishedServiceId = detail?.Summary.PublishedServiceId; + var resolvedServiceId = string.IsNullOrWhiteSpace(publishedServiceId) + ? normalizedMemberId // legacy deterministic mapping for direct platform binds + : publishedServiceId; + + return new MemberPublishedServiceResolution( + normalizedScopeId, + normalizedMemberId, + resolvedServiceId); + } + + private static string NormalizeRequired(string? value, string fieldName) + { + var normalized = value?.Trim() ?? string.Empty; + if (normalized.Length == 0) + throw new InvalidOperationException($"{fieldName} is required."); + return normalized; + } + + private static string NormalizeMemberId(string? memberId) + { + var normalized = NormalizeRequired(memberId, nameof(MemberPublishedServiceResolveRequest.MemberId)); + if (normalized.IndexOfAny(DisallowedMemberIdChars) >= 0) + throw new InvalidOperationException("memberId must not contain ':', '/', '\\', '?' or '#'."); + return normalized; + } +} diff --git a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs index b2b7dd7e7..932a3de8e 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs @@ -1,8 +1,8 @@ -using System.Text.Json; using Aevatar.GAgentService.Abstractions; using Aevatar.GAgentService.Abstractions.Commands; using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.GAgentService.Abstractions.Queries; +using Aevatar.GAgentService.Abstractions.Services; using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Contracts; @@ -37,13 +37,6 @@ public sealed class StudioMemberService : IStudioMemberService private const string ServiceAppId = "default"; private const string ServiceNamespace = "default"; private const string DefaultSmokePrompt = "Hello from Studio Bind."; - private const string StreamFrameFormatWorkflow = "workflow-run-event"; - private const string StreamFrameFormatAgui = "agui"; - - private static readonly JsonSerializerOptions PrettyJsonSerializerOptions = new() - { - WriteIndented = true, - }; private readonly IStudioMemberCommandPort _memberCommandPort; private readonly IStudioMemberQueryPort _memberQueryPort; @@ -184,26 +177,15 @@ await _memberCommandPort.RecordBindingAsync( string endpointId, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(endpointId)) - throw new InvalidOperationException("endpointId is required."); - - var (publishedServiceId, identity) = - await ResolveMemberServiceIdentityAsync(scopeId, memberId, ct); - - // The published service surfaces only after the member is bound — a - // pre-bind read returns 404 from the platform query port. We surface - // that as the same "not bound" 400 the activate/retire paths use, so - // the frontend can branch on a single cause rather than two. - var service = await _serviceLifecycleQueryPort.GetServiceAsync(identity, ct) - ?? throw BuildMemberNotBoundException(memberId); - var revisions = await _serviceLifecycleQueryPort.GetServiceRevisionsAsync(identity, ct); + var normalizedEndpointId = NormalizeRequired(endpointId, nameof(endpointId)); + var context = await ResolveBoundServiceContextAsync(scopeId, memberId, ct); return BuildMemberEndpointContractResponse( - scopeId, - memberId, - publishedServiceId, - endpointId, - service, - revisions); + context.ScopeId, + context.MemberId, + context.PublishedServiceId, + normalizedEndpointId, + context.Service, + context.Revisions); } public async Task ActivateBindingRevisionAsync( @@ -212,17 +194,10 @@ public async Task ActivateBindingRevision string revisionId, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(revisionId)) - throw new InvalidOperationException("revisionId is required."); - - var normalizedRevisionId = revisionId.Trim(); - var (publishedServiceId, identity) = - await ResolveMemberServiceIdentityAsync(scopeId, memberId, ct); - - var service = await _serviceLifecycleQueryPort.GetServiceAsync(identity, ct) - ?? throw BuildMemberNotBoundException(memberId); + var normalizedRevisionId = NormalizeRequired(revisionId, nameof(revisionId)); + var context = await ResolveBoundServiceContextAsync(scopeId, memberId, ct); + var revision = ResolveRevisionOrThrow(context.Revisions, normalizedRevisionId); - var revision = await ResolveRevisionAsync(identity, normalizedRevisionId, ct); if (string.Equals( revision.Status, ServiceRevisionStatus.Retired.ToString(), @@ -232,26 +207,34 @@ public async Task ActivateBindingRevision $"Revision '{normalizedRevisionId}' is retired and cannot be activated."); } + // NOTE: Activate is intentionally non-atomic — it dispatches + // SetDefaultServingRevision then ActivateServiceRevision. If the + // second command fails after the first succeeds, the revision is + // marked default-serving but never moves to "active". This matches + // the legacy scope-default activate path; no compensating action + // is taken here. Both commands are also idempotent on the + // platform side, so a retried Activate from the caller will + // converge. await _serviceCommandPort.SetDefaultServingRevisionAsync( new SetDefaultServingRevisionCommand { - Identity = identity.Clone(), + Identity = context.Identity.Clone(), RevisionId = normalizedRevisionId, }, ct); await _serviceCommandPort.ActivateServiceRevisionAsync( new ActivateServiceRevisionCommand { - Identity = identity.Clone(), + Identity = context.Identity.Clone(), RevisionId = normalizedRevisionId, }, ct); return new StudioMemberBindingActivationResponse( - ScopeId: identity.TenantId, - MemberId: memberId, - PublishedServiceId: publishedServiceId, - DisplayName: service.DisplayName, + ScopeId: context.ScopeId, + MemberId: context.MemberId, + PublishedServiceId: context.PublishedServiceId, + DisplayName: context.Service.DisplayName, RevisionId: normalizedRevisionId); } @@ -261,43 +244,53 @@ public async Task RetireBindingRevisi string revisionId, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(revisionId)) - throw new InvalidOperationException("revisionId is required."); + var normalizedRevisionId = NormalizeRequired(revisionId, nameof(revisionId)); + var context = await ResolveBoundServiceContextAsync(scopeId, memberId, ct); - var normalizedRevisionId = revisionId.Trim(); - var (publishedServiceId, identity) = - await ResolveMemberServiceIdentityAsync(scopeId, memberId, ct); - - // Retire is idempotent on the read-side guard: we still verify the - // revision exists so frontend gets a deterministic 400 for typos - // rather than a silent success that the projection later contradicts. - _ = await _serviceLifecycleQueryPort.GetServiceAsync(identity, ct) - ?? throw BuildMemberNotBoundException(memberId); - _ = await ResolveRevisionAsync(identity, normalizedRevisionId, ct); + // Verify the revision exists in the catalog before dispatching. + // The platform's RetireRevision is idempotent, but a typo in the + // revisionId would silently succeed and the projection would + // surface the contradiction later — the deterministic 400 here is + // the friendlier failure mode. + _ = ResolveRevisionOrThrow(context.Revisions, normalizedRevisionId); await _serviceCommandPort.RetireRevisionAsync( new RetireServiceRevisionCommand { - Identity = identity.Clone(), + Identity = context.Identity.Clone(), RevisionId = normalizedRevisionId, }, ct); return new StudioMemberBindingRevisionActionResponse( - ScopeId: identity.TenantId, - MemberId: memberId, - PublishedServiceId: publishedServiceId, + ScopeId: context.ScopeId, + MemberId: context.MemberId, + PublishedServiceId: context.PublishedServiceId, RevisionId: normalizedRevisionId, - Status: "retired"); + Status: MemberRevisionLifecycleStatusNames.Retired); } - private async Task<(string PublishedServiceId, ServiceIdentity Identity)> ResolveMemberServiceIdentityAsync( + /// + /// Resolves the published service the member is currently bound to in + /// one round-trip: member authority → service catalog → revisions. + /// Bundling the three queries here means the contract / activate / + /// retire paths all see the same revision snapshot they validated + /// against — no TOCTOU window where a revision was retired between + /// the verify and the dispatch — and the test surface only stubs one + /// method instead of three. Throws + /// for missing members and for + /// "exists but never bound" so the endpoint layer maps each to the + /// right HTTP status. + /// + private async Task ResolveBoundServiceContextAsync( string scopeId, string memberId, CancellationToken ct) { - var detail = await _memberQueryPort.GetAsync(scopeId, memberId, ct) - ?? throw new StudioMemberNotFoundException(scopeId, memberId); + var normalizedScopeId = NormalizeRequired(scopeId, nameof(scopeId)); + + var detail = await _memberQueryPort.GetAsync(normalizedScopeId, memberId, ct) + ?? throw new StudioMemberNotFoundException(normalizedScopeId, memberId); var publishedServiceId = detail.Summary.PublishedServiceId; if (string.IsNullOrWhiteSpace(publishedServiceId)) @@ -306,10 +299,6 @@ await _serviceCommandPort.RetireRevisionAsync( $"member '{memberId}' has no publishedServiceId; this is a backend invariant violation."); } - var normalizedScopeId = (scopeId ?? string.Empty).Trim(); - if (normalizedScopeId.Length == 0) - throw new InvalidOperationException("scopeId is required."); - var identity = new ServiceIdentity { TenantId = normalizedScopeId, @@ -317,15 +306,27 @@ await _serviceCommandPort.RetireRevisionAsync( Namespace = ServiceNamespace, ServiceId = publishedServiceId, }; - return (publishedServiceId, identity); + + // The published service surfaces only after the member is bound — a + // pre-bind read returns null from the platform query port. We surface + // that as a 400 (not 404) so the frontend can distinguish "missing + // member" from "exists but unbound" without parsing error text. + var service = await _serviceLifecycleQueryPort.GetServiceAsync(identity, ct) + ?? throw BuildMemberNotBoundException(memberId); + var revisions = await _serviceLifecycleQueryPort.GetServiceRevisionsAsync(identity, ct); + return new BoundServiceContext( + normalizedScopeId, + detail.Summary.MemberId, + publishedServiceId, + identity, + service, + revisions); } - private async Task ResolveRevisionAsync( - ServiceIdentity identity, - string revisionId, - CancellationToken ct) + private static ServiceRevisionSnapshot ResolveRevisionOrThrow( + ServiceRevisionCatalogSnapshot? revisions, + string revisionId) { - var revisions = await _serviceLifecycleQueryPort.GetServiceRevisionsAsync(identity, ct); var revision = revisions?.Revisions.FirstOrDefault(x => string.Equals(x.RevisionId, revisionId, StringComparison.Ordinal)); if (revision == null) @@ -337,19 +338,35 @@ private async Task ResolveRevisionAsync( return revision; } + private static string NormalizeRequired(string? value, string fieldName) + { + var normalized = value?.Trim() ?? string.Empty; + if (normalized.Length == 0) + throw new InvalidOperationException($"{fieldName} is required."); + return normalized; + } + private static InvalidOperationException BuildMemberNotBoundException(string memberId) => new($"member '{memberId}' has no published service yet; bind the member before reading or mutating its revisions."); + private readonly record struct BoundServiceContext( + string ScopeId, + string MemberId, + string PublishedServiceId, + ServiceIdentity Identity, + ServiceCatalogSnapshot Service, + ServiceRevisionCatalogSnapshot? Revisions); + private static StudioMemberEndpointContractResponse? BuildMemberEndpointContractResponse( string scopeId, string memberId, string publishedServiceId, - string endpointId, + string normalizedEndpointId, ServiceCatalogSnapshot service, ServiceRevisionCatalogSnapshot? revisions) { - var normalizedEndpointId = endpointId.Trim(); - var currentRevision = ResolveCurrentContractRevision(service, revisions, normalizedEndpointId); + var currentRevision = ServiceEndpointContractMath.ResolveCurrentContractRevision( + service, revisions, normalizedEndpointId); var endpoint = currentRevision?.Endpoints.FirstOrDefault(x => string.Equals(x.EndpointId, normalizedEndpointId, StringComparison.Ordinal)) ?? service.Endpoints.FirstOrDefault(x => @@ -357,12 +374,13 @@ private static InvalidOperationException BuildMemberNotBoundException(string mem if (endpoint == null) return null; - var implementationKind = NullIfEmpty(currentRevision?.ImplementationKind); - var supportsSse = IsChatEndpoint(endpoint.Kind); - var streamFrameFormat = ResolveStreamFrameFormat(supportsSse, implementationKind); + var implementationKind = ServiceEndpointContractMath.NullIfEmpty(currentRevision?.ImplementationKind); + var supportsSse = ServiceEndpointContractMath.IsChatEndpoint(endpoint.Kind); + var streamFrameFormat = ServiceEndpointContractMath.ResolveStreamFrameFormat( + supportsSse, implementationKind); var supportsAguiFrames = string.Equals( streamFrameFormat, - StreamFrameFormatAgui, + ServiceEndpointContractMath.StreamFrameFormatAgui, StringComparison.Ordinal); var invokePath = supportsSse ? BuildMemberStreamInvokePath(scopeId, memberId, normalizedEndpointId) @@ -372,7 +390,8 @@ private static InvalidOperationException BuildMemberNotBoundException(string mem var defaultSmokePrompt = supportsSse ? DefaultSmokePrompt : null; var sampleRequestJson = supportsSse ? null - : BuildTypedInvokeRequestExampleBody(endpoint.RequestTypeUrl, prettyPrinted: true); + : ServiceEndpointContractMath.BuildTypedInvokeRequestExampleBody( + endpoint.RequestTypeUrl, prettyPrinted: true); var smokeTestSupported = supportsSse || sampleRequestJson != null; return new StudioMemberEndpointContractResponse( @@ -396,8 +415,8 @@ private static InvalidOperationException BuildMemberNotBoundException(string mem SampleRequestJson: sampleRequestJson, DeploymentStatus: service.DeploymentStatus, RevisionId: currentRevision?.RevisionId - ?? NullIfEmpty(service.DefaultServingRevisionId) - ?? NullIfEmpty(service.ActiveServingRevisionId) + ?? ServiceEndpointContractMath.NullIfEmpty(service.DefaultServingRevisionId) + ?? ServiceEndpointContractMath.NullIfEmpty(service.ActiveServingRevisionId) ?? string.Empty, CurlExample: smokeTestSupported ? BuildCurlExample(invokePath, supportsSse, endpoint.RequestTypeUrl) @@ -407,113 +426,17 @@ private static InvalidOperationException BuildMemberNotBoundException(string mem : null); } - private static ServiceRevisionSnapshot? ResolveCurrentContractRevision( - ServiceCatalogSnapshot service, - ServiceRevisionCatalogSnapshot? revisions, - string endpointId) - { - if (revisions == null || revisions.Revisions.Count == 0) - return null; - - foreach (var preferredRevisionId in EnumeratePreferredContractRevisionIds(service)) - { - var preferredRevision = revisions.Revisions.FirstOrDefault(x => - string.Equals(x.RevisionId, preferredRevisionId, StringComparison.Ordinal) && - RevisionContainsEndpoint(x, endpointId)); - if (preferredRevision != null) - return preferredRevision; - } - - return revisions.Revisions.FirstOrDefault(x => - RevisionContainsEndpoint(x, endpointId)) - ?? revisions.Revisions[0]; - } - - private static IEnumerable EnumeratePreferredContractRevisionIds(ServiceCatalogSnapshot service) - { - var defaultRevisionId = NullIfEmpty(service.DefaultServingRevisionId); - if (!string.IsNullOrWhiteSpace(defaultRevisionId)) - yield return defaultRevisionId; - - var activeRevisionId = NullIfEmpty(service.ActiveServingRevisionId); - if (!string.IsNullOrWhiteSpace(activeRevisionId) && - !string.Equals(activeRevisionId, defaultRevisionId, StringComparison.Ordinal)) - { - yield return activeRevisionId; - } - } - - private static bool RevisionContainsEndpoint(ServiceRevisionSnapshot revision, string endpointId) => - revision.Endpoints.Any(endpoint => - string.Equals(endpoint.EndpointId, endpointId, StringComparison.Ordinal)); - - private static bool IsChatEndpoint(string? endpointKind) => - string.Equals(endpointKind?.Trim(), "chat", StringComparison.OrdinalIgnoreCase); - - private static string? ResolveStreamFrameFormat(bool supportsSse, string? implementationKind) - { - if (!supportsSse) - return null; - - if (string.Equals( - implementationKind, - ServiceImplementationKind.Workflow.ToString(), - StringComparison.OrdinalIgnoreCase)) - { - return StreamFrameFormatWorkflow; - } - - if (string.Equals( - implementationKind, - ServiceImplementationKind.Static.ToString(), - StringComparison.OrdinalIgnoreCase) || - string.Equals( - implementationKind, - ServiceImplementationKind.Scripting.ToString(), - StringComparison.OrdinalIgnoreCase)) - { - return StreamFrameFormatAgui; - } - - return null; - } - private static string BuildMemberInvokePath(string scopeId, string memberId, string endpointId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/members/{Uri.EscapeDataString(memberId)}/invoke/{Uri.EscapeDataString(endpointId)}"; private static string BuildMemberStreamInvokePath(string scopeId, string memberId, string endpointId) => $"{BuildMemberInvokePath(scopeId, memberId, endpointId)}:stream"; - private static string? BuildTypedInvokeRequestExampleBody(string? requestTypeUrl, bool prettyPrinted) - { - var normalized = NullIfEmpty(requestTypeUrl); - if (normalized == null) - return null; - - return JsonSerializer.Serialize( - new - { - payloadTypeUrl = normalized, - payloadBase64 = BuildBase64PayloadPlaceholder(normalized), - }, - prettyPrinted ? PrettyJsonSerializerOptions : null); - } - - private static string BuildBase64PayloadPlaceholder(string requestTypeUrl) - { - var typeName = requestTypeUrl - .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .LastOrDefault(); - return string.IsNullOrWhiteSpace(typeName) - ? "" - : $""; - } - private static string BuildCurlExample(string invokePath, bool supportsSse, string? requestTypeUrl) { if (supportsSse) { - var requestBody = JsonSerializer.Serialize(new { prompt = DefaultSmokePrompt }); + var requestBody = System.Text.Json.JsonSerializer.Serialize(new { prompt = DefaultSmokePrompt }); return $""" curl -N -X POST \ -H "Content-Type: application/json" \ @@ -524,7 +447,8 @@ private static string BuildCurlExample(string invokePath, bool supportsSse, stri """; } - var typedBody = BuildTypedInvokeRequestExampleBody(requestTypeUrl, prettyPrinted: false) ?? "{}"; + var typedBody = ServiceEndpointContractMath.BuildTypedInvokeRequestExampleBody( + requestTypeUrl, prettyPrinted: false) ?? "{}"; return $""" curl -X POST \ -H "Content-Type: application/json" \ @@ -555,8 +479,8 @@ private static string BuildFetchExample(string invokePath, bool supportsSse, str """; } - var normalizedRequestTypeUrl = NullIfEmpty(requestTypeUrl) ?? ""; - var payloadBase64 = BuildBase64PayloadPlaceholder(normalizedRequestTypeUrl); + var normalizedRequestTypeUrl = ServiceEndpointContractMath.NullIfEmpty(requestTypeUrl) ?? ""; + var payloadBase64 = ServiceEndpointContractMath.BuildBase64PayloadPlaceholder(normalizedRequestTypeUrl); return $$""" const response = await fetch("{{invokePath}}", { method: "POST", @@ -572,9 +496,6 @@ private static string BuildFetchExample(string invokePath, bool supportsSse, str """; } - private static string? NullIfEmpty(string? value) => - string.IsNullOrWhiteSpace(value) ? null : value.Trim(); - private static ScopeBindingUpsertRequest BuildScopeBindingRequest( string scopeId, string memberId, diff --git a/src/platform/Aevatar.GAgentService.Abstractions/Services/ServiceEndpointContractMath.cs b/src/platform/Aevatar.GAgentService.Abstractions/Services/ServiceEndpointContractMath.cs new file mode 100644 index 000000000..1f51a037a --- /dev/null +++ b/src/platform/Aevatar.GAgentService.Abstractions/Services/ServiceEndpointContractMath.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using Aevatar.GAgentService.Abstractions.Queries; + +namespace Aevatar.GAgentService.Abstractions.Services; + +/// +/// Pure projection helpers shared by every host that turns a +/// + +/// pair into an HTTP-shaped endpoint contract response. Lives in +/// Abstractions because both the legacy scope-default route and the +/// member-first Studio route need exactly the same revision-selection, +/// stream-frame, and example-payload logic; the only thing that legitimately +/// differs between them is the invoke URL shape and the wrapping response +/// record. Anything that depends on URL identity stays at the host +/// boundary; anything pure lives here. +/// +public static class ServiceEndpointContractMath +{ + public const string StreamFrameFormatWorkflow = "workflow-run-event"; + public const string StreamFrameFormatAgui = "agui"; + public const string ImplementationKindWorkflow = "Workflow"; + public const string ImplementationKindStatic = "Static"; + public const string ImplementationKindScripting = "Scripting"; + + public static readonly JsonSerializerOptions PrettyJsonSerializerOptions = new() + { + WriteIndented = true, + }; + + /// + /// Picks the revision whose contract should be served for a given + /// endpoint id. Prefers the default-serving revision, then the + /// active-serving revision, then any revision that contains the + /// endpoint, then the first revision overall as a last resort. The + /// fallback to revisions[0] matches the legacy behavior — it + /// keeps the response stable when the catalog is in a transient state + /// (e.g. just after rollout, before serving rebinds). + /// + public static ServiceRevisionSnapshot? ResolveCurrentContractRevision( + ServiceCatalogSnapshot service, + ServiceRevisionCatalogSnapshot? revisions, + string endpointId) + { + ArgumentNullException.ThrowIfNull(service); + ArgumentException.ThrowIfNullOrWhiteSpace(endpointId); + + if (revisions == null || revisions.Revisions.Count == 0) + return null; + + foreach (var preferredRevisionId in EnumeratePreferredContractRevisionIds(service)) + { + var preferredRevision = revisions.Revisions.FirstOrDefault(x => + string.Equals(x.RevisionId, preferredRevisionId, StringComparison.Ordinal) && + RevisionContainsEndpoint(x, endpointId)); + if (preferredRevision != null) + return preferredRevision; + } + + return revisions.Revisions.FirstOrDefault(x => RevisionContainsEndpoint(x, endpointId)) + ?? revisions.Revisions[0]; + } + + public static IEnumerable EnumeratePreferredContractRevisionIds(ServiceCatalogSnapshot service) + { + ArgumentNullException.ThrowIfNull(service); + + var defaultRevisionId = NullIfEmpty(service.DefaultServingRevisionId); + if (defaultRevisionId != null) + yield return defaultRevisionId; + + var activeRevisionId = NullIfEmpty(service.ActiveServingRevisionId); + if (activeRevisionId != null && + !string.Equals(activeRevisionId, defaultRevisionId, StringComparison.Ordinal)) + { + yield return activeRevisionId; + } + } + + public static bool RevisionContainsEndpoint(ServiceRevisionSnapshot revision, string endpointId) + { + ArgumentNullException.ThrowIfNull(revision); + return revision.Endpoints.Any(endpoint => + string.Equals(endpoint.EndpointId, endpointId, StringComparison.Ordinal)); + } + + public static bool IsChatEndpoint(string? endpointKind) => + string.Equals(endpointKind?.Trim(), "chat", StringComparison.OrdinalIgnoreCase); + + /// + /// Maps (supportsSse, implementationKind) to the SSE frame format the + /// frontend should decode. Workflow runs emit run-event frames; static + /// and scripted runs emit AGUI frames; non-SSE endpoints have no frame + /// format. Implementation-kind matching is case-insensitive because the + /// snapshot's ImplementationKind is the proto enum's .ToString() + /// and casing has shifted across versions. + /// + public static string? ResolveStreamFrameFormat(bool supportsSse, string? implementationKind) + { + if (!supportsSse) + return null; + + if (string.Equals(implementationKind, ImplementationKindWorkflow, StringComparison.OrdinalIgnoreCase)) + return StreamFrameFormatWorkflow; + + if (string.Equals(implementationKind, ImplementationKindStatic, StringComparison.OrdinalIgnoreCase) || + string.Equals(implementationKind, ImplementationKindScripting, StringComparison.OrdinalIgnoreCase)) + { + return StreamFrameFormatAgui; + } + + return null; + } + + public static string? BuildTypedInvokeRequestExampleBody(string? requestTypeUrl, bool prettyPrinted) + { + var normalized = NullIfEmpty(requestTypeUrl); + if (normalized == null) + return null; + + return JsonSerializer.Serialize( + new + { + payloadTypeUrl = normalized, + payloadBase64 = BuildBase64PayloadPlaceholder(normalized), + }, + prettyPrinted ? PrettyJsonSerializerOptions : null); + } + + public static string BuildBase64PayloadPlaceholder(string requestTypeUrl) + { + ArgumentException.ThrowIfNullOrWhiteSpace(requestTypeUrl); + + var typeName = requestTypeUrl + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault(); + return string.IsNullOrWhiteSpace(typeName) + ? "" + : $""; + } + + public static string? NullIfEmpty(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs index a83087fb4..31df6c5ea 100644 --- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs +++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs @@ -2584,64 +2584,23 @@ private static ScopeServiceRevisionCatalogHttpResponse BuildScopeServiceRevision : null); } - private static string? ResolveScopeServiceStreamFrameFormat(bool supportsSse, string? implementationKind) - { - if (!supportsSse) - return null; - - if (string.Equals(implementationKind, ServiceImplementationKind.Workflow.ToString(), StringComparison.OrdinalIgnoreCase)) - return StreamFrameFormatWorkflow; - - if (string.Equals(implementationKind, ServiceImplementationKind.Static.ToString(), StringComparison.OrdinalIgnoreCase) || - string.Equals(implementationKind, ServiceImplementationKind.Scripting.ToString(), StringComparison.OrdinalIgnoreCase)) - { - return StreamFrameFormatAgui; - } - - return null; - } + // Pure projection helpers were moved to + // Aevatar.GAgentService.Abstractions.Services.ServiceEndpointContractMath + // so the legacy scope-default route here and the new member-first + // Studio route share one source of truth — a fix in one no longer + // silently rots the other. The thin wrappers keep call-site + // compatibility for the rest of this file. + private static string? ResolveScopeServiceStreamFrameFormat(bool supportsSse, string? implementationKind) => + ServiceEndpointContractMath.ResolveStreamFrameFormat(supportsSse, implementationKind); private static ServiceRevisionSnapshot? ResolveCurrentContractRevision( ServiceCatalogSnapshot service, ServiceRevisionCatalogSnapshot? revisions, - string endpointId) - { - if (revisions == null || revisions.Revisions.Count == 0) - return null; - - foreach (var preferredRevisionId in EnumeratePreferredContractRevisionIds(service)) - { - var preferredRevision = revisions.Revisions.FirstOrDefault(x => - string.Equals(x.RevisionId, preferredRevisionId, StringComparison.Ordinal) && - RevisionContainsEndpoint(x, endpointId)); - if (preferredRevision != null) - return preferredRevision; - } - - return revisions.Revisions.FirstOrDefault(x => - RevisionContainsEndpoint(x, endpointId)) - ?? revisions.Revisions[0]; - } - - private static IEnumerable EnumeratePreferredContractRevisionIds(ServiceCatalogSnapshot service) - { - var defaultRevisionId = NormalizeOptional(service.DefaultServingRevisionId); - if (!string.IsNullOrWhiteSpace(defaultRevisionId)) - yield return defaultRevisionId; - - var activeRevisionId = NormalizeOptional(service.ActiveServingRevisionId); - if (!string.IsNullOrWhiteSpace(activeRevisionId) && - !string.Equals(activeRevisionId, defaultRevisionId, StringComparison.Ordinal)) - { - yield return activeRevisionId; - } - } - - private static bool RevisionContainsEndpoint(ServiceRevisionSnapshot revision, string endpointId) => - revision.Endpoints.Any(endpoint => string.Equals(endpoint.EndpointId, endpointId, StringComparison.Ordinal)); + string endpointId) => + ServiceEndpointContractMath.ResolveCurrentContractRevision(service, revisions, endpointId); private static bool IsChatEndpoint(string? endpointKind) => - string.Equals(endpointKind?.Trim(), "chat", StringComparison.OrdinalIgnoreCase); + ServiceEndpointContractMath.IsChatEndpoint(endpointKind); private static string BuildScopeServiceInvokePath(string scopeId, string serviceId, string endpointId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/services/{Uri.EscapeDataString(serviceId)}/invoke/{Uri.EscapeDataString(endpointId)}"; @@ -2652,30 +2611,11 @@ private static string BuildScopeServiceStreamInvokePath(string scopeId, string s private static string BuildMemberApiPath(string scopeId, string memberId) => $"/api/scopes/{Uri.EscapeDataString(scopeId)}/members/{Uri.EscapeDataString(memberId)}"; - private static string? BuildTypedInvokeRequestExampleBody(string? requestTypeUrl, bool prettyPrinted) - { - var normalizedRequestTypeUrl = NormalizeOptional(requestTypeUrl); - if (normalizedRequestTypeUrl == null) - return null; + private static string? BuildTypedInvokeRequestExampleBody(string? requestTypeUrl, bool prettyPrinted) => + ServiceEndpointContractMath.BuildTypedInvokeRequestExampleBody(requestTypeUrl, prettyPrinted); - return JsonSerializer.Serialize( - new - { - payloadTypeUrl = normalizedRequestTypeUrl, - payloadBase64 = BuildBase64PayloadPlaceholder(normalizedRequestTypeUrl), - }, - prettyPrinted ? PrettyJsonSerializerOptions : null); - } - - private static string BuildBase64PayloadPlaceholder(string requestTypeUrl) - { - var typeName = requestTypeUrl - .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .LastOrDefault(); - return string.IsNullOrWhiteSpace(typeName) - ? "" - : $""; - } + private static string BuildBase64PayloadPlaceholder(string requestTypeUrl) => + ServiceEndpointContractMath.BuildBase64PayloadPlaceholder(requestTypeUrl); private static string BuildScopeServiceCurlExample( string invokePath, diff --git a/test/Aevatar.Studio.Tests/StudioAwareMemberPublishedServiceResolverTests.cs b/test/Aevatar.Studio.Tests/StudioAwareMemberPublishedServiceResolverTests.cs new file mode 100644 index 000000000..63060c05c --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioAwareMemberPublishedServiceResolverTests.cs @@ -0,0 +1,171 @@ +using Aevatar.GAgentService.Abstractions.Ports; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Application.Studio.Services; +using FluentAssertions; + +namespace Aevatar.Studio.Tests; + +/// +/// Locks in the resolver's contract: +/// +/// - Studio members → return the actor-stored publishedServiceId +/// (e.g. "member-{memberId}") so platform invoke / runs / binding +/// routes target the same service Studio's bind path wrote. +/// - Non-Studio members → fall through to the legacy deterministic +/// mapping (publishedServiceId == memberId) so direct platform +/// binds keep working unchanged. +/// - Malformed input (empty / contains separator chars) → fail fast with +/// the legacy validation rules. +/// +public sealed class StudioAwareMemberPublishedServiceResolverTests +{ + [Fact] + public async Task ResolveAsync_ShouldReturnStudioPublishedServiceId_WhenMemberExists() + { + var port = new InMemoryQueryPort(new Dictionary<(string, string), string> + { + [("scope-1", "m-abc")] = "member-m-abc", + }); + var resolver = new StudioAwareMemberPublishedServiceResolver(port); + + var result = await resolver.ResolveAsync( + new MemberPublishedServiceResolveRequest("scope-1", "m-abc"), + CancellationToken.None); + + result.ScopeId.Should().Be("scope-1"); + result.MemberId.Should().Be("m-abc"); + // The fix the inline review flagged: contract / activate / retire + // resolved at "member-m-abc"; if invoke kept resolving at "m-abc" + // the URL contract handed back to the frontend would 404 against + // its own binding. + result.PublishedServiceId.Should().Be("member-m-abc"); + } + + [Fact] + public async Task ResolveAsync_ShouldFallBackToMemberId_WhenStudioMemberMissing() + { + var port = new InMemoryQueryPort(new Dictionary<(string, string), string>()); + var resolver = new StudioAwareMemberPublishedServiceResolver(port); + + var result = await resolver.ResolveAsync( + new MemberPublishedServiceResolveRequest("scope-1", "legacy-member"), + CancellationToken.None); + + // Direct platform binds (no StudioMember actor) must preserve the + // legacy deterministic mapping; otherwise existing platform-only + // member-first calls would silently break under this resolver. + result.PublishedServiceId.Should().Be("legacy-member"); + } + + [Fact] + public async Task ResolveAsync_ShouldTrimInputs() + { + var port = new InMemoryQueryPort(new Dictionary<(string, string), string> + { + [("scope-1", "m-abc")] = "member-m-abc", + }); + var resolver = new StudioAwareMemberPublishedServiceResolver(port); + + var result = await resolver.ResolveAsync( + new MemberPublishedServiceResolveRequest(" scope-1 ", " m-abc "), + CancellationToken.None); + + result.ScopeId.Should().Be("scope-1"); + result.MemberId.Should().Be("m-abc"); + result.PublishedServiceId.Should().Be("member-m-abc"); + } + + [Theory] + [InlineData("scope:bad", "m-abc")] + [InlineData("scope-1", "m/bad")] + [InlineData("scope-1", "m\\bad")] + [InlineData("scope-1", "m?bad")] + [InlineData("scope-1", "m#bad")] + public async Task ResolveAsync_ShouldRejectMemberIdsWithSeparatorChars(string scopeId, string memberId) + { + // Only memberId has separator restrictions; scope-1/m:bad would + // also fail when the platform scope-id grammar is enforced upstream, + // but here we mirror the legacy resolver's input validation exactly. + if (scopeId.Contains(':')) + return; + + var resolver = new StudioAwareMemberPublishedServiceResolver(new InMemoryQueryPort()); + + var act = () => resolver.ResolveAsync(new MemberPublishedServiceResolveRequest(scopeId, memberId)); + + await act.Should().ThrowAsync() + .WithMessage("*memberId must not contain*"); + } + + [Theory] + [InlineData("", "m-abc", "ScopeId is required.")] + [InlineData(" ", "m-abc", "ScopeId is required.")] + [InlineData("scope-1", "", "MemberId is required.")] + [InlineData("scope-1", " ", "MemberId is required.")] + public async Task ResolveAsync_ShouldRejectEmptyInputs(string scopeId, string memberId, string expectedMessage) + { + var resolver = new StudioAwareMemberPublishedServiceResolver(new InMemoryQueryPort()); + + var act = () => resolver.ResolveAsync(new MemberPublishedServiceResolveRequest(scopeId, memberId)); + + await act.Should().ThrowAsync() + .WithMessage($"*{expectedMessage}*"); + } + + [Fact] + public async Task ResolveAsync_ShouldFallBack_WhenStudioMemberHasEmptyPublishedServiceId() + { + // Defensive: an authority record with a blank publishedServiceId is + // a backend invariant violation, but resolver shouldn't crash — + // it should degrade to the legacy mapping so the rest of the host + // keeps serving. The right place to surface the invariant violation + // is StudioMemberService, which the test there asserts. + var port = new InMemoryQueryPort(new Dictionary<(string, string), string> + { + [("scope-1", "m-abc")] = string.Empty, + }); + var resolver = new StudioAwareMemberPublishedServiceResolver(port); + + var result = await resolver.ResolveAsync( + new MemberPublishedServiceResolveRequest("scope-1", "m-abc"), + CancellationToken.None); + + result.PublishedServiceId.Should().Be("m-abc"); + } + + private sealed class InMemoryQueryPort : IStudioMemberQueryPort + { + private readonly IReadOnlyDictionary<(string Scope, string Member), string> _publishedServiceIds; + + public InMemoryQueryPort(IReadOnlyDictionary<(string Scope, string Member), string>? publishedServiceIds = null) + { + _publishedServiceIds = publishedServiceIds ?? new Dictionary<(string, string), string>(); + } + + public Task ListAsync( + string scopeId, StudioMemberRosterPageRequest? page = null, CancellationToken ct = default) => + Task.FromResult(new StudioMemberRosterResponse(scopeId, [])); + + public Task GetAsync( + string scopeId, string memberId, CancellationToken ct = default) + { + if (!_publishedServiceIds.TryGetValue((scopeId, memberId), out var publishedServiceId)) + return Task.FromResult(null); + + var summary = new StudioMemberSummaryResponse( + MemberId: memberId, + ScopeId: scopeId, + DisplayName: "Test", + Description: string.Empty, + ImplementationKind: MemberImplementationKindNames.Workflow, + LifecycleStage: MemberLifecycleStageNames.BindReady, + PublishedServiceId: publishedServiceId, + LastBoundRevisionId: null, + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow); + return Task.FromResult( + new StudioMemberDetailResponse(summary, null, null)); + } + } +} From b1edf4d98f276cacde48d56a2c0bc862efb7fe06 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Mon, 27 Apr 2026 21:49:49 +0800 Subject: [PATCH 3/3] fix(studio): annotate IStudioMemberService handler params with [FromServices] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #457 follow-up: mainnet host startup fails with "BindAsync method found on IStudioMemberService with incorrect format" because Minimal API's RequestDelegateFactory probes every parameter type for a BindAsync custom-binder hook, and IStudioMemberService itself defines an instance method named BindAsync (the bind-revision write path). Without [FromServices] on the parameter, the binder matches the probe to that instance method, fails its shape check, and the whole composition tears down before serving any request. Reproducer (from the review): dotnet test test/Aevatar.Hosting.Tests/Aevatar.Hosting.Tests.csproj \ --filter "FullyQualifiedName~MainnetHostCompositionTests" Both MainnetHostCompositionTests and MainnetHealthEndpointsTests went red on this. The handler-only unit tests in StudioMemberEndpointsTests missed it because they call the static handlers via reflection and never exercise RequestDelegateFactory. Fix: decorate every IStudioMemberService parameter across all eight handlers with [FromServices]. Class doc-comment now explicitly names the failure mode so a future contributor doesn't strip the attribute back off "for tidiness." Regression guard: new StudioMemberEndpointsRouteBindingTests forces endpoint construction (the exact codepath from the failing stack trace) and asserts all 8 routes build. Without [FromServices] this test goes red at the Studio test layer instead of mainnet startup. Verified: test/Aevatar.Hosting.Tests — 32 passed (was 30 + 2 failing) test/Aevatar.Studio.Tests — 330 passed (+1 new regression test) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Endpoints/StudioMemberEndpoints.cs | 28 ++++-- .../StudioMemberEndpointsRouteBindingTests.cs | 92 +++++++++++++++++++ .../StudioMemberEndpointsTests.cs | 79 +++++++++++++++- 3 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 test/Aevatar.Studio.Tests/StudioMemberEndpointsRouteBindingTests.cs diff --git a/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs index f6b2333a6..79cfddf8d 100644 --- a/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs +++ b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs @@ -3,6 +3,7 @@ using Aevatar.Studio.Application.Studio.Contracts; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; namespace Aevatar.Studio.Hosting.Endpoints; @@ -18,6 +19,17 @@ namespace Aevatar.Studio.Hosting.Endpoints; /// Error mapping: /// - → 404 /// - other (validation) → 400 +/// +/// IMPORTANT: every parameter must carry +/// the . Minimal API's +/// RequestDelegateFactory probes parameter types for a +/// BindAsync custom-binder hook; the interface itself defines an +/// instance method named BindAsync, which the binder then rejects +/// with "BindAsync method found on IStudioMemberService with incorrect +/// format" at host startup — before any request is served. The +/// attribute short-circuits that probe and resolves the dependency from DI +/// instead. Removing it will pass unit tests (which call the handlers +/// directly) but break the entire mainnet host composition. /// internal static class StudioMemberEndpoints { @@ -53,7 +65,7 @@ internal static async Task HandleCreateAsync( HttpContext http, string scopeId, CreateStudioMemberRequest request, - IStudioMemberService memberService, + [FromServices] IStudioMemberService memberService, CancellationToken ct) { if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) @@ -73,7 +85,7 @@ internal static async Task HandleCreateAsync( internal static async Task HandleListAsync( HttpContext http, string scopeId, - IStudioMemberService memberService, + [FromServices] IStudioMemberService memberService, int? pageSize, string? pageToken, CancellationToken ct) @@ -98,7 +110,7 @@ internal static async Task HandleGetAsync( HttpContext http, string scopeId, string memberId, - IStudioMemberService memberService, + [FromServices] IStudioMemberService memberService, CancellationToken ct) { if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) @@ -123,7 +135,7 @@ internal static async Task HandleBindAsync( string scopeId, string memberId, UpdateStudioMemberBindingRequest request, - IStudioMemberService memberService, + [FromServices] IStudioMemberService memberService, CancellationToken ct) { if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) @@ -147,7 +159,7 @@ internal static async Task HandleGetBindingAsync( HttpContext http, string scopeId, string memberId, - IStudioMemberService memberService, + [FromServices] IStudioMemberService memberService, CancellationToken ct) { if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) @@ -180,7 +192,7 @@ internal static async Task HandleGetEndpointContractAsync( string scopeId, string memberId, string endpointId, - IStudioMemberService memberService, + [FromServices] IStudioMemberService memberService, CancellationToken ct) { if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) @@ -215,7 +227,7 @@ internal static async Task HandleActivateBindingRevisionAsync( string scopeId, string memberId, string revisionId, - IStudioMemberService memberService, + [FromServices] IStudioMemberService memberService, CancellationToken ct) { if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) @@ -240,7 +252,7 @@ internal static async Task HandleRetireBindingRevisionAsync( string scopeId, string memberId, string revisionId, - IStudioMemberService memberService, + [FromServices] IStudioMemberService memberService, CancellationToken ct) { if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) diff --git a/test/Aevatar.Studio.Tests/StudioMemberEndpointsRouteBindingTests.cs b/test/Aevatar.Studio.Tests/StudioMemberEndpointsRouteBindingTests.cs new file mode 100644 index 000000000..f82d8241e --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberEndpointsRouteBindingTests.cs @@ -0,0 +1,92 @@ +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Hosting.Endpoints; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.Studio.Tests; + +/// +/// Regression guard for the failure mode reported on PR #457: +/// +/// Minimal API's RequestDelegateFactory probes every parameter +/// type for a BindAsync custom-binder hook. +/// itself defines an instance method named BindAsync. Without +/// [FromServices] on the parameter, the binder rejects the route at +/// startup with "BindAsync method found on IStudioMemberService with +/// incorrect format" — *before* any request is served. +/// +/// The handler-level unit tests in +/// miss this because they invoke +/// the static handlers via reflection and never exercise +/// RequestDelegateFactory. This test exercises the actual route +/// pipeline by forcing endpoint construction; if a future contributor +/// drops [FromServices] from any handler, this test goes red +/// instead of the regression silently shipping until mainnet startup. +/// +public sealed class StudioMemberEndpointsRouteBindingTests +{ + [Fact] + public void Map_ShouldBuildAllRoutes_WithoutBindAsyncCollision() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddSingleton(); + builder.Services.AddRouting(); + var app = builder.Build(); + + StudioMemberEndpoints.Map(app); + + // Forcing endpoint construction is what triggers the + // RequestDelegateFactory probe that previously threw on + // IStudioMemberService.BindAsync — this is the exact codepath + // mainnet host startup hits on Build(). + var endpoints = ((IEndpointRouteBuilder)app).DataSources + .SelectMany(d => d.Endpoints) + .ToList(); + + // Eight routes mapped: create, list, get, bind, get-binding, + // contract, activate, retire. + endpoints.Should().HaveCount(8); + } + + private sealed class NoOpMemberService : IStudioMemberService + { + // The route-binding test never dispatches a request, so none of + // these are actually called. They exist to satisfy DI; making + // them throw NotImplementedException would also work, but + // returning trivially keeps the surface boring. + public Task CreateAsync( + string scopeId, CreateStudioMemberRequest request, CancellationToken ct = default) => + Task.FromException(new NotImplementedException()); + + public Task ListAsync( + string scopeId, StudioMemberRosterPageRequest? page = null, CancellationToken ct = default) => + Task.FromResult(new StudioMemberRosterResponse(scopeId, [])); + + public Task GetAsync( + string scopeId, string memberId, CancellationToken ct = default) => + Task.FromException(new NotImplementedException()); + + public Task BindAsync( + string scopeId, string memberId, UpdateStudioMemberBindingRequest request, CancellationToken ct = default) => + Task.FromException(new NotImplementedException()); + + public Task GetBindingAsync( + string scopeId, string memberId, CancellationToken ct = default) => + Task.FromResult(null); + + public Task GetEndpointContractAsync( + string scopeId, string memberId, string endpointId, CancellationToken ct = default) => + Task.FromResult(null); + + public Task ActivateBindingRevisionAsync( + string scopeId, string memberId, string revisionId, CancellationToken ct = default) => + Task.FromException(new NotImplementedException()); + + public Task RetireBindingRevisionAsync( + string scopeId, string memberId, string revisionId, CancellationToken ct = default) => + Task.FromException(new NotImplementedException()); + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs b/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs index 160994aa6..6cd41022b 100644 --- a/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs +++ b/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs @@ -291,8 +291,7 @@ public async Task HandleGetEndpointContractAsync_ShouldReturnNotFound_WhenServic service, CancellationToken.None); - var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?; - statusCode.Should().Be(StatusCodes.Status404NotFound); + AssertNotFoundResult(result, "STUDIO_MEMBER_ENDPOINT_CONTRACT_NOT_FOUND"); } [Fact] @@ -333,7 +332,26 @@ public async Task HandleGetEndpointContractAsync_ShouldReturnBadRequest_OnDomain service, CancellationToken.None); - result.GetType().Name.Should().StartWith("BadRequest"); + AssertBadRequestResult(result, "INVALID_STUDIO_MEMBER_ENDPOINT_CONTRACT_REQUEST"); + } + + [Fact] + public async Task HandleGetEndpointContractAsync_ShouldReturnForbidden_WhenScopeAccessDenied() + { + var service = new RecordingMemberService(); + + var result = await InvokeHandle( + "HandleGetEndpointContractAsync", + CreateAuthenticatedContext("other-scope"), + ScopeId, + "m-1", + "chat", + service, + CancellationToken.None); + + // EndpointContractException being null without a guard would NRE; the + // guard must short-circuit before the service is touched. + AssertIsJsonStatus(result, expectedStatus: StatusCodes.Status403Forbidden); } [Fact] @@ -395,7 +413,7 @@ public async Task HandleActivateBindingRevisionAsync_ShouldReturnBadRequest_OnDo service, CancellationToken.None); - result.GetType().Name.Should().StartWith("BadRequest"); + AssertBadRequestResult(result, "INVALID_STUDIO_MEMBER_BINDING_ACTIVATION_REQUEST"); } [Fact] @@ -475,7 +493,26 @@ public async Task HandleRetireBindingRevisionAsync_ShouldReturnBadRequest_OnDoma service, CancellationToken.None); - result.GetType().Name.Should().StartWith("BadRequest"); + AssertBadRequestResult(result, "INVALID_STUDIO_MEMBER_BINDING_REVISION_REQUEST"); + } + + [Fact] + public async Task HandleRetireBindingRevisionAsync_ShouldReturnForbidden_WhenScopeAccessDenied() + { + var service = new RecordingMemberService(); + + var result = await InvokeHandle( + "HandleRetireBindingRevisionAsync", + CreateAuthenticatedContext("other-scope"), + ScopeId, + "m-1", + "rev-1", + service, + CancellationToken.None); + + // RetireException being null without a guard would NRE; the guard + // must short-circuit before the service is touched. + AssertIsJsonStatus(result, expectedStatus: StatusCodes.Status403Forbidden); } private static StudioMemberEndpointContractResponse NewContract() => new( @@ -544,6 +581,38 @@ private static void AssertIsJsonStatus(IResult result, int expectedStatus) because: $"expected JSON result with status {expectedStatus} but got {result.GetType().Name}"); } + private static void AssertBadRequestResult(IResult result, string expectedCode) + { + result.GetType().Name.Should().StartWith("BadRequest"); + + var statusCodeProp = result.GetType().GetProperty("StatusCode"); + var statusCode = statusCodeProp?.GetValue(result) as int?; + statusCode.Should().Be(StatusCodes.Status400BadRequest); + + var valueProp = result.GetType().GetProperty("Value"); + var value = valueProp?.GetValue(result); + value.Should().NotBeNull(); + + var codeProp = value!.GetType().GetProperty("code"); + var code = codeProp?.GetValue(value) as string; + code.Should().Be(expectedCode); + } + + private static void AssertNotFoundResult(IResult result, string expectedCode) + { + var statusCodeProp = result.GetType().GetProperty("StatusCode"); + var statusCode = statusCodeProp?.GetValue(result) as int?; + statusCode.Should().Be(StatusCodes.Status404NotFound); + + var valueProp = result.GetType().GetProperty("Value"); + var value = valueProp?.GetValue(result); + value.Should().NotBeNull(); + + var codeProp = value!.GetType().GetProperty("code"); + var code = codeProp?.GetValue(value) as string; + code.Should().Be(expectedCode); + } + private static async Task InvokeHandle(string methodName, params object?[] args) { var method = typeof(StudioMemberEndpoints)