diff --git a/aevatar.slnx b/aevatar.slnx index e49e88ad9..bd3c86746 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -16,6 +16,7 @@ + diff --git a/agents/Aevatar.GAgents.StudioMember/Aevatar.GAgents.StudioMember.csproj b/agents/Aevatar.GAgents.StudioMember/Aevatar.GAgents.StudioMember.csproj new file mode 100644 index 000000000..6dc06ac9c --- /dev/null +++ b/agents/Aevatar.GAgents.StudioMember/Aevatar.GAgents.StudioMember.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + Aevatar.GAgents.StudioMember + Aevatar.GAgents.StudioMember + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/agents/Aevatar.GAgents.StudioMember/StudioMemberConventions.cs b/agents/Aevatar.GAgents.StudioMember/StudioMemberConventions.cs new file mode 100644 index 000000000..810d8134d --- /dev/null +++ b/agents/Aevatar.GAgents.StudioMember/StudioMemberConventions.cs @@ -0,0 +1,62 @@ +namespace Aevatar.GAgents.StudioMember; + +/// +/// Canonical naming for StudioMember actor IDs and the rename-safe +/// publishedServiceId derived from the immutable member id. +/// +/// Both helpers are pure functions of their inputs; they do not read any +/// runtime state and they produce no side effects. The publishedServiceId +/// computed here is meant to be persisted on first create_member and +/// then read back from state — never recomputed from a mutable display name. +/// +public static class StudioMemberConventions +{ + public const string ActorIdPrefix = "studio-member"; + public const string PublishedServiceIdPrefix = "member"; + + /// + /// Builds the actor id used by . + /// + public static string BuildActorId(string scopeId, string memberId) + { + var normalizedScopeId = NormalizeScopeId(scopeId); + var normalizedMemberId = NormalizeMemberId(memberId); + return $"{ActorIdPrefix}:{normalizedScopeId}:{normalizedMemberId}"; + } + + /// + /// Derives the rename-safe published service id from the immutable + /// . Should be invoked once at creation time + /// and the result persisted on the actor state — callers must not + /// recompute it on read. + /// + public static string BuildPublishedServiceId(string memberId) + { + var normalizedMemberId = NormalizeMemberId(memberId); + return $"{PublishedServiceIdPrefix}-{normalizedMemberId}"; + } + + public static string NormalizeScopeId(string? scopeId) + { + var trimmed = scopeId?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + throw new ArgumentException("scopeId is required.", nameof(scopeId)); + if (ContainsActorIdSeparator(trimmed)) + throw new ArgumentException( + "scopeId must not contain ':' (it is the actor-id separator).", nameof(scopeId)); + return trimmed; + } + + public static string NormalizeMemberId(string? memberId) + { + var trimmed = memberId?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + throw new ArgumentException("memberId is required.", nameof(memberId)); + if (ContainsActorIdSeparator(trimmed)) + throw new ArgumentException( + "memberId must not contain ':' (it is the actor-id separator).", nameof(memberId)); + return trimmed; + } + + private static bool ContainsActorIdSeparator(string value) => value.Contains(':'); +} diff --git a/agents/Aevatar.GAgents.StudioMember/StudioMemberGAgent.cs b/agents/Aevatar.GAgents.StudioMember/StudioMemberGAgent.cs new file mode 100644 index 000000000..b7b452c21 --- /dev/null +++ b/agents/Aevatar.GAgents.StudioMember/StudioMemberGAgent.cs @@ -0,0 +1,217 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.GAgents.StudioMember; + +/// +/// Per-member actor that owns the canonical StudioMember authority state. +/// +/// Actor ID convention: studio-member:{scopeId}:{memberId}. +/// The actor is the only writer of published_service_id, which is +/// generated once at creation from the immutable member_id and never +/// recomputed on rename. The convention is re-derived inside the actor in +/// so a stale or hand-crafted event payload +/// cannot break the rename-safe invariant. +/// +public sealed class StudioMemberGAgent : GAgentBase, IProjectedActor +{ + public static string ProjectionKind => "studio-member"; + + [EventHandler(EndpointName = "createMember")] + public async Task HandleCreated(StudioMemberCreatedEvent evt) + { + if (!string.IsNullOrEmpty(State.MemberId)) + { + // First-write-wins on identity: a re-create with a different + // memberId is a hard conflict (someone is reusing an existing + // actor id for a different member). A re-create with the same + // memberId but mismatched non-identity fields is also rejected + // so a stray duplicate cannot silently overwrite the persisted + // displayName / kind / description and leave callers confused + // about which version persisted. + if (!string.Equals(State.MemberId, evt.MemberId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"member already initialized with id '{State.MemberId}'."); + } + + if (!string.Equals(State.DisplayName, evt.DisplayName, StringComparison.Ordinal) + || !string.Equals(State.Description, evt.Description, StringComparison.Ordinal) + || State.ImplementationKind != evt.ImplementationKind) + { + throw new InvalidOperationException( + $"member '{State.MemberId}' already exists with different displayName / description / implementationKind. " + + "First-write-wins on member identity; use rename / updateImplementation to change later."); + } + + // Same memberId + same identity-stable fields = idempotent no-op. + return; + } + + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "renameMember")] + public async Task HandleRenamed(StudioMemberRenamedEvent evt) + { + if (string.IsNullOrEmpty(State.MemberId)) + { + throw new InvalidOperationException("member not yet created."); + } + + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "updateImplementation")] + public async Task HandleImplementationUpdated(StudioMemberImplementationUpdatedEvent evt) + { + if (string.IsNullOrEmpty(State.MemberId)) + { + throw new InvalidOperationException("member not yet created."); + } + + // ImplementationKind is locked at create. Reject mismatched kinds so + // a Script member can't be silently mutated into a Workflow member by + // dispatching an UpdatedEvent with a different kind. Unspecified is + // accepted as "carry the existing kind" (defensive default). + if (evt.ImplementationKind != StudioMemberImplementationKind.Unspecified + && evt.ImplementationKind != State.ImplementationKind) + { + throw new InvalidOperationException( + $"member '{State.MemberId}' implementationKind is locked at create. " + + $"Was {State.ImplementationKind}, attempted {evt.ImplementationKind}. " + + "Use create with the correct kind, or rename / impl-update with the same kind."); + } + + await PersistDomainEventAsync(evt); + } + + [EventHandler(EndpointName = "recordBinding")] + public async Task HandleBound(StudioMemberBoundEvent evt) + { + if (string.IsNullOrEmpty(State.MemberId)) + { + throw new InvalidOperationException("member not yet created."); + } + + await PersistDomainEventAsync(evt); + } + + protected override StudioMemberState TransitionState( + StudioMemberState current, IMessage evt) + { + return StateTransitionMatcher + .Match(current, evt) + .On(ApplyCreated) + .On(ApplyRenamed) + .On(ApplyImplementationUpdated) + .On(ApplyBound) + .OrCurrent(); + } + + private static StudioMemberState ApplyCreated( + StudioMemberState state, StudioMemberCreatedEvent evt) + { + // Re-derive publishedServiceId from the immutable memberId rather + // than trusting evt.PublishedServiceId. The dispatcher today already + // builds it via the same convention; deriving here keeps the + // single-source-of-truth on the actor and protects against a + // historical or hand-rolled event whose derivation rule drifted. + var derivedPublishedServiceId = StudioMemberConventions.BuildPublishedServiceId(evt.MemberId); + + return new StudioMemberState + { + MemberId = evt.MemberId, + ScopeId = evt.ScopeId, + DisplayName = evt.DisplayName, + Description = evt.Description, + ImplementationKind = evt.ImplementationKind, + ImplementationRef = null, + PublishedServiceId = derivedPublishedServiceId, + LifecycleStage = StudioMemberLifecycleStage.Created, + CreatedAtUtc = evt.CreatedAtUtc, + UpdatedAtUtc = evt.CreatedAtUtc, + LastBinding = null, + }; + } + + private static StudioMemberState ApplyRenamed( + StudioMemberState state, StudioMemberRenamedEvent evt) + { + var next = state.Clone(); + next.DisplayName = evt.DisplayName; + next.Description = evt.Description; + next.UpdatedAtUtc = evt.UpdatedAtUtc; + return next; + } + + private static StudioMemberState ApplyImplementationUpdated( + StudioMemberState state, StudioMemberImplementationUpdatedEvent evt) + { + var next = state.Clone(); + // ImplementationKind is locked at create — see HandleImplementationUpdated. + // Do not mutate it here even if the event payload disagrees, so the + // invariant holds even on hand-rolled / replayed events. + next.ImplementationRef = evt.ImplementationRef?.Clone(); + next.UpdatedAtUtc = evt.UpdatedAtUtc; + + // Lifecycle: + // Created + resolved impl ref → BuildReady + // BindReady + new impl event → downgrade to BuildReady + // (the published revision is now stale until next bind) + // BuildReady + new impl event → stays BuildReady + // + // The bind orchestration explicitly does (impl_updated → bound), + // so the temporary downgrade is upgraded again by ApplyBound on + // the same bind; only out-of-band impl updates leave the member + // visibly non-bind-ready until rebind. + var hasResolvedRef = HasResolvedImplementationRef(evt.ImplementationRef); + if (hasResolvedRef) + { + next.LifecycleStage = StudioMemberLifecycleStage.BuildReady; + } + else if (next.LifecycleStage == StudioMemberLifecycleStage.BindReady) + { + // Cleared impl ref on a previously-bound member: still need to + // surface that the bound revision is stale. + next.LifecycleStage = StudioMemberLifecycleStage.BuildReady; + } + + return next; + } + + private static StudioMemberState ApplyBound( + StudioMemberState state, StudioMemberBoundEvent evt) + { + var next = state.Clone(); + next.LastBinding = new StudioMemberBindingContract + { + PublishedServiceId = evt.PublishedServiceId, + RevisionId = evt.RevisionId, + ImplementationKind = evt.ImplementationKind, + BoundAtUtc = evt.BoundAtUtc, + }; + next.LifecycleStage = StudioMemberLifecycleStage.BindReady; + next.UpdatedAtUtc = evt.BoundAtUtc; + return next; + } + + private static bool HasResolvedImplementationRef(StudioMemberImplementationRef? implRef) + { + if (implRef == null) + return false; + + if (implRef.Workflow != null && !string.IsNullOrEmpty(implRef.Workflow.WorkflowId)) + return true; + if (implRef.Script != null && !string.IsNullOrEmpty(implRef.Script.ScriptId)) + return true; + if (implRef.Gagent != null && !string.IsNullOrEmpty(implRef.Gagent.ActorTypeName)) + return true; + + return false; + } +} diff --git a/agents/Aevatar.GAgents.StudioMember/studio_member_messages.proto b/agents/Aevatar.GAgents.StudioMember/studio_member_messages.proto new file mode 100644 index 000000000..85d71f52f --- /dev/null +++ b/agents/Aevatar.GAgents.StudioMember/studio_member_messages.proto @@ -0,0 +1,124 @@ +syntax = "proto3"; +package aevatar.gagents.studio_member; +option csharp_namespace = "Aevatar.GAgents.StudioMember"; + +import "google/protobuf/timestamp.proto"; + +// ─── Canonical enums ─── +// +// StudioMember is the Studio's only first-class subject. workflow / script / +// gagent are implementation kinds for a member, not parallel member types. + +enum StudioMemberImplementationKind { + STUDIO_MEMBER_IMPLEMENTATION_KIND_UNSPECIFIED = 0; + STUDIO_MEMBER_IMPLEMENTATION_KIND_WORKFLOW = 1; + STUDIO_MEMBER_IMPLEMENTATION_KIND_SCRIPT = 2; + STUDIO_MEMBER_IMPLEMENTATION_KIND_GAGENT = 3; +} + +// Lifecycle stages mirror the Build/Bind/Invoke/Observe spec: +// created - member exists but has no implementation +// build_ready - implementation revision available, can bind +// bind_ready - bound to its publishedServiceId, can invoke +enum StudioMemberLifecycleStage { + STUDIO_MEMBER_LIFECYCLE_STAGE_UNSPECIFIED = 0; + STUDIO_MEMBER_LIFECYCLE_STAGE_CREATED = 1; + STUDIO_MEMBER_LIFECYCLE_STAGE_BUILD_READY = 2; + STUDIO_MEMBER_LIFECYCLE_STAGE_BIND_READY = 3; +} + +// ─── Implementation refs ─── +// +// Each implementation kind carries a typed sub-message rather than a generic +// metadata bag. Only one of these is populated at a time, matching +// implementation_kind on the parent state. + +message StudioMemberWorkflowRef { + string workflow_id = 1; + string workflow_revision = 2; +} + +message StudioMemberScriptRef { + string script_id = 1; + string script_revision = 2; +} + +message StudioMemberGAgentRef { + string actor_type_name = 1; +} + +message StudioMemberImplementationRef { + StudioMemberWorkflowRef workflow = 1; + StudioMemberScriptRef script = 2; + StudioMemberGAgentRef gagent = 3; +} + +// ─── Last bound contract ─── +// +// Record of the last successful Bind. publishedServiceId is the stable +// per-member service identity; revisionId is the most recently published +// implementation revision routed through that service. + +message StudioMemberBindingContract { + string published_service_id = 1; + string revision_id = 2; + StudioMemberImplementationKind implementation_kind = 3; + google.protobuf.Timestamp bound_at_utc = 4; +} + +// ─── State ─── +// +// State for a single StudioMember actor. Actor ID format: +// studio-member:{scopeId}:{memberId} +// +// publishedServiceId is generated once at creation from the immutable +// memberId and never recomputed afterwards. Renaming the display name does +// not change publishedServiceId. + +message StudioMemberState { + string member_id = 1; + string scope_id = 2; + string display_name = 3; + string description = 4; + StudioMemberImplementationKind implementation_kind = 5; + StudioMemberImplementationRef implementation_ref = 6; + string published_service_id = 7; + StudioMemberLifecycleStage lifecycle_stage = 8; + google.protobuf.Timestamp created_at_utc = 9; + google.protobuf.Timestamp updated_at_utc = 10; + StudioMemberBindingContract last_binding = 11; +} + +// ─── Events ─── +// +// One event per discrete transition. The actor materializes state from these +// events; projectors materialize the read model from the same envelope. + +message StudioMemberCreatedEvent { + string member_id = 1; + string scope_id = 2; + string display_name = 3; + string description = 4; + StudioMemberImplementationKind implementation_kind = 5; + string published_service_id = 6; + google.protobuf.Timestamp created_at_utc = 7; +} + +message StudioMemberRenamedEvent { + string display_name = 1; + string description = 2; + google.protobuf.Timestamp updated_at_utc = 3; +} + +message StudioMemberImplementationUpdatedEvent { + StudioMemberImplementationKind implementation_kind = 1; + StudioMemberImplementationRef implementation_ref = 2; + google.protobuf.Timestamp updated_at_utc = 3; +} + +message StudioMemberBoundEvent { + string published_service_id = 1; + string revision_id = 2; + StudioMemberImplementationKind implementation_kind = 3; + google.protobuf.Timestamp bound_at_utc = 4; +} diff --git a/src/Aevatar.Studio.Application/Aevatar.Studio.Application.csproj b/src/Aevatar.Studio.Application/Aevatar.Studio.Application.csproj index 7c7c54023..e19edc6f7 100644 --- a/src/Aevatar.Studio.Application/Aevatar.Studio.Application.csproj +++ b/src/Aevatar.Studio.Application/Aevatar.Studio.Application.csproj @@ -33,4 +33,8 @@ GrpcServices="None" AdditionalImportDirs="..\Aevatar.Scripting.Abstractions\Protos" /> + + + + diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs new file mode 100644 index 000000000..8c5c19588 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs @@ -0,0 +1,47 @@ +using Aevatar.Studio.Application.Studio.Contracts; + +namespace Aevatar.Studio.Application.Studio.Abstractions; + +/// +/// Write-side port for the StudioMember authority. Dispatches commands to +/// the per-member StudioMemberGAgent actor; never reads downstream state +/// (queries flow through ). +/// +public interface IStudioMemberCommandPort +{ + /// + /// Creates a new member under the given scope and returns the resulting + /// summary. and the request's + /// ImplementationKind are required; publishedServiceId is + /// generated by this call from the immutable member id and persisted on + /// the actor state. + /// + Task CreateAsync( + string scopeId, + CreateStudioMemberRequest request, + CancellationToken ct = default); + + /// + /// Updates the implementation reference for an existing member (e.g. once + /// Studio has produced a workflow yaml, script revision, or wired up a + /// gagent type for the member). Idempotent on identical inputs. + /// + Task UpdateImplementationAsync( + string scopeId, + string memberId, + StudioMemberImplementationRefResponse implementation, + CancellationToken ct = default); + + /// + /// Records that the member has been bound to its published service at the + /// given revision. Called by the member binding orchestrator after the + /// underlying scope binding upsert succeeds. + /// + Task RecordBindingAsync( + string scopeId, + string memberId, + string publishedServiceId, + string revisionId, + string implementationKindName, + CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberQueryPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberQueryPort.cs new file mode 100644 index 000000000..35afb4ba0 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberQueryPort.cs @@ -0,0 +1,21 @@ +using Aevatar.Studio.Application.Studio.Contracts; + +namespace Aevatar.Studio.Application.Studio.Abstractions; + +/// +/// Pure-read query port for StudioMember. Reads exclusively from the +/// projection document store; does not replay events or read actor state +/// directly. +/// +public interface IStudioMemberQueryPort +{ + Task ListAsync( + string scopeId, + StudioMemberRosterPageRequest? page = null, + CancellationToken ct = default); + + Task GetAsync( + string scopeId, + string memberId, + CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs new file mode 100644 index 000000000..c8e3ea03c --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs @@ -0,0 +1,61 @@ +using Aevatar.Studio.Application.Studio.Contracts; + +namespace Aevatar.Studio.Application.Studio.Abstractions; + +/// +/// Application-level facade for member-first Studio APIs. Orchestrates the +/// command and query ports plus the underlying scope binding capability so +/// the HTTP layer never has to know about ServiceId or scope-default +/// fallback. Endpoints depend on this interface rather than reaching for +/// , +/// or the platform binding port directly. +/// +public interface IStudioMemberService +{ + Task CreateAsync( + string scopeId, + CreateStudioMemberRequest request, + CancellationToken ct = default); + + Task ListAsync( + string scopeId, + StudioMemberRosterPageRequest? page = null, + CancellationToken ct = default); + + /// + /// Returns the member detail. Throws + /// when no member with + /// the given id exists in the scope — endpoints map this to 404 + /// STUDIO_MEMBER_NOT_FOUND, the same body every member-centric + /// endpoint returns for missing-member. + /// + Task GetAsync( + string scopeId, + string memberId, + CancellationToken ct = default); + + /// + /// Binds the given member to its own stable publishedServiceId + /// (never the scope default service). Resolves the member, builds a + /// scope binding request with ServiceId = publishedServiceId, + /// delegates to the existing scope binding command port, and records the + /// resulting revision back on the member authority. + /// + Task BindAsync( + string scopeId, + string memberId, + UpdateStudioMemberBindingRequest request, + CancellationToken ct = default); + + /// + /// Returns the last successful binding contract for the member, or + /// null when the member exists but has never been bound. + /// Throws when the member + /// itself does not exist — endpoints distinguish "missing member" (404) + /// from "exists, never bound" (200 with null binding). + /// + Task GetBindingAsync( + string scopeId, + string memberId, + CancellationToken ct = default); +} diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/StudioMemberNotFoundException.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/StudioMemberNotFoundException.cs new file mode 100644 index 000000000..f37ceb97d --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/StudioMemberNotFoundException.cs @@ -0,0 +1,25 @@ +namespace Aevatar.Studio.Application.Studio.Abstractions; + +/// +/// Thrown when a member-centric operation targets an id that has no +/// corresponding member document in the requested scope. Endpoints map this +/// to HTTP 404 — distinct from validation errors that map to 400. +/// +/// Inherits so the 404 / 400 mapping in +/// endpoints is type-disjoint from +/// (validation throws). Reordering catch blocks cannot silently downgrade a +/// 404 to a 400. +/// +public sealed class StudioMemberNotFoundException : KeyNotFoundException +{ + public StudioMemberNotFoundException(string scopeId, string memberId) + : base($"member '{memberId}' not found in scope '{scopeId}'.") + { + ScopeId = scopeId; + MemberId = memberId; + } + + public string ScopeId { get; } + + public string MemberId { get; } +} diff --git a/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs new file mode 100644 index 000000000..411f88230 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs @@ -0,0 +1,136 @@ +namespace Aevatar.Studio.Application.Studio.Contracts; + +/// +/// Wire-format implementation kind for HTTP/JSON. Uses lowercase strings so +/// Studio's HTTP surface stays member-centric and frontend-friendly. Mapped +/// onto StudioMemberImplementationKind at the boundary. +/// +public static class MemberImplementationKindNames +{ + public const string Workflow = "workflow"; + public const string Script = "script"; + public const string GAgent = "gagent"; +} + +/// +/// Wire-format lifecycle stage. Mirrors the +/// StudioMemberLifecycleStage proto enum but with stable string +/// values that Studio's frontend can switch on without taking a generated +/// proto dependency. +/// +public static class MemberLifecycleStageNames +{ + public const string Created = "created"; + public const string BuildReady = "build_ready"; + public const string BindReady = "bind_ready"; +} + +/// +/// Implementation reference returned to the caller. Always typed — never a +/// generic property bag — so the frontend can dispatch on +/// without parsing arbitrary keys. +/// +public sealed record StudioMemberImplementationRefResponse( + string ImplementationKind, + string? WorkflowId = null, + string? WorkflowRevision = null, + string? ScriptId = null, + string? ScriptRevision = null, + string? ActorTypeName = null); + +public sealed record StudioMemberSummaryResponse( + string MemberId, + string ScopeId, + string DisplayName, + string Description, + string ImplementationKind, + string LifecycleStage, + string PublishedServiceId, + string? LastBoundRevisionId, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record StudioMemberDetailResponse( + StudioMemberSummaryResponse Summary, + StudioMemberImplementationRefResponse? ImplementationRef, + StudioMemberBindingContractResponse? LastBinding); + +public sealed record StudioMemberBindingContractResponse( + string PublishedServiceId, + string RevisionId, + string ImplementationKind, + DateTimeOffset BoundAt); + +/// +/// Wrapper returned from GET /members/{memberId}/binding so the +/// response is always a JSON object — distinguishes "exists but never +/// bound" ( is null, status 200) from +/// "member missing" (typed 404 STUDIO_MEMBER_NOT_FOUND). +/// +public sealed record StudioMemberBindingViewResponse( + StudioMemberBindingContractResponse? LastBinding); + +public sealed record StudioMemberRosterResponse( + string ScopeId, + IReadOnlyList Members, + string? NextPageToken = null); + +public sealed record StudioMemberRosterPageRequest( + int? PageSize = null, + string? PageToken = null); + +public sealed record CreateStudioMemberRequest( + string DisplayName, + string ImplementationKind, + string? Description = null, + string? MemberId = null); + +/// +/// Centralized input bounds applied at the create boundary so a single +/// request cannot push 10MB of displayName / description / memberId all the +/// way through to the actor state and read model. Slug pattern on +/// memberId keeps caller-supplied ids URL-safe and free of separators that +/// the actor-id convention reserves. +/// +public static class StudioMemberInputLimits +{ + public const int MaxDisplayNameLength = 256; + public const int MaxDescriptionLength = 2048; + public const int MaxMemberIdLength = 64; + + public static readonly System.Text.RegularExpressions.Regex MemberIdPattern = + new(@"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$", System.Text.RegularExpressions.RegexOptions.Compiled); +} + +public sealed record UpdateStudioMemberBindingRequest( + string? RevisionId = null, + StudioMemberWorkflowBindingSpec? Workflow = null, + StudioMemberScriptBindingSpec? Script = null, + StudioMemberGAgentBindingSpec? GAgent = null); + +public sealed record StudioMemberWorkflowBindingSpec( + IReadOnlyList WorkflowYamls); + +public sealed record StudioMemberScriptBindingSpec( + string ScriptId, + string? ScriptRevision = null); + +public sealed record StudioMemberGAgentEndpointSpec( + string EndpointId, + string DisplayName, + string Kind, + string RequestTypeUrl, + string ResponseTypeUrl, + string? Description = null); + +public sealed record StudioMemberGAgentBindingSpec( + string ActorTypeName, + IReadOnlyList? Endpoints = null); + +public sealed record StudioMemberBindingResponse( + string MemberId, + string PublishedServiceId, + string RevisionId, + string ImplementationKind, + string ScopeId, + string ExpectedActorId); diff --git a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs index 502d5f6f2..5cafc4ad3 100644 --- a/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Application/Studio/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ +using Aevatar.Studio.Application.Studio.Abstractions; using Aevatar.Studio.Application.Studio.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aevatar.Studio.Application.Studio.DependencyInjection; @@ -15,6 +17,7 @@ public static IServiceCollection AddStudioApplication(this IServiceCollection se services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.TryAddSingleton(); return services; } } diff --git a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberCreateRequestValidator.cs b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberCreateRequestValidator.cs new file mode 100644 index 000000000..5422dd59a --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberCreateRequestValidator.cs @@ -0,0 +1,76 @@ +using Aevatar.Studio.Application.Studio.Contracts; + +namespace Aevatar.Studio.Application.Studio.Services; + +/// +/// Application-layer enforcement of . +/// Lives next to so a swap of the +/// projection-side command port (alternate impl, in-memory test variant, +/// migration tool) cannot silently drop the bounds with it. +/// +/// The Projection-layer command service is intentionally lenient on these +/// fields — it trusts the caller already validated. The Application layer +/// is the single boundary where length caps + slug pattern are +/// enforced. +/// +internal static class StudioMemberCreateRequestValidator +{ + public static void Validate(CreateStudioMemberRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + ValidateDisplayName(request.DisplayName); + ValidateDescription(request.Description); + ValidateMemberId(request.MemberId); + } + + private static void ValidateDisplayName(string? displayName) + { + var trimmed = displayName?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + throw new InvalidOperationException( + "displayName is required when creating a member."); + } + + if (trimmed.Length > StudioMemberInputLimits.MaxDisplayNameLength) + { + throw new InvalidOperationException( + $"displayName must be at most {StudioMemberInputLimits.MaxDisplayNameLength} characters."); + } + } + + private static void ValidateDescription(string? description) + { + var trimmed = description?.Trim() ?? string.Empty; + if (trimmed.Length > StudioMemberInputLimits.MaxDescriptionLength) + { + throw new InvalidOperationException( + $"description must be at most {StudioMemberInputLimits.MaxDescriptionLength} characters."); + } + } + + private static void ValidateMemberId(string? rawMemberId) + { + if (string.IsNullOrWhiteSpace(rawMemberId)) + { + // Empty is allowed — the command service generates a random + // member id when the caller leaves it empty. + return; + } + + var trimmed = rawMemberId.Trim(); + if (trimmed.Length > StudioMemberInputLimits.MaxMemberIdLength) + { + throw new InvalidOperationException( + $"memberId must be at most {StudioMemberInputLimits.MaxMemberIdLength} characters."); + } + + if (!StudioMemberInputLimits.MemberIdPattern.IsMatch(trimmed)) + { + throw new InvalidOperationException( + "memberId must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ " + + "(alphanumeric, dash, underscore; starts with alphanumeric)."); + } + } +} diff --git a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs new file mode 100644 index 000000000..790d381d8 --- /dev/null +++ b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs @@ -0,0 +1,300 @@ +using Aevatar.GAgentService.Abstractions; +using Aevatar.GAgentService.Abstractions.Ports; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; + +namespace Aevatar.Studio.Application.Studio.Services; + +/// +/// Member-first Studio facade. Owns the orchestration that turns a +/// member-scoped request into the existing scope binding pipeline: +/// +/// 1. resolve the StudioMember authority (read model) +/// 2. build a with +/// ServiceId = publishedServiceId — never the scope default +/// 3. delegate to +/// 4. derive the resolved implementation_ref from the binding +/// result and persist it on the member authority +/// 5. record the resulting revision back on the member actor +/// +/// Steps 4 + 5 are what populate StudioMemberState.ImplementationRef +/// and traverse the lifecycle Created → BuildReady → BindReady the +/// issue specifies. Endpoints depend on this facade and never reach for the +/// platform binding port directly, which is what kept Studio's old surface +/// in scope-default fallback mode. +/// +public sealed class StudioMemberService : IStudioMemberService +{ + private readonly IStudioMemberCommandPort _memberCommandPort; + private readonly IStudioMemberQueryPort _memberQueryPort; + private readonly IScopeBindingCommandPort _scopeBindingCommandPort; + + public StudioMemberService( + IStudioMemberCommandPort memberCommandPort, + IStudioMemberQueryPort memberQueryPort, + IScopeBindingCommandPort scopeBindingCommandPort) + { + _memberCommandPort = memberCommandPort ?? throw new ArgumentNullException(nameof(memberCommandPort)); + _memberQueryPort = memberQueryPort ?? throw new ArgumentNullException(nameof(memberQueryPort)); + _scopeBindingCommandPort = scopeBindingCommandPort + ?? throw new ArgumentNullException(nameof(scopeBindingCommandPort)); + } + + public Task CreateAsync( + string scopeId, + CreateStudioMemberRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + // Validation lives at this Application boundary (CLAUDE.md + // `严格分层 / 上层依赖抽象`). The Projection-layer command port is + // an interchangeable transport; if it ever swaps, the bounds must + // not silently disappear with it. Callers receive a single typed + // error path here regardless of which command port is wired in. + StudioMemberCreateRequestValidator.Validate(request); + + return _memberCommandPort.CreateAsync(scopeId, request, ct); + } + + public Task ListAsync( + string scopeId, + StudioMemberRosterPageRequest? page = null, + CancellationToken ct = default) => + _memberQueryPort.ListAsync(scopeId, page, ct); + + public async Task GetAsync( + string scopeId, + string memberId, + CancellationToken ct = default) + { + // Mirrors GetBindingAsync semantics: a missing member is + // unambiguous "404 STUDIO_MEMBER_NOT_FOUND", not a 200-with-null + // body that the frontend would have to pattern-match. Endpoints + // catch the typed exception and return the same body shape from + // every member-centric endpoint. + var detail = await _memberQueryPort.GetAsync(scopeId, memberId, ct) + ?? throw new StudioMemberNotFoundException(scopeId, memberId); + return detail; + } + + public async Task BindAsync( + string scopeId, + string memberId, + UpdateStudioMemberBindingRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + 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 implementationKindWire = detail.Summary.ImplementationKind; + var bindingRequest = BuildScopeBindingRequest( + scopeId, + memberId, + publishedServiceId, + implementationKindWire, + detail.Summary.DisplayName, + request); + + // Two-phase write: scope binding first, then update the member + // authority. This is intentionally last-write-wins — if step 2 or + // step 3 fails, the platform side has a fresh revision but the + // member doesn't yet observe it; the next bind will create another + // upstream revision and only that one will be recorded. We accept + // this drift over a distributed transaction because the member's + // last_binding is a query convenience, not the source of truth — + // the platform read model is. + var bindingResult = await _scopeBindingCommandPort.UpsertAsync(bindingRequest, ct); + + var resolvedImplementationRef = BuildResolvedImplementationRef( + implementationKindWire, bindingResult, request); + if (resolvedImplementationRef != null) + { + await _memberCommandPort.UpdateImplementationAsync( + scopeId, memberId, resolvedImplementationRef, ct); + } + + await _memberCommandPort.RecordBindingAsync( + scopeId, + memberId, + bindingResult.ServiceId, + bindingResult.RevisionId, + implementationKindWire, + ct); + + return new StudioMemberBindingResponse( + MemberId: memberId, + PublishedServiceId: bindingResult.ServiceId, + RevisionId: bindingResult.RevisionId, + ImplementationKind: implementationKindWire, + ScopeId: scopeId, + ExpectedActorId: bindingResult.ExpectedActorId); + } + + public async Task GetBindingAsync( + string scopeId, + string memberId, + CancellationToken ct = default) + { + var detail = await _memberQueryPort.GetAsync(scopeId, memberId, ct) + ?? throw new StudioMemberNotFoundException(scopeId, memberId); + return detail.LastBinding; + } + + private static ScopeBindingUpsertRequest BuildScopeBindingRequest( + string scopeId, + string memberId, + string publishedServiceId, + string implementationKindWire, + string displayName, + UpdateStudioMemberBindingRequest request) + { + return implementationKindWire switch + { + MemberImplementationKindNames.Workflow => new ScopeBindingUpsertRequest( + ScopeId: scopeId, + ImplementationKind: ScopeBindingImplementationKind.Workflow, + Workflow: BuildWorkflowSpec(memberId, request), + DisplayName: displayName, + RevisionId: request.RevisionId, + ServiceId: publishedServiceId), + + MemberImplementationKindNames.Script => new ScopeBindingUpsertRequest( + ScopeId: scopeId, + ImplementationKind: ScopeBindingImplementationKind.Scripting, + Script: BuildScriptSpec(memberId, request), + DisplayName: displayName, + RevisionId: request.RevisionId, + ServiceId: publishedServiceId), + + MemberImplementationKindNames.GAgent => new ScopeBindingUpsertRequest( + ScopeId: scopeId, + ImplementationKind: ScopeBindingImplementationKind.GAgent, + GAgent: BuildGAgentSpec(memberId, request), + DisplayName: displayName, + RevisionId: request.RevisionId, + ServiceId: publishedServiceId), + + _ => throw new InvalidOperationException( + $"member '{memberId}' has unsupported implementationKind '{implementationKindWire}'."), + }; + } + + private static StudioMemberImplementationRefResponse? BuildResolvedImplementationRef( + string implementationKindWire, + ScopeBindingUpsertResult bindingResult, + UpdateStudioMemberBindingRequest request) + { + switch (implementationKindWire) + { + case MemberImplementationKindNames.Workflow: + if (bindingResult.Workflow == null + || string.IsNullOrEmpty(bindingResult.Workflow.WorkflowName)) + { + return null; + } + return new StudioMemberImplementationRefResponse( + ImplementationKind: MemberImplementationKindNames.Workflow, + WorkflowId: bindingResult.Workflow.WorkflowName, + WorkflowRevision: bindingResult.RevisionId); + + case MemberImplementationKindNames.Script: + var scriptId = bindingResult.Script?.ScriptId + ?? request.Script?.ScriptId + ?? string.Empty; + if (string.IsNullOrEmpty(scriptId)) + return null; + return new StudioMemberImplementationRefResponse( + ImplementationKind: MemberImplementationKindNames.Script, + ScriptId: scriptId, + ScriptRevision: bindingResult.Script?.ScriptRevision + ?? request.Script?.ScriptRevision); + + case MemberImplementationKindNames.GAgent: + var actorTypeName = bindingResult.GAgent?.ActorTypeName + ?? request.GAgent?.ActorTypeName + ?? string.Empty; + if (string.IsNullOrEmpty(actorTypeName)) + return null; + return new StudioMemberImplementationRefResponse( + ImplementationKind: MemberImplementationKindNames.GAgent, + ActorTypeName: actorTypeName); + + default: + return null; + } + } + + private static ScopeBindingWorkflowSpec BuildWorkflowSpec( + string memberId, + UpdateStudioMemberBindingRequest request) + { + if (request.Workflow == null || request.Workflow.WorkflowYamls.Count == 0) + { + throw new InvalidOperationException( + $"member '{memberId}' bind: workflow yamls are required for workflow members."); + } + + return new ScopeBindingWorkflowSpec(request.Workflow.WorkflowYamls); + } + + private static ScopeBindingScriptSpec BuildScriptSpec( + string memberId, + UpdateStudioMemberBindingRequest request) + { + if (request.Script == null || string.IsNullOrWhiteSpace(request.Script.ScriptId)) + { + throw new InvalidOperationException( + $"member '{memberId}' bind: scriptId is required for script members."); + } + + return new ScopeBindingScriptSpec( + ScriptId: request.Script.ScriptId, + ScriptRevision: request.Script.ScriptRevision); + } + + private static ScopeBindingGAgentSpec BuildGAgentSpec( + string memberId, + UpdateStudioMemberBindingRequest request) + { + if (request.GAgent == null || string.IsNullOrWhiteSpace(request.GAgent.ActorTypeName)) + { + throw new InvalidOperationException( + $"member '{memberId}' bind: actorTypeName is required for gagent members."); + } + + var endpoints = (request.GAgent.Endpoints ?? []) + .Select(static e => new ScopeBindingGAgentEndpoint( + EndpointId: e.EndpointId, + DisplayName: e.DisplayName, + Kind: ParseEndpointKind(e.Kind), + RequestTypeUrl: e.RequestTypeUrl, + ResponseTypeUrl: e.ResponseTypeUrl, + Description: e.Description ?? string.Empty)) + .ToList(); + + return new ScopeBindingGAgentSpec( + ActorTypeName: request.GAgent.ActorTypeName, + Endpoints: endpoints); + } + + private static ServiceEndpointKind ParseEndpointKind(string? kind) + { + var normalized = kind?.Trim().ToLowerInvariant(); + return normalized switch + { + "command" => ServiceEndpointKind.Command, + "chat" => ServiceEndpointKind.Chat, + _ => ServiceEndpointKind.Unspecified, + }; + } +} diff --git a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj index 8acad441b..097700073 100644 --- a/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj +++ b/src/Aevatar.Studio.Hosting/Aevatar.Studio.Hosting.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs new file mode 100644 index 000000000..c3506f146 --- /dev/null +++ b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs @@ -0,0 +1,179 @@ +using Aevatar.Hosting; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Aevatar.Studio.Hosting.Endpoints; + +/// +/// Member-first Studio HTTP surface mounted under +/// /api/scopes/{scopeId}/members. Endpoints depend only on +/// ; they never reach for the platform +/// scope binding port directly. ServiceId is never accepted as a user-facing +/// input — Studio binds to the member's own stable +/// publishedServiceId. +/// +/// Error mapping: +/// - → 404 +/// - other (validation) → 400 +/// +internal static class StudioMemberEndpoints +{ + public static void Map(IEndpointRouteBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + app.MapPost("/api/scopes/{scopeId}/members", HandleCreateAsync) + .WithTags("StudioMembers"); + app.MapGet("/api/scopes/{scopeId}/members", HandleListAsync) + .WithTags("StudioMembers"); + app.MapGet("/api/scopes/{scopeId}/members/{memberId}", HandleGetAsync) + .WithTags("StudioMembers"); + app.MapPut("/api/scopes/{scopeId}/members/{memberId}/binding", HandleBindAsync) + .WithTags("StudioMembers"); + app.MapGet("/api/scopes/{scopeId}/members/{memberId}/binding", HandleGetBindingAsync) + .WithTags("StudioMembers"); + } + + internal static async Task HandleCreateAsync( + HttpContext http, + string scopeId, + CreateStudioMemberRequest request, + IStudioMemberService memberService, + CancellationToken ct) + { + if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) + return denied; + + try + { + var summary = await memberService.CreateAsync(scopeId, request, ct); + return Results.Created($"/api/scopes/{scopeId}/members/{summary.MemberId}", summary); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_STUDIO_MEMBER_REQUEST", ex.Message); + } + } + + internal static async Task HandleListAsync( + HttpContext http, + string scopeId, + IStudioMemberService memberService, + int? pageSize, + string? pageToken, + CancellationToken ct) + { + if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) + return denied; + + try + { + var page = (pageSize.HasValue || !string.IsNullOrWhiteSpace(pageToken)) + ? new StudioMemberRosterPageRequest(pageSize, pageToken) + : null; + return Results.Ok(await memberService.ListAsync(scopeId, page, ct)); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_STUDIO_MEMBER_REQUEST", ex.Message); + } + } + + internal static async Task HandleGetAsync( + HttpContext http, + string scopeId, + string memberId, + IStudioMemberService memberService, + CancellationToken ct) + { + if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) + return denied; + + try + { + return Results.Ok(await memberService.GetAsync(scopeId, memberId, ct)); + } + catch (StudioMemberNotFoundException ex) + { + return NotFound(ex); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_STUDIO_MEMBER_REQUEST", ex.Message); + } + } + + internal static async Task HandleBindAsync( + HttpContext http, + string scopeId, + string memberId, + UpdateStudioMemberBindingRequest request, + IStudioMemberService memberService, + CancellationToken ct) + { + if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) + return denied; + + try + { + return Results.Ok(await memberService.BindAsync(scopeId, memberId, request, ct)); + } + catch (StudioMemberNotFoundException ex) + { + return NotFound(ex); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_STUDIO_MEMBER_BINDING", ex.Message); + } + } + + internal static async Task HandleGetBindingAsync( + HttpContext http, + string scopeId, + string memberId, + IStudioMemberService memberService, + CancellationToken ct) + { + if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied)) + return denied; + + try + { + // Three semantically distinct outcomes, three distinct HTTP shapes: + // - member missing → 404 STUDIO_MEMBER_NOT_FOUND + // - member exists, has been bound → 200 { lastBinding: } + // - member exists, never bound → 200 { lastBinding: null } + // Bare `404 NotFound` for the "exists but never bound" case used + // to overload 404 with two different meanings; the wrapper keeps + // the response always a JSON object with a single nullable field. + var binding = await memberService.GetBindingAsync(scopeId, memberId, ct); + return Results.Ok(new StudioMemberBindingViewResponse(binding)); + } + catch (StudioMemberNotFoundException ex) + { + return NotFound(ex); + } + catch (InvalidOperationException ex) + { + return BadRequest("INVALID_STUDIO_MEMBER_REQUEST", ex.Message); + } + } + + private static IResult BadRequest(string code, string message) => + Results.BadRequest(new { code, message }); + + private static IResult NotFound(StudioMemberNotFoundException ex) => + Results.Json( + new + { + code = "STUDIO_MEMBER_NOT_FOUND", + message = ex.Message, + scopeId = ex.ScopeId, + memberId = ex.MemberId, + }, + statusCode: StatusCodes.Status404NotFound); +} diff --git a/src/Aevatar.Studio.Hosting/StudioCapabilityExtensions.cs b/src/Aevatar.Studio.Hosting/StudioCapabilityExtensions.cs index 49f05a403..56b543afa 100644 --- a/src/Aevatar.Studio.Hosting/StudioCapabilityExtensions.cs +++ b/src/Aevatar.Studio.Hosting/StudioCapabilityExtensions.cs @@ -59,6 +59,7 @@ public static WebApplicationBuilder AddStudioCapability(this WebApplicationBuild { app.MapControllers(); StudioEndpoints.Map(app, embeddedWorkflowMode: true); + StudioMemberEndpoints.Map(app); Controllers.ChatHistoryEndpoints.MapChatHistoryEndpoints(app); app.MapExplorerEndpoints(); }); diff --git a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs index c866cec2b..99c8d70f4 100644 --- a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Aevatar.GAgents.Registry; using Aevatar.GAgents.RoleCatalog; using Aevatar.GAgents.StreamingProxyParticipant; +using Aevatar.GAgents.StudioMember; using Aevatar.GAgents.UserConfig; using Aevatar.GAgents.UserMemory; using Aevatar.Studio.Projection.ReadModels; @@ -66,6 +67,7 @@ public static IServiceCollection AddStudioProjectionReadModelProviders( RegisterElasticsearch(services, configuration); RegisterElasticsearch(services, configuration); RegisterElasticsearch(services, configuration); + RegisterElasticsearch(services, configuration); } else { @@ -77,6 +79,7 @@ public static IServiceCollection AddStudioProjectionReadModelProviders( RegisterInMemory(services); RegisterInMemory(services); RegisterInMemory(services); + RegisterInMemory(services); } return services; @@ -124,7 +127,8 @@ private static bool HasAllStudioDocumentReaders( && HasDocumentReaderForProvider(services, providerKind) && HasDocumentReaderForProvider(services, providerKind) && HasDocumentReaderForProvider(services, providerKind) - && HasDocumentReaderForProvider(services, providerKind); + && HasDocumentReaderForProvider(services, providerKind) + && HasDocumentReaderForProvider(services, providerKind); } private static bool HasAnyDocumentReader(IServiceCollection services) @@ -206,7 +210,8 @@ private static TypeRegistry BuildStudioStateTypeRegistry() UserMemoryState.Descriptor, StreamingProxyParticipantGAgentState.Descriptor, ChatHistoryIndexState.Descriptor, - ChatConversationState.Descriptor); + ChatConversationState.Descriptor, + StudioMemberState.Descriptor); } private enum DocumentProviderKind diff --git a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj index f297c57df..f20376e63 100644 --- a/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj +++ b/src/Aevatar.Studio.Projection/Aevatar.Studio.Projection.csproj @@ -22,6 +22,7 @@ + @@ -35,4 +36,8 @@ + + + + diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs new file mode 100644 index 000000000..b6b10d444 --- /dev/null +++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs @@ -0,0 +1,197 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.StudioMember; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Projection.Mapping; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.CommandServices; + +/// +/// Dispatches StudioMember command events to the per-member +/// actor. Uses the canonical actor-id +/// convention (studio-member:{scopeId}:{memberId}) and ensures the +/// actor + projection scope are activated before dispatch via +/// . +/// +internal sealed class ActorDispatchStudioMemberCommandService : IStudioMemberCommandPort +{ + private const string DirectRoute = "aevatar.studio.projection.studio-member"; + + private readonly IStudioActorBootstrap _bootstrap; + private readonly IActorDispatchPort _dispatchPort; + + public ActorDispatchStudioMemberCommandService( + IStudioActorBootstrap bootstrap, + IActorDispatchPort dispatchPort) + { + _bootstrap = bootstrap ?? throw new ArgumentNullException(nameof(bootstrap)); + _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort)); + } + + public async Task CreateAsync( + string scopeId, + CreateStudioMemberRequest request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + + // Length caps + slug pattern are enforced at the Application + // boundary (StudioMemberCreateRequestValidator). The transport- + // level guards here only ensure the actor-id remains derivable — + // scope normalization rejects ':', and an empty memberId is + // replaced by a generated one. Anything else is the caller's + // already-validated input. + var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId); + var memberId = string.IsNullOrWhiteSpace(request.MemberId) + ? GenerateMemberId() + : StudioMemberConventions.NormalizeMemberId(request.MemberId); + + var displayName = (request.DisplayName ?? string.Empty).Trim(); + var description = (request.Description ?? string.Empty).Trim(); + + var implementationKind = MemberImplementationKindMapper.Parse(request.ImplementationKind); + var publishedServiceId = StudioMemberConventions.BuildPublishedServiceId(memberId); + var createdAt = DateTimeOffset.UtcNow; + + var evt = new StudioMemberCreatedEvent + { + MemberId = memberId, + ScopeId = normalizedScopeId, + DisplayName = displayName, + Description = description, + ImplementationKind = implementationKind, + PublishedServiceId = publishedServiceId, + CreatedAtUtc = Timestamp.FromDateTimeOffset(createdAt), + }; + + await DispatchAsync(normalizedScopeId, memberId, evt, ct); + + return new StudioMemberSummaryResponse( + MemberId: memberId, + ScopeId: normalizedScopeId, + DisplayName: displayName, + Description: evt.Description, + ImplementationKind: MemberImplementationKindMapper.ToWireName(implementationKind), + LifecycleStage: MemberLifecycleStageNames.Created, + PublishedServiceId: publishedServiceId, + LastBoundRevisionId: null, + CreatedAt: createdAt, + UpdatedAt: createdAt); + } + + public async Task UpdateImplementationAsync( + string scopeId, + string memberId, + StudioMemberImplementationRefResponse implementation, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(implementation); + + var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId); + var normalizedMemberId = StudioMemberConventions.NormalizeMemberId(memberId); + var implementationKind = MemberImplementationKindMapper.Parse(implementation.ImplementationKind); + + var evt = new StudioMemberImplementationUpdatedEvent + { + ImplementationKind = implementationKind, + ImplementationRef = BuildImplementationRefMessage(implementation), + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + + await DispatchAsync(normalizedScopeId, normalizedMemberId, evt, ct); + } + + public async Task RecordBindingAsync( + string scopeId, + string memberId, + string publishedServiceId, + string revisionId, + string implementationKindName, + CancellationToken ct = default) + { + var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId); + var normalizedMemberId = StudioMemberConventions.NormalizeMemberId(memberId); + + if (string.IsNullOrWhiteSpace(publishedServiceId)) + { + throw new InvalidOperationException( + $"member '{normalizedMemberId}' bind: publishedServiceId is required to record binding."); + } + + if (string.IsNullOrWhiteSpace(revisionId)) + { + throw new InvalidOperationException( + $"member '{normalizedMemberId}' bind: revisionId is required to record binding."); + } + + var evt = new StudioMemberBoundEvent + { + PublishedServiceId = publishedServiceId, + RevisionId = revisionId, + ImplementationKind = MemberImplementationKindMapper.Parse(implementationKindName), + BoundAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + + await DispatchAsync(normalizedScopeId, normalizedMemberId, evt, ct); + } + + private static StudioMemberImplementationRef BuildImplementationRefMessage( + StudioMemberImplementationRefResponse implementation) + { + var message = new StudioMemberImplementationRef(); + switch (implementation.ImplementationKind) + { + case MemberImplementationKindNames.Workflow: + message.Workflow = new StudioMemberWorkflowRef + { + WorkflowId = implementation.WorkflowId ?? string.Empty, + WorkflowRevision = implementation.WorkflowRevision ?? string.Empty, + }; + break; + case MemberImplementationKindNames.Script: + message.Script = new StudioMemberScriptRef + { + ScriptId = implementation.ScriptId ?? string.Empty, + ScriptRevision = implementation.ScriptRevision ?? string.Empty, + }; + break; + case MemberImplementationKindNames.GAgent: + message.Gagent = new StudioMemberGAgentRef + { + ActorTypeName = implementation.ActorTypeName ?? string.Empty, + }; + break; + default: + throw new InvalidOperationException( + $"Unknown implementationKind '{implementation.ImplementationKind}'."); + } + + return message; + } + + private async Task DispatchAsync(string scopeId, string memberId, IMessage payload, CancellationToken ct) + { + var actorId = StudioMemberConventions.BuildActorId(scopeId, memberId); + var actor = await _bootstrap.EnsureAsync(actorId, ct); + + var envelope = new EventEnvelope + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(payload), + Route = EnvelopeRouteSemantics.CreateDirect(DirectRoute, actor.Id), + }; + + await _dispatchPort.DispatchAsync(actor.Id, envelope, ct); + } + + private static string GenerateMemberId() + { + // Member ids are immutable identifiers; the publishedServiceId is + // derived directly from this value, so keep the format URL-safe and + // free of separators that StudioMemberConventions builds with (':'). + return $"m-{Guid.NewGuid():N}"; + } +} diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 4f556cece..1d2f88de1 100644 --- a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -78,6 +78,10 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl StudioMaterializationContext, ChatConversationCurrentStateProjector>(); + services.AddCurrentStateProjectionMaterializer< + StudioMaterializationContext, + StudioMemberCurrentStateProjector>(); + // ── Document metadata providers (for index creation in Elasticsearch) ── services.TryAddSingleton< @@ -112,6 +116,10 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl IProjectionDocumentMetadataProvider, ChatConversationCurrentStateDocumentMetadataProvider>(); + services.TryAddSingleton< + IProjectionDocumentMetadataProvider, + StudioMemberCurrentStateDocumentMetadataProvider>(); + // Projection scope activation port — required so Studio projectors // actually subscribe to their actor streams and materialize events. services.TryAddSingleton(); @@ -124,9 +132,11 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl // Query ports (read side) services.TryAddSingleton(); + services.TryAddSingleton(); // Command services (write side) services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs b/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs new file mode 100644 index 000000000..1e680e5a7 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs @@ -0,0 +1,48 @@ +using Aevatar.GAgents.StudioMember; +using Aevatar.Studio.Application.Studio.Contracts; + +namespace Aevatar.Studio.Projection.Mapping; + +/// +/// Boundary mapping between the lowercase wire string used by Studio's HTTP +/// surface and the strongly-typed proto enum used by the StudioMember actor. +/// Lives in the Projection layer (alongside other StudioMember adapters) +/// because the Application layer does not depend on the agent proto package +/// — see CLAUDE.md `严格分层 / 上层依赖抽象`. +/// +public static class MemberImplementationKindMapper +{ + public static StudioMemberImplementationKind Parse(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidOperationException("implementationKind is required."); + + return value.Trim().ToLowerInvariant() switch + { + MemberImplementationKindNames.Workflow => StudioMemberImplementationKind.Workflow, + MemberImplementationKindNames.Script => StudioMemberImplementationKind.Script, + MemberImplementationKindNames.GAgent => StudioMemberImplementationKind.Gagent, + _ => throw new InvalidOperationException( + $"Unknown implementationKind '{value}'. " + + $"Expected one of: {MemberImplementationKindNames.Workflow}, " + + $"{MemberImplementationKindNames.Script}, " + + $"{MemberImplementationKindNames.GAgent}."), + }; + } + + public static string ToWireName(StudioMemberImplementationKind kind) => kind switch + { + StudioMemberImplementationKind.Workflow => MemberImplementationKindNames.Workflow, + StudioMemberImplementationKind.Script => MemberImplementationKindNames.Script, + StudioMemberImplementationKind.Gagent => MemberImplementationKindNames.GAgent, + _ => string.Empty, + }; + + public static string ToWireName(StudioMemberLifecycleStage stage) => stage switch + { + StudioMemberLifecycleStage.Created => MemberLifecycleStageNames.Created, + StudioMemberLifecycleStage.BuildReady => MemberLifecycleStageNames.BuildReady, + StudioMemberLifecycleStage.BindReady => MemberLifecycleStageNames.BindReady, + _ => string.Empty, + }; +} diff --git a/src/Aevatar.Studio.Projection/Metadata/StudioMemberCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/StudioMemberCurrentStateDocumentMetadataProvider.cs new file mode 100644 index 000000000..cc3e8c2a5 --- /dev/null +++ b/src/Aevatar.Studio.Projection/Metadata/StudioMemberCurrentStateDocumentMetadataProvider.cs @@ -0,0 +1,17 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.Metadata; + +public sealed class StudioMemberCurrentStateDocumentMetadataProvider + : IProjectionDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "studio-members", + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs new file mode 100644 index 000000000..7a9107d8a --- /dev/null +++ b/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs @@ -0,0 +1,117 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.StudioMember; +using Aevatar.Studio.Projection.Mapping; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.ReadModels; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Projection.Projectors; + +/// +/// Materializes committed events into +/// . Surfaces a fully-typed +/// projection of the authority — wire-stable string enums, denormalized +/// implementation_ref, denormalized last_binding — so the query port never +/// has to the actor's internal state. +/// +public sealed class StudioMemberCurrentStateProjector + : ICurrentStateProjectionMaterializer +{ + private readonly IProjectionWriteDispatcher _writeDispatcher; + private readonly IProjectionClock _clock; + + public StudioMemberCurrentStateProjector( + IProjectionWriteDispatcher writeDispatcher, + IProjectionClock clock) + { + _writeDispatcher = writeDispatcher ?? throw new ArgumentNullException(nameof(writeDispatcher)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + } + + public async ValueTask ProjectAsync( + StudioMaterializationContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(envelope); + + if (!CommittedStateEventEnvelope.TryUnpackState( + envelope, + out _, + out var stateEvent, + out var state) || + stateEvent?.EventData == null || + state == null) + { + return; + } + + var updatedAt = CommittedStateEventEnvelope.ResolveTimestamp(envelope, _clock.UtcNow); + + var document = new StudioMemberCurrentStateDocument + { + Id = context.RootActorId, + ActorId = context.RootActorId, + StateVersion = stateEvent.Version, + LastEventId = stateEvent.EventId ?? string.Empty, + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + MemberId = state.MemberId, + ScopeId = state.ScopeId, + DisplayName = state.DisplayName, + Description = state.Description, + ImplementationKind = MemberImplementationKindMapper.ToWireName(state.ImplementationKind), + LifecycleStage = MemberImplementationKindMapper.ToWireName(state.LifecycleStage), + PublishedServiceId = state.PublishedServiceId, + CreatedAt = state.CreatedAtUtc, + }; + + ApplyImplementationRef(document, state.ImplementationRef); + ApplyLastBinding(document, state.LastBinding); + + await _writeDispatcher.UpsertAsync(document, ct); + } + + private static void ApplyImplementationRef( + StudioMemberCurrentStateDocument document, + StudioMemberImplementationRef? implementationRef) + { + if (implementationRef == null) + return; + + if (implementationRef.Workflow != null) + { + document.ImplementationWorkflowId = implementationRef.Workflow.WorkflowId ?? string.Empty; + document.ImplementationWorkflowRevision = implementationRef.Workflow.WorkflowRevision ?? string.Empty; + } + + if (implementationRef.Script != null) + { + document.ImplementationScriptId = implementationRef.Script.ScriptId ?? string.Empty; + document.ImplementationScriptRevision = implementationRef.Script.ScriptRevision ?? string.Empty; + } + + if (implementationRef.Gagent != null) + { + document.ImplementationActorTypeName = implementationRef.Gagent.ActorTypeName ?? string.Empty; + } + } + + private static void ApplyLastBinding( + StudioMemberCurrentStateDocument document, + StudioMemberBindingContract? lastBinding) + { + if (lastBinding == null || string.IsNullOrEmpty(lastBinding.PublishedServiceId)) + return; + + document.LastBoundPublishedServiceId = lastBinding.PublishedServiceId; + document.LastBoundRevisionId = lastBinding.RevisionId ?? string.Empty; + document.LastBoundImplementationKind = MemberImplementationKindMapper.ToWireName( + lastBinding.ImplementationKind); + if (lastBinding.BoundAtUtc != null) + document.LastBoundAt = lastBinding.BoundAtUtc; + } +} diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs new file mode 100644 index 000000000..6b1c9232a --- /dev/null +++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs @@ -0,0 +1,176 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.GAgents.StudioMember; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Projection.ReadModels; + +namespace Aevatar.Studio.Projection.QueryPorts; + +/// +/// Reads StudioMember roster and detail from the projection document store. +/// Pure query semantics — never replays events, never calls the actor +/// runtime, never falls back to the scope binding read model. Roster scans +/// are constrained to the requested scope_id using the denormalized +/// projector field, so members from other scopes are not visible. +/// +/// All fields read from the document are wire-stable strings. The query +/// port does not unpack any +/// payload — see CLAUDE.md `状态镜像契约面向查询` and the proto comment +/// on . +/// +public sealed class ProjectionStudioMemberQueryPort : IStudioMemberQueryPort +{ + public const int MaxRosterPageSize = 200; + + private readonly IProjectionDocumentReader _documentReader; + + public ProjectionStudioMemberQueryPort( + IProjectionDocumentReader documentReader) + { + _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader)); + } + + public async Task ListAsync( + string scopeId, + StudioMemberRosterPageRequest? page = null, + CancellationToken ct = default) + { + var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId); + var requestedPageSize = page?.PageSize ?? MaxRosterPageSize; + if (requestedPageSize <= 0 || requestedPageSize > MaxRosterPageSize) + requestedPageSize = MaxRosterPageSize; + + var query = new ProjectionDocumentQuery + { + Filters = + [ + new ProjectionDocumentFilter + { + FieldPath = "scope_id", + Operator = ProjectionDocumentFilterOperator.Eq, + Value = ProjectionDocumentValue.FromString(normalizedScopeId), + }, + ], + Take = requestedPageSize, + Cursor = string.IsNullOrWhiteSpace(page?.PageToken) ? null : page!.PageToken, + }; + + var result = await _documentReader.QueryAsync(query, ct); + var summaries = result.Items + .Where(item => string.Equals(item.ScopeId, normalizedScopeId, StringComparison.Ordinal)) + .Select(ToSummary) + .ToList(); + + return new StudioMemberRosterResponse( + ScopeId: normalizedScopeId, + Members: summaries, + NextPageToken: string.IsNullOrWhiteSpace(result.NextCursor) ? null : result.NextCursor); + } + + public async Task GetAsync( + string scopeId, + string memberId, + CancellationToken ct = default) + { + var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId); + var normalizedMemberId = StudioMemberConventions.NormalizeMemberId(memberId); + var actorId = StudioMemberConventions.BuildActorId(normalizedScopeId, normalizedMemberId); + + var document = await _documentReader.GetAsync(actorId, ct); + if (document == null) + return null; + + if (!string.Equals(document.ScopeId, normalizedScopeId, StringComparison.Ordinal)) + return null; + + return ToDetail(document); + } + + private static StudioMemberSummaryResponse ToSummary(StudioMemberCurrentStateDocument document) + { + return new StudioMemberSummaryResponse( + MemberId: document.MemberId, + ScopeId: document.ScopeId, + DisplayName: document.DisplayName, + Description: document.Description, + ImplementationKind: NormalizeImplementationKindWire(document.ImplementationKind), + LifecycleStage: NormalizeLifecycleStageWire(document.LifecycleStage), + PublishedServiceId: document.PublishedServiceId, + LastBoundRevisionId: string.IsNullOrEmpty(document.LastBoundRevisionId) + ? null + : document.LastBoundRevisionId, + CreatedAt: document.CreatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue, + UpdatedAt: document.UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue); + } + + private static StudioMemberDetailResponse ToDetail(StudioMemberCurrentStateDocument document) + { + var summary = ToSummary(document); + var implementationRef = ToImplementationRefResponse(document, summary.ImplementationKind); + var lastBinding = ToLastBindingResponse(document); + return new StudioMemberDetailResponse(summary, implementationRef, lastBinding); + } + + private static StudioMemberImplementationRefResponse? ToImplementationRefResponse( + StudioMemberCurrentStateDocument document, + string implementationKindWire) + { + if (!string.IsNullOrEmpty(document.ImplementationWorkflowId)) + { + return new StudioMemberImplementationRefResponse( + ImplementationKind: implementationKindWire, + WorkflowId: document.ImplementationWorkflowId, + WorkflowRevision: string.IsNullOrEmpty(document.ImplementationWorkflowRevision) + ? null + : document.ImplementationWorkflowRevision); + } + + if (!string.IsNullOrEmpty(document.ImplementationScriptId)) + { + return new StudioMemberImplementationRefResponse( + ImplementationKind: implementationKindWire, + ScriptId: document.ImplementationScriptId, + ScriptRevision: string.IsNullOrEmpty(document.ImplementationScriptRevision) + ? null + : document.ImplementationScriptRevision); + } + + if (!string.IsNullOrEmpty(document.ImplementationActorTypeName)) + { + return new StudioMemberImplementationRefResponse( + ImplementationKind: implementationKindWire, + ActorTypeName: document.ImplementationActorTypeName); + } + + return null; + } + + private static StudioMemberBindingContractResponse? ToLastBindingResponse( + StudioMemberCurrentStateDocument document) + { + if (string.IsNullOrEmpty(document.LastBoundPublishedServiceId)) + return null; + + return new StudioMemberBindingContractResponse( + PublishedServiceId: document.LastBoundPublishedServiceId, + RevisionId: document.LastBoundRevisionId, + ImplementationKind: NormalizeImplementationKindWire(document.LastBoundImplementationKind), + BoundAt: document.LastBoundAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue); + } + + private static string NormalizeImplementationKindWire(string? wire) => wire switch + { + MemberImplementationKindNames.Workflow => MemberImplementationKindNames.Workflow, + MemberImplementationKindNames.Script => MemberImplementationKindNames.Script, + MemberImplementationKindNames.GAgent => MemberImplementationKindNames.GAgent, + _ => string.Empty, + }; + + private static string NormalizeLifecycleStageWire(string? wire) => wire switch + { + MemberLifecycleStageNames.Created => MemberLifecycleStageNames.Created, + MemberLifecycleStageNames.BuildReady => MemberLifecycleStageNames.BuildReady, + MemberLifecycleStageNames.BindReady => MemberLifecycleStageNames.BindReady, + _ => string.Empty, + }; +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/StudioMemberCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/StudioMemberCurrentStateDocument.Partial.cs new file mode 100644 index 000000000..de06791ce --- /dev/null +++ b/src/Aevatar.Studio.Projection/ReadModels/StudioMemberCurrentStateDocument.Partial.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.Studio.Projection.ReadModels; + +public sealed partial class StudioMemberCurrentStateDocument + : IProjectionReadModel +{ + string IProjectionReadModel.ActorId => ActorId; + + long IProjectionReadModel.StateVersion => StateVersion; + + string IProjectionReadModel.LastEventId => LastEventId; + + DateTimeOffset IProjectionReadModel.UpdatedAt + { + get => UpdatedAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue; + } +} diff --git a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto index 3da8758bb..37518d7ba 100644 --- a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto +++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto @@ -100,3 +100,51 @@ message ChatConversationCurrentStateDocument { google.protobuf.Timestamp updated_at = 5; google.protobuf.Any state_root = 10; } + +// ─── StudioMember Current State ReadModel ─── +// +// Materialized from StudioMemberGAgent committed events. Document id is the +// actor id (studio-member:{scopeId}:{memberId}). scope_id is the prefix +// used by query ports to list members per scope; published_service_id is the +// stable per-member service id used by member binding orchestration. +// +// CLAUDE.md `状态镜像契约面向查询` — read-model payload is a query-shaped +// strongly-typed contract. Implementation_ref and last_binding are +// denormalized to top-level fields (not packed via google.protobuf.Any of +// the actor's internal state) so the query port never has to peek at the +// authority's internal state layout. wire-stable strings (e.g. +// implementation_kind = "workflow") are preferred over int enum codes so a +// stale document can never silently round-trip an out-of-range value. + +message StudioMemberCurrentStateDocument { + string id = 1; + string actor_id = 2; + int64 state_version = 3; + string last_event_id = 4; + google.protobuf.Timestamp updated_at = 5; + + // ── Identity / display ── + string member_id = 20; + string scope_id = 21; + string display_name = 22; + string description = 23; + + // ── Lifecycle (wire-stable) ── + string implementation_kind = 24; // "workflow" | "script" | "gagent" | "" + string lifecycle_stage = 25; // "created" | "build_ready" | "bind_ready" | "" + string published_service_id = 26; + google.protobuf.Timestamp created_at = 28; + + // ── Implementation ref (typed, only one of the *_id fields is set) ── + string implementation_workflow_id = 30; + string implementation_workflow_revision = 31; + string implementation_script_id = 32; + string implementation_script_revision = 33; + string implementation_actor_type_name = 34; + + // ── Last-binding contract (denormalized) ── + string last_bound_published_service_id = 40; + string last_bound_revision_id = 41; + string last_bound_implementation_kind = 42; // wire-stable string + google.protobuf.Timestamp last_bound_at = 43; +} diff --git a/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs new file mode 100644 index 000000000..3b8d245e9 --- /dev/null +++ b/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs @@ -0,0 +1,255 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.StudioMember; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Projection.CommandServices; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Tests; + +/// +/// Locks in the write-side invariants for the StudioMember command service: +/// +/// - CreateAsync routes through the canonical actor id and seeds the +/// immutable publishedServiceId from the member id (rename-safe). +/// - All three implementation kinds (workflow / script / gagent) build the +/// typed implementation_ref the actor expects. +/// - RecordBindingAsync rejects empty publishedServiceId / revisionId so the +/// member authority cannot record a degenerate binding. +/// - Dispatch always goes through IStudioActorBootstrap before +/// IActorDispatchPort, so the projection scope is active before the +/// command lands on the inbox. +/// +public sealed class ActorDispatchStudioMemberCommandServiceTests +{ + private const string ScopeId = "scope-1"; + + [Fact] + public async Task CreateAsync_ShouldDispatchCreatedEventToCanonicalActor() + { + var bootstrap = new RecordingBootstrap(); + var dispatch = new RecordingDispatchPort(); + var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch); + + var summary = await service.CreateAsync( + ScopeId, + new CreateStudioMemberRequest( + DisplayName: "Alpha", + ImplementationKind: MemberImplementationKindNames.Workflow, + Description: "first member", + MemberId: "m-alpha"), + CancellationToken.None); + + summary.MemberId.Should().Be("m-alpha"); + summary.ScopeId.Should().Be(ScopeId); + summary.PublishedServiceId.Should().Be("member-m-alpha"); + summary.LifecycleStage.Should().Be(MemberLifecycleStageNames.Created); + summary.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow); + + bootstrap.EnsuredActorIds.Should().ContainSingle() + .Which.Should().Be("studio-member:scope-1:m-alpha"); + dispatch.Dispatches.Should().ContainSingle(); + + var dispatched = dispatch.Dispatches[0]; + dispatched.ActorId.Should().Be("studio-member:scope-1:m-alpha"); + dispatched.Envelope.Payload.Is(StudioMemberCreatedEvent.Descriptor).Should().BeTrue(); + var evt = dispatched.Envelope.Payload.Unpack(); + evt.MemberId.Should().Be("m-alpha"); + evt.PublishedServiceId.Should().Be("member-m-alpha"); + evt.DisplayName.Should().Be("Alpha"); + evt.Description.Should().Be("first member"); + } + + [Fact] + public async Task CreateAsync_ShouldGenerateMemberId_WhenRequestOmitsIt() + { + var bootstrap = new RecordingBootstrap(); + var dispatch = new RecordingDispatchPort(); + var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch); + + var summary = await service.CreateAsync( + ScopeId, + new CreateStudioMemberRequest( + DisplayName: "Auto", + ImplementationKind: MemberImplementationKindNames.Script), + CancellationToken.None); + + summary.MemberId.Should().StartWith("m-"); + summary.PublishedServiceId.Should().Be($"member-{summary.MemberId}"); + summary.MemberId.Should().NotContain(":"); + } + + // Note: input validation (length caps, slug pattern, empty display + // name) is now enforced at the Application boundary in + // StudioMemberCreateRequestValidator. The Projection-layer command + // service is intentionally lenient and trusts already-validated input. + // Validator-level coverage lives in StudioMemberCreateRequestValidatorTests. + + [Fact] + public async Task CreateAsync_ShouldRejectUnknownImplementationKind() + { + var service = new ActorDispatchStudioMemberCommandService( + new RecordingBootstrap(), + new RecordingDispatchPort()); + + var act = () => service.CreateAsync( + ScopeId, + new CreateStudioMemberRequest( + DisplayName: "Test", + ImplementationKind: "weird"), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*Unknown implementationKind*"); + } + + [Theory] + [InlineData(MemberImplementationKindNames.Workflow)] + [InlineData(MemberImplementationKindNames.Script)] + [InlineData(MemberImplementationKindNames.GAgent)] + public async Task UpdateImplementationAsync_ShouldDispatchTypedRefForEachKind(string kind) + { + var bootstrap = new RecordingBootstrap(); + var dispatch = new RecordingDispatchPort(); + var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch); + + var implementation = kind switch + { + MemberImplementationKindNames.Workflow => new StudioMemberImplementationRefResponse( + ImplementationKind: kind, + WorkflowId: "wf-1", + WorkflowRevision: "v1"), + MemberImplementationKindNames.Script => new StudioMemberImplementationRefResponse( + ImplementationKind: kind, + ScriptId: "s-1", + ScriptRevision: "v2"), + MemberImplementationKindNames.GAgent => new StudioMemberImplementationRefResponse( + ImplementationKind: kind, + ActorTypeName: "MyActor"), + _ => throw new InvalidOperationException("unreachable"), + }; + + await service.UpdateImplementationAsync(ScopeId, "m-1", implementation, CancellationToken.None); + + dispatch.Dispatches.Should().ContainSingle(); + var evt = dispatch.Dispatches[0].Envelope.Payload.Unpack(); + switch (kind) + { + case MemberImplementationKindNames.Workflow: + evt.ImplementationKind.Should().Be(StudioMemberImplementationKind.Workflow); + evt.ImplementationRef.Workflow.WorkflowId.Should().Be("wf-1"); + evt.ImplementationRef.Workflow.WorkflowRevision.Should().Be("v1"); + break; + case MemberImplementationKindNames.Script: + evt.ImplementationKind.Should().Be(StudioMemberImplementationKind.Script); + evt.ImplementationRef.Script.ScriptId.Should().Be("s-1"); + evt.ImplementationRef.Script.ScriptRevision.Should().Be("v2"); + break; + case MemberImplementationKindNames.GAgent: + evt.ImplementationKind.Should().Be(StudioMemberImplementationKind.Gagent); + evt.ImplementationRef.Gagent.ActorTypeName.Should().Be("MyActor"); + break; + } + } + + [Fact] + public async Task RecordBindingAsync_ShouldRejectEmptyPublishedServiceId() + { + var service = new ActorDispatchStudioMemberCommandService( + new RecordingBootstrap(), new RecordingDispatchPort()); + + var act = () => service.RecordBindingAsync( + ScopeId, "m-1", "", "rev-1", MemberImplementationKindNames.Workflow, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*publishedServiceId is required*"); + } + + [Fact] + public async Task RecordBindingAsync_ShouldRejectEmptyRevisionId() + { + var service = new ActorDispatchStudioMemberCommandService( + new RecordingBootstrap(), new RecordingDispatchPort()); + + var act = () => service.RecordBindingAsync( + ScopeId, "m-1", "member-m-1", "", MemberImplementationKindNames.Workflow, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*revisionId is required*"); + } + + [Fact] + public async Task RecordBindingAsync_ShouldDispatchBoundEvent() + { + var bootstrap = new RecordingBootstrap(); + var dispatch = new RecordingDispatchPort(); + var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch); + + await service.RecordBindingAsync( + ScopeId, + "m-1", + "member-m-1", + "rev-7", + MemberImplementationKindNames.GAgent, + CancellationToken.None); + + bootstrap.EnsuredActorIds.Should().ContainSingle() + .Which.Should().Be("studio-member:scope-1:m-1"); + dispatch.Dispatches.Should().ContainSingle(); + var evt = dispatch.Dispatches[0].Envelope.Payload.Unpack(); + evt.PublishedServiceId.Should().Be("member-m-1"); + evt.RevisionId.Should().Be("rev-7"); + evt.ImplementationKind.Should().Be(StudioMemberImplementationKind.Gagent); + } + + [Fact] + public void Constructor_ShouldRejectNullDependencies() + { + FluentActions.Invoking(() => + new ActorDispatchStudioMemberCommandService(null!, new RecordingDispatchPort())) + .Should().Throw(); + FluentActions.Invoking(() => + new ActorDispatchStudioMemberCommandService(new RecordingBootstrap(), null!)) + .Should().Throw(); + } + + private sealed class RecordingBootstrap : IStudioActorBootstrap + { + public List EnsuredActorIds { get; } = []; + + public Task EnsureAsync(string actorId, CancellationToken ct = default) + where TAgent : IAgent, IProjectedActor + { + EnsuredActorIds.Add(actorId); + return Task.FromResult(new StubActor(actorId)); + } + } + + private sealed class StubActor(string id) : IActor + { + public string Id { get; } = id; + public IAgent Agent => throw new NotSupportedException(); + public Task ActivateAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task DeactivateAsync(CancellationToken ct = default) => Task.CompletedTask; + public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => + Task.CompletedTask; + public Task GetParentIdAsync() => Task.FromResult(null); + public Task> GetChildrenIdsAsync() => + Task.FromResult>([]); + } + + private sealed class RecordingDispatchPort : IActorDispatchPort + { + public List Dispatches { get; } = []; + + public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) + { + Dispatches.Add(new DispatchedCommand(actorId, envelope)); + return Task.CompletedTask; + } + + public sealed record DispatchedCommand(string ActorId, EventEnvelope Envelope); + } +} diff --git a/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj b/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj index e72be78c4..d4ce6a4c9 100644 --- a/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj +++ b/test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj @@ -22,6 +22,9 @@ + + + diff --git a/test/Aevatar.Studio.Tests/StudioMemberConventionsTests.cs b/test/Aevatar.Studio.Tests/StudioMemberConventionsTests.cs new file mode 100644 index 000000000..d3b01830c --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberConventionsTests.cs @@ -0,0 +1,71 @@ +using Aevatar.GAgents.StudioMember; +using FluentAssertions; + +namespace Aevatar.Studio.Tests; + +public sealed class StudioMemberConventionsTests +{ + [Fact] + public void BuildActorId_ShouldFollowCanonicalLayout() + { + var actorId = StudioMemberConventions.BuildActorId("scope-1", "m-abc"); + actorId.Should().Be("studio-member:scope-1:m-abc"); + } + + [Fact] + public void BuildActorId_ShouldRejectMissingScope() + { + var act = () => StudioMemberConventions.BuildActorId(" ", "m-abc"); + act.Should().Throw().WithParameterName("scopeId"); + } + + [Fact] + public void BuildActorId_ShouldRejectMissingMember() + { + var act = () => StudioMemberConventions.BuildActorId("scope-1", null!); + act.Should().Throw().WithParameterName("memberId"); + } + + [Fact] + public void BuildPublishedServiceId_ShouldOnlyDependOnMemberId() + { + // Renaming display name later must not affect publishedServiceId, + // which is enforced by deriving it solely from the immutable id. + var first = StudioMemberConventions.BuildPublishedServiceId("m-abc"); + var second = StudioMemberConventions.BuildPublishedServiceId("m-abc"); + first.Should().Be(second); + first.Should().Be("member-m-abc"); + } + + [Fact] + public void BuildPublishedServiceId_ShouldDifferAcrossMembers() + { + var a = StudioMemberConventions.BuildPublishedServiceId("m-aaa"); + var b = StudioMemberConventions.BuildPublishedServiceId("m-bbb"); + a.Should().NotBe(b); + } + + [Fact] + public void NormalizeScopeId_ShouldRejectActorIdSeparator() + { + // Allowing ':' would let a caller forge actor IDs by collision — + // e.g. "scope-1:m-evil" would round-trip to the actor id of a real + // member in scope-1. Reject at the boundary. + var act = () => StudioMemberConventions.NormalizeScopeId("scope:1"); + act.Should().Throw().WithParameterName("scopeId"); + } + + [Fact] + public void NormalizeMemberId_ShouldRejectActorIdSeparator() + { + var act = () => StudioMemberConventions.NormalizeMemberId("m:nested"); + act.Should().Throw().WithParameterName("memberId"); + } + + [Fact] + public void BuildActorId_ShouldRejectActorIdSeparatorInMemberId() + { + var act = () => StudioMemberConventions.BuildActorId("scope-1", "m:nested"); + act.Should().Throw().WithParameterName("memberId"); + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberCreateRequestValidatorTests.cs b/test/Aevatar.Studio.Tests/StudioMemberCreateRequestValidatorTests.cs new file mode 100644 index 000000000..6e22053b4 --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberCreateRequestValidatorTests.cs @@ -0,0 +1,102 @@ +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Application.Studio.Services; +using FluentAssertions; + +namespace Aevatar.Studio.Tests; + +/// +/// Application-layer enforcement of . +/// These tests guard against the regression where the bounds drifted into +/// the Projection-layer command service: swap the command port and they +/// silently disappear. The validator is the single boundary now, and a +/// missing call from would +/// fail integration-level coverage too. +/// +public sealed class StudioMemberCreateRequestValidatorTests +{ + [Fact] + public void Validate_ShouldRejectEmptyDisplayName() + { + var act = () => StudioMemberCreateRequestValidator.Validate( + new CreateStudioMemberRequest( + DisplayName: " ", + ImplementationKind: MemberImplementationKindNames.Workflow)); + + act.Should().Throw() + .WithMessage("*displayName is required*"); + } + + [Fact] + public void Validate_ShouldRejectDisplayNameOverCap() + { + var act = () => StudioMemberCreateRequestValidator.Validate( + new CreateStudioMemberRequest( + DisplayName: new string('a', StudioMemberInputLimits.MaxDisplayNameLength + 1), + ImplementationKind: MemberImplementationKindNames.Workflow)); + + act.Should().Throw() + .WithMessage("*displayName must be at most*"); + } + + [Fact] + public void Validate_ShouldRejectDescriptionOverCap() + { + var act = () => StudioMemberCreateRequestValidator.Validate( + new CreateStudioMemberRequest( + DisplayName: "Alpha", + ImplementationKind: MemberImplementationKindNames.Workflow, + Description: new string('a', StudioMemberInputLimits.MaxDescriptionLength + 1))); + + act.Should().Throw() + .WithMessage("*description must be at most*"); + } + + [Fact] + public void Validate_ShouldRejectMemberIdViolatingSlugPattern() + { + var act = () => StudioMemberCreateRequestValidator.Validate( + new CreateStudioMemberRequest( + DisplayName: "Alpha", + ImplementationKind: MemberImplementationKindNames.Workflow, + MemberId: "m bad space")); + + act.Should().Throw() + .WithMessage("*memberId must match*"); + } + + [Fact] + public void Validate_ShouldRejectMemberIdContainingActorIdSeparator() + { + var act = () => StudioMemberCreateRequestValidator.Validate( + new CreateStudioMemberRequest( + DisplayName: "Alpha", + ImplementationKind: MemberImplementationKindNames.Workflow, + MemberId: "m:nested")); + + act.Should().Throw() + .WithMessage("*memberId must match*"); + } + + [Fact] + public void Validate_ShouldAcceptOmittedMemberId() + { + // Empty memberId is allowed — the projection-layer command service + // generates a random one. The validator must not reject this case. + var act = () => StudioMemberCreateRequestValidator.Validate( + new CreateStudioMemberRequest( + DisplayName: "Alpha", + ImplementationKind: MemberImplementationKindNames.Workflow)); + act.Should().NotThrow(); + } + + [Fact] + public void Validate_ShouldAcceptValidSlugMemberId() + { + var act = () => StudioMemberCreateRequestValidator.Validate( + new CreateStudioMemberRequest( + DisplayName: "Alpha", + ImplementationKind: MemberImplementationKindNames.Workflow, + MemberId: "m-good_1")); + act.Should().NotThrow(); + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberCurrentStateProjectorTests.cs b/test/Aevatar.Studio.Tests/StudioMemberCurrentStateProjectorTests.cs new file mode 100644 index 000000000..718a7492a --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberCurrentStateProjectorTests.cs @@ -0,0 +1,240 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.GAgents.StudioMember; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Projection.Orchestration; +using Aevatar.Studio.Projection.Projectors; +using Aevatar.Studio.Projection.ReadModels; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Tests; + +/// +/// Locks in the projector contract: committed StudioMember state events are +/// materialized into with the +/// denormalized roster fields populated, and unrelated payloads are +/// silently skipped (no spurious upserts). +/// +public sealed class StudioMemberCurrentStateProjectorTests +{ + private const string RootActorId = "studio-member:scope-1:m-1"; + + [Fact] + public async Task ProjectAsync_ShouldUpsertDocument_WhenCommittedStateEventArrives() + { + var dispatcher = new RecordingWriteDispatcher(); + var clock = new FixedProjectionClock(DateTimeOffset.Parse("2026-04-27T00:00:00Z")); + var projector = new StudioMemberCurrentStateProjector(dispatcher, clock); + + var state = new StudioMemberState + { + MemberId = "m-1", + ScopeId = "scope-1", + DisplayName = "Test Member", + Description = "desc", + ImplementationKind = StudioMemberImplementationKind.Workflow, + PublishedServiceId = "member-m-1", + LifecycleStage = StudioMemberLifecycleStage.BindReady, + CreatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(-1)), + UpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + ImplementationRef = new StudioMemberImplementationRef + { + Workflow = new StudioMemberWorkflowRef + { + WorkflowId = "wf-1", + WorkflowRevision = "rev-9", + }, + }, + LastBinding = new StudioMemberBindingContract + { + PublishedServiceId = "member-m-1", + RevisionId = "rev-9", + ImplementationKind = StudioMemberImplementationKind.Workflow, + BoundAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + }, + }; + + var envelope = WrapCommitted( + payload: new StudioMemberBoundEvent { RevisionId = "rev-9" }, + state: state, + version: 5, + eventId: "evt-9"); + + await projector.ProjectAsync(NewContext(), envelope); + + dispatcher.Upserts.Should().ContainSingle(); + var written = dispatcher.Upserts[0]; + + // Document identity follows the actor-id convention. + written.Id.Should().Be(RootActorId); + written.ActorId.Should().Be(RootActorId); + written.StateVersion.Should().Be(5); + written.LastEventId.Should().Be("evt-9"); + + // Denormalized roster fields are written so the query port doesn't + // have to unpack state_root for ListAsync. + written.MemberId.Should().Be("m-1"); + written.ScopeId.Should().Be("scope-1"); + written.DisplayName.Should().Be("Test Member"); + written.PublishedServiceId.Should().Be("member-m-1"); + written.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow); + written.LifecycleStage.Should().Be(MemberLifecycleStageNames.BindReady); + + // implementation_ref denormalized — no Any-pack of internal state. + written.ImplementationWorkflowId.Should().Be("wf-1"); + written.ImplementationWorkflowRevision.Should().Be("rev-9"); + written.ImplementationScriptId.Should().BeEmpty(); + written.ImplementationActorTypeName.Should().BeEmpty(); + + // last_binding denormalized. + written.LastBoundPublishedServiceId.Should().Be("member-m-1"); + written.LastBoundRevisionId.Should().Be("rev-9"); + written.LastBoundImplementationKind.Should().Be(MemberImplementationKindNames.Workflow); + written.LastBoundAt.Should().NotBeNull(); + } + + [Fact] + public async Task ProjectAsync_ShouldDenormalizeScriptImplementation() + { + var dispatcher = new RecordingWriteDispatcher(); + var projector = new StudioMemberCurrentStateProjector( + dispatcher, new FixedProjectionClock(DateTimeOffset.UtcNow)); + + var state = new StudioMemberState + { + MemberId = "m-1", + ScopeId = "scope-1", + DisplayName = "Script Member", + ImplementationKind = StudioMemberImplementationKind.Script, + PublishedServiceId = "member-m-1", + LifecycleStage = StudioMemberLifecycleStage.BuildReady, + CreatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + UpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + ImplementationRef = new StudioMemberImplementationRef + { + Script = new StudioMemberScriptRef { ScriptId = "s-1", ScriptRevision = "v3" }, + }, + }; + + await projector.ProjectAsync( + NewContext(), + WrapCommitted(new StudioMemberImplementationUpdatedEvent(), state, 1, "evt-1")); + + var written = dispatcher.Upserts[0]; + written.ImplementationKind.Should().Be(MemberImplementationKindNames.Script); + written.ImplementationScriptId.Should().Be("s-1"); + written.ImplementationScriptRevision.Should().Be("v3"); + written.ImplementationWorkflowId.Should().BeEmpty(); + written.LastBoundPublishedServiceId.Should().BeEmpty(); + } + + [Fact] + public async Task ProjectAsync_ShouldNoOp_WhenPayloadIsNotCommittedStateEvent() + { + var dispatcher = new RecordingWriteDispatcher(); + var clock = new FixedProjectionClock(DateTimeOffset.UtcNow); + var projector = new StudioMemberCurrentStateProjector(dispatcher, clock); + + // A bare event envelope without the CommittedStateEventPublished + // wrapper must not produce a write — the projector is downstream of + // committed events only. + var envelope = new EventEnvelope + { + Id = "raw", + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(new StudioMemberCreatedEvent { MemberId = "m-1" }), + }; + + await projector.ProjectAsync(NewContext(), envelope); + + dispatcher.Upserts.Should().BeEmpty(); + } + + [Fact] + public async Task ProjectAsync_ShouldRejectNullArguments() + { + var dispatcher = new RecordingWriteDispatcher(); + var clock = new FixedProjectionClock(DateTimeOffset.UtcNow); + var projector = new StudioMemberCurrentStateProjector(dispatcher, clock); + + await FluentActions + .Awaiting(() => projector.ProjectAsync(null!, new EventEnvelope()).AsTask()) + .Should().ThrowAsync(); + await FluentActions + .Awaiting(() => projector.ProjectAsync(NewContext(), null!).AsTask()) + .Should().ThrowAsync(); + } + + [Fact] + public void Constructor_ShouldRejectNullDependencies() + { + var dispatcher = new RecordingWriteDispatcher(); + var clock = new FixedProjectionClock(DateTimeOffset.UtcNow); + + FluentActions + .Invoking(() => new StudioMemberCurrentStateProjector(null!, clock)) + .Should().Throw(); + FluentActions + .Invoking(() => new StudioMemberCurrentStateProjector(dispatcher, null!)) + .Should().Throw(); + } + + private static StudioMaterializationContext NewContext() => new() + { + RootActorId = RootActorId, + ProjectionKind = "studio-current-state", + }; + + private static EventEnvelope WrapCommitted( + IMessage payload, + StudioMemberState state, + long version, + string eventId) + { + return new EventEnvelope + { + Id = eventId, + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Route = EnvelopeRouteSemantics.CreateObserverPublication(RootActorId), + Payload = Any.Pack(new CommittedStateEventPublished + { + StateEvent = new StateEvent + { + EventId = eventId, + Version = version, + EventData = Any.Pack(payload), + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + }, + StateRoot = Any.Pack(state), + }), + }; + } + + private sealed class RecordingWriteDispatcher : IProjectionWriteDispatcher + where TReadModel : class, IProjectionReadModel + { + public List Upserts { get; } = []; + + public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + Upserts.Add(readModel); + return Task.FromResult(ProjectionWriteResult.Applied()); + } + + public Task DeleteAsync(string id, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(ProjectionWriteResult.Applied()); + } + } + + private sealed class FixedProjectionClock(DateTimeOffset utcNow) : IProjectionClock + { + public DateTimeOffset UtcNow { get; } = utcNow; + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs b/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs new file mode 100644 index 000000000..a64f21091 --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberEndpointsTests.cs @@ -0,0 +1,364 @@ +using System.Reflection; +using System.Security.Claims; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Hosting.Endpoints; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Aevatar.Studio.Tests; + +/// +/// Locks in the HTTP-handler invariants for member-first endpoints: +/// +/// - Each handler defers to only. +/// - Scope-access guard short-circuits with 403 before any service call. +/// - Domain validation failures from the service map to 400 with a stable +/// error code Studio's frontend can switch on. +/// - GET endpoints map "no document" to 404 (not 200 with a null body). +/// +public sealed class StudioMemberEndpointsTests +{ + private const string ScopeId = "scope-1"; + + [Fact] + public async Task HandleCreateAsync_ShouldReturnCreated_OnSuccess() + { + var service = new RecordingMemberService + { + CreateResponse = NewSummary(), + }; + + var result = await InvokeHandle( + "HandleCreateAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + new CreateStudioMemberRequest( + DisplayName: "Alpha", + ImplementationKind: MemberImplementationKindNames.Workflow), + service, + CancellationToken.None); + + result.Should().BeOfType>() + .Which.Location.Should().Be($"/api/scopes/{ScopeId}/members/{NewSummary().MemberId}"); + service.CreateInvoked.Should().BeTrue(); + } + + [Fact] + public async Task HandleCreateAsync_ShouldReturnBadRequest_OnDomainError() + { + var service = new RecordingMemberService + { + CreateException = new InvalidOperationException("displayName is required."), + }; + + var result = await InvokeHandle( + "HandleCreateAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + new CreateStudioMemberRequest( + DisplayName: string.Empty, + ImplementationKind: MemberImplementationKindNames.Workflow), + service, + CancellationToken.None); + + // BadRequest — the anonymous type is internal, so we + // assert via the open generic shape rather than nailing the closed type. + result.GetType().Name.Should().StartWith("BadRequest"); + } + + [Fact] + public async Task HandleCreateAsync_ShouldReturnForbidden_WhenScopeAccessDenied() + { + var service = new RecordingMemberService(); + + var result = await InvokeHandle( + "HandleCreateAsync", + CreateAuthenticatedContext("other-scope"), + ScopeId, + new CreateStudioMemberRequest( + DisplayName: "Alpha", + ImplementationKind: MemberImplementationKindNames.Workflow), + service, + CancellationToken.None); + + // Service must not be touched after the guard short-circuits. + service.CreateInvoked.Should().BeFalse(); + // The denied result is JSON with statusCode 403; assertion via shape. + AssertIsJsonStatus(result, expectedStatus: StatusCodes.Status403Forbidden); + } + + [Fact] + public async Task HandleListAsync_ShouldReturnOk_OnSuccess() + { + var service = new RecordingMemberService + { + ListResponse = new StudioMemberRosterResponse(ScopeId, [NewSummary()]), + }; + + var result = await InvokeHandle( + "HandleListAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + service, + (int?)null, + (string?)null, + CancellationToken.None); + + result.Should().BeOfType>() + .Which.Value!.Members.Should().ContainSingle(); + } + + [Fact] + public async Task HandleGetAsync_ShouldReturnTyped404_WhenMemberMissing() + { + // GetAsync now throws StudioMemberNotFoundException for missing + // members; the endpoint returns the same typed 404 body that + // bind / get-binding do — three endpoints, one 404 shape. + var service = new RecordingMemberService + { + GetException = new StudioMemberNotFoundException(ScopeId, "m-missing"), + }; + + var result = await InvokeHandle( + "HandleGetAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-missing", + service, + CancellationToken.None); + + var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?; + statusCode.Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public async Task HandleGetAsync_ShouldReturnOk_WhenServiceReturnsDetail() + { + var detail = new StudioMemberDetailResponse(NewSummary(), null, null); + var service = new RecordingMemberService + { + GetResponse = detail, + }; + + var result = await InvokeHandle( + "HandleGetAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + service, + CancellationToken.None); + + result.Should().BeOfType>() + .Which.Value.Should().BeSameAs(detail); + } + + [Fact] + public async Task HandleBindAsync_ShouldReturnOk_OnSuccess() + { + var binding = new StudioMemberBindingResponse( + MemberId: "m-1", + PublishedServiceId: "member-m-1", + RevisionId: "rev-1", + ImplementationKind: MemberImplementationKindNames.Workflow, + ScopeId: ScopeId, + ExpectedActorId: "actor"); + var service = new RecordingMemberService + { + BindResponse = binding, + }; + + var result = await InvokeHandle( + "HandleBindAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + new UpdateStudioMemberBindingRequest( + Workflow: new StudioMemberWorkflowBindingSpec(["w:"])), + service, + CancellationToken.None); + + result.Should().BeOfType>() + .Which.Value.Should().BeSameAs(binding); + } + + [Fact] + public async Task HandleBindAsync_ShouldReturnBadRequest_OnDomainError() + { + var service = new RecordingMemberService + { + BindException = new InvalidOperationException("workflow yamls are required."), + }; + + var result = await InvokeHandle( + "HandleBindAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + new UpdateStudioMemberBindingRequest(), + service, + CancellationToken.None); + + // BadRequest — the anonymous type is internal, so we + // assert via the open generic shape rather than nailing the closed type. + result.GetType().Name.Should().StartWith("BadRequest"); + } + + [Fact] + public async Task HandleGetBindingAsync_ShouldReturnOk_WithNullBinding_WhenMemberExistsButNeverBound() + { + // Disambiguates the prior 404 shape: a member that exists but has + // never been bound is NOT missing (which has its own typed 404). + // It's a member with a null binding — surface as 200 with the + // wrapper and let the frontend dispatch on `lastBinding === null`. + var service = new RecordingMemberService + { + GetBindingResponse = null, + }; + + var result = await InvokeHandle( + "HandleGetBindingAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + service, + CancellationToken.None); + + result.Should().BeOfType>() + .Which.Value!.LastBinding.Should().BeNull(); + } + + [Fact] + public async Task HandleGetBindingAsync_ShouldReturnOk_WhenServiceReturnsBinding() + { + var contract = new StudioMemberBindingContractResponse( + "member-m-1", "rev-1", MemberImplementationKindNames.Workflow, DateTimeOffset.UtcNow); + var service = new RecordingMemberService + { + GetBindingResponse = contract, + }; + + var result = await InvokeHandle( + "HandleGetBindingAsync", + CreateAuthenticatedContext(ScopeId), + ScopeId, + "m-1", + service, + CancellationToken.None); + + result.Should().BeOfType>() + .Which.Value!.LastBinding.Should().BeSameAs(contract); + } + + private static StudioMemberSummaryResponse NewSummary() => new( + MemberId: "m-1", + ScopeId: ScopeId, + DisplayName: "Alpha", + Description: string.Empty, + ImplementationKind: MemberImplementationKindNames.Workflow, + LifecycleStage: MemberLifecycleStageNames.Created, + PublishedServiceId: "member-m-1", + LastBoundRevisionId: null, + CreatedAt: DateTimeOffset.UtcNow, + UpdatedAt: DateTimeOffset.UtcNow); + + private static HttpContext CreateAuthenticatedContext(string claimedScopeId) + { + var identity = new ClaimsIdentity( + [new Claim("scope_id", claimedScopeId)], + "test"); + var services = new ServiceCollection() + .AddSingleton(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Aevatar:Authentication:Enabled"] = "true", + }) + .Build()) + .AddSingleton(new TestHostEnvironment()) + .BuildServiceProvider(); + return new DefaultHttpContext + { + User = new ClaimsPrincipal(identity), + RequestServices = services, + }; + } + + private static void AssertIsJsonStatus(IResult result, int expectedStatus) + { + // ASP.NET Core's Results.Json yields a JsonHttpResult whose + // StatusCode property exposes the configured status. We check by + // reflection so this test stays decoupled from the precise generic. + var statusCodeProperty = result.GetType().GetProperty("StatusCode"); + var statusCode = statusCodeProperty?.GetValue(result) as int?; + statusCode.Should().Be(expectedStatus, + because: $"expected JSON result with status {expectedStatus} but got {result.GetType().Name}"); + } + + private static async Task InvokeHandle(string methodName, params object?[] args) + { + var method = typeof(StudioMemberEndpoints) + .GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException($"Method {methodName} not found."); + var task = (Task)method.Invoke(null, args)!; + return (TResult)(object)await task; + } + + private sealed class RecordingMemberService : IStudioMemberService + { + public StudioMemberSummaryResponse? CreateResponse { get; set; } + public Exception? CreateException { get; set; } + public bool CreateInvoked { get; private set; } + + public StudioMemberRosterResponse? ListResponse { get; set; } + public StudioMemberDetailResponse? GetResponse { get; set; } + public Exception? GetException { get; set; } + public StudioMemberBindingResponse? BindResponse { get; set; } + public Exception? BindException { get; set; } + public StudioMemberBindingContractResponse? GetBindingResponse { get; set; } + + public Task CreateAsync( + string scopeId, CreateStudioMemberRequest request, CancellationToken ct = default) + { + CreateInvoked = true; + if (CreateException != null) throw CreateException; + return Task.FromResult(CreateResponse!); + } + + public Task ListAsync( + string scopeId, + StudioMemberRosterPageRequest? page = null, + CancellationToken ct = default) + => Task.FromResult(ListResponse ?? new StudioMemberRosterResponse(scopeId, [])); + + public Task GetAsync( + string scopeId, string memberId, CancellationToken ct = default) + { + if (GetException != null) throw GetException; + return Task.FromResult( + GetResponse ?? throw new StudioMemberNotFoundException(scopeId, memberId)); + } + + public Task BindAsync( + string scopeId, string memberId, UpdateStudioMemberBindingRequest request, CancellationToken ct = default) + { + if (BindException != null) throw BindException; + return Task.FromResult(BindResponse!); + } + + public Task GetBindingAsync( + string scopeId, string memberId, CancellationToken ct = default) + => Task.FromResult(GetBindingResponse); + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = Environments.Production; + public string ApplicationName { get; set; } = "Aevatar.Studio.Tests"; + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberGAgentStateTests.cs b/test/Aevatar.Studio.Tests/StudioMemberGAgentStateTests.cs new file mode 100644 index 000000000..ad713fb0c --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberGAgentStateTests.cs @@ -0,0 +1,146 @@ +using System.Reflection; +using Aevatar.GAgents.StudioMember; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Tests; + +/// +/// Tests the StudioMember state machine in isolation by feeding events +/// directly into the GAgent's TransitionState. Reflection bridges to +/// the protected method so we can lock in the rename-safe publishedServiceId +/// invariant from the issue without standing up the full actor runtime. +/// +public sealed class StudioMemberGAgentStateTests +{ + private readonly StudioMemberStateApplier _agent = new(); + + [Fact] + public void Created_ShouldPersistPublishedServiceId() + { + var initial = new StudioMemberState(); + var createdAt = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var afterCreate = _agent.Apply(initial, new StudioMemberCreatedEvent + { + MemberId = "m-1", + ScopeId = "scope-1", + DisplayName = "Original", + ImplementationKind = StudioMemberImplementationKind.Workflow, + PublishedServiceId = "member-m-1", + CreatedAtUtc = createdAt, + }); + + afterCreate.MemberId.Should().Be("m-1"); + afterCreate.PublishedServiceId.Should().Be("member-m-1"); + afterCreate.LifecycleStage.Should().Be(StudioMemberLifecycleStage.Created); + } + + [Fact] + public void Renamed_ShouldNotChangePublishedServiceId() + { + var created = _agent.Apply(new StudioMemberState(), new StudioMemberCreatedEvent + { + MemberId = "m-1", + ScopeId = "scope-1", + DisplayName = "Original", + ImplementationKind = StudioMemberImplementationKind.Workflow, + PublishedServiceId = "member-m-1", + CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }); + + var renamed = _agent.Apply(created, new StudioMemberRenamedEvent + { + DisplayName = "Renamed Member", + Description = "Now with different name", + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddSeconds(1)), + }); + + // Acceptance criterion from issue #325: + // "publishedServiceId is backend-generated, stable, and rename-safe" + renamed.PublishedServiceId.Should().Be(created.PublishedServiceId); + renamed.MemberId.Should().Be(created.MemberId); + renamed.DisplayName.Should().Be("Renamed Member"); + renamed.Description.Should().Be("Now with different name"); + } + + [Fact] + public void ImplementationUpdated_ShouldAdvanceLifecycleToBuildReady() + { + var created = _agent.Apply(new StudioMemberState(), new StudioMemberCreatedEvent + { + MemberId = "m-1", + ScopeId = "scope-1", + DisplayName = "Original", + ImplementationKind = StudioMemberImplementationKind.Script, + PublishedServiceId = "member-m-1", + CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }); + created.LifecycleStage.Should().Be(StudioMemberLifecycleStage.Created); + + var withImpl = _agent.Apply(created, new StudioMemberImplementationUpdatedEvent + { + ImplementationKind = StudioMemberImplementationKind.Script, + ImplementationRef = new StudioMemberImplementationRef + { + Script = new StudioMemberScriptRef + { + ScriptId = "s-1", + ScriptRevision = "v1", + }, + }, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddSeconds(1)), + }); + + withImpl.LifecycleStage.Should().Be(StudioMemberLifecycleStage.BuildReady); + withImpl.ImplementationRef.Should().NotBeNull(); + withImpl.ImplementationRef.Script.ScriptId.Should().Be("s-1"); + } + + [Fact] + public void Bound_ShouldCaptureLastBindingAndAdvanceLifecycle() + { + var withImpl = _agent.Apply(new StudioMemberState(), new StudioMemberCreatedEvent + { + MemberId = "m-1", + ScopeId = "scope-1", + DisplayName = "Original", + ImplementationKind = StudioMemberImplementationKind.Workflow, + PublishedServiceId = "member-m-1", + CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }); + + var bound = _agent.Apply(withImpl, new StudioMemberBoundEvent + { + PublishedServiceId = "member-m-1", + RevisionId = "rev-7", + ImplementationKind = StudioMemberImplementationKind.Workflow, + BoundAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow.AddSeconds(2)), + }); + + bound.LifecycleStage.Should().Be(StudioMemberLifecycleStage.BindReady); + bound.LastBinding.Should().NotBeNull(); + bound.LastBinding.PublishedServiceId.Should().Be("member-m-1"); + bound.LastBinding.RevisionId.Should().Be("rev-7"); + bound.PublishedServiceId.Should().Be("member-m-1"); + } + + private sealed class StudioMemberStateApplier + { + private static readonly MethodInfo TransitionStateMethod = + typeof(StudioMemberGAgent).GetMethod( + "TransitionState", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("TransitionState method not found."); + + private readonly StudioMemberGAgent _agent = new(); + + public StudioMemberState Apply(StudioMemberState current, IMessage evt) + { + var result = TransitionStateMethod.Invoke(_agent, [current, evt]) + ?? throw new InvalidOperationException("TransitionState returned null."); + return (StudioMemberState)result; + } + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberImplementationKindMapperTests.cs b/test/Aevatar.Studio.Tests/StudioMemberImplementationKindMapperTests.cs new file mode 100644 index 000000000..7d5c5cda9 --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberImplementationKindMapperTests.cs @@ -0,0 +1,68 @@ +using Aevatar.GAgents.StudioMember; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Projection.Mapping; +using FluentAssertions; + +namespace Aevatar.Studio.Tests; + +public sealed class StudioMemberImplementationKindMapperTests +{ + [Theory] + [InlineData("workflow", StudioMemberImplementationKind.Workflow)] + [InlineData("WORKFLOW", StudioMemberImplementationKind.Workflow)] + [InlineData("script", StudioMemberImplementationKind.Script)] + [InlineData("gagent", StudioMemberImplementationKind.Gagent)] + public void Parse_ShouldMapKnownKindsCaseInsensitive(string wire, StudioMemberImplementationKind expected) + { + MemberImplementationKindMapper.Parse(wire).Should().Be(expected); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Parse_ShouldRejectMissingValue(string? wire) + { + var act = () => MemberImplementationKindMapper.Parse(wire); + act.Should().Throw() + .WithMessage("*implementationKind is required*"); + } + + [Fact] + public void Parse_ShouldRejectUnknownValue() + { + var act = () => MemberImplementationKindMapper.Parse("worker"); + act.Should().Throw() + .WithMessage("*Unknown implementationKind*"); + } + + [Theory] + [InlineData(StudioMemberImplementationKind.Workflow, "workflow")] + [InlineData(StudioMemberImplementationKind.Script, "script")] + [InlineData(StudioMemberImplementationKind.Gagent, "gagent")] + public void ToWireName_ImplementationKind_ShouldMapBack( + StudioMemberImplementationKind kind, string expected) + { + MemberImplementationKindMapper.ToWireName(kind).Should().Be(expected); + } + + [Theory] + [InlineData(StudioMemberLifecycleStage.Created, "created")] + [InlineData(StudioMemberLifecycleStage.BuildReady, "build_ready")] + [InlineData(StudioMemberLifecycleStage.BindReady, "bind_ready")] + public void ToWireName_LifecycleStage_ShouldMapToWireName( + StudioMemberLifecycleStage stage, string expected) + { + MemberImplementationKindMapper.ToWireName(stage).Should().Be(expected); + } + + [Fact] + public void ToWireName_LifecycleStage_ShouldReturnEmpty_ForUnspecified() + { + // An Unspecified value indicates a malformed projection; previously + // this silently mapped to "created", which lied to callers. + MemberImplementationKindMapper + .ToWireName(StudioMemberLifecycleStage.Unspecified) + .Should().BeEmpty(); + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs b/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs new file mode 100644 index 000000000..4a2bf4657 --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberPRReviewFixesTests.cs @@ -0,0 +1,272 @@ +using System.Reflection; +using System.Security.Claims; +using Aevatar.GAgents.StudioMember; +using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Hosting.Endpoints; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Aevatar.Studio.Tests; + +/// +/// Regression tests for the PR-review fix-ups: +/// - lifecycle downgrade on out-of-band implementation update post-bind +/// - ApplyCreated re-derives publishedServiceId from convention +/// - HandleCreated rejects re-create with mismatched non-identity fields +/// - input validation: length caps + slug pattern on memberId +/// - HTTP 404 for missing member (not 400) +/// - StudioMemberRosterResponse carries NextPageToken +/// +public sealed class StudioMemberPRReviewFixesTests +{ + private static readonly MethodInfo TransitionStateMethod = + typeof(StudioMemberGAgent).GetMethod( + "TransitionState", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("TransitionState method not found."); + + [Fact] + public void ApplyCreated_ShouldRederivePublishedServiceId_IgnoringEventValue() + { + // The dispatcher today builds publishedServiceId via the same + // convention; rebuilding inside the actor protects against a stale + // / hand-rolled event whose derivation rule drifted. + var agent = new StudioMemberGAgent(); + var current = new StudioMemberState(); + var evt = new StudioMemberCreatedEvent + { + MemberId = "m-1", + ScopeId = "scope-1", + DisplayName = "Original", + ImplementationKind = StudioMemberImplementationKind.Workflow, + // Adversarial: event payload claims a different publishedServiceId. + PublishedServiceId = "evil-service-id", + CreatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + + var next = (StudioMemberState)TransitionStateMethod.Invoke(agent, [current, evt])!; + + next.PublishedServiceId.Should().Be(StudioMemberConventions.BuildPublishedServiceId("m-1")); + next.PublishedServiceId.Should().NotBe("evil-service-id"); + } + + [Fact] + public void ApplyImplementationUpdated_ShouldDowngradeBindReadyToBuildReady() + { + var agent = new StudioMemberGAgent(); + var bound = new StudioMemberState + { + MemberId = "m-1", + ScopeId = "scope-1", + ImplementationKind = StudioMemberImplementationKind.Workflow, + PublishedServiceId = "member-m-1", + LifecycleStage = StudioMemberLifecycleStage.BindReady, + ImplementationRef = new StudioMemberImplementationRef + { + Workflow = new StudioMemberWorkflowRef { WorkflowId = "wf-1", WorkflowRevision = "v1" }, + }, + }; + + var implUpdate = new StudioMemberImplementationUpdatedEvent + { + ImplementationKind = StudioMemberImplementationKind.Workflow, + ImplementationRef = new StudioMemberImplementationRef + { + Workflow = new StudioMemberWorkflowRef { WorkflowId = "wf-1", WorkflowRevision = "v2" }, + }, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + + var next = (StudioMemberState)TransitionStateMethod.Invoke(agent, [bound, implUpdate])!; + + next.LifecycleStage.Should().Be(StudioMemberLifecycleStage.BuildReady, + because: "the published revision is now stale until the next bind"); + next.ImplementationRef.Workflow.WorkflowRevision.Should().Be("v2"); + } + + [Fact] + public void ApplyImplementationUpdated_ShouldUpgradeCreatedToBuildReady() + { + var agent = new StudioMemberGAgent(); + var created = new StudioMemberState + { + MemberId = "m-1", + LifecycleStage = StudioMemberLifecycleStage.Created, + }; + + var implUpdate = new StudioMemberImplementationUpdatedEvent + { + ImplementationKind = StudioMemberImplementationKind.Script, + ImplementationRef = new StudioMemberImplementationRef + { + Script = new StudioMemberScriptRef { ScriptId = "s-1", ScriptRevision = "v1" }, + }, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + + var next = (StudioMemberState)TransitionStateMethod.Invoke(agent, [created, implUpdate])!; + + next.LifecycleStage.Should().Be(StudioMemberLifecycleStage.BuildReady); + } + + // The HandleCreated conflict-detection branch is exercised via the + // actor runtime (StateGuard requires an EventHandler scope to mutate + // state). The unit-level state-machine view is locked in by + // StudioMemberGAgentStateTests; a future host-level test covers the + // full HandleCreated flow. + + [Fact] + public void ApplyImplementationUpdated_ShouldNotMutateImplementationKind() + { + // ImplementationKind is locked at create. Even on a replayed or + // hand-rolled event whose ImplementationKind disagrees with the + // existing state, the apply step must preserve State.ImplementationKind + // so a Script member can never be silently mutated into a Workflow + // member through the implementation-updated event path. + var agent = new StudioMemberGAgent(); + var existing = new StudioMemberState + { + MemberId = "m-1", + ImplementationKind = StudioMemberImplementationKind.Script, + LifecycleStage = StudioMemberLifecycleStage.BuildReady, + }; + + var driftEvent = new StudioMemberImplementationUpdatedEvent + { + // Adversarial: event tries to switch the kind to Workflow. + ImplementationKind = StudioMemberImplementationKind.Workflow, + ImplementationRef = new StudioMemberImplementationRef + { + Workflow = new StudioMemberWorkflowRef { WorkflowId = "wf-1" }, + }, + UpdatedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }; + + var next = (StudioMemberState)TransitionStateMethod.Invoke(agent, [existing, driftEvent])!; + + next.ImplementationKind.Should().Be(StudioMemberImplementationKind.Script, + because: "ImplementationKind is locked at create; the apply step must not let it drift"); + } + + [Fact] + public void StudioMemberInputLimits_ShouldRejectLongDisplayName() + { + // Sanity: the constants are reasonable and the regex rejects ':' / spaces. + StudioMemberInputLimits.MaxDisplayNameLength.Should().BeGreaterThan(64); + StudioMemberInputLimits.MemberIdPattern.IsMatch("m-good_1").Should().BeTrue(); + StudioMemberInputLimits.MemberIdPattern.IsMatch("m:nested").Should().BeFalse(); + StudioMemberInputLimits.MemberIdPattern.IsMatch(" leadingSpace").Should().BeFalse(); + StudioMemberInputLimits.MemberIdPattern.IsMatch(new string('a', 65)).Should().BeFalse(); + } + + [Fact] + public async Task HandleBindAsync_ShouldReturn404_WhenMemberNotFoundExceptionThrown() + { + var service = new ThrowingService(new StudioMemberNotFoundException("scope-1", "m-missing")); + + var result = await InvokeHandle( + "HandleBindAsync", + CreateAuthenticatedContext("scope-1"), + "scope-1", + "m-missing", + new UpdateStudioMemberBindingRequest(), + service, + CancellationToken.None); + + var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?; + statusCode.Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public async Task HandleGetBindingAsync_ShouldReturn404_WhenMemberNotFoundExceptionThrown() + { + var service = new ThrowingService(new StudioMemberNotFoundException("scope-1", "m-missing")); + + var result = await InvokeHandle( + "HandleGetBindingAsync", + CreateAuthenticatedContext("scope-1"), + "scope-1", + "m-missing", + service, + CancellationToken.None); + + var statusCode = result.GetType().GetProperty("StatusCode")?.GetValue(result) as int?; + statusCode.Should().Be(StatusCodes.Status404NotFound); + } + + [Fact] + public void StudioMemberRosterResponse_ShouldCarryNextPageToken() + { + var roster = new StudioMemberRosterResponse( + ScopeId: "scope-1", + Members: [], + NextPageToken: "cursor-1"); + + roster.NextPageToken.Should().Be("cursor-1"); + } + + private static HttpContext CreateAuthenticatedContext(string scopeId) + { + var identity = new ClaimsIdentity([new Claim("scope_id", scopeId)], "test"); + var services = new ServiceCollection() + .AddSingleton(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Aevatar:Authentication:Enabled"] = "true", + }) + .Build()) + .AddSingleton(new TestHostEnvironment()) + .BuildServiceProvider(); + return new DefaultHttpContext + { + User = new ClaimsPrincipal(identity), + RequestServices = services, + }; + } + + private static async Task InvokeHandle(string methodName, params object?[] args) + { + var method = typeof(StudioMemberEndpoints).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException($"Method {methodName} not found."); + var task = (Task)method.Invoke(null, args)!; + return await task; + } + + private sealed class ThrowingService : IStudioMemberService + { + private readonly Exception _ex; + + public ThrowingService(Exception ex) + { + _ex = ex; + } + + public Task CreateAsync( + string scopeId, CreateStudioMemberRequest request, CancellationToken ct = default) => throw _ex; + + public Task ListAsync( + string scopeId, StudioMemberRosterPageRequest? page = null, CancellationToken ct = default) => throw _ex; + + public Task GetAsync( + string scopeId, string memberId, CancellationToken ct = default) => throw _ex; + + public Task BindAsync( + string scopeId, string memberId, UpdateStudioMemberBindingRequest request, CancellationToken ct = default) => throw _ex; + + public Task GetBindingAsync( + string scopeId, string memberId, CancellationToken ct = default) => throw _ex; + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = Environments.Production; + public string ApplicationName { get; set; } = "Aevatar.Studio.Tests"; + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberQueryPortTests.cs b/test/Aevatar.Studio.Tests/StudioMemberQueryPortTests.cs new file mode 100644 index 000000000..165344467 --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberQueryPortTests.cs @@ -0,0 +1,298 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.GAgents.StudioMember; +using Aevatar.Studio.Application.Studio.Contracts; +using Aevatar.Studio.Projection.QueryPorts; +using Aevatar.Studio.Projection.ReadModels; +using FluentAssertions; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Tests; + +/// +/// Locks in the read-side invariants for the StudioMember query port: +/// +/// - GetAsync uses the canonical actor-id key and is scope-pinned (a member +/// from another scope must not leak). +/// - ListAsync filters by scope_id and surfaces the denormalized roster +/// fields the projector wrote (publishedServiceId, lifecycle, etc.). +/// - Detail unpacks the typed implementation_ref and last_binding from the +/// state_root rather than re-deriving them. +/// +public sealed class ProjectionStudioMemberQueryPortTests +{ + private const string ScopeId = "scope-1"; + + [Fact] + public async Task GetAsync_ShouldReturnDetail_WhenDocumentExists() + { + var document = NewDocument( + scopeId: ScopeId, + memberId: "m-1", + implementationKind: StudioMemberImplementationKind.Workflow, + lifecycle: StudioMemberLifecycleStage.BuildReady, + includeImplementationRef: true, + includeLastBinding: true); + + var reader = new StubDocumentReader([document]); + var port = new ProjectionStudioMemberQueryPort(reader); + + var detail = await port.GetAsync(ScopeId, "m-1"); + + detail.Should().NotBeNull(); + detail!.Summary.MemberId.Should().Be("m-1"); + detail.Summary.PublishedServiceId.Should().Be("member-m-1"); + detail.Summary.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow); + detail.Summary.LifecycleStage.Should().Be(MemberLifecycleStageNames.BuildReady); + detail.ImplementationRef.Should().NotBeNull(); + detail.ImplementationRef!.WorkflowId.Should().Be("wf-1"); + detail.ImplementationRef.WorkflowRevision.Should().Be("v2"); + detail.LastBinding.Should().NotBeNull(); + detail.LastBinding!.RevisionId.Should().Be("rev-bind"); + } + + [Fact] + public async Task GetAsync_ShouldReturnNull_WhenDocumentMissing() + { + var reader = new StubDocumentReader([]); + var port = new ProjectionStudioMemberQueryPort(reader); + + var detail = await port.GetAsync(ScopeId, "m-missing"); + + detail.Should().BeNull(); + } + + [Fact] + public async Task GetAsync_ShouldReturnNull_WhenDocumentExistsInDifferentScope() + { + // The document with this id exists, but its scope_id is different. + // Read port must reject so callers cannot probe across scopes by + // guessing the actor-id layout. + var foreign = NewDocument(scopeId: "scope-other", memberId: "m-1"); + // Stub reader lookups by id, so the lookup will succeed here but the + // port should still filter by the scope_id field. + var reader = new StubDocumentReader([foreign]); + var port = new ProjectionStudioMemberQueryPort(reader); + + var detail = await port.GetAsync(ScopeId, "m-1"); + + detail.Should().BeNull(); + } + + [Fact] + public async Task ListAsync_ShouldReturnOnlyMembersInScope() + { + var inScopeA = NewDocument(scopeId: ScopeId, memberId: "m-1"); + var inScopeB = NewDocument(scopeId: ScopeId, memberId: "m-2"); + var inOtherScope = NewDocument(scopeId: "scope-other", memberId: "m-3"); + + var reader = new StubDocumentReader([inScopeA, inScopeB, inOtherScope]); + var port = new ProjectionStudioMemberQueryPort(reader); + + var roster = await port.ListAsync(ScopeId); + + roster.ScopeId.Should().Be(ScopeId); + roster.Members.Select(m => m.MemberId).Should().BeEquivalentTo("m-1", "m-2"); + } + + [Fact] + public async Task ListAsync_ShouldReturnEmpty_WhenScopeHasNoMembers() + { + var reader = new StubDocumentReader([]); + var port = new ProjectionStudioMemberQueryPort(reader); + + var roster = await port.ListAsync(ScopeId); + + roster.ScopeId.Should().Be(ScopeId); + roster.Members.Should().BeEmpty(); + } + + [Fact] + public async Task GetAsync_ShouldSurfaceScriptImplementationRef() + { + var document = NewDocumentWithImplementation( + implementationKind: StudioMemberImplementationKind.Script, + implementationRef: new StudioMemberImplementationRef + { + Script = new StudioMemberScriptRef + { + ScriptId = "s-1", + ScriptRevision = "v9", + }, + }); + + var reader = new StubDocumentReader([document]); + var port = new ProjectionStudioMemberQueryPort(reader); + + var detail = await port.GetAsync(ScopeId, "m-1"); + + detail!.ImplementationRef!.ScriptId.Should().Be("s-1"); + detail.ImplementationRef.ScriptRevision.Should().Be("v9"); + detail.ImplementationRef.ImplementationKind.Should().Be(MemberImplementationKindNames.Script); + } + + [Fact] + public async Task GetAsync_ShouldSurfaceGAgentImplementationRef() + { + var document = NewDocumentWithImplementation( + implementationKind: StudioMemberImplementationKind.Gagent, + implementationRef: new StudioMemberImplementationRef + { + Gagent = new StudioMemberGAgentRef + { + ActorTypeName = "MyActor", + }, + }); + + var reader = new StubDocumentReader([document]); + var port = new ProjectionStudioMemberQueryPort(reader); + + var detail = await port.GetAsync(ScopeId, "m-1"); + + detail!.ImplementationRef!.ActorTypeName.Should().Be("MyActor"); + detail.ImplementationRef.ImplementationKind.Should().Be(MemberImplementationKindNames.GAgent); + } + + [Fact] + public async Task GetAsync_ShouldReturnNullImplementationRef_WhenMissing() + { + var document = NewDocument( + scopeId: ScopeId, + memberId: "m-1", + includeImplementationRef: false, + includeLastBinding: false); + + var reader = new StubDocumentReader([document]); + var port = new ProjectionStudioMemberQueryPort(reader); + + var detail = await port.GetAsync(ScopeId, "m-1"); + + detail!.ImplementationRef.Should().BeNull(); + detail.LastBinding.Should().BeNull(); + } + + private static StudioMemberCurrentStateDocument NewDocument( + string scopeId, + string memberId, + StudioMemberImplementationKind implementationKind = StudioMemberImplementationKind.Workflow, + StudioMemberLifecycleStage lifecycle = StudioMemberLifecycleStage.Created, + bool includeImplementationRef = false, + bool includeLastBinding = false) + { + var actorId = StudioMemberConventions.BuildActorId(scopeId, memberId); + var publishedServiceId = StudioMemberConventions.BuildPublishedServiceId(memberId); + var now = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow); + + var doc = new StudioMemberCurrentStateDocument + { + Id = actorId, + ActorId = actorId, + StateVersion = 1, + LastEventId = "evt-1", + UpdatedAt = now, + MemberId = memberId, + ScopeId = scopeId, + DisplayName = $"Member {memberId}", + Description = string.Empty, + ImplementationKind = ToWireKind(implementationKind), + LifecycleStage = ToWireStage(lifecycle), + PublishedServiceId = publishedServiceId, + CreatedAt = now, + }; + + if (includeImplementationRef) + { + doc.ImplementationWorkflowId = "wf-1"; + doc.ImplementationWorkflowRevision = "v2"; + } + + if (includeLastBinding) + { + doc.LastBoundPublishedServiceId = publishedServiceId; + doc.LastBoundRevisionId = "rev-bind"; + doc.LastBoundImplementationKind = ToWireKind(implementationKind); + doc.LastBoundAt = now; + } + + return doc; + } + + private static StudioMemberCurrentStateDocument NewDocumentWithImplementation( + StudioMemberImplementationKind implementationKind, + StudioMemberImplementationRef implementationRef) + { + var doc = NewDocument(ScopeId, "m-1", implementationKind); + // Reset implementation_ref fields and apply the supplied one. + doc.ImplementationWorkflowId = string.Empty; + doc.ImplementationWorkflowRevision = string.Empty; + doc.ImplementationScriptId = string.Empty; + doc.ImplementationScriptRevision = string.Empty; + doc.ImplementationActorTypeName = string.Empty; + if (implementationRef.Workflow != null) + { + doc.ImplementationWorkflowId = implementationRef.Workflow.WorkflowId; + doc.ImplementationWorkflowRevision = implementationRef.Workflow.WorkflowRevision; + } + if (implementationRef.Script != null) + { + doc.ImplementationScriptId = implementationRef.Script.ScriptId; + doc.ImplementationScriptRevision = implementationRef.Script.ScriptRevision; + } + if (implementationRef.Gagent != null) + { + doc.ImplementationActorTypeName = implementationRef.Gagent.ActorTypeName; + } + return doc; + } + + private static string ToWireKind(StudioMemberImplementationKind kind) => kind switch + { + StudioMemberImplementationKind.Workflow => MemberImplementationKindNames.Workflow, + StudioMemberImplementationKind.Script => MemberImplementationKindNames.Script, + StudioMemberImplementationKind.Gagent => MemberImplementationKindNames.GAgent, + _ => string.Empty, + }; + + private static string ToWireStage(StudioMemberLifecycleStage stage) => stage switch + { + StudioMemberLifecycleStage.Created => MemberLifecycleStageNames.Created, + StudioMemberLifecycleStage.BuildReady => MemberLifecycleStageNames.BuildReady, + StudioMemberLifecycleStage.BindReady => MemberLifecycleStageNames.BindReady, + _ => string.Empty, + }; + + private sealed class StubDocumentReader + : IProjectionDocumentReader + { + private readonly Dictionary _byId; + + public StubDocumentReader(IReadOnlyList documents) + { + _byId = documents.ToDictionary(d => d.Id, StringComparer.Ordinal); + } + + public Task GetAsync( + string key, CancellationToken ct = default) + { + return Task.FromResult(_byId.TryGetValue(key, out var doc) ? doc : null); + } + + public Task> QueryAsync( + ProjectionDocumentQuery query, CancellationToken ct = default) + { + // Honor the scope_id filter the query port issues. + var scopeFilter = query.Filters.FirstOrDefault( + f => string.Equals(f.FieldPath, "scope_id", StringComparison.Ordinal)); + + IEnumerable items = _byId.Values; + if (scopeFilter != null && scopeFilter.Value.RawValue is string scope) + { + items = items.Where(d => string.Equals(d.ScopeId, scope, StringComparison.Ordinal)); + } + + return Task.FromResult(new ProjectionDocumentQueryResult + { + Items = items.Take(query.Take).ToList(), + }); + } + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberReadModelMetadataTests.cs b/test/Aevatar.Studio.Tests/StudioMemberReadModelMetadataTests.cs new file mode 100644 index 000000000..c84d34ed2 --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberReadModelMetadataTests.cs @@ -0,0 +1,55 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Studio.Projection.Metadata; +using Aevatar.Studio.Projection.ReadModels; +using FluentAssertions; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Studio.Tests; + +/// +/// Surface tests for the StudioMember projection metadata + read-model +/// adapter. These are intentionally narrow — they only ensure the +/// configured index name stays under studio-members and that the +/// read-model's IProjectionReadModel surface returns the values the +/// projector wrote. +/// +public sealed class StudioMemberReadModelMetadataTests +{ + [Fact] + public void MetadataProvider_ShouldExposeStudioMembersIndex() + { + var provider = new StudioMemberCurrentStateDocumentMetadataProvider(); + provider.Metadata.IndexName.Should().Be("studio-members"); + provider.Metadata.Mappings.Should().ContainKey("dynamic"); + } + + [Fact] + public void ReadModel_ShouldSurfaceProjectionContractFields() + { + var updatedAt = DateTimeOffset.Parse("2026-04-27T01:02:03Z"); + var doc = new StudioMemberCurrentStateDocument + { + Id = "studio-member:scope-1:m-1", + ActorId = "studio-member:scope-1:m-1", + StateVersion = 7, + LastEventId = "evt-7", + UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt), + }; + + IProjectionReadModel readModel = doc; + readModel.ActorId.Should().Be(doc.ActorId); + readModel.StateVersion.Should().Be(doc.StateVersion); + readModel.LastEventId.Should().Be(doc.LastEventId); + readModel.UpdatedAt.Should().Be(updatedAt); + } + + [Fact] + public void ReadModel_ShouldReturnMinValueWhenUpdatedAtIsNull() + { + IProjectionReadModel readModel = new StudioMemberCurrentStateDocument + { + Id = "studio-member:scope-1:m-1", + }; + readModel.UpdatedAt.Should().Be(DateTimeOffset.MinValue); + } +} diff --git a/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs b/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs new file mode 100644 index 000000000..8e79ac78e --- /dev/null +++ b/test/Aevatar.Studio.Tests/StudioMemberServiceBindingTests.cs @@ -0,0 +1,347 @@ +using Aevatar.GAgentService.Abstractions; +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 most important invariants from issue #325: +/// +/// - Member binding never falls back to the scope default service. +/// - The ServiceId Studio sends to the underlying scope binding command is +/// the member's stable publishedServiceId, sourced from the authority +/// state — not derived from a frontend-supplied value. +/// - Renaming a member does not change publishedServiceId. +/// - workflow / script / gagent each route through the same orchestration. +/// - The resulting revision is recorded back on the member authority. +/// +public sealed class StudioMemberServiceBindingTests +{ + private const string ScopeId = "scope-1"; + private const string MemberId = "m-bind-test"; + private const string PublishedServiceId = "member-m-bind-test"; + + [Fact] + public async Task BindAsync_Workflow_ShouldUseMemberPublishedServiceId() + { + var detail = NewDetail(MemberImplementationKindNames.Workflow); + var queryPort = new InMemoryQueryPort(detail); + var commandPort = new RecordingCommandPort(); + var bindingPort = new RecordingScopeBindingPort(); + + var service = new StudioMemberService(commandPort, queryPort, bindingPort); + + var response = await service.BindAsync( + ScopeId, + MemberId, + new UpdateStudioMemberBindingRequest( + Workflow: new StudioMemberWorkflowBindingSpec(["workflow:\n name: x"])), + CancellationToken.None); + + // The bind orchestration MUST hand the platform binding port the + // member's stable publishedServiceId — never null/empty (which would + // fall back to the scope default service). + bindingPort.LastRequest.Should().NotBeNull(); + bindingPort.LastRequest!.ServiceId.Should().Be(PublishedServiceId); + bindingPort.LastRequest.ImplementationKind.Should().Be(ScopeBindingImplementationKind.Workflow); + bindingPort.LastRequest.Workflow!.WorkflowYamls.Should().ContainSingle(); + + // Lifecycle fix: BindAsync must persist the resolved impl_ref on the + // member (UpdateImplementationAsync) BEFORE recording the binding + // (RecordBindingAsync), so the actor walks Created → BuildReady → + // BindReady on every bind. Both must run, and impl_update must + // happen first. + commandPort.OperationsInOrder.Should().Equal( + "UpdateImplementation", "RecordBinding"); + commandPort.RecordedImplementationUpdates.Should().ContainSingle() + .Which.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow); + + // The orchestrator records the resulting revision back on the + // member authority so /members/.../binding can read it from the + // read model later. + commandPort.RecordedBindings.Should().ContainSingle() + .Which.PublishedServiceId.Should().Be(PublishedServiceId); + response.PublishedServiceId.Should().Be(PublishedServiceId); + response.RevisionId.Should().Be(bindingPort.IssuedRevisionId); + response.ImplementationKind.Should().Be(MemberImplementationKindNames.Workflow); + } + + [Fact] + public async Task BindAsync_Script_ShouldRouteThroughScriptingKind() + { + var detail = NewDetail(MemberImplementationKindNames.Script); + var queryPort = new InMemoryQueryPort(detail); + var commandPort = new RecordingCommandPort(); + var bindingPort = new RecordingScopeBindingPort(); + + var service = new StudioMemberService(commandPort, queryPort, bindingPort); + + await service.BindAsync( + ScopeId, + MemberId, + new UpdateStudioMemberBindingRequest( + Script: new StudioMemberScriptBindingSpec(ScriptId: "s-1", ScriptRevision: "v3")), + CancellationToken.None); + + bindingPort.LastRequest!.ServiceId.Should().Be(PublishedServiceId); + bindingPort.LastRequest.ImplementationKind.Should().Be(ScopeBindingImplementationKind.Scripting); + bindingPort.LastRequest.Script!.ScriptId.Should().Be("s-1"); + bindingPort.LastRequest.Script.ScriptRevision.Should().Be("v3"); + + commandPort.OperationsInOrder.Should().Equal( + "UpdateImplementation", "RecordBinding"); + commandPort.RecordedImplementationUpdates.Should().ContainSingle() + .Which.ScriptId.Should().Be("s-1"); + } + + [Fact] + public async Task BindAsync_GAgent_ShouldRouteThroughGAgentKind() + { + var detail = NewDetail(MemberImplementationKindNames.GAgent); + var queryPort = new InMemoryQueryPort(detail); + var commandPort = new RecordingCommandPort(); + var bindingPort = new RecordingScopeBindingPort(); + + var service = new StudioMemberService(commandPort, queryPort, bindingPort); + + await service.BindAsync( + ScopeId, + MemberId, + new UpdateStudioMemberBindingRequest( + GAgent: new StudioMemberGAgentBindingSpec( + ActorTypeName: "MyActor", + Endpoints: [ + new StudioMemberGAgentEndpointSpec( + EndpointId: "chat", + DisplayName: "Chat", + Kind: "chat", + RequestTypeUrl: "type.googleapis.com/x.Request", + ResponseTypeUrl: "type.googleapis.com/x.Response") + ])), + CancellationToken.None); + + bindingPort.LastRequest!.ServiceId.Should().Be(PublishedServiceId); + bindingPort.LastRequest.ImplementationKind.Should().Be(ScopeBindingImplementationKind.GAgent); + bindingPort.LastRequest.GAgent!.ActorTypeName.Should().Be("MyActor"); + bindingPort.LastRequest.GAgent.Endpoints.Should().ContainSingle(); + bindingPort.LastRequest.GAgent.Endpoints[0].Kind.Should().Be(ServiceEndpointKind.Chat); + + commandPort.OperationsInOrder.Should().Equal( + "UpdateImplementation", "RecordBinding"); + commandPort.RecordedImplementationUpdates.Should().ContainSingle() + .Which.ActorTypeName.Should().Be("MyActor"); + } + + [Fact] + public async Task BindAsync_ShouldFail_WhenMemberDoesNotExist() + { + var queryPort = new InMemoryQueryPort(detail: null); + var service = new StudioMemberService( + new RecordingCommandPort(), + queryPort, + new RecordingScopeBindingPort()); + + var act = () => service.BindAsync( + ScopeId, + MemberId, + new UpdateStudioMemberBindingRequest( + Workflow: new StudioMemberWorkflowBindingSpec(["workflow:"])), + CancellationToken.None); + + // Assert the typed exception so a regression that swaps it for a + // plain InvalidOperationException is caught — endpoints map the + // typed one to 404 and untyped IOEx to 400. + await act.Should().ThrowAsync() + .WithMessage("*not found in scope*"); + } + + [Fact] + public async Task BindAsync_ShouldFail_WhenWorkflowYamlsAreMissing() + { + var detail = NewDetail(MemberImplementationKindNames.Workflow); + var service = new StudioMemberService( + new RecordingCommandPort(), + new InMemoryQueryPort(detail), + new RecordingScopeBindingPort()); + + var act = () => service.BindAsync( + ScopeId, + MemberId, + new UpdateStudioMemberBindingRequest(), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*workflow yamls are required*"); + } + + [Fact] + public async Task GetBindingAsync_ShouldReturnLastRecordedBinding() + { + var detail = NewDetail(MemberImplementationKindNames.Workflow); + var withBinding = detail with + { + LastBinding = new StudioMemberBindingContractResponse( + PublishedServiceId: PublishedServiceId, + RevisionId: "rev-9", + ImplementationKind: MemberImplementationKindNames.Workflow, + BoundAt: DateTimeOffset.UtcNow), + }; + + var service = new StudioMemberService( + new RecordingCommandPort(), + new InMemoryQueryPort(withBinding), + new RecordingScopeBindingPort()); + + var binding = await service.GetBindingAsync(ScopeId, MemberId); + + binding.Should().NotBeNull(); + binding!.PublishedServiceId.Should().Be(PublishedServiceId); + binding.RevisionId.Should().Be("rev-9"); + } + + private static StudioMemberDetailResponse NewDetail(string implementationKindWire) + { + var summary = new StudioMemberSummaryResponse( + MemberId: MemberId, + ScopeId: ScopeId, + DisplayName: "Test Member", + Description: string.Empty, + ImplementationKind: implementationKindWire, + LifecycleStage: MemberLifecycleStageNames.BuildReady, + PublishedServiceId: PublishedServiceId, + LastBoundRevisionId: null, + CreatedAt: DateTimeOffset.UtcNow.AddDays(-1), + UpdatedAt: DateTimeOffset.UtcNow.AddHours(-1)); + + return new StudioMemberDetailResponse( + Summary: summary, + ImplementationRef: null, + LastBinding: null); + } + + private sealed class InMemoryQueryPort : IStudioMemberQueryPort + { + private readonly StudioMemberDetailResponse? _detail; + + public InMemoryQueryPort(StudioMemberDetailResponse? detail) + { + _detail = detail; + } + + public Task ListAsync( + string scopeId, + StudioMemberRosterPageRequest? page = null, + CancellationToken ct = default) + { + return Task.FromResult(new StudioMemberRosterResponse( + ScopeId: scopeId, + Members: _detail == null ? [] : [_detail.Summary])); + } + + public Task GetAsync( + string scopeId, string memberId, CancellationToken ct = default) + { + return Task.FromResult(_detail); + } + } + + private sealed class RecordingCommandPort : IStudioMemberCommandPort + { + public List RecordedBindings { get; } = []; + + public List RecordedImplementationUpdates { get; } = []; + + public List OperationsInOrder { get; } = []; + + public Task CreateAsync( + string scopeId, CreateStudioMemberRequest request, CancellationToken ct = default) + { + throw new NotImplementedException("Not exercised in this test."); + } + + public Task UpdateImplementationAsync( + string scopeId, + string memberId, + StudioMemberImplementationRefResponse implementation, + CancellationToken ct = default) + { + RecordedImplementationUpdates.Add(implementation); + OperationsInOrder.Add("UpdateImplementation"); + return Task.CompletedTask; + } + + public Task RecordBindingAsync( + string scopeId, + string memberId, + string publishedServiceId, + string revisionId, + string implementationKindName, + CancellationToken ct = default) + { + RecordedBindings.Add(new RecordedBinding( + scopeId, memberId, publishedServiceId, revisionId, implementationKindName)); + OperationsInOrder.Add("RecordBinding"); + return Task.CompletedTask; + } + + public sealed record RecordedBinding( + string ScopeId, + string MemberId, + string PublishedServiceId, + string RevisionId, + string ImplementationKindName); + } + + private sealed class RecordingScopeBindingPort : IScopeBindingCommandPort + { + public string IssuedRevisionId { get; } = "rev-test"; + + public ScopeBindingUpsertRequest? LastRequest { get; private set; } + + public Task UpsertAsync( + ScopeBindingUpsertRequest request, CancellationToken ct = default) + { + LastRequest = request; + // Mirror the production binding ports: populate the kind-specific + // result so BindAsync can derive the resolved implementation_ref + // and call UpdateImplementationAsync. Leaving these null skips + // the lifecycle wiring entirely and silently passes any test + // that doesn't assert on the call ordering. + ScopeBindingWorkflowResult? workflowResult = null; + ScopeBindingScriptResult? scriptResult = null; + ScopeBindingGAgentResult? gagentResult = null; + switch (request.ImplementationKind) + { + case ScopeBindingImplementationKind.Workflow: + workflowResult = new ScopeBindingWorkflowResult( + WorkflowName: $"wf-{request.ServiceId}", + DefinitionActorIdPrefix: $"def-{request.ServiceId}"); + break; + case ScopeBindingImplementationKind.Scripting: + scriptResult = new ScopeBindingScriptResult( + ScriptId: request.Script?.ScriptId ?? string.Empty, + ScriptRevision: request.Script?.ScriptRevision ?? IssuedRevisionId, + DefinitionActorId: $"def-{request.ServiceId}"); + break; + case ScopeBindingImplementationKind.GAgent: + gagentResult = new ScopeBindingGAgentResult( + ActorTypeName: request.GAgent?.ActorTypeName ?? string.Empty); + break; + } + + return Task.FromResult(new ScopeBindingUpsertResult( + ScopeId: request.ScopeId, + ServiceId: request.ServiceId ?? string.Empty, + DisplayName: request.DisplayName ?? string.Empty, + RevisionId: IssuedRevisionId, + ImplementationKind: request.ImplementationKind, + ExpectedActorId: $"actor-{request.ServiceId}-{IssuedRevisionId}", + Workflow: workflowResult, + Script: scriptResult, + GAgent: gagentResult)); + } + } +}