Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,50 @@ Task<StudioMemberBindingResponse> BindAsync(
string scopeId,
string memberId,
CancellationToken ct = default);

/// <summary>
/// Returns the request/response contract for a single endpoint on the
/// member-owned published service. Resolves the member's
/// <c>publishedServiceId</c> internally so callers never pass a serviceId.
/// Returns <c>null</c> when the member is bound but no matching endpoint
/// exists; throws <see cref="StudioMemberNotFoundException"/> when the
/// member itself does not exist; throws
/// <see cref="InvalidOperationException"/> when the member exists but has
/// not been bound yet (no published service to read a contract from).
/// </summary>
Task<StudioMemberEndpointContractResponse?> GetEndpointContractAsync(
string scopeId,
string memberId,
string endpointId,
CancellationToken ct = default);

/// <summary>
/// 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
/// <c>publishedServiceId</c> internally; callers never pass a serviceId.
/// Throws <see cref="StudioMemberNotFoundException"/> when the member
/// is missing, and <see cref="InvalidOperationException"/> when the
/// member exists but has not been bound, or when the requested revision
/// is missing/retired.
/// </summary>
Task<StudioMemberBindingActivationResponse> ActivateBindingRevisionAsync(
string scopeId,
string memberId,
string revisionId,
CancellationToken ct = default);

/// <summary>
/// Retires a binding revision on the member's published service.
/// Resolves the member-owned <c>publishedServiceId</c> internally;
/// callers never pass a serviceId.
/// Throws <see cref="StudioMemberNotFoundException"/> when the member
/// is missing, and <see cref="InvalidOperationException"/> when the
/// member is unbound or the revision does not exist.
/// </summary>
Task<StudioMemberBindingRevisionActionResponse> RetireBindingRevisionAsync(
string scopeId,
string memberId,
string revisionId,
CancellationToken ct = default);
}
69 changes: 69 additions & 0 deletions src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ public static class MemberLifecycleStageNames
public const string BindReady = "bind_ready";
}

/// <summary>
/// Wire-format status values returned in
/// <see cref="StudioMemberBindingRevisionActionResponse.Status"/>. Centralizing
/// the literal lets future lifecycle actions (e.g. "deprecated") declare
/// themselves alongside <see cref="Retired"/> instead of rotting as a magic
/// string scattered across handler bodies.
/// </summary>
public static class MemberRevisionLifecycleStatusNames
{
public const string Retired = "retired";
}

/// <summary>
/// Implementation reference returned to the caller. Always typed — never a
/// generic property bag — so the frontend can dispatch on
Expand Down Expand Up @@ -134,3 +146,60 @@ public sealed record StudioMemberBindingResponse(
string ImplementationKind,
string ScopeId,
string ExpectedActorId);

/// <summary>
/// Member-first endpoint contract. Mirrors the existing scope-default
/// <c>ScopeServiceEndpointContractHttpResponse</c> shape so the frontend can
/// keep its rendering, while pinning <see cref="MemberId"/> and exposing the
/// member-first <see cref="InvokePath"/>. <see cref="PublishedServiceId"/> is
/// included for parity with the legacy serviceId-based payload, not as a
/// required field for the caller.
/// </summary>
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);

/// <summary>
/// Activation result for a member's binding revision. Carries
/// <see cref="MemberId"/> as the stable identity; <see cref="PublishedServiceId"/>
/// is included so the frontend can fall back to the legacy serviceId-keyed
/// store while it migrates, but no caller should require it.
/// </summary>
public sealed record StudioMemberBindingActivationResponse(
string ScopeId,
string MemberId,
string PublishedServiceId,
string DisplayName,
string RevisionId);

/// <summary>
/// Generic member-first revision lifecycle action result, mirroring the
/// legacy <c>ScopeServiceRevisionActionHttpResponse</c>. <see cref="Status"/>
/// is the lowercase verb (e.g. <c>retired</c>) the legacy payload uses.
/// </summary>
public sealed record StudioMemberBindingRevisionActionResponse(
string ScopeId,
string MemberId,
string PublishedServiceId,
string RevisionId,
string Status);
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,6 +19,13 @@ public static IServiceCollection AddStudioApplication(this IServiceCollection se
services.AddSingleton<RoleCatalogService>();
services.AddSingleton<SettingsService>();
services.TryAddSingleton<IStudioMemberService, StudioMemberService>();

// 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<T>.
services.Replace(ServiceDescriptor.Singleton<IMemberPublishedServiceResolver, StudioAwareMemberPublishedServiceResolver>());
return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using Aevatar.GAgentService.Abstractions.Ports;
using Aevatar.Studio.Application.Studio.Abstractions;

namespace Aevatar.Studio.Application.Studio.Services;

/// <summary>
/// Reconciles the platform's member-first invoke / runs / binding routes
/// with the StudioMember authority introduced in PR #428.
///
/// The legacy <see cref="DefaultMemberPublishedServiceResolver"/> returns
/// <c>publishedServiceId == memberId</c>. Studio's bind path persists
/// <c>publishedServiceId = "member-{memberId}"</c> on the member actor
/// (per <c>StudioMemberConventions.BuildPublishedServiceId</c>). Without this
/// resolver, contract reads / activate / retire would target
/// <c>member-{memberId}</c> while invoke would target <c>{memberId}</c>, 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 <c>publishedServiceId</c> — this is the Studio-bound case.
/// 2. Otherwise fall through to the deterministic legacy mapping
/// (<c>publishedServiceId == memberId</c>) so direct platform binds
/// keep working unchanged.
///
/// Registered with <c>AddSingleton</c> in Studio's capability so it wins over
/// the platform's <c>TryAddSingleton</c> default; only Studio-enabled hosts
/// take this branch — pure platform integration tests still see the legacy
/// resolver.
/// </summary>
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<MemberPublishedServiceResolution> 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;
}
}
Loading
Loading