-
Notifications
You must be signed in to change notification settings - Fork 1
feat(studio): member-first authority + bind orchestration (#325) #428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
95c96e5
5d6d575
09ce7d4
c15f734
03a51d7
c934ec9
9cc0ae6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
| <PropertyGroup> | ||
| <TargetFramework>net10.0</TargetFramework> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| <Nullable>enable</Nullable> | ||
| <AssemblyName>Aevatar.GAgents.StudioMember</AssemblyName> | ||
| <RootNamespace>Aevatar.GAgents.StudioMember</RootNamespace> | ||
| </PropertyGroup> | ||
| <ItemGroup> | ||
| <ProjectReference Include="..\..\src\Aevatar.Foundation.Abstractions\Aevatar.Foundation.Abstractions.csproj" /> | ||
| <ProjectReference Include="..\..\src\Aevatar.Foundation.Core\Aevatar.Foundation.Core.csproj" /> | ||
| </ItemGroup> | ||
| <ItemGroup> | ||
| <PackageReference Include="Google.Protobuf" /> | ||
| <PackageReference Include="Grpc.Tools"> | ||
| <PrivateAssets>all</PrivateAssets> | ||
| <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||
| </PackageReference> | ||
| <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> | ||
| </ItemGroup> | ||
| <ItemGroup> | ||
| <Protobuf Include="studio_member_messages.proto" GrpcServices="None" /> | ||
| </ItemGroup> | ||
| </Project> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| namespace Aevatar.GAgents.StudioMember; | ||
|
|
||
| /// <summary> | ||
| /// Canonical naming for StudioMember actor IDs and the rename-safe | ||
| /// <c>publishedServiceId</c> 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 <c>publishedServiceId</c> | ||
| /// computed here is meant to be persisted on first <c>create_member</c> and | ||
| /// then read back from state — never recomputed from a mutable display name. | ||
| /// </summary> | ||
| public static class StudioMemberConventions | ||
| { | ||
| public const string ActorIdPrefix = "studio-member"; | ||
| public const string PublishedServiceIdPrefix = "member"; | ||
|
|
||
| /// <summary> | ||
| /// Builds the actor id used by <see cref="StudioMemberGAgent"/>. | ||
| /// </summary> | ||
| public static string BuildActorId(string scopeId, string memberId) | ||
| { | ||
| var normalizedScopeId = NormalizeScopeId(scopeId); | ||
| var normalizedMemberId = NormalizeMemberId(memberId); | ||
| return $"{ActorIdPrefix}:{normalizedScopeId}:{normalizedMemberId}"; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Derives the rename-safe published service id from the immutable | ||
| /// <paramref name="memberId"/>. Should be invoked once at creation time | ||
| /// and the result persisted on the actor state — callers must not | ||
| /// recompute it on read. | ||
| /// </summary> | ||
| 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(':'); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
|
||
| /// <summary> | ||
| /// Per-member actor that owns the canonical StudioMember authority state. | ||
| /// | ||
| /// Actor ID convention: <c>studio-member:{scopeId}:{memberId}</c>. | ||
| /// The actor is the only writer of <c>published_service_id</c>, which is | ||
| /// generated once at creation from the immutable <c>member_id</c> and never | ||
| /// recomputed on rename. The convention is re-derived inside the actor in | ||
| /// <see cref="ApplyCreated"/> so a stale or hand-crafted event payload | ||
| /// cannot break the rename-safe invariant. | ||
| /// </summary> | ||
| public sealed class StudioMemberGAgent : GAgentBase<StudioMemberState>, 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}'."); | ||
| } | ||
|
Comment on lines
+36
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The duplicate-create guard only compares Useful? React with 👍 / 👎. |
||
|
|
||
| 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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Idempotent re-create: same |
||
| } | ||
|
|
||
| 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); | ||
| } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| [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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| && 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); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| protected override StudioMemberState TransitionState( | ||
| StudioMemberState current, IMessage evt) | ||
| { | ||
| return StateTransitionMatcher | ||
| .Match(current, evt) | ||
| .On<StudioMemberCreatedEvent>(ApplyCreated) | ||
| .On<StudioMemberRenamedEvent>(ApplyRenamed) | ||
| .On<StudioMemberImplementationUpdatedEvent>(ApplyImplementationUpdated) | ||
| .On<StudioMemberBoundEvent>(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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NormalizeMemberIddoesn’t reject:.ActorDispatchStudioMemberCommandService.GenerateMemberIdsays the generated format is “URL-safe and free of separators that StudioMemberConventions builds with (':')”, butCreateStudioMemberRequest.MemberIdlets a caller supply an arbitrary id, which goes through this normalize and back intoBuildActorId. A memberId ofm:nestedproduces actor idstudio-member:scope-1:m:nested— ambiguous if anything ever parses it, and bypasses the immutable-id intent. Either reject:(and other unsafe chars) here, or stop accepting user-supplied ids and always generate them in the command service.