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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions aevatar.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<Project Path="agents\Aevatar.GAgents.Channel.Runtime\Aevatar.GAgents.Channel.Runtime.csproj" />
<Project Path="agents\Aevatar.GAgents.ChannelRuntime\Aevatar.GAgents.ChannelRuntime.csproj" />
<Project Path="agents\Aevatar.GAgents.Household\Aevatar.GAgents.Household.csproj" />
<Project Path="agents/Aevatar.GAgents.StudioMember/Aevatar.GAgents.StudioMember.csproj" />
</Folder>
<Folder Name="/demos/">
<Project Path="demos\Aevatar.Demos.Cli\Aevatar.Demos.Cli.csproj" />
Expand Down
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>
62 changes: 62 additions & 0 deletions agents/Aevatar.GAgents.StudioMember/StudioMemberConventions.cs
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;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NormalizeMemberId doesn’t reject :. ActorDispatchStudioMemberCommandService.GenerateMemberId says the generated format is “URL-safe and free of separators that StudioMemberConventions builds with (':')”, but CreateStudioMemberRequest.MemberId lets a caller supply an arbitrary id, which goes through this normalize and back into BuildActorId. A memberId of m:nested produces actor id studio-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.


private static bool ContainsActorIdSeparator(string value) => value.Contains(':');
}
217 changes: 217 additions & 0 deletions agents/Aevatar.GAgents.StudioMember/StudioMemberGAgent.cs
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject conflicting re-create payloads for existing member IDs

The duplicate-create guard only compares member_id; any re-create with the same ID but different display name/description/implementation silently passes this check and is treated as a no-op. That means conflicting create requests can be accepted without surfacing an error, leaving persisted authority state unchanged while callers believe their new payload was applied.

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;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idempotent re-create: same member_id returns silently even if display_name / implementation_kind / description in the new event differ from the persisted ones. Probably the right call (don’t let a duplicate POST mutate stable identity), but it’s a first-write-wins policy and right now it’s implicit. Worth either a comment, or making the mismatch on the non-identity fields throw the same way member_id mismatch does — otherwise the silent ignore can mask a real bug client-side.

}

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);
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HandleRenamed (and StudioMemberRenamedEvent) has no producer in this PR — there is no service / endpoint that emits a rename event. The rename-safe-publishedServiceId invariant is unit-tested at the state machine level (good), but the rename surface itself is unreachable end-to-end. Either expose PUT /members/{memberId} to drive it, or remove until the next PR needs it.


[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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ImplementationKind is now locked, but the payload shape is still unchecked. Because StudioMemberImplementationRef is not a oneof, a caller can send ImplementationKind = Unspecified (or even the matching kind) with the wrong branch populated, e.g. a Script member carrying Workflow. ApplyImplementationUpdated will clone that ref and the projector can later publish implementation_kind = "script" with workflow fields set. Please validate that exactly the branch matching the existing State.ImplementationKind is populated before persisting the event.

&& 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);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recordBinding can still be dispatched directly after create and will make the actor BindReady with no resolved ImplementationRef; StudioMemberGAgentStateTests.Bound_ShouldCaptureLastBindingAndAdvanceLifecycle currently locks in that path. The service now sends UpdateImplementationAsync before RecordBindingAsync, but the actor authority should enforce the invariant too: reject binding unless a matching implementation ref is already present, and also verify evt.PublishedServiceId == State.PublishedServiceId and the event kind matches the locked member kind. Otherwise a malformed command can publish a read model that says the member is bind-ready while it has no implementation, or records another service id as this member's binding.

}

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;
}
}
Loading
Loading