{
gAgentTypesError={gAgentTypesQuery.isError ? gAgentTypesQuery.error : null}
selectedGAgentTypeName={selectedGAgentTypeName}
onSelectGAgentTypeName={setSelectedGAgentTypeName}
- onContinueToBind={() => applyStudioTarget('bind')}
+ onContinueToBind={() => applyStudioTarget('bind', undefined, lifecycleSurfaceMemberKey)}
/>
);
@@ -8299,12 +8818,12 @@ const StudioPage: React.FC = () => {
}
initialEndpointId={bindInitialEndpointId}
memberId={workbenchStudioMemberId || undefined}
- initialServiceId={bindSelectedMemberServiceId}
+ initialServiceId={bindPendingCandidate ? '' : bindSelectedMemberServiceId}
onBindPendingCandidate={handleBindPendingCandidate}
onContinueToInvoke={handleUseBindingEndpoint}
onSelectionChange={handleBindingSelectionChange}
pendingBindingCandidate={bindPendingCandidate}
- preferredServiceId={bindSelectedMemberServiceId}
+ preferredServiceId={bindPendingCandidate ? '' : bindSelectedMemberServiceId}
scopeId={resolvedStudioScopeId}
servicesLoading={scopeServicesQuery.isLoading || scopeServicesQuery.isFetching}
services={bindTargetServices}
@@ -8455,13 +8974,13 @@ const StudioPage: React.FC = () => {
setCreateMemberName(event.target.value)}
placeholder={
createMemberKind === 'workflow'
? suggestedCreateWorkflowName
: suggestedCreateScriptName
}
+ ref={createMemberNameInputRef}
style={inventoryCreateInputStyle}
type="text"
value={createMemberName}
diff --git a/apps/aevatar-console-web/src/shared/studio/api.test.ts b/apps/aevatar-console-web/src/shared/studio/api.test.ts
index faeb7c592..90bebef09 100644
--- a/apps/aevatar-console-web/src/shared/studio/api.test.ts
+++ b/apps/aevatar-console-web/src/shared/studio/api.test.ts
@@ -793,17 +793,12 @@ describe('studioApi host-session requests', () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
- status: 200,
+ status: 202,
json: async () => ({
+ status: 'accepted',
+ bindingRunId: 'bind-1',
scopeId: 'scope-1',
- publishedServiceId: 'joker',
- displayName: 'joker',
- revisionId: 'rev-1',
- implementationKind: 'workflow',
- workflow: {
- workflowName: 'joker',
- definitionActorIdPrefix: 'scope-workflow:scope-1:joker',
- },
+ memberId: 'joker',
}),
} as Response);
global.fetch = fetchMock as typeof global.fetch;
@@ -816,12 +811,11 @@ describe('studioApi host-session requests', () => {
revisionId: 'rev-1',
});
- expect(result.serviceId).toBe('joker');
- expect(result.implementationKind).toBe('workflow');
- expect(result.targetKind).toBe('workflow');
- expect(result.workflow).toEqual({
- workflowName: 'joker',
- definitionActorIdPrefix: 'scope-workflow:scope-1:joker',
+ expect(result).toEqual({
+ status: 'accepted',
+ bindingRunId: 'bind-1',
+ scopeId: 'scope-1',
+ memberId: 'joker',
});
const [input, init] = fetchMock.mock.calls[0] as [
@@ -997,27 +991,33 @@ describe('studioApi host-session requests', () => {
ok: true,
status: 200,
json: async () => ({
- available: true,
- scopeId: 'scope-1',
- publishedServiceId: 'joker',
- displayName: 'joker',
- publishedServiceKey: 'scope-1:default:joker',
- defaultServingRevisionId: 'rev-2',
- activeServingRevisionId: 'rev-2',
- deploymentId: 'deploy-2',
- deploymentStatus: 'Active',
- primaryActorId: 'actor://scope/joker',
- updatedAt: '2026-03-26T08:00:00Z',
- revisions: [],
+ lastBinding: {
+ publishedServiceId: 'member-joker',
+ revisionId: 'rev-2',
+ implementationKind: 'workflow',
+ boundAt: '2026-03-26T08:00:00Z',
+ },
+ currentBindingRun: {
+ bindingRunId: 'bind-1',
+ status: 'platform_binding_pending',
+ scopeId: 'scope-1',
+ memberId: 'joker',
+ updatedAt: '2026-03-26T08:01:00Z',
+ },
}),
} as Response);
global.fetch = fetchMock as typeof global.fetch;
await expect(studioApi.getMemberBinding('scope-1', 'joker')).resolves.toEqual(
expect.objectContaining({
- serviceId: 'joker',
- serviceKey: 'scope-1:default:joker',
- displayName: 'joker',
+ lastBinding: expect.objectContaining({
+ publishedServiceId: 'member-joker',
+ revisionId: 'rev-2',
+ }),
+ currentBindingRun: expect.objectContaining({
+ bindingRunId: 'bind-1',
+ status: 'platform_binding_pending',
+ }),
}),
);
diff --git a/apps/aevatar-console-web/src/shared/studio/api.ts b/apps/aevatar-console-web/src/shared/studio/api.ts
index c6ca7866a..8aafbaf3b 100644
--- a/apps/aevatar-console-web/src/shared/studio/api.ts
+++ b/apps/aevatar-console-web/src/shared/studio/api.ts
@@ -14,6 +14,11 @@ import type {
StudioExecutionDetail,
StudioExecutionSummary,
StudioMemberBindingContract,
+ StudioMemberBindingAcceptedResponse,
+ StudioMemberBindingFailure,
+ StudioMemberBindingRunStatus,
+ StudioMemberBindingRunStatusResponse,
+ StudioMemberBindingViewResponse,
StudioMemberDetail,
StudioMemberImplementationKind,
StudioMemberImplementationRef,
@@ -1205,8 +1210,162 @@ function decodeStudioMemberBindingContract(
};
}
+function normalizeStudioMemberBindingRunStatus(
+ value: string | number | null | undefined
+): StudioMemberBindingRunStatus {
+ if (value == null) {
+ return "unknown";
+ }
+
+ return normalizeEnumValue(value, "status", {
+ "0": "unknown",
+ "1": "accepted",
+ "2": "admission_pending",
+ "3": "admitted",
+ "4": "platform_binding_pending",
+ "5": "succeeded",
+ "6": "failed",
+ "7": "rejected",
+ "8": "member_notification_pending",
+ accepted: "accepted",
+ admission_pending: "admission_pending",
+ admissionpending: "admission_pending",
+ admitted: "admitted",
+ platform_binding_pending: "platform_binding_pending",
+ platformbindingpending: "platform_binding_pending",
+ platform_pending: "platform_binding_pending",
+ platformpending: "platform_binding_pending",
+ member_notification_pending: "member_notification_pending",
+ membernotificationpending: "member_notification_pending",
+ notification_pending: "member_notification_pending",
+ notificationpending: "member_notification_pending",
+ succeeded: "succeeded",
+ completed: "succeeded",
+ failed: "failed",
+ rejected: "rejected",
+ unspecified: "unknown",
+ unknown: "unknown",
+ }) as StudioMemberBindingRunStatus;
+}
+
+function decodeStudioMemberBindingFailure(
+ value: unknown
+): StudioMemberBindingFailure {
+ const record = expectRecord(value, "StudioMemberBindingFailure");
+ return {
+ code: readString(record, ["code", "Code"], "StudioMemberBindingFailure.code"),
+ message:
+ readNullableString(
+ record,
+ ["message", "Message"],
+ "StudioMemberBindingFailure.message"
+ ) ?? null,
+ failedAt:
+ readNullableString(
+ record,
+ ["failedAt", "FailedAt", "failedAtUtc", "FailedAtUtc"],
+ "StudioMemberBindingFailure.failedAt"
+ ) ?? null,
+ };
+}
+
+function decodeStudioMemberBindingRunStatusResponse(
+ value: unknown
+): StudioMemberBindingRunStatusResponse {
+ const record = expectRecord(value, "StudioMemberBindingRunStatusResponse");
+ return {
+ status: normalizeStudioMemberBindingRunStatus(
+ readOptionalScalar(record, ["status", "Status"])
+ ),
+ bindingRunId: readString(
+ record,
+ ["bindingRunId", "BindingRunId"],
+ "StudioMemberBindingRunStatusResponse.bindingRunId"
+ ),
+ scopeId: readString(
+ record,
+ ["scopeId", "ScopeId"],
+ "StudioMemberBindingRunStatusResponse.scopeId"
+ ),
+ memberId: readString(
+ record,
+ ["memberId", "MemberId"],
+ "StudioMemberBindingRunStatusResponse.memberId"
+ ),
+ platformBindingCommandId:
+ readNullableString(
+ record,
+ ["platformBindingCommandId", "PlatformBindingCommandId"],
+ "StudioMemberBindingRunStatusResponse.platformBindingCommandId"
+ ) ?? null,
+ failure:
+ record.failure == null && record.Failure == null
+ ? null
+ : decodeStudioMemberBindingFailure(record.failure ?? record.Failure),
+ updatedAt:
+ readNullableString(
+ record,
+ ["updatedAt", "UpdatedAt", "updatedAtUtc", "UpdatedAtUtc"],
+ "StudioMemberBindingRunStatusResponse.updatedAt"
+ ) ?? null,
+ };
+}
+
+function decodeStudioMemberBindingAcceptedResponse(
+ value: unknown
+): StudioMemberBindingAcceptedResponse {
+ const record = expectRecord(value, "StudioMemberBindingAcceptedResponse");
+ return {
+ status: normalizeStudioMemberBindingRunStatus(
+ readOptionalScalar(record, ["status", "Status"])
+ ),
+ bindingRunId: readString(
+ record,
+ ["bindingRunId", "BindingRunId"],
+ "StudioMemberBindingAcceptedResponse.bindingRunId"
+ ),
+ scopeId: readString(
+ record,
+ ["scopeId", "ScopeId"],
+ "StudioMemberBindingAcceptedResponse.scopeId"
+ ),
+ memberId: readString(
+ record,
+ ["memberId", "MemberId"],
+ "StudioMemberBindingAcceptedResponse.memberId"
+ ),
+ };
+}
+
+function decodeStudioMemberBindingViewResponse(
+ value: unknown
+): StudioMemberBindingViewResponse {
+ const record = expectRecord(value, "StudioMemberBindingViewResponse");
+ const currentBindingRun =
+ record.currentBindingRun == null && record.CurrentBindingRun == null
+ ? undefined
+ : decodeStudioMemberBindingRunStatusResponse(
+ record.currentBindingRun ?? record.CurrentBindingRun
+ );
+ return {
+ lastBinding:
+ record.lastBinding == null && record.LastBinding == null
+ ? null
+ : decodeStudioMemberBindingContract(
+ record.lastBinding ?? record.LastBinding
+ ),
+ ...(currentBindingRun === undefined ? {} : { currentBindingRun }),
+ };
+}
+
function decodeStudioMemberDetail(value: unknown): StudioMemberDetail {
const record = expectRecord(value, "StudioMemberDetail");
+ const currentBindingRun =
+ record.currentBindingRun == null && record.CurrentBindingRun == null
+ ? undefined
+ : decodeStudioMemberBindingRunStatusResponse(
+ record.currentBindingRun ?? record.CurrentBindingRun
+ );
return {
summary: decodeStudioMemberSummary(
expectRecord(record.summary ?? record.Summary, "StudioMemberDetail.summary")
@@ -1223,6 +1382,7 @@ function decodeStudioMemberDetail(value: unknown): StudioMemberDetail {
: decodeStudioMemberBindingContract(
record.lastBinding ?? record.LastBinding
),
+ ...(currentBindingRun === undefined ? {} : { currentBindingRun }),
};
}
@@ -1685,10 +1845,10 @@ export const studioApi = {
displayName?: string | null;
workflowYamls: readonly string[];
revisionId?: string | null;
- }): Promise
{
+ }): Promise {
return requestDecodedJson(
`/api/scopes/${encodeURIComponent(input.scopeId.trim())}/members/${encodeURIComponent(input.memberId.trim())}/binding`,
- decodeStudioScopeBindingResult,
+ decodeStudioMemberBindingAcceptedResponse,
{
method: "PUT",
headers: JSON_HEADERS,
@@ -1713,10 +1873,10 @@ export const studioApi = {
scriptId: string;
scriptRevision: string;
revisionId?: string | null;
- }): Promise {
+ }): Promise {
return requestDecodedJson(
`/api/scopes/${encodeURIComponent(input.scopeId.trim())}/members/${encodeURIComponent(input.memberId.trim())}/binding`,
- decodeStudioScopeBindingResult,
+ decodeStudioMemberBindingAcceptedResponse,
{
method: "PUT",
headers: JSON_HEADERS,
@@ -1742,10 +1902,10 @@ export const studioApi = {
actorTypeName: string;
endpoints: StudioScopeGAgentBindingInput["endpoints"];
revisionId?: string | null;
- }): Promise {
+ }): Promise {
return requestDecodedJson(
`/api/scopes/${encodeURIComponent(input.scopeId.trim())}/members/${encodeURIComponent(input.memberId.trim())}/binding`,
- decodeStudioScopeBindingResult,
+ decodeStudioMemberBindingAcceptedResponse,
{
method: "PUT",
headers: JSON_HEADERS,
@@ -1778,10 +1938,21 @@ export const studioApi = {
getMemberBinding(
scopeId: string,
memberId: string
- ): Promise {
+ ): Promise {
return requestDecodedJson(
`/api/scopes/${encodeURIComponent(scopeId.trim())}/members/${encodeURIComponent(memberId.trim())}/binding`,
- decodeStudioScopeBindingStatus
+ decodeStudioMemberBindingViewResponse
+ );
+ },
+
+ getMemberBindingRun(
+ scopeId: string,
+ memberId: string,
+ bindingRunId: string
+ ): Promise {
+ return requestDecodedJson(
+ `/api/scopes/${encodeURIComponent(scopeId.trim())}/members/${encodeURIComponent(memberId.trim())}/binding-runs/${encodeURIComponent(bindingRunId.trim())}`,
+ decodeStudioMemberBindingRunStatusResponse
);
},
diff --git a/apps/aevatar-console-web/src/shared/studio/models.ts b/apps/aevatar-console-web/src/shared/studio/models.ts
index 2539c831c..3608b5af9 100644
--- a/apps/aevatar-console-web/src/shared/studio/models.ts
+++ b/apps/aevatar-console-web/src/shared/studio/models.ts
@@ -461,10 +461,50 @@ export interface StudioMemberBindingContract {
readonly boundAt: string;
}
+export type StudioMemberBindingRunStatus =
+ | 'accepted'
+ | 'admission_pending'
+ | 'admitted'
+ | 'platform_binding_pending'
+ | 'member_notification_pending'
+ | 'succeeded'
+ | 'failed'
+ | 'rejected'
+ | 'unknown';
+
+export interface StudioMemberBindingFailure {
+ readonly code: string;
+ readonly message?: string | null;
+ readonly failedAt?: string | null;
+}
+
+export interface StudioMemberBindingRunStatusResponse {
+ readonly status: StudioMemberBindingRunStatus;
+ readonly bindingRunId: string;
+ readonly scopeId: string;
+ readonly memberId: string;
+ readonly platformBindingCommandId?: string | null;
+ readonly failure?: StudioMemberBindingFailure | null;
+ readonly updatedAt?: string | null;
+}
+
+export interface StudioMemberBindingAcceptedResponse {
+ readonly status: StudioMemberBindingRunStatus;
+ readonly bindingRunId: string;
+ readonly scopeId: string;
+ readonly memberId: string;
+}
+
export interface StudioMemberDetail {
readonly summary: StudioMemberSummary;
readonly implementationRef?: StudioMemberImplementationRef | null;
readonly lastBinding?: StudioMemberBindingContract | null;
+ readonly currentBindingRun?: StudioMemberBindingRunStatusResponse | null;
+}
+
+export interface StudioMemberBindingViewResponse {
+ readonly lastBinding?: StudioMemberBindingContract | null;
+ readonly currentBindingRun?: StudioMemberBindingRunStatusResponse | null;
}
export interface StudioMemberRoster {
@@ -535,7 +575,7 @@ export interface StudioTeamUpdateInput {
export type StudioMemberBindingTargetKind = StudioScopeBindingTargetKind;
export type StudioMemberBindingResult = StudioScopeBindingResult;
export type StudioMemberBindingRevision = StudioScopeBindingRevision;
-export type StudioMemberBindingStatus = StudioScopeBindingStatus;
+export type StudioMemberBindingStatus = StudioMemberBindingViewResponse;
export type StudioMemberBindingActivationResult =
StudioScopeBindingActivationResult;
export type StudioMemberBindingRetirementResult =
diff --git a/docs/2026-04-27-member-first-studio-apis.md b/docs/2026-04-27-member-first-studio-apis.md
index a384fa601..686aebbc0 100644
--- a/docs/2026-04-27-member-first-studio-apis.md
+++ b/docs/2026-04-27-member-first-studio-apis.md
@@ -20,8 +20,9 @@ The resolver is exposed through `IMemberPublishedServiceResolver`, so a later ac
| Route | Purpose |
|---|---|
| `GET /api/scopes/{scopeId}/members/{memberId}/published-service` | Resolve the member-owned published service id. |
-| `GET /api/scopes/{scopeId}/members/{memberId}/binding` | Read current binding status for the member-owned published service. |
-| `PUT /api/scopes/{scopeId}/members/{memberId}/binding` | Publish workflow/script/GAgent implementation to the member-owned published service. |
+| `GET /api/scopes/{scopeId}/members/{memberId}/binding` | Read the member authority's last successful binding and current async binding run. |
+| `PUT /api/scopes/{scopeId}/members/{memberId}/binding` | Start an async workflow/script/GAgent binding run for the member-owned published service. Returns `202 Accepted`. |
+| `GET /api/scopes/{scopeId}/members/{memberId}/binding-runs/{bindingRunId}` | Read the eventually-consistent status read model for one binding run. |
| `POST /api/scopes/{scopeId}/members/{memberId}/invoke/{endpointId}` | Invoke a typed endpoint by member id. |
| `POST /api/scopes/{scopeId}/members/{memberId}/invoke/{endpointId}:stream` | Invoke an SSE endpoint by member id. |
| `GET /api/scopes/{scopeId}/members/{memberId}/runs` | List read-model-backed runs for the member-owned published service. |
@@ -34,7 +35,10 @@ The resolver is exposed through `IMemberPublishedServiceResolver`, so a later ac
## Semantics
- Member routes for Bind / Invoke / Observe-read / run lifecycle control do not require frontend callers to know or pass `serviceId`.
-- Binding and invoke still use the existing service command/runtime path after the resolver has produced `publishedServiceId`.
+- Binding writes are asynchronous. The `PUT /binding` response only means the command was accepted for dispatch and returns a stable `bindingRunId`; completion is observed through `GET /binding-runs/{bindingRunId}` or `GET /binding`.
+- The binding-run `Location` is read-model backed and can be briefly unavailable immediately after `202 Accepted`. Clients should treat a short-lived `404` for the accepted run id as pending/read-model lag, not as terminal failure. Only explicit `failed` or `rejected` run status should surface as a binding error.
+- Binding execution still publishes through the existing service command/runtime path after the member actor has admitted the request and resolved its `publishedServiceId`.
- Runs and run detail still read workflow run read models; they do not query actor state or replay events.
- Responses use `publishedServiceId` instead of overloading `serviceId` in member-centric DTOs.
- The member-first public contract does not accept an `appId` override or expose the fixed service namespace.
+- The legacy scope-service member binding routes under `Aevatar.GAgentService.Hosting` are intentionally removed; member binding uses the StudioMember async protocol so the member actor remains the single authority for `LastBinding`, active run status, and `BindReady`.
diff --git a/docs/history/2026-04/2026-04-30-studio-member-bind-async-protocol-design.md b/docs/history/2026-04/2026-04-30-studio-member-bind-async-protocol-design.md
new file mode 100644
index 000000000..9ea4c439d
--- /dev/null
+++ b/docs/history/2026-04/2026-04-30-studio-member-bind-async-protocol-design.md
@@ -0,0 +1,482 @@
+---
+title: "StudioMember Bind Async Protocol Design"
+status: history
+owner: codex
+---
+
+# StudioMember Bind Async Protocol Design
+
+This is an archived design snapshot for the StudioMember async binding work tracked by
+[#516](https://github.com/aevatarAI/aevatar/issues/516). It is retained for PR review
+context only; the authoritative behavior is the committed actor protocol, generated proto
+contracts, tests, and any future document promoted under `docs/canon/`.
+
+## Background
+
+Issue [#516](https://github.com/aevatarAI/aevatar/issues/516) tracks the remaining StudioMember binding problem from #462. Today `StudioMemberService.BindAsync` reads `IStudioMemberQueryPort.GetAsync` before it dispatches the bind workflow. That query port is projection-backed, so a freshly created `StudioMemberGAgent` can already own the committed member fact while `StudioMemberCurrentStateDocument` still lags. In that window bind fails with `STUDIO_MEMBER_NOT_FOUND` even though the authoritative member exists.
+
+This design follows the repository architecture rules:
+
+- command paths must not depend on read-model freshness for admission;
+- cross-actor waits must be continuation/eventized, not synchronous request-reply;
+- projection remains committed-fact materialization, not business orchestration;
+- command ACKs must be honest about the stage they reached.
+
+## Current Objects
+
+`StudioMemberGAgent` already exists in `agents/Aevatar.GAgents.StudioMember`. It owns the authoritative `StudioMemberState`, including `member_id`, `scope_id`, `implementation_kind`, `published_service_id`, `implementation_ref`, lifecycle stage, team assignment, and the last bound contract.
+
+`StudioMemberBindingRunGAgent` does not exist today. This design introduces it as a short-lived run actor. One bind request maps to one binding run actor.
+
+## Goals
+
+1. `PUT /api/scopes/{scopeId}/members/{memberId}/binding` must not reject a valid member because the StudioMember read model is stale.
+2. Bind admission must be owned by `StudioMemberGAgent`, because it is the authority for whether the member exists and what its stable bind inputs are.
+3. The HTTP command must return `202 Accepted` with a stable `bindingRunId`; it must not imply the platform binding has already completed or the read model has observed it.
+4. Bind progress, success, duplicate handling, and failure must be represented as durable actor facts/events.
+5. Read models expose binding status as projection copies of committed facts.
+6. Projection turns must not call platform binding commands or drive business continuations.
+
+## Non-Goals
+
+- Do not redesign NyxID relay admission. #506 showed that coupling NyxID callback tokens to Aevatar scope ownership is the wrong boundary.
+- Do not move StudioMember authority into the read model.
+- Do not add generic actor query/reply.
+- Do not make the API wait for read-model visibility as the normal command contract.
+- Do not preserve the current synchronous `200 + revision` bind response as the primary contract.
+
+## Proposed Architecture
+
+### Actors
+
+`StudioMemberGAgent` remains the long-lived authoritative actor for a member. It gains binding protocol events that record:
+
+- a bind run was admitted for this member;
+- the bind is in progress;
+- the bind completed with a published service revision;
+- the bind failed with an open failure detail code/message;
+- duplicate or stale run messages were ignored without changing authority state.
+
+`StudioMemberGAgent` also owns the member-level binding authority state used to reject stale run continuations. That state records the current active binding run id, the current binding status, the last terminal binding run id, and the last failure detail. The existing `StudioMemberState.last_binding` remains the only source of truth for the last successful binding contract. Projection copies these facts into read models; it does not infer the current run by replaying event order outside the actor.
+
+`StudioMemberBindingRunGAgent` is a run/session/task-scoped actor. It owns one binding attempt and persists its own run state:
+
+- `binding_run_id`
+- `scope_id`
+- `member_id`
+- request payload
+- status
+- admitted member snapshot
+- platform binding result
+- failure detail
+- timestamps
+- attempt count / retry cursor if retries are added
+
+The run actor can be removed later by retention cleanup once the member actor and read model have observed a terminal state.
+
+The run actor must not call the current `IScopeBindingCommandPort.UpsertAsync` and await its result inside an actor turn. The current port performs a multi-step synchronous orchestration and includes bounded read-model visibility polling. Using it as-is would reintroduce synchronous cross-component waiting and projection freshness coupling inside the run actor.
+
+The implementation must first split platform binding into an asynchronous command/continuation contract:
+
+- `IPlatformBindingCommandPort.StartAsync` accepts a typed binding command and returns only `accepted + platform_binding_command_id`;
+- platform binding execution commits its own facts or publishes a typed completion/failure continuation;
+- `StudioMemberBindingRunGAgent` consumes `PlatformBindingSucceeded` / `PlatformBindingFailed` messages in later turns and then notifies `StudioMemberGAgent`;
+- any read-model visibility wait remains outside the actor command path and is not part of this protocol's ACK semantics.
+
+### Command Flow
+
+```mermaid
+%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%%
+sequenceDiagram
+ autonumber
+ participant API as "StudioMember API"
+ participant Run as "StudioMemberBindingRunGAgent"
+ participant Member as "StudioMemberGAgent"
+ participant Binding as "Async Platform Binding Command"
+ participant Projection as "Projection Pipeline"
+ participant ReadModel as "StudioMember Read Model"
+
+ API->>Run: "Dispatch StudioMemberBindingRunRequested"
+ API-->>API: "202 Accepted + bindingRunId"
+ Run->>Member: "Dispatch StudioMemberBindAdmissionRequested"
+ Member->>Member: "Validate member exists and bind input authority"
+ Member->>Run: "Dispatch StudioMemberBindAdmitted or Rejected"
+ Run->>Run: "Persist admitted / rejected state"
+ Run->>Binding: "Dispatch PlatformBindingStartRequested"
+ Binding-->>Run: "Accepted + platformBindingCommandId"
+ Binding->>Run: "Dispatch PlatformBindingSucceeded or Failed"
+ Run->>Member: "Dispatch StudioMemberBindingCompleted or Failed"
+ Member->>Member: "Persist terminal binding fact"
+ Member-->>Projection: "CommittedStateEventPublished"
+ Projection->>ReadModel: "Materialize status / last binding"
+```
+
+The diagram shows a logical message flow. Each actor-to-actor step is an event/command dispatch and ends the current actor turn. No actor turn blocks waiting for another actor reply, and the run actor does not wait for platform read models to become visible.
+
+### Routing And Identity
+
+`bindingRunId` is the stable command/run identity returned to clients. The runtime maps it to a run actor address through an internal convention such as `studio-member-binding-run:{bindingRunId}`. That actor id is an opaque runtime address; no caller may parse it for scope, member, or implementation facts.
+
+All protocol messages carry `binding_run_id`; dispatch adapters use that value to derive or look up the run actor target when routing continuations. This avoids an API/service-level in-memory `bindingRunId -> context` map.
+
+The member target remains the existing canonical `StudioMemberConventions.BuildActorId(scopeId, memberId)` address. The member actor never trusts mutable business facts from actor id text; it validates against its persisted `StudioMemberState`.
+
+Platform binding continuations route back to the run actor by `binding_run_id` and `platform_binding_command_id`. The payload carries those correlation ids; the `EventEnvelope.Route` carries the actual target actor address.
+
+### HTTP Contract
+
+`PUT /api/scopes/{scopeId}/members/{memberId}/binding` changes from a synchronous completion response to an accepted command response:
+
+```json
+{
+ "status": "accepted",
+ "bindingRunId": "bind-01HV...",
+ "scopeId": "scope-1",
+ "memberId": "m-1"
+}
+```
+
+This ACK only means the binding request was accepted for dispatch and has a stable run id. It does not promise that:
+
+- the member exists;
+- the platform binding succeeded;
+- a service revision was published;
+- the read model has observed the result.
+
+Consumers read status through query endpoints:
+
+- `GET /api/scopes/{scopeId}/members/{memberId}/binding` returns the member's current binding view, including last successful binding and current/last run status.
+- `GET /api/scopes/{scopeId}/members/{memberId}/binding-runs/{bindingRunId}` returns exact status for one run. This is useful for frontend progress and failure details without overloading the member summary.
+
+### Status Model
+
+Use explicit wire-stable status names:
+
+- `accepted`
+- `admission_pending`
+- `admitted`
+- `platform_binding_pending`
+- `succeeded`
+- `failed`
+- `rejected`
+
+Actor, API, and frontend control flow must branch on the small typed status set above, not on arbitrary failure detail codes. `rejected` means member-side admission did not pass; `failed` means admission passed but platform binding did not complete.
+
+Failure detail codes remain open string values, because they are not control-flow inputs in v1. They are for display, logs, diagnostics, and analytics. Stable internal failures should still use consistent code strings:
+
+- `STUDIO_MEMBER_NOT_FOUND`
+- `STUDIO_MEMBER_BIND_INPUT_INVALID`
+- `STUDIO_MEMBER_BIND_KIND_MISMATCH`
+- `SCOPE_BINDING_FAILED`
+- `STUDIO_MEMBER_BIND_DUPLICATE`
+- `STUDIO_MEMBER_BIND_CANCELLED`
+
+`STUDIO_MEMBER_NOT_FOUND` becomes an asynchronous terminal status for this command protocol, not a stale read-model admission failure from the initial HTTP call.
+
+If a future implementation needs programmatic branching beyond status, add a small typed field for that decision, such as `retry_policy`, rather than turning every failure detail code into an enum.
+
+### Proto Changes
+
+Extend `studio_member_messages.proto` with strongly typed binding protocol messages. Exact field numbers can be assigned during implementation, but the shape should stay close to:
+
+```proto
+enum StudioMemberBindingRunStatus {
+ STUDIO_MEMBER_BINDING_RUN_STATUS_UNSPECIFIED = 0;
+ STUDIO_MEMBER_BINDING_RUN_STATUS_ACCEPTED = 1;
+ STUDIO_MEMBER_BINDING_RUN_STATUS_ADMISSION_PENDING = 2;
+ STUDIO_MEMBER_BINDING_RUN_STATUS_ADMITTED = 3;
+ STUDIO_MEMBER_BINDING_RUN_STATUS_PLATFORM_BINDING_PENDING = 4;
+ STUDIO_MEMBER_BINDING_RUN_STATUS_SUCCEEDED = 5;
+ STUDIO_MEMBER_BINDING_RUN_STATUS_FAILED = 6;
+ STUDIO_MEMBER_BINDING_RUN_STATUS_REJECTED = 7;
+ STUDIO_MEMBER_BINDING_RUN_STATUS_MEMBER_NOTIFICATION_PENDING = 8;
+}
+
+message StudioMemberBindingRequest {
+ string binding_run_id = 1;
+ string scope_id = 2;
+ string member_id = 3;
+ string request_hash = 4;
+ optional string revision_id = 5;
+ oneof implementation {
+ StudioMemberWorkflowBindingRequest workflow = 10;
+ StudioMemberScriptBindingRequest script = 11;
+ StudioMemberGAgentBindingRequest gagent = 12;
+ }
+}
+
+message StudioMemberWorkflowBindingRequest {
+ repeated string workflow_yamls = 1;
+}
+
+message StudioMemberScriptBindingRequest {
+ string script_id = 1;
+ optional string script_revision = 2;
+}
+
+message StudioMemberGAgentBindingRequest {
+ string actor_type_name = 1;
+ repeated StudioMemberGAgentEndpointBindingRequest endpoints = 2;
+}
+
+message StudioMemberGAgentEndpointBindingRequest {
+ string endpoint_id = 1;
+ string display_name = 2;
+ string kind = 3;
+ string request_type_url = 4;
+ string response_type_url = 5;
+ string description = 6;
+}
+
+message StudioMemberBindingRunState {
+ string binding_run_id = 1;
+ string scope_id = 2;
+ string member_id = 3;
+ string request_hash = 4;
+ StudioMemberBindingRunStatus status = 5;
+ StudioMemberBindingRequest request = 6;
+ StudioMemberBindingAdmittedSnapshot admitted = 7;
+ StudioMemberPlatformBindingResult platform_result = 8;
+ StudioMemberBindingFailure failure = 9;
+ google.protobuf.Timestamp accepted_at_utc = 10;
+ google.protobuf.Timestamp updated_at_utc = 11;
+ int32 attempt_count = 12;
+ string platform_binding_command_id = 13;
+ bool platform_execution_in_flight = 14;
+ google.protobuf.Timestamp platform_execution_started_at_utc = 15;
+}
+
+message StudioMemberBindingAuthorityState {
+ string current_binding_run_id = 1;
+ StudioMemberBindingRunStatus current_status = 2;
+ string last_terminal_binding_run_id = 3;
+ StudioMemberBindingFailure last_failure = 4;
+ google.protobuf.Timestamp updated_at_utc = 5;
+}
+
+// Existing StudioMemberState keeps `last_binding = 11` as the only source of
+// truth for the last successful binding contract. The new binding sub-state
+// records async run/protocol status only; it must not duplicate `last_binding`.
+//
+// message StudioMemberState {
+// ...
+// StudioMemberBindingAuthorityState binding = 12;
+// }
+
+message StudioMemberBindingAdmittedSnapshot {
+ string member_id = 1;
+ string scope_id = 2;
+ string published_service_id = 3;
+ StudioMemberImplementationKind implementation_kind = 4;
+ string display_name = 5;
+}
+
+message StudioMemberBindingFailure {
+ string code = 1;
+ string message = 2;
+ google.protobuf.Timestamp failed_at_utc = 3;
+}
+
+message StudioMemberBindingRunRequested {
+ StudioMemberBindingRequest request = 1;
+ google.protobuf.Timestamp requested_at_utc = 2;
+}
+
+message StudioMemberBindAdmissionRequested {
+ string binding_run_id = 1;
+ string scope_id = 2;
+ string member_id = 3;
+ string request_hash = 4;
+ StudioMemberBindingRequest request = 5;
+ google.protobuf.Timestamp requested_at_utc = 6;
+}
+
+message StudioMemberBindingAdmittedEvent {
+ string binding_run_id = 1;
+ string member_id = 2;
+ string scope_id = 3;
+ string published_service_id = 4;
+ StudioMemberImplementationKind implementation_kind = 5;
+ string display_name = 6;
+ google.protobuf.Timestamp admitted_at_utc = 7;
+}
+
+message StudioMemberBindingPlatformPendingEvent {
+ string binding_run_id = 1;
+ string platform_binding_command_id = 2;
+ google.protobuf.Timestamp pending_at_utc = 3;
+}
+
+message StudioMemberBindingRejectedEvent {
+ string binding_run_id = 1;
+ string scope_id = 2;
+ string member_id = 3;
+ StudioMemberBindingFailure failure = 4;
+}
+
+message StudioMemberPlatformBindingStartRequested {
+ string binding_run_id = 1;
+ string platform_binding_command_id = 2;
+ StudioMemberBindingRequest request = 3;
+ StudioMemberBindingAdmittedSnapshot admitted = 4;
+ google.protobuf.Timestamp requested_at_utc = 5;
+}
+
+message StudioMemberPlatformBindingAccepted {
+ string binding_run_id = 1;
+ string platform_binding_command_id = 2;
+ google.protobuf.Timestamp accepted_at_utc = 3;
+}
+
+message StudioMemberPlatformBindingResult {
+ string published_service_id = 1;
+ string revision_id = 2;
+ StudioMemberImplementationKind implementation_kind = 3;
+ string expected_actor_id = 4;
+ StudioMemberImplementationRef implementation_ref = 5;
+}
+
+message StudioMemberPlatformBindingSucceeded {
+ string binding_run_id = 1;
+ string platform_binding_command_id = 2;
+ StudioMemberPlatformBindingResult result = 3;
+ google.protobuf.Timestamp completed_at_utc = 4;
+}
+
+message StudioMemberPlatformBindingFailed {
+ string binding_run_id = 1;
+ string platform_binding_command_id = 2;
+ StudioMemberBindingFailure failure = 3;
+}
+
+message StudioMemberPlatformBindingExecutionStarted {
+ string binding_run_id = 1;
+ string platform_binding_command_id = 2;
+ google.protobuf.Timestamp started_at_utc = 3;
+}
+
+message StudioMemberBindingTerminalAcknowledged {
+ string binding_run_id = 1;
+ StudioMemberBindingRunStatus status = 2;
+ google.protobuf.Timestamp acknowledged_at_utc = 3;
+}
+
+message StudioMemberBindingCompletedEvent {
+ string binding_run_id = 1;
+ string published_service_id = 2;
+ string revision_id = 3;
+ StudioMemberImplementationKind implementation_kind = 4;
+ StudioMemberImplementationRef implementation_ref = 5;
+ google.protobuf.Timestamp completed_at_utc = 6;
+}
+
+message StudioMemberBindingFailedEvent {
+ string binding_run_id = 1;
+ StudioMemberBindingFailure failure = 2;
+}
+```
+
+`StudioMemberBindingCompletedEvent` is the authoritative successful bind terminal event. Binding completion must flow through the async binding run protocol and the member actor's current-run guard; no parallel compatibility transition is kept.
+
+### Read Models
+
+Extend `StudioMemberCurrentStateDocument` with query-shaped fields:
+
+- `current_binding_run_id`
+- `binding_status`
+- `binding_failure_code`
+- `binding_failure_message`
+- `last_terminal_binding_run_id`
+- existing last bound fields remain for successful bind results
+
+Add `StudioMemberBindingRunDocument` in v1. The member binding endpoint can show the member's current binding summary, while `GET /binding-runs/{bindingRunId}` reads the run document for exact progress and failure details. The run document is still projection output from committed run/member facts; it is not the authority.
+
+### Duplicate And Retry Semantics
+
+V1 does not accept a client-provided idempotency key. Each `PUT /binding` creates a new server-generated `bindingRunId`.
+
+V1 duplicate handling is actor-protocol idempotency:
+
+- `request_hash` is computed by the Studio command adapter from the canonical protobuf bytes of `StudioMemberBindingRequest` with `request_hash` cleared;
+- repeated `StudioMemberBindingRunRequested` messages for the same `binding_run_id` and same `request_hash` are no-ops;
+- repeated `StudioMemberBindingRunRequested` messages for the same `binding_run_id` with a different `request_hash` or payload are rejected as conflicts;
+- repeated admission messages for the same `binding_run_id` and same admitted snapshot are no-ops;
+- repeated terminal completion events are idempotent if `binding_run_id` and result match;
+- stale completion/failure events for a superseded active run are ignored or recorded as stale, but must not overwrite a newer successful binding.
+
+Client-provided idempotency keys are out of scope for v1. If a future workflow needs client retry de-duplication, add them as a separate design. The authoritative key-to-run binding must be actor-owned, preferably by deterministic run actor identity or member actor state. It must not be an API/service-level in-memory map.
+
+Retries belong to the run actor. A retry must re-enter the run actor as a self/internal message, then re-execute the platform binding effect from a new actor turn. Timer or delay callbacks must only publish internal events.
+
+### Layering
+
+Application:
+
+- normalizes HTTP input;
+- dispatches a binding run command;
+- returns `202 + bindingRunId`;
+- does not query `IStudioMemberQueryPort` for command admission.
+
+Domain/Actor:
+
+- `StudioMemberGAgent` owns member admission and terminal binding facts;
+- `StudioMemberBindingRunGAgent` owns per-run progress and retries.
+
+Infrastructure:
+
+- implements command dispatch through existing dispatch/runtime ports;
+- introduces the async platform binding command/continuation port used by the run protocol;
+- may adapt the existing scope binding internals behind that async port only after removing actor-turn waits on `UpsertAsync` and read-model visibility polling from the run path;
+- does not drive business orchestration from projection.
+
+Projection:
+
+- consumes committed actor facts;
+- materializes member current-state and optional run documents;
+- does not call binding commands.
+
+## Migration Plan
+
+1. Add proto contracts for member binding admission/completion/failure, binding run state, platform binding continuations, and retry/self messages.
+2. Add `StudioMemberBindingRunGAgent`.
+3. Split platform binding into an async command/continuation contract; do not call the current synchronous `IScopeBindingCommandPort.UpsertAsync` from the run actor.
+4. Add command port method for starting a binding run.
+5. Change `StudioMemberService.BindAsync` to dispatch the run and return accepted response.
+6. Extend read model projection with binding status fields.
+7. Add `StudioMemberBindingRunDocument` and `GET /binding-runs/{bindingRunId}`.
+8. Update frontend bind flow to show accepted/platform_binding_pending/succeeded/failed states from read model or run query.
+9. Remove the obsolete synchronous bind path and tests that require immediate `200 + revision`.
+
+## Tests
+
+Focused tests should cover:
+
+- stale member read model: `BindAsync` returns accepted and never calls `IStudioMemberQueryPort.GetAsync`;
+- missing member: run reaches rejected/failed status with `STUDIO_MEMBER_NOT_FOUND`;
+- successful workflow/script/gagent bind: run records success and member read model exposes last binding;
+- duplicate run messages with the same `binding_run_id` and same `request_hash` are idempotent;
+- duplicate run messages with the same `binding_run_id` and different `request_hash` or payload are rejected;
+- duplicate terminal events with the same result are idempotent;
+- platform binding failure records durable failed status;
+- stale terminal event cannot overwrite a newer successful binding;
+- run actor does not call or await the existing synchronous `IScopeBindingCommandPort.UpsertAsync`;
+- projection only materializes committed facts and never invokes command ports.
+
+Because tests are changing command behavior, run:
+
+```bash
+bash tools/ci/test_stability_guards.sh
+dotnet test test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj --no-restore --nologo
+```
+
+If proto or projection guards are affected, also run the relevant projection guards from `AGENTS.md`.
+
+## Spec Review Notes
+
+- No implementation is included in this document.
+- `StudioMemberGAgent` is an existing object.
+- `StudioMemberBindingRunGAgent` is a proposed new short-lived actor.
+- The command API is intentionally asynchronous and returns an honest ACK.
+- Read models never participate in bind admission.
+- V1 does not include client-provided idempotency keys.
+- The run actor must use an async platform binding continuation contract, not the existing synchronous binding port as-is.
diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberBindingRunQueryPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberBindingRunQueryPort.cs
new file mode 100644
index 000000000..f07d53fa9
--- /dev/null
+++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberBindingRunQueryPort.cs
@@ -0,0 +1,17 @@
+using Aevatar.Studio.Application.Studio.Contracts;
+
+namespace Aevatar.Studio.Application.Studio.Abstractions;
+
+///
+/// Pure-read query port for a StudioMember binding run actor's current-state
+/// read model. Reads run-owned status; does not infer run state from the
+/// member projection.
+///
+public interface IStudioMemberBindingRunQueryPort
+{
+ Task GetAsync(
+ string scopeId,
+ string memberId,
+ string bindingRunId,
+ CancellationToken ct = default);
+}
diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs
index 42b2f2e98..62e1f1dd2 100644
--- a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs
+++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberCommandPort.cs
@@ -33,16 +33,13 @@ Task UpdateImplementationAsync(
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.
+ /// Starts an asynchronous binding run. Implementations dispatch a
+ /// StudioMemberBindingRunRequested message to a run actor and
+ /// return after the message is accepted for dispatch; they do not wait
+ /// for member admission, platform binding, or read-model visibility.
///
- Task RecordBindingAsync(
- string scopeId,
- string memberId,
- string publishedServiceId,
- string revisionId,
- string implementationKindName,
+ Task StartBindingRunAsync(
+ StudioMemberBindingRunStartRequest request,
CancellationToken ct = default);
///
diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs
index 231a89d4c..957d37da7 100644
--- a/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs
+++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IStudioMemberService.cs
@@ -35,13 +35,12 @@ Task GetAsync(
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.
+ /// Accepts a binding request for asynchronous actor-owned execution.
+ /// The returned receipt only means the command was dispatched with a
+ /// stable binding run id; admission and platform completion are observed
+ /// later through binding run status queries.
///
- Task BindAsync(
+ Task BindAsync(
string scopeId,
string memberId,
UpdateStudioMemberBindingRequest request,
@@ -54,11 +53,17 @@ Task BindAsync(
/// itself does not exist — endpoints distinguish "missing member" (404)
/// from "exists, never bound" (200 with null binding).
///
- Task GetBindingAsync(
+ Task GetBindingAsync(
string scopeId,
string memberId,
CancellationToken ct = default);
+ Task GetBindingRunAsync(
+ string scopeId,
+ string memberId,
+ string bindingRunId,
+ CancellationToken ct = default);
+
///
/// Returns the request/response contract for a single endpoint on the
/// member-owned published service. Resolves the member's
diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/StudioMemberBindingRunNotFoundException.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/StudioMemberBindingRunNotFoundException.cs
new file mode 100644
index 000000000..71aadddb0
--- /dev/null
+++ b/src/Aevatar.Studio.Application/Studio/Abstractions/StudioMemberBindingRunNotFoundException.cs
@@ -0,0 +1,18 @@
+namespace Aevatar.Studio.Application.Studio.Abstractions;
+
+public sealed class StudioMemberBindingRunNotFoundException : KeyNotFoundException
+{
+ public StudioMemberBindingRunNotFoundException(string scopeId, string memberId, string bindingRunId)
+ : base($"binding run '{bindingRunId}' for member '{memberId}' not found in scope '{scopeId}'.")
+ {
+ ScopeId = scopeId;
+ MemberId = memberId;
+ BindingRunId = bindingRunId;
+ }
+
+ public string ScopeId { get; }
+
+ public string MemberId { get; }
+
+ public string BindingRunId { get; }
+}
diff --git a/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs
index ce7e6df97..b2e215010 100644
--- a/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs
+++ b/src/Aevatar.Studio.Application/Studio/Contracts/MemberContracts.cs
@@ -25,6 +25,19 @@ public static class MemberLifecycleStageNames
public const string BindReady = "bind_ready";
}
+public static class StudioMemberBindingRunStatusNames
+{
+ public const string Accepted = "accepted";
+ public const string AdmissionPending = "admission_pending";
+ public const string Admitted = "admitted";
+ public const string PlatformBindingPending = "platform_binding_pending";
+ public const string MemberNotificationPending = "member_notification_pending";
+ public const string Succeeded = "succeeded";
+ public const string Failed = "failed";
+ public const string Rejected = "rejected";
+ public const string Unknown = "unknown";
+}
+
///
/// Wire-format status values returned in
/// . Centralizing
@@ -75,7 +88,10 @@ public sealed record StudioMemberSummaryResponse(
public sealed record StudioMemberDetailResponse(
StudioMemberSummaryResponse Summary,
StudioMemberImplementationRefResponse? ImplementationRef,
- StudioMemberBindingContractResponse? LastBinding);
+ StudioMemberBindingContractResponse? LastBinding)
+{
+ public StudioMemberBindingRunStatusResponse? CurrentBindingRun { get; init; }
+}
public sealed record StudioMemberBindingContractResponse(
string PublishedServiceId,
@@ -90,7 +106,26 @@ public sealed record StudioMemberBindingContractResponse(
/// "member missing" (typed 404 STUDIO_MEMBER_NOT_FOUND).
///
public sealed record StudioMemberBindingViewResponse(
- StudioMemberBindingContractResponse? LastBinding);
+ StudioMemberBindingContractResponse? LastBinding)
+{
+ public StudioMemberBindingRunStatusResponse? CurrentBindingRun { get; init; }
+}
+
+public sealed record StudioMemberBindingFailureResponse(
+ string Code,
+ string Message,
+ DateTimeOffset FailedAt);
+
+public sealed record StudioMemberBindingRunStatusResponse(
+ string BindingRunId,
+ string ScopeId,
+ string MemberId,
+ string Status,
+ StudioMemberBindingFailureResponse? Failure = null,
+ DateTimeOffset? UpdatedAt = null)
+{
+ public string? PlatformBindingCommandId { get; init; }
+}
public sealed record StudioMemberRosterResponse(
string ScopeId,
@@ -162,13 +197,18 @@ public sealed record StudioMemberGAgentBindingSpec(
string ActorTypeName,
IReadOnlyList? Endpoints = null);
-public sealed record StudioMemberBindingResponse(
+public sealed record StudioMemberBindingAcceptedResponse(
+ string Status,
+ string BindingRunId,
+ string ScopeId,
+ string MemberId);
+
+public sealed record StudioMemberBindingRunStartRequest(
+ string BindingRunId,
+ string ScopeId,
string MemberId,
- string PublishedServiceId,
- string RevisionId,
string ImplementationKind,
- string ScopeId,
- string ExpectedActorId);
+ UpdateStudioMemberBindingRequest Binding);
///
/// Member-first endpoint contract. Mirrors the existing scope-default
diff --git a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs
index 34c5b8524..15f1cce33 100644
--- a/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs
+++ b/src/Aevatar.Studio.Application/Studio/Services/StudioMemberService.cs
@@ -9,22 +9,10 @@
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.
+/// Member-first Studio facade. Bind commands now return an honest accepted
+/// receipt and hand the request to actor-owned admission, so a stale
+/// StudioMember read model cannot reject a member that the actor already
+/// owns.
///
public sealed class StudioMemberService : IStudioMemberService
{
@@ -40,24 +28,23 @@ public sealed class StudioMemberService : IStudioMemberService
private readonly IStudioMemberCommandPort _memberCommandPort;
private readonly IStudioMemberQueryPort _memberQueryPort;
+ private readonly IStudioMemberBindingRunQueryPort _bindingRunQueryPort;
private readonly IStudioTeamQueryPort _teamQueryPort;
- private readonly IScopeBindingCommandPort _scopeBindingCommandPort;
private readonly IServiceLifecycleQueryPort _serviceLifecycleQueryPort;
private readonly IServiceCommandPort _serviceCommandPort;
public StudioMemberService(
IStudioMemberCommandPort memberCommandPort,
IStudioMemberQueryPort memberQueryPort,
+ IStudioMemberBindingRunQueryPort bindingRunQueryPort,
IStudioTeamQueryPort teamQueryPort,
- IScopeBindingCommandPort scopeBindingCommandPort,
IServiceLifecycleQueryPort serviceLifecycleQueryPort,
IServiceCommandPort serviceCommandPort)
{
_memberCommandPort = memberCommandPort ?? throw new ArgumentNullException(nameof(memberCommandPort));
_memberQueryPort = memberQueryPort ?? throw new ArgumentNullException(nameof(memberQueryPort));
+ _bindingRunQueryPort = bindingRunQueryPort ?? throw new ArgumentNullException(nameof(bindingRunQueryPort));
_teamQueryPort = teamQueryPort ?? throw new ArgumentNullException(nameof(teamQueryPort));
- _scopeBindingCommandPort = scopeBindingCommandPort
- ?? throw new ArgumentNullException(nameof(scopeBindingCommandPort));
_serviceLifecycleQueryPort = serviceLifecycleQueryPort
?? throw new ArgumentNullException(nameof(serviceLifecycleQueryPort));
_serviceCommandPort = serviceCommandPort
@@ -101,10 +88,10 @@ public async Task GetAsync(
// every member-centric endpoint.
var detail = await _memberQueryPort.GetAsync(scopeId, memberId, ct)
?? throw new StudioMemberNotFoundException(scopeId, memberId);
- return detail;
+ return await HydrateCurrentBindingRunAsync(detail, ct);
}
- public async Task BindAsync(
+ public async Task BindAsync(
string scopeId,
string memberId,
UpdateStudioMemberBindingRequest request,
@@ -112,68 +99,82 @@ public async Task BindAsync(
{
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,
+ var normalizedScopeId = NormalizeRequired(scopeId, nameof(scopeId));
+ var normalizedMemberId = NormalizeRequired(memberId, nameof(memberId));
+ var implementationKindWire = ResolveBindingImplementationKind(normalizedMemberId, request);
+ var bindingRunId = GenerateBindingRunId();
+
+ await _memberCommandPort.StartBindingRunAsync(
+ new StudioMemberBindingRunStartRequest(
+ BindingRunId: bindingRunId,
+ ScopeId: normalizedScopeId,
+ MemberId: normalizedMemberId,
+ ImplementationKind: implementationKindWire,
+ Binding: request),
ct);
- return new StudioMemberBindingResponse(
- MemberId: memberId,
- PublishedServiceId: bindingResult.ServiceId,
- RevisionId: bindingResult.RevisionId,
- ImplementationKind: implementationKindWire,
- ScopeId: scopeId,
- ExpectedActorId: bindingResult.ExpectedActorId);
+ return new StudioMemberBindingAcceptedResponse(
+ Status: StudioMemberBindingRunStatusNames.Accepted,
+ BindingRunId: bindingRunId,
+ ScopeId: normalizedScopeId,
+ MemberId: normalizedMemberId);
}
- public async Task GetBindingAsync(
+ 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;
+
+ var currentBindingRun = await ResolveCurrentBindingRunAsync(detail, ct);
+ return new StudioMemberBindingViewResponse(detail.LastBinding)
+ {
+ CurrentBindingRun = currentBindingRun,
+ };
+ }
+
+ public async Task GetBindingRunAsync(
+ string scopeId,
+ string memberId,
+ string bindingRunId,
+ CancellationToken ct = default)
+ {
+ var normalizedBindingRunId = NormalizeRequired(bindingRunId, nameof(bindingRunId));
+ var run = await _bindingRunQueryPort.GetAsync(scopeId, memberId, normalizedBindingRunId, ct);
+ if (run == null)
+ throw new StudioMemberBindingRunNotFoundException(scopeId, memberId, normalizedBindingRunId);
+
+ return run;
+ }
+
+ private async Task HydrateCurrentBindingRunAsync(
+ StudioMemberDetailResponse detail,
+ CancellationToken ct)
+ {
+ var currentBindingRun = await ResolveCurrentBindingRunAsync(detail, ct);
+ return detail with
+ {
+ CurrentBindingRun = currentBindingRun,
+ };
+ }
+
+ private async Task ResolveCurrentBindingRunAsync(
+ StudioMemberDetailResponse detail,
+ CancellationToken ct)
+ {
+ var memberCurrentRun = detail.CurrentBindingRun;
+ if (memberCurrentRun == null || string.IsNullOrEmpty(memberCurrentRun.BindingRunId))
+ return null;
+
+ var run = await _bindingRunQueryPort.GetAsync(
+ memberCurrentRun.ScopeId,
+ memberCurrentRun.MemberId,
+ memberCurrentRun.BindingRunId,
+ ct);
+
+ return run ?? memberCurrentRun;
}
public async Task GetEndpointContractAsync(
@@ -419,6 +420,59 @@ private static string NormalizeRequired(string? value, string fieldName)
return normalized;
}
+ private static string GenerateBindingRunId() =>
+ $"bind-{Guid.NewGuid():N}";
+
+ private static string ResolveBindingImplementationKind(
+ string memberId,
+ UpdateStudioMemberBindingRequest request)
+ {
+ var count = 0;
+ string? implementationKind = null;
+
+ if (request.Workflow != null)
+ {
+ count++;
+ implementationKind = MemberImplementationKindNames.Workflow;
+ if (request.Workflow.WorkflowYamls.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"member '{memberId}' bind: workflow yamls are required for workflow members.");
+ }
+ }
+
+ if (request.Script != null)
+ {
+ count++;
+ implementationKind = MemberImplementationKindNames.Script;
+ if (string.IsNullOrWhiteSpace(request.Script.ScriptId))
+ {
+ throw new InvalidOperationException(
+ $"member '{memberId}' bind: scriptId is required for script members.");
+ }
+ }
+
+ if (request.GAgent != null)
+ {
+ count++;
+ implementationKind = MemberImplementationKindNames.GAgent;
+ if (string.IsNullOrWhiteSpace(request.GAgent.ActorTypeName))
+ {
+ throw new InvalidOperationException(
+ $"member '{memberId}' bind: actorTypeName is required for gagent members.");
+ }
+ }
+
+ return count switch
+ {
+ 1 => implementationKind!,
+ 0 => throw new InvalidOperationException(
+ $"member '{memberId}' bind: exactly one binding implementation is required."),
+ _ => throw new InvalidOperationException(
+ $"member '{memberId}' bind: only one binding implementation may be supplied."),
+ };
+ }
+
private static InvalidOperationException BuildMemberNotBoundException(string memberId) =>
new($"member '{memberId}' has no published service yet; bind the member before reading or mutating its revisions.");
@@ -569,151 +623,4 @@ private static string BuildFetchExample(string invokePath, bool supportsSse, str
""";
}
- 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/Endpoints/StudioMemberEndpoints.cs b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs
index 74010520a..439555168 100644
--- a/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs
+++ b/src/Aevatar.Studio.Hosting/Endpoints/StudioMemberEndpoints.cs
@@ -47,6 +47,10 @@ public static void Map(IEndpointRouteBuilder app)
.WithTags("StudioMembers");
app.MapGet("/api/scopes/{scopeId}/members/{memberId}/binding", HandleGetBindingAsync)
.WithTags("StudioMembers");
+ app.MapGet(
+ "/api/scopes/{scopeId}/members/{memberId}/binding-runs/{bindingRunId}",
+ HandleGetBindingRunAsync)
+ .WithTags("StudioMembers");
app.MapGet(
"/api/scopes/{scopeId}/members/{memberId}/endpoints/{endpointId}/contract",
HandleGetEndpointContractAsync)
@@ -148,11 +152,10 @@ internal static async Task HandleBindAsync(
try
{
- return Results.Ok(await memberService.BindAsync(scopeId, memberId, request, ct));
- }
- catch (StudioMemberNotFoundException ex)
- {
- return NotFound(ex);
+ var receipt = await memberService.BindAsync(scopeId, memberId, request, ct);
+ return Results.Accepted(
+ $"/api/scopes/{Uri.EscapeDataString(scopeId)}/members/{Uri.EscapeDataString(memberId)}/binding-runs/{Uri.EscapeDataString(receipt.BindingRunId)}",
+ receipt);
}
catch (InvalidOperationException ex)
{
@@ -179,8 +182,7 @@ internal static async Task HandleGetBindingAsync(
// 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));
+ return Results.Ok(await memberService.GetBindingAsync(scopeId, memberId, ct));
}
catch (StudioMemberNotFoundException ex)
{
@@ -192,6 +194,31 @@ internal static async Task HandleGetBindingAsync(
}
}
+ internal static async Task HandleGetBindingRunAsync(
+ HttpContext http,
+ string scopeId,
+ string memberId,
+ string bindingRunId,
+ [FromServices] IStudioMemberService memberService,
+ CancellationToken ct)
+ {
+ if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied))
+ return denied;
+
+ try
+ {
+ return Results.Ok(await memberService.GetBindingRunAsync(scopeId, memberId, bindingRunId, ct));
+ }
+ catch (StudioMemberBindingRunNotFoundException ex)
+ {
+ return BindingRunNotFound(ex);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest("INVALID_STUDIO_MEMBER_REQUEST", ex.Message);
+ }
+ }
+
internal static async Task HandleGetEndpointContractAsync(
HttpContext http,
string scopeId,
@@ -371,4 +398,16 @@ private static IResult NotFound(StudioMemberNotFoundException ex) =>
memberId = ex.MemberId,
},
statusCode: StatusCodes.Status404NotFound);
+
+ private static IResult BindingRunNotFound(StudioMemberBindingRunNotFoundException ex) =>
+ Results.Json(
+ new
+ {
+ code = "STUDIO_MEMBER_BINDING_RUN_NOT_FOUND",
+ message = ex.Message,
+ scopeId = ex.ScopeId,
+ memberId = ex.MemberId,
+ bindingRunId = ex.BindingRunId,
+ },
+ statusCode: StatusCodes.Status404NotFound);
}
diff --git a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs
index d8803fda8..1d9ea2bb9 100644
--- a/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs
+++ b/src/Aevatar.Studio.Hosting/StudioProjectionReadModelServiceCollectionExtensions.cs
@@ -69,6 +69,7 @@ public static IServiceCollection AddStudioProjectionReadModelProviders(
RegisterElasticsearch(services, configuration);
RegisterElasticsearch(services, configuration);
RegisterElasticsearch(services, configuration);
+ RegisterElasticsearch(services, configuration);
RegisterElasticsearch(services, configuration);
}
else
@@ -82,6 +83,7 @@ public static IServiceCollection AddStudioProjectionReadModelProviders(
RegisterInMemory(services);
RegisterInMemory(services);
RegisterInMemory(services);
+ RegisterInMemory(services);
RegisterInMemory(services);
}
@@ -132,6 +134,7 @@ private static bool HasAllStudioDocumentReaders(
&& HasDocumentReaderForProvider(services, providerKind)
&& HasDocumentReaderForProvider(services, providerKind)
&& HasDocumentReaderForProvider(services, providerKind)
+ && HasDocumentReaderForProvider(services, providerKind)
&& HasDocumentReaderForProvider(services, providerKind);
}
@@ -216,6 +219,7 @@ private static TypeRegistry BuildStudioStateTypeRegistry()
ChatHistoryIndexState.Descriptor,
ChatConversationState.Descriptor,
StudioMemberState.Descriptor,
+ StudioMemberBindingRunState.Descriptor,
StudioTeamState.Descriptor);
}
diff --git a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs
index 2aa8d665e..045bd7ada 100644
--- a/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs
+++ b/src/Aevatar.Studio.Projection/CommandServices/ActorDispatchStudioMemberCommandService.cs
@@ -6,6 +6,7 @@
using Aevatar.Studio.Projection.Mapping;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
+using System.Security.Cryptography;
namespace Aevatar.Studio.Projection.CommandServices;
@@ -19,6 +20,7 @@ namespace Aevatar.Studio.Projection.CommandServices;
internal sealed class ActorDispatchStudioMemberCommandService : IStudioMemberCommandPort
{
private const string DirectRoute = "aevatar.studio.projection.studio-member";
+ private const string BindingRunDirectRoute = "aevatar.studio.projection.studio-member-binding-run";
private readonly IStudioActorBootstrap _bootstrap;
private readonly IActorDispatchPort _dispatchPort;
@@ -224,38 +226,41 @@ public async Task UpdateImplementationAsync(
await DispatchAsync(normalizedScopeId, normalizedMemberId, evt, ct);
}
- public async Task RecordBindingAsync(
- string scopeId,
- string memberId,
- string publishedServiceId,
- string revisionId,
- string implementationKindName,
+ public async Task StartBindingRunAsync(
+ StudioMemberBindingRunStartRequest request,
CancellationToken ct = default)
{
- var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId);
- var normalizedMemberId = StudioMemberConventions.NormalizeMemberId(memberId);
+ ArgumentNullException.ThrowIfNull(request);
- if (string.IsNullOrWhiteSpace(publishedServiceId))
- {
- throw new InvalidOperationException(
- $"member '{normalizedMemberId}' bind: publishedServiceId is required to record binding.");
- }
+ var normalizedBindingRunId = StudioMemberConventions.NormalizeBindingRunId(request.BindingRunId);
+ var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(request.ScopeId);
+ var normalizedMemberId = StudioMemberConventions.NormalizeMemberId(request.MemberId);
+ var actorId = StudioMemberConventions.BuildBindingRunActorId(normalizedBindingRunId);
+ var actor = await _bootstrap.EnsureAsync(actorId, ct);
+ await _bootstrap.EnsureAsync(
+ StudioMemberConventions.BuildActorId(normalizedScopeId, normalizedMemberId),
+ ct);
- if (string.IsNullOrWhiteSpace(revisionId))
+ var payload = new StudioMemberBindingRunRequested
{
- throw new InvalidOperationException(
- $"member '{normalizedMemberId}' bind: revisionId is required to record binding.");
- }
+ Request = BuildBindingRequest(
+ normalizedBindingRunId,
+ normalizedScopeId,
+ normalizedMemberId,
+ request.ImplementationKind,
+ request.Binding),
+ RequestedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ };
- var evt = new StudioMemberBoundEvent
+ var envelope = new EventEnvelope
{
- PublishedServiceId = publishedServiceId,
- RevisionId = revisionId,
- ImplementationKind = MemberImplementationKindMapper.Parse(implementationKindName),
- BoundAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ Id = Guid.NewGuid().ToString("N"),
+ Timestamp = Timestamp.FromDateTime(DateTime.UtcNow),
+ Payload = Any.Pack(payload),
+ Route = EnvelopeRouteSemantics.CreateDirect(BindingRunDirectRoute, actor.Id),
};
- await DispatchAsync(normalizedScopeId, normalizedMemberId, evt, ct);
+ await _dispatchPort.DispatchAsync(actor.Id, envelope, ct);
}
private static StudioMemberImplementationRef BuildImplementationRefMessage(
@@ -292,6 +297,79 @@ private static StudioMemberImplementationRef BuildImplementationRefMessage(
return message;
}
+ private static StudioMemberBindingRequest BuildBindingRequest(
+ string bindingRunId,
+ string scopeId,
+ string memberId,
+ string implementationKindName,
+ UpdateStudioMemberBindingRequest binding)
+ {
+ var request = new StudioMemberBindingRequest
+ {
+ BindingRunId = bindingRunId,
+ ScopeId = scopeId,
+ MemberId = memberId,
+ };
+ if (!string.IsNullOrWhiteSpace(binding.RevisionId))
+ request.RevisionId = binding.RevisionId;
+
+ switch (implementationKindName)
+ {
+ case MemberImplementationKindNames.Workflow:
+ request.Workflow = new StudioMemberWorkflowBindingRequest();
+ request.Workflow.WorkflowYamls.Add(binding.Workflow?.WorkflowYamls ?? []);
+ break;
+ case MemberImplementationKindNames.Script:
+ request.Script = new StudioMemberScriptBindingRequest
+ {
+ ScriptId = binding.Script?.ScriptId ?? string.Empty,
+ };
+ if (!string.IsNullOrWhiteSpace(binding.Script?.ScriptRevision))
+ request.Script.ScriptRevision = binding.Script.ScriptRevision;
+ break;
+ case MemberImplementationKindNames.GAgent:
+ request.Gagent = new StudioMemberGAgentBindingRequest
+ {
+ ActorTypeName = binding.GAgent?.ActorTypeName ?? string.Empty,
+ };
+ foreach (var endpoint in binding.GAgent?.Endpoints ?? [])
+ {
+ request.Gagent.Endpoints.Add(new StudioMemberGAgentEndpointBindingRequest
+ {
+ EndpointId = endpoint.EndpointId,
+ DisplayName = endpoint.DisplayName,
+ Kind = ParseGAgentEndpointKind(endpoint.Kind),
+ RequestTypeUrl = endpoint.RequestTypeUrl,
+ ResponseTypeUrl = endpoint.ResponseTypeUrl,
+ Description = endpoint.Description ?? string.Empty,
+ });
+ }
+ break;
+ default:
+ throw new InvalidOperationException(
+ $"Unknown implementationKind '{implementationKindName}'.");
+ }
+
+ request.RequestHash = ComputeRequestHash(request);
+ return request;
+ }
+
+ private static StudioMemberGAgentEndpointKind ParseGAgentEndpointKind(string? rawValue) =>
+ rawValue?.Trim().ToLowerInvariant() switch
+ {
+ null or "" => throw new InvalidOperationException("gagent endpoint kind is required."),
+ "command" => StudioMemberGAgentEndpointKind.Command,
+ "chat" => StudioMemberGAgentEndpointKind.Chat,
+ _ => throw new InvalidOperationException($"Unsupported gagent endpoint kind '{rawValue}'."),
+ };
+
+ private static string ComputeRequestHash(StudioMemberBindingRequest request)
+ {
+ var normalized = request.Clone();
+ normalized.RequestHash = string.Empty;
+ return Convert.ToHexString(SHA256.HashData(normalized.ToByteArray())).ToLowerInvariant();
+ }
+
private async Task DispatchAsync(string scopeId, string memberId, IMessage payload, CancellationToken ct)
{
var actorId = StudioMemberConventions.BuildActorId(scopeId, memberId);
diff --git a/src/Aevatar.Studio.Projection/CommandServices/ScopeBindingStudioMemberPlatformBindingCommandService.cs b/src/Aevatar.Studio.Projection/CommandServices/ScopeBindingStudioMemberPlatformBindingCommandService.cs
new file mode 100644
index 000000000..30a924d40
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/CommandServices/ScopeBindingStudioMemberPlatformBindingCommandService.cs
@@ -0,0 +1,328 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.GAgentService.Abstractions;
+using Aevatar.GAgentService.Abstractions.Ports;
+using Aevatar.GAgents.StudioMember;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Microsoft.Extensions.Logging;
+
+namespace Aevatar.Studio.Projection.CommandServices;
+
+internal sealed class ScopeBindingStudioMemberPlatformBindingCommandService : IStudioMemberPlatformBindingCommandPort
+{
+ private const string BindingRunDirectRoute = "aevatar.studio.projection.studio-member-binding-run";
+
+ private readonly IScopeBindingCommandPort _scopeBindingCommandPort;
+ private readonly IActorDispatchPort _dispatchPort;
+ private readonly ILogger _logger;
+
+ public ScopeBindingStudioMemberPlatformBindingCommandService(
+ IScopeBindingCommandPort scopeBindingCommandPort,
+ IActorDispatchPort dispatchPort,
+ ILogger logger)
+ {
+ _scopeBindingCommandPort = scopeBindingCommandPort ?? throw new ArgumentNullException(nameof(scopeBindingCommandPort));
+ _dispatchPort = dispatchPort ?? throw new ArgumentNullException(nameof(dispatchPort));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public Task StartAsync(
+ string replyActorId,
+ StudioMemberPlatformBindingStartRequested request,
+ CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(replyActorId);
+ ArgumentNullException.ThrowIfNull(request);
+
+ var commandId = string.IsNullOrWhiteSpace(request.PlatformBindingCommandId)
+ ? StudioMemberConventions.BuildPlatformBindingCommandId(request.BindingRunId, 1)
+ : request.PlatformBindingCommandId;
+
+ return Task.FromResult(new StudioMemberPlatformBindingAccepted
+ {
+ BindingRunId = request.BindingRunId,
+ PlatformBindingCommandId = commandId,
+ AcceptedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ });
+ }
+
+ public Task ExecuteAsync(
+ string replyActorId,
+ string platformBindingCommandId,
+ StudioMemberPlatformBindingStartRequested request)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(replyActorId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(platformBindingCommandId);
+ ArgumentNullException.ThrowIfNull(request);
+
+ var executionRequest = request.Clone();
+ executionRequest.PlatformBindingCommandId = platformBindingCommandId;
+ _ = Task.Run(
+ () => RunBindingFireAndForgetAsync(replyActorId, platformBindingCommandId, executionRequest),
+ CancellationToken.None);
+ return Task.CompletedTask;
+ }
+
+ private async Task RunBindingFireAndForgetAsync(
+ string replyActorId,
+ string commandId,
+ StudioMemberPlatformBindingStartRequested request)
+ {
+ try
+ {
+ await RunBindingAsync(replyActorId, commandId, request, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "StudioMember platform binding detached execution failed unexpectedly. bindingRunId={BindingRunId} platformBindingCommandId={CommandId}",
+ request.BindingRunId,
+ commandId);
+ }
+ }
+
+ private async Task RunBindingAsync(
+ string replyActorId,
+ string commandId,
+ StudioMemberPlatformBindingStartRequested request,
+ CancellationToken ct)
+ {
+ ScopeBindingUpsertResult result;
+ try
+ {
+ result = await _scopeBindingCommandPort
+ .UpsertAsync(BuildScopeBindingRequest(request), ct)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "StudioMember platform binding failed. bindingRunId={BindingRunId} platformBindingCommandId={CommandId}",
+ request.BindingRunId,
+ commandId);
+
+ try
+ {
+ await DispatchAsync(
+ replyActorId,
+ new StudioMemberPlatformBindingFailed
+ {
+ BindingRunId = request.BindingRunId,
+ PlatformBindingCommandId = commandId,
+ Failure = new StudioMemberBindingFailure
+ {
+ Code = "STUDIO_MEMBER_PLATFORM_BINDING_FAILED",
+ Message = ex.Message,
+ FailedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ },
+ },
+ ct).ConfigureAwait(false);
+ }
+ catch (Exception dispatchEx)
+ {
+ _logger.LogError(
+ dispatchEx,
+ "StudioMember platform binding failure continuation dispatch failed. bindingRunId={BindingRunId} platformBindingCommandId={CommandId}",
+ request.BindingRunId,
+ commandId);
+ }
+
+ return;
+ }
+
+ try
+ {
+ await DispatchAsync(
+ replyActorId,
+ new StudioMemberPlatformBindingSucceeded
+ {
+ BindingRunId = request.BindingRunId,
+ PlatformBindingCommandId = commandId,
+ CompletedAtUtc = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
+ Result = new StudioMemberPlatformBindingResult
+ {
+ PublishedServiceId = result.ServiceId,
+ RevisionId = result.RevisionId,
+ ImplementationKind = ToStudioKind(result.ImplementationKind),
+ ExpectedActorId = result.ExpectedActorId,
+ ImplementationRef = BuildImplementationRef(result),
+ },
+ },
+ ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "StudioMember platform binding succeeded but success continuation dispatch failed. bindingRunId={BindingRunId} platformBindingCommandId={CommandId}",
+ request.BindingRunId,
+ commandId);
+ }
+ }
+
+ private Task DispatchAsync(string actorId, IMessage payload, CancellationToken ct)
+ {
+ var envelope = new EventEnvelope
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Timestamp = Timestamp.FromDateTime(DateTime.UtcNow),
+ Payload = Any.Pack(payload),
+ Route = EnvelopeRouteSemantics.CreateDirect(BindingRunDirectRoute, actorId),
+ };
+
+ return _dispatchPort.DispatchAsync(actorId, envelope, ct);
+ }
+
+ private static ScopeBindingUpsertRequest BuildScopeBindingRequest(
+ StudioMemberPlatformBindingStartRequested request)
+ {
+ var bindingRequest = request.Request;
+ var revisionId = ResolveRevisionId(request);
+ var replayRevisionId = ResolveReplayRevisionId(request, revisionId);
+ return bindingRequest.ImplementationCase switch
+ {
+ StudioMemberBindingRequest.ImplementationOneofCase.Workflow => new ScopeBindingUpsertRequest(
+ ScopeId: bindingRequest.ScopeId,
+ ImplementationKind: ScopeBindingImplementationKind.Workflow,
+ Workflow: new ScopeBindingWorkflowSpec(bindingRequest.Workflow.WorkflowYamls.ToArray()),
+ DisplayName: request.Admitted.DisplayName,
+ RevisionId: revisionId,
+ ServiceId: request.Admitted.PublishedServiceId,
+ AllowExistingRevisionReplay: replayRevisionId != null,
+ ReplayRevisionId: replayRevisionId),
+ StudioMemberBindingRequest.ImplementationOneofCase.Script => new ScopeBindingUpsertRequest(
+ ScopeId: bindingRequest.ScopeId,
+ ImplementationKind: ScopeBindingImplementationKind.Scripting,
+ Script: new ScopeBindingScriptSpec(
+ bindingRequest.Script.ScriptId,
+ bindingRequest.Script.HasScriptRevision ? bindingRequest.Script.ScriptRevision : null),
+ DisplayName: request.Admitted.DisplayName,
+ RevisionId: revisionId,
+ ServiceId: request.Admitted.PublishedServiceId,
+ AllowExistingRevisionReplay: true,
+ ReplayRevisionId: replayRevisionId),
+ StudioMemberBindingRequest.ImplementationOneofCase.Gagent => new ScopeBindingUpsertRequest(
+ ScopeId: bindingRequest.ScopeId,
+ ImplementationKind: ScopeBindingImplementationKind.GAgent,
+ GAgent: new ScopeBindingGAgentSpec(
+ bindingRequest.Gagent.ActorTypeName,
+ bindingRequest.Gagent.Endpoints.Select(ToScopeBindingEndpoint).ToArray()),
+ DisplayName: request.Admitted.DisplayName,
+ RevisionId: revisionId,
+ ServiceId: request.Admitted.PublishedServiceId,
+ AllowExistingRevisionReplay: replayRevisionId != null,
+ ReplayRevisionId: replayRevisionId),
+ _ => throw new InvalidOperationException("binding request must carry exactly one implementation payload."),
+ };
+ }
+
+ private static string ResolveRevisionId(StudioMemberPlatformBindingStartRequested request)
+ {
+ var explicitRevisionId = request.Request.HasRevisionId
+ ? request.Request.RevisionId?.Trim()
+ : null;
+ if (!string.IsNullOrWhiteSpace(explicitRevisionId))
+ return explicitRevisionId;
+
+ var source = !string.IsNullOrWhiteSpace(request.PlatformBindingCommandId)
+ ? request.PlatformBindingCommandId
+ : request.BindingRunId;
+ return $"rev-{BuildStableRevisionComponent(source)}";
+ }
+
+ private static string? ResolveReplayRevisionId(
+ StudioMemberPlatformBindingStartRequested request,
+ string revisionId)
+ {
+ var explicitRevisionId = request.Request.HasRevisionId
+ ? request.Request.RevisionId?.Trim()
+ : null;
+ if (!string.IsNullOrWhiteSpace(explicitRevisionId))
+ return null;
+
+ var expectedRevisionId = $"rev-{BuildStableRevisionComponent(request.PlatformBindingCommandId)}";
+ return string.Equals(revisionId, expectedRevisionId, StringComparison.Ordinal)
+ ? revisionId
+ : null;
+ }
+
+ private static string BuildStableRevisionComponent(string value)
+ {
+ var component = new System.Text.StringBuilder(value.Length);
+ foreach (var ch in value)
+ {
+ if (char.IsLetterOrDigit(ch))
+ {
+ component.Append(char.ToLowerInvariant(ch));
+ continue;
+ }
+
+ if (component.Length > 0 && component[^1] != '-')
+ component.Append('-');
+ }
+
+ while (component.Length > 0 && component[^1] == '-')
+ component.Length--;
+
+ return component.Length == 0 ? "binding" : component.ToString();
+ }
+
+ private static ScopeBindingGAgentEndpoint ToScopeBindingEndpoint(
+ StudioMemberGAgentEndpointBindingRequest endpoint) =>
+ new(
+ endpoint.EndpointId,
+ endpoint.DisplayName,
+ ToEndpointKind(endpoint.Kind),
+ endpoint.RequestTypeUrl,
+ endpoint.ResponseTypeUrl,
+ endpoint.Description);
+
+ private static ServiceEndpointKind ToEndpointKind(StudioMemberGAgentEndpointKind kind) =>
+ kind switch
+ {
+ StudioMemberGAgentEndpointKind.Command => ServiceEndpointKind.Command,
+ StudioMemberGAgentEndpointKind.Chat => ServiceEndpointKind.Chat,
+ _ => throw new InvalidOperationException($"Unsupported gagent endpoint kind '{kind}'."),
+ };
+
+ private static StudioMemberImplementationKind ToStudioKind(ScopeBindingImplementationKind kind) =>
+ kind switch
+ {
+ ScopeBindingImplementationKind.Workflow => StudioMemberImplementationKind.Workflow,
+ ScopeBindingImplementationKind.Scripting => StudioMemberImplementationKind.Script,
+ ScopeBindingImplementationKind.GAgent => StudioMemberImplementationKind.Gagent,
+ _ => StudioMemberImplementationKind.Unspecified,
+ };
+
+ private static StudioMemberImplementationRef BuildImplementationRef(ScopeBindingUpsertResult result) =>
+ result.ImplementationKind switch
+ {
+ ScopeBindingImplementationKind.Workflow => new StudioMemberImplementationRef
+ {
+ Workflow = new StudioMemberWorkflowRef
+ {
+ WorkflowId = result.Workflow?.WorkflowName ?? result.WorkflowName,
+ WorkflowRevision = result.RevisionId,
+ },
+ },
+ ScopeBindingImplementationKind.Scripting => new StudioMemberImplementationRef
+ {
+ Script = new StudioMemberScriptRef
+ {
+ ScriptId = result.Script?.ScriptId ?? string.Empty,
+ ScriptRevision = result.Script?.ScriptRevision ?? result.RevisionId,
+ },
+ },
+ ScopeBindingImplementationKind.GAgent => new StudioMemberImplementationRef
+ {
+ Gagent = new StudioMemberGAgentRef
+ {
+ ActorTypeName = result.GAgent?.ActorTypeName ?? string.Empty,
+ },
+ },
+ _ => new StudioMemberImplementationRef(),
+ };
+}
diff --git a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs
index cd765379d..22e11777f 100644
--- a/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/src/Aevatar.Studio.Projection/DependencyInjection/ServiceCollectionExtensions.cs
@@ -10,6 +10,7 @@
using Aevatar.Studio.Projection.Projectors;
using Aevatar.Studio.Projection.QueryPorts;
using Aevatar.Studio.Projection.ReadModels;
+using Aevatar.GAgents.StudioMember;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
@@ -82,6 +83,10 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl
StudioMaterializationContext,
StudioMemberCurrentStateProjector>();
+ services.AddCurrentStateProjectionMaterializer<
+ StudioMaterializationContext,
+ StudioMemberBindingRunCurrentStateProjector>();
+
services.AddCurrentStateProjectionMaterializer<
StudioMaterializationContext,
StudioTeamCurrentStateProjector>();
@@ -124,6 +129,10 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl
IProjectionDocumentMetadataProvider,
StudioMemberCurrentStateDocumentMetadataProvider>();
+ services.TryAddSingleton<
+ IProjectionDocumentMetadataProvider,
+ StudioMemberBindingRunCurrentStateDocumentMetadataProvider>();
+
services.TryAddSingleton<
IProjectionDocumentMetadataProvider,
StudioTeamCurrentStateDocumentMetadataProvider>();
@@ -141,11 +150,13 @@ public static IServiceCollection AddStudioProjectionComponents(this IServiceColl
// Query ports (read side)
services.TryAddSingleton();
services.TryAddSingleton();
+ services.TryAddSingleton();
services.TryAddSingleton();
// Command services (write side)
services.TryAddSingleton();
services.TryAddSingleton();
+ services.TryAddSingleton();
services.TryAddSingleton();
return services;
diff --git a/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs b/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs
index 1e680e5a7..7485a02b9 100644
--- a/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs
+++ b/src/Aevatar.Studio.Projection/Mapping/MemberImplementationKindMapper.cs
@@ -45,4 +45,17 @@ public static StudioMemberImplementationKind Parse(string? value)
StudioMemberLifecycleStage.BindReady => MemberLifecycleStageNames.BindReady,
_ => string.Empty,
};
+
+ public static string ToWireName(StudioMemberBindingRunStatus status) => status switch
+ {
+ StudioMemberBindingRunStatus.Accepted => StudioMemberBindingRunStatusNames.Accepted,
+ StudioMemberBindingRunStatus.AdmissionPending => StudioMemberBindingRunStatusNames.AdmissionPending,
+ StudioMemberBindingRunStatus.Admitted => StudioMemberBindingRunStatusNames.Admitted,
+ StudioMemberBindingRunStatus.PlatformBindingPending => StudioMemberBindingRunStatusNames.PlatformBindingPending,
+ StudioMemberBindingRunStatus.MemberNotificationPending => StudioMemberBindingRunStatusNames.MemberNotificationPending,
+ StudioMemberBindingRunStatus.Succeeded => StudioMemberBindingRunStatusNames.Succeeded,
+ StudioMemberBindingRunStatus.Failed => StudioMemberBindingRunStatusNames.Failed,
+ StudioMemberBindingRunStatus.Rejected => StudioMemberBindingRunStatusNames.Rejected,
+ _ => string.Empty,
+ };
}
diff --git a/src/Aevatar.Studio.Projection/Metadata/StudioMemberBindingRunCurrentStateDocumentMetadataProvider.cs b/src/Aevatar.Studio.Projection/Metadata/StudioMemberBindingRunCurrentStateDocumentMetadataProvider.cs
new file mode 100644
index 000000000..431993d6a
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/Metadata/StudioMemberBindingRunCurrentStateDocumentMetadataProvider.cs
@@ -0,0 +1,17 @@
+using Aevatar.CQRS.Projection.Stores.Abstractions;
+using Aevatar.Studio.Projection.ReadModels;
+
+namespace Aevatar.Studio.Projection.Metadata;
+
+public sealed class StudioMemberBindingRunCurrentStateDocumentMetadataProvider
+ : IProjectionDocumentMetadataProvider
+{
+ public DocumentIndexMetadata Metadata { get; } = new(
+ IndexName: "studio-member-binding-runs",
+ 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/StudioMemberBindingRunCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/StudioMemberBindingRunCurrentStateProjector.cs
new file mode 100644
index 000000000..94f2f0df9
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/Projectors/StudioMemberBindingRunCurrentStateProjector.cs
@@ -0,0 +1,97 @@
+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 StudioMemberBindingRunGAgent committed state into the run-owned
+/// status read model consumed by the binding-run status API.
+///
+public sealed class StudioMemberBindingRunCurrentStateProjector
+ : ICurrentStateProjectionMaterializer
+{
+ private readonly IProjectionWriteDispatcher _writeDispatcher;
+ private readonly IProjectionClock _clock;
+
+ public StudioMemberBindingRunCurrentStateProjector(
+ 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 StudioMemberBindingRunCurrentStateDocument
+ {
+ Id = context.RootActorId,
+ ActorId = context.RootActorId,
+ StateVersion = stateEvent.Version,
+ LastEventId = stateEvent.EventId ?? string.Empty,
+ UpdatedAt = Timestamp.FromDateTimeOffset(updatedAt),
+ BindingRunId = state.BindingRunId,
+ ScopeId = state.ScopeId,
+ MemberId = state.MemberId,
+ RequestHash = state.RequestHash,
+ Status = MemberImplementationKindMapper.ToWireName(state.Status),
+ PlatformBindingCommandId = state.PlatformBindingCommandId,
+ AttemptCount = state.AttemptCount,
+ };
+
+ ApplyFailure(document, state.Failure);
+ ApplyPlatformResult(document, state.PlatformResult);
+
+ await _writeDispatcher.UpsertAsync(document, ct);
+ }
+
+ private static void ApplyFailure(
+ StudioMemberBindingRunCurrentStateDocument document,
+ StudioMemberBindingFailure? failure)
+ {
+ if (failure == null)
+ return;
+
+ document.FailureCode = failure.Code ?? string.Empty;
+ document.FailureMessage = failure.Message ?? string.Empty;
+ document.FailureAt = failure.FailedAtUtc;
+ }
+
+ private static void ApplyPlatformResult(
+ StudioMemberBindingRunCurrentStateDocument document,
+ StudioMemberPlatformBindingResult? result)
+ {
+ if (result == null)
+ return;
+
+ document.ResultPublishedServiceId = result.PublishedServiceId ?? string.Empty;
+ document.ResultRevisionId = result.RevisionId ?? string.Empty;
+ document.ResultImplementationKind = MemberImplementationKindMapper.ToWireName(result.ImplementationKind);
+ document.ResultExpectedActorId = result.ExpectedActorId ?? string.Empty;
+ }
+}
diff --git a/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs b/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs
index 3ad3fabe9..9eb15bad5 100644
--- a/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs
+++ b/src/Aevatar.Studio.Projection/Projectors/StudioMemberCurrentStateProjector.cs
@@ -71,6 +71,7 @@ public async ValueTask ProjectAsync(
ApplyImplementationRef(document, state.ImplementationRef);
ApplyLastBinding(document, state.LastBinding);
+ ApplyBindingStatus(document, state.Binding);
// Team membership (ADR-0017). Mirror the actor's optional team_id
// into the document — absence means "unassigned" on both the actor
@@ -122,4 +123,24 @@ private static void ApplyLastBinding(
if (lastBinding.BoundAtUtc != null)
document.LastBoundAt = lastBinding.BoundAtUtc;
}
+
+ private static void ApplyBindingStatus(
+ StudioMemberCurrentStateDocument document,
+ StudioMemberBindingAuthorityState? binding)
+ {
+ if (binding == null)
+ return;
+
+ document.BindingCurrentRunId = binding.CurrentBindingRunId ?? string.Empty;
+ document.BindingCurrentStatus = MemberImplementationKindMapper.ToWireName(binding.CurrentStatus);
+ document.BindingLastTerminalRunId = binding.LastTerminalBindingRunId ?? string.Empty;
+ document.BindingUpdatedAt = binding.UpdatedAtUtc;
+
+ if (binding.LastFailure != null)
+ {
+ document.BindingFailureCode = binding.LastFailure.Code ?? string.Empty;
+ document.BindingFailureMessage = binding.LastFailure.Message ?? string.Empty;
+ document.BindingFailureAt = binding.LastFailure.FailedAtUtc;
+ }
+ }
}
diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberBindingRunQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberBindingRunQueryPort.cs
new file mode 100644
index 000000000..ddb9f1b6d
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberBindingRunQueryPort.cs
@@ -0,0 +1,79 @@
+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 binding-run status from the run actor's current-state read model.
+///
+public sealed class ProjectionStudioMemberBindingRunQueryPort : IStudioMemberBindingRunQueryPort
+{
+ private readonly IProjectionDocumentReader _documentReader;
+
+ public ProjectionStudioMemberBindingRunQueryPort(
+ IProjectionDocumentReader documentReader)
+ {
+ _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader));
+ }
+
+ public async Task GetAsync(
+ string scopeId,
+ string memberId,
+ string bindingRunId,
+ CancellationToken ct = default)
+ {
+ var normalizedScopeId = StudioMemberConventions.NormalizeScopeId(scopeId);
+ var normalizedMemberId = StudioMemberConventions.NormalizeMemberId(memberId);
+ var normalizedBindingRunId = StudioMemberConventions.NormalizeBindingRunId(bindingRunId);
+ var actorId = StudioMemberConventions.BuildBindingRunActorId(normalizedBindingRunId);
+
+ var document = await _documentReader.GetAsync(actorId, ct);
+ if (document == null)
+ return null;
+
+ if (!string.Equals(document.ScopeId, normalizedScopeId, StringComparison.Ordinal)
+ || !string.Equals(document.MemberId, normalizedMemberId, StringComparison.Ordinal)
+ || !string.Equals(document.BindingRunId, normalizedBindingRunId, StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ StudioMemberBindingFailureResponse? failure = null;
+ if (!string.IsNullOrEmpty(document.FailureCode))
+ {
+ failure = new StudioMemberBindingFailureResponse(
+ Code: document.FailureCode,
+ Message: document.FailureMessage,
+ FailedAt: document.FailureAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue);
+ }
+
+ return new StudioMemberBindingRunStatusResponse(
+ BindingRunId: document.BindingRunId,
+ ScopeId: document.ScopeId,
+ MemberId: document.MemberId,
+ Status: NormalizeBindingRunStatusWire(document.Status),
+ Failure: failure,
+ UpdatedAt: document.UpdatedAt?.ToDateTimeOffset())
+ {
+ PlatformBindingCommandId = string.IsNullOrEmpty(document.PlatformBindingCommandId)
+ ? null
+ : document.PlatformBindingCommandId,
+ };
+ }
+
+ private static string NormalizeBindingRunStatusWire(string? wire) => wire switch
+ {
+ StudioMemberBindingRunStatusNames.Accepted => StudioMemberBindingRunStatusNames.Accepted,
+ StudioMemberBindingRunStatusNames.AdmissionPending => StudioMemberBindingRunStatusNames.AdmissionPending,
+ StudioMemberBindingRunStatusNames.Admitted => StudioMemberBindingRunStatusNames.Admitted,
+ StudioMemberBindingRunStatusNames.PlatformBindingPending => StudioMemberBindingRunStatusNames.PlatformBindingPending,
+ StudioMemberBindingRunStatusNames.MemberNotificationPending => StudioMemberBindingRunStatusNames.MemberNotificationPending,
+ StudioMemberBindingRunStatusNames.Succeeded => StudioMemberBindingRunStatusNames.Succeeded,
+ StudioMemberBindingRunStatusNames.Failed => StudioMemberBindingRunStatusNames.Failed,
+ StudioMemberBindingRunStatusNames.Rejected => StudioMemberBindingRunStatusNames.Rejected,
+ _ => StudioMemberBindingRunStatusNames.Unknown,
+ };
+}
diff --git a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs
index 72abf081b..02646aa5d 100644
--- a/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs
+++ b/src/Aevatar.Studio.Projection/QueryPorts/ProjectionStudioMemberQueryPort.cs
@@ -114,7 +114,10 @@ private static StudioMemberDetailResponse ToDetail(StudioMemberCurrentStateDocum
var summary = ToSummary(document);
var implementationRef = ToImplementationRefResponse(document, summary.ImplementationKind);
var lastBinding = ToLastBindingResponse(document);
- return new StudioMemberDetailResponse(summary, implementationRef, lastBinding);
+ return new StudioMemberDetailResponse(summary, implementationRef, lastBinding)
+ {
+ CurrentBindingRun = ToBindingRunStatusResponse(document),
+ };
}
private static StudioMemberImplementationRefResponse? ToImplementationRefResponse(
@@ -164,6 +167,30 @@ private static StudioMemberDetailResponse ToDetail(StudioMemberCurrentStateDocum
BoundAt: document.LastBoundAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue);
}
+ private static StudioMemberBindingRunStatusResponse? ToBindingRunStatusResponse(
+ StudioMemberCurrentStateDocument document)
+ {
+ if (string.IsNullOrEmpty(document.BindingCurrentRunId))
+ return null;
+
+ StudioMemberBindingFailureResponse? failure = null;
+ if (!string.IsNullOrEmpty(document.BindingFailureCode))
+ {
+ failure = new StudioMemberBindingFailureResponse(
+ Code: document.BindingFailureCode,
+ Message: document.BindingFailureMessage,
+ FailedAt: document.BindingFailureAt?.ToDateTimeOffset() ?? DateTimeOffset.MinValue);
+ }
+
+ return new StudioMemberBindingRunStatusResponse(
+ BindingRunId: document.BindingCurrentRunId,
+ ScopeId: document.ScopeId,
+ MemberId: document.MemberId,
+ Status: NormalizeBindingRunStatusWire(document.BindingCurrentStatus),
+ Failure: failure,
+ UpdatedAt: document.BindingUpdatedAt?.ToDateTimeOffset());
+ }
+
private static string NormalizeImplementationKindWire(string? wire) => wire switch
{
MemberImplementationKindNames.Workflow => MemberImplementationKindNames.Workflow,
@@ -179,4 +206,17 @@ private static StudioMemberDetailResponse ToDetail(StudioMemberCurrentStateDocum
MemberLifecycleStageNames.BindReady => MemberLifecycleStageNames.BindReady,
_ => string.Empty,
};
+
+ private static string NormalizeBindingRunStatusWire(string? wire) => wire switch
+ {
+ StudioMemberBindingRunStatusNames.Accepted => StudioMemberBindingRunStatusNames.Accepted,
+ StudioMemberBindingRunStatusNames.AdmissionPending => StudioMemberBindingRunStatusNames.AdmissionPending,
+ StudioMemberBindingRunStatusNames.Admitted => StudioMemberBindingRunStatusNames.Admitted,
+ StudioMemberBindingRunStatusNames.PlatformBindingPending => StudioMemberBindingRunStatusNames.PlatformBindingPending,
+ StudioMemberBindingRunStatusNames.MemberNotificationPending => StudioMemberBindingRunStatusNames.MemberNotificationPending,
+ StudioMemberBindingRunStatusNames.Succeeded => StudioMemberBindingRunStatusNames.Succeeded,
+ StudioMemberBindingRunStatusNames.Failed => StudioMemberBindingRunStatusNames.Failed,
+ StudioMemberBindingRunStatusNames.Rejected => StudioMemberBindingRunStatusNames.Rejected,
+ _ => string.Empty,
+ };
}
diff --git a/src/Aevatar.Studio.Projection/ReadModels/StudioMemberBindingRunCurrentStateDocument.Partial.cs b/src/Aevatar.Studio.Projection/ReadModels/StudioMemberBindingRunCurrentStateDocument.Partial.cs
new file mode 100644
index 000000000..703896216
--- /dev/null
+++ b/src/Aevatar.Studio.Projection/ReadModels/StudioMemberBindingRunCurrentStateDocument.Partial.cs
@@ -0,0 +1,18 @@
+using Aevatar.CQRS.Projection.Stores.Abstractions;
+
+namespace Aevatar.Studio.Projection.ReadModels;
+
+public sealed partial class StudioMemberBindingRunCurrentStateDocument
+ : 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 4db756682..2f923adee 100644
--- a/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto
+++ b/src/Aevatar.Studio.Projection/ReadModels/studio_projection_readmodels.proto
@@ -152,6 +152,48 @@ message StudioMemberCurrentStateDocument {
// Optional. Absence = unassigned; empty string is not a valid serialized
// value (the application layer rejects empty `teamId` on the wire).
optional string team_id = 50;
+
+ // ── Async binding run status (projected from StudioMemberState.binding) ──
+ string binding_current_run_id = 60;
+ string binding_current_status = 61;
+ string binding_last_terminal_run_id = 62;
+ string binding_failure_code = 63;
+ string binding_failure_message = 64;
+ google.protobuf.Timestamp binding_failure_at = 65;
+ google.protobuf.Timestamp binding_updated_at = 66;
+}
+
+// ─── StudioMemberBindingRun Current State ReadModel ───
+//
+// Materialized from StudioMemberBindingRunGAgent committed events. Document id
+// is the run actor id (studio-member-binding-run:{bindingRunId}).
+// This read model is the status endpoint's authority-shaped query surface:
+// member documents may reference the active run, but they do not own the run's
+// accepted/platform-pending/succeeded/failed execution state.
+
+message StudioMemberBindingRunCurrentStateDocument {
+ string id = 1;
+ string actor_id = 2;
+ int64 state_version = 3;
+ string last_event_id = 4;
+ google.protobuf.Timestamp updated_at = 5;
+
+ string binding_run_id = 20;
+ string scope_id = 21;
+ string member_id = 22;
+ string request_hash = 23;
+ string status = 24;
+ string platform_binding_command_id = 25;
+ int32 attempt_count = 26;
+
+ string failure_code = 30;
+ string failure_message = 31;
+ google.protobuf.Timestamp failure_at = 32;
+
+ string result_published_service_id = 40;
+ string result_revision_id = 41;
+ string result_implementation_kind = 42;
+ string result_expected_actor_id = 43;
}
// ─── StudioTeam Current State ReadModel ───
diff --git a/src/platform/Aevatar.GAgentService.Abstractions/ScopeBindings/ScopeBindingModels.cs b/src/platform/Aevatar.GAgentService.Abstractions/ScopeBindings/ScopeBindingModels.cs
index 59e30e59d..0d25e4421 100644
--- a/src/platform/Aevatar.GAgentService.Abstractions/ScopeBindings/ScopeBindingModels.cs
+++ b/src/platform/Aevatar.GAgentService.Abstractions/ScopeBindings/ScopeBindingModels.cs
@@ -36,7 +36,9 @@ public sealed record ScopeBindingUpsertRequest(
string? DisplayName = null,
string? RevisionId = null,
string? AppId = null,
- string? ServiceId = null);
+ string? ServiceId = null,
+ bool AllowExistingRevisionReplay = false,
+ string? ReplayRevisionId = null);
public sealed record ScopeBindingWorkflowResult(
string WorkflowName,
diff --git a/src/platform/Aevatar.GAgentService.Application/Bindings/ScopeBindingCommandApplicationService.cs b/src/platform/Aevatar.GAgentService.Application/Bindings/ScopeBindingCommandApplicationService.cs
index 21e7cccd3..8bded41d6 100644
--- a/src/platform/Aevatar.GAgentService.Application/Bindings/ScopeBindingCommandApplicationService.cs
+++ b/src/platform/Aevatar.GAgentService.Application/Bindings/ScopeBindingCommandApplicationService.cs
@@ -200,9 +200,6 @@ private async Task ShouldCreateRevisionAsync(
ServiceRevisionSpec revisionSpec,
CancellationToken ct)
{
- if (request.ImplementationKind != ScopeBindingImplementationKind.Scripting)
- return true;
-
var requestedRevisionId = ScopeWorkflowCapabilityConventions.NormalizeOptional(request.RevisionId);
if (string.IsNullOrWhiteSpace(requestedRevisionId))
return true;
@@ -216,7 +213,7 @@ private async Task ShouldCreateRevisionAsync(
if (existingRevision == null)
return true;
- if (!string.Equals(existingRevision.ImplementationKind, ServiceImplementationKind.Scripting.ToString(), StringComparison.OrdinalIgnoreCase))
+ if (!string.Equals(existingRevision.ImplementationKind, revisionSpec.ImplementationKind.ToString(), StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Revision '{revisionId}' already exists for service '{ServiceKeys.Build(identity)}' with implementation '{existingRevision.ImplementationKind}'.");
@@ -228,6 +225,18 @@ private async Task ShouldCreateRevisionAsync(
$"Revision '{revisionId}' already exists for service '{ServiceKeys.Build(identity)}' but has been retired.");
}
+ if (request.ImplementationKind != ScopeBindingImplementationKind.Scripting)
+ {
+ if (!request.AllowExistingRevisionReplay ||
+ !string.Equals(request.ReplayRevisionId, revisionId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Revision '{revisionId}' already exists for service '{ServiceKeys.Build(identity)}'.");
+ }
+
+ return false;
+ }
+
var expectedArtifactHash = await ComputeScriptingArtifactHashAsync(revisionSpec, ct);
if (!string.Equals(existingRevision.ArtifactHash, expectedArtifactHash, StringComparison.Ordinal))
{
diff --git a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs
index 8f90c2a56..1f2a21638 100644
--- a/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs
+++ b/src/platform/Aevatar.GAgentService.Hosting/Endpoints/ScopeServiceEndpoints.cs
@@ -54,8 +54,6 @@ public static IEndpointRouteBuilder MapScopeServiceEndpoints(this IEndpointRoute
group.MapPut("/{scopeId}/binding", HandleUpsertBindingAsync);
group.MapGet("/{scopeId}/binding", HandleGetBindingAsync);
group.MapGet("/{scopeId}/members/{memberId}/published-service", HandleGetMemberPublishedServiceAsync);
- group.MapPut("/{scopeId}/members/{memberId}/binding", HandleUpsertMemberBindingAsync);
- group.MapGet("/{scopeId}/members/{memberId}/binding", HandleGetMemberBindingAsync);
group.MapPost("/{scopeId}/binding/revisions/{revisionId}:activate", HandleActivateBindingRevisionAsync);
group.MapGet("/{scopeId}/revisions", HandleGetDefaultServiceRevisionsAsync);
group.MapGet("/{scopeId}/revisions/{revisionId}", HandleGetDefaultServiceRevisionAsync);
@@ -327,112 +325,6 @@ private static async Task HandleGetMemberPublishedServiceAsync(
}
}
- private static async Task HandleUpsertMemberBindingAsync(
- HttpContext http,
- string scopeId,
- string memberId,
- UpsertMemberScopeBindingHttpRequest request,
- [FromServices] IMemberPublishedServiceResolver memberPublishedServiceResolver,
- [FromServices] IScopeBindingCommandPort commandPort,
- CancellationToken ct)
- {
- try
- {
- if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied))
- return denied;
-
- if (AevatarMemberAccessGuard.TryCreateScopeAdminRequiredResult(http, memberId, out var memberDenied))
- return memberDenied;
-
- var memberResolution = await memberPublishedServiceResolver.ResolveAsync(
- new MemberPublishedServiceResolveRequest(scopeId, memberId),
- ct);
- var result = await commandPort.UpsertAsync(
- new ScopeBindingUpsertRequest(
- memberResolution.ScopeId,
- ParseScopeBindingImplementationKind(request.ImplementationKind),
- ToWorkflowSpec(request),
- request.Script == null
- ? null
- : new ScopeBindingScriptSpec(
- request.Script.ScriptId,
- request.Script.ScriptRevision),
- request.GAgent == null
- ? null
- : new ScopeBindingGAgentSpec(
- request.GAgent.ActorTypeName,
- (request.GAgent.Endpoints ?? [])
- .Select(endpoint => new ScopeBindingGAgentEndpoint(
- endpoint.EndpointId,
- endpoint.DisplayName,
- ParseEndpointKind(endpoint.Kind),
- endpoint.RequestTypeUrl,
- endpoint.ResponseTypeUrl,
- endpoint.Description))
- .ToArray()),
- request.DisplayName,
- request.RevisionId,
- null,
- memberResolution.PublishedServiceId),
- ct);
-
- return Results.Ok(BuildMemberBindingUpsertResponse(memberResolution, result));
- }
- catch (InvalidOperationException ex)
- {
- return Results.BadRequest(new
- {
- code = "INVALID_MEMBER_BINDING_REQUEST",
- message = ex.Message,
- });
- }
- }
-
- private static async Task HandleGetMemberBindingAsync(
- HttpContext http,
- string scopeId,
- string memberId,
- [FromServices] IMemberPublishedServiceResolver memberPublishedServiceResolver,
- [FromServices] IServiceLifecycleQueryPort lifecycleQueryPort,
- [FromServices] IServiceServingQueryPort servingQueryPort,
- [FromServices] IOptions options,
- CancellationToken ct)
- {
- try
- {
- if (AevatarScopeAccessGuard.TryCreateScopeAccessDeniedResult(http, scopeId, out var denied))
- return denied;
-
- if (AevatarMemberAccessGuard.TryCreateMemberAccessDeniedResult(http, memberId, out var memberDenied))
- return memberDenied;
-
- var memberResolution = await memberPublishedServiceResolver.ResolveAsync(
- new MemberPublishedServiceResolveRequest(scopeId, memberId),
- ct);
- var identity = BuildScopeServiceIdentity(
- options.Value,
- memberResolution.ScopeId,
- memberResolution.PublishedServiceId);
- var service = await lifecycleQueryPort.GetServiceAsync(identity, ct);
- if (service == null)
- {
- return Results.Ok(BuildUnavailableMemberBindingStatusResponse(memberResolution, identity));
- }
-
- var revisions = await lifecycleQueryPort.GetServiceRevisionsAsync(identity, ct);
- var servingSet = await servingQueryPort.GetServiceServingSetAsync(identity, ct);
- return Results.Ok(BuildMemberBindingStatusResponse(memberResolution, service, revisions, servingSet));
- }
- catch (InvalidOperationException ex)
- {
- return Results.BadRequest(new
- {
- code = "INVALID_MEMBER_BINDING_REQUEST",
- message = ex.Message,
- });
- }
- }
-
private static async Task HandleActivateBindingRevisionAsync(
HttpContext http,
string scopeId,
@@ -2458,72 +2350,6 @@ private static MemberPublishedServiceHttpResponse BuildMemberPublishedServiceRes
ServiceKeys.Build(identity));
}
- private static MemberScopeBindingStatusHttpResponse BuildUnavailableMemberBindingStatusResponse(
- MemberPublishedServiceResolution memberResolution,
- ServiceIdentity identity)
- {
- return new MemberScopeBindingStatusHttpResponse(
- false,
- memberResolution.ScopeId,
- memberResolution.MemberId,
- memberResolution.PublishedServiceId,
- string.Empty,
- ServiceKeys.Build(identity),
- string.Empty,
- string.Empty,
- string.Empty,
- string.Empty,
- string.Empty,
- null,
- [],
- 0,
- string.Empty);
- }
-
- private static MemberScopeBindingStatusHttpResponse BuildMemberBindingStatusResponse(
- MemberPublishedServiceResolution memberResolution,
- ServiceCatalogSnapshot service,
- ServiceRevisionCatalogSnapshot? revisions,
- ServiceServingSetSnapshot? servingSet)
- {
- var status = BuildScopeBindingStatusResponse(memberResolution.ScopeId, service, revisions, servingSet);
- return new MemberScopeBindingStatusHttpResponse(
- status.Available,
- status.ScopeId,
- memberResolution.MemberId,
- memberResolution.PublishedServiceId,
- status.DisplayName,
- status.ServiceKey,
- status.DefaultServingRevisionId,
- status.ActiveServingRevisionId,
- status.DeploymentId,
- status.DeploymentStatus,
- status.PrimaryActorId,
- status.UpdatedAt,
- status.Revisions,
- status.CatalogStateVersion,
- status.CatalogLastEventId);
- }
-
- private static MemberScopeBindingUpsertHttpResponse BuildMemberBindingUpsertResponse(
- MemberPublishedServiceResolution memberResolution,
- ScopeBindingUpsertResult result)
- {
- return new MemberScopeBindingUpsertHttpResponse(
- memberResolution.ScopeId,
- memberResolution.MemberId,
- memberResolution.PublishedServiceId,
- result.DisplayName,
- result.RevisionId,
- result.ImplementationKind,
- result.ExpectedActorId,
- result.WorkflowName,
- result.DefinitionActorIdPrefix,
- result.Workflow,
- result.Script,
- result.GAgent);
- }
-
private static ScopeServiceRevisionCatalogHttpResponse BuildScopeServiceRevisionCatalogResponse(
string scopeId,
ServiceCatalogSnapshot service,
@@ -3315,12 +3141,6 @@ private static ScopeBindingImplementationKind ParseScopeBindingImplementationKin
return workflowYamls == null ? null : new ScopeBindingWorkflowSpec(workflowYamls);
}
- private static ScopeBindingWorkflowSpec? ToWorkflowSpec(UpsertMemberScopeBindingHttpRequest request)
- {
- var workflowYamls = request.Workflow?.WorkflowYamls ?? request.WorkflowYamls;
- return workflowYamls == null ? null : new ScopeBindingWorkflowSpec(workflowYamls);
- }
-
private static string? NormalizeOptional(string? value)
{
var normalized = (value ?? string.Empty).Trim();
@@ -3371,15 +3191,6 @@ public sealed record UpsertScopeBindingHttpRequest(
string? AppId = null,
string? ServiceId = null);
- public sealed record UpsertMemberScopeBindingHttpRequest(
- string ImplementationKind,
- IReadOnlyList? WorkflowYamls = null,
- ScopeBindingWorkflowHttpRequest? Workflow = null,
- ScopeBindingScriptHttpRequest? Script = null,
- ScopeBindingGAgentHttpRequest? GAgent = null,
- string? DisplayName = null,
- string? RevisionId = null);
-
public sealed record ScopeBindingWorkflowHttpRequest(
IReadOnlyList? WorkflowYamls);
@@ -3482,37 +3293,6 @@ public sealed record MemberPublishedServiceHttpResponse(
string PublishedServiceId,
string PublishedServiceKey);
- public sealed record MemberScopeBindingStatusHttpResponse(
- bool Available,
- string ScopeId,
- string MemberId,
- string PublishedServiceId,
- string DisplayName,
- string PublishedServiceKey,
- string DefaultServingRevisionId,
- string ActiveServingRevisionId,
- string DeploymentId,
- string DeploymentStatus,
- string PrimaryActorId,
- DateTimeOffset? UpdatedAt,
- IReadOnlyList Revisions,
- long CatalogStateVersion = 0,
- string CatalogLastEventId = "");
-
- public sealed record MemberScopeBindingUpsertHttpResponse(
- string ScopeId,
- string MemberId,
- string PublishedServiceId,
- string DisplayName,
- string RevisionId,
- ScopeBindingImplementationKind ImplementationKind,
- string ExpectedActorId,
- string WorkflowName = "",
- string DefinitionActorIdPrefix = "",
- ScopeBindingWorkflowResult? Workflow = null,
- ScopeBindingScriptResult? Script = null,
- ScopeBindingGAgentResult? GAgent = null);
-
public sealed record ScopeBindingRevisionHttpResponse(
string RevisionId,
string ImplementationKind,
diff --git a/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs b/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs
index e1cfda705..8446fe44c 100644
--- a/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs
+++ b/test/Aevatar.GAgentService.Integration.Tests/ScopeServiceEndpointsTests.cs
@@ -464,109 +464,6 @@ public async Task GetMemberPublishedServiceEndpoint_ShouldRejectDifferentAuthent
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
- [Fact]
- public async Task MemberBindingEndpoint_ShouldBindToMemberPublishedService()
- {
- await using var host = await ScopeServiceEndpointTestHost.StartAsync();
-
- using var request = new HttpRequestMessage(HttpMethod.Put, "/api/scopes/scope-a/members/member-a/binding")
- {
- Content = JsonContent.Create(new
- {
- implementationKind = "workflow",
- displayName = "Member A",
- workflowYamls = new[]
- {
- "name: main\nsteps:\n - run: echo hello",
- },
- }),
- };
- request.Headers.Add("X-Test-Role", "scope-admin");
- var response = await host.Client.SendAsync(request);
- var body = await response.Content.ReadFromJsonAsync();
-
- response.StatusCode.Should().Be(HttpStatusCode.OK);
- body.Should().NotBeNull();
- body!.MemberId.Should().Be("member-a");
- body.PublishedServiceId.Should().Be("member-a");
- host.ScopeBindingPort.LastRequest.Should().NotBeNull();
- host.ScopeBindingPort.LastRequest!.ScopeId.Should().Be("scope-a");
- host.ScopeBindingPort.LastRequest.ServiceId.Should().Be("member-a");
- host.ScopeBindingPort.LastRequest.AppId.Should().BeNull();
- }
-
- [Fact]
- public async Task MemberBindingEndpoint_ShouldRequireScopeAdminUntilMemberCatalogIsAuthoritative()
- {
- await using var host = await ScopeServiceEndpointTestHost.StartAsync();
-
- var response = await host.Client.PutAsJsonAsync("/api/scopes/scope-a/members/member-a/binding", new
- {
- implementationKind = "workflow",
- displayName = "Member A",
- workflowYamls = new[]
- {
- "name: main\nsteps:\n - run: echo hello",
- },
- });
-
- response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
- host.ScopeBindingPort.LastRequest.Should().BeNull();
- }
-
- [Fact]
- public async Task GetMemberBindingEndpoint_ShouldReturnMemberBindingSummary()
- {
- await using var host = await ScopeServiceEndpointTestHost.StartAsync();
- host.LifecycleQueryPort.Service = BuildService("scope-a", "member-a", "def-member-a");
- host.LifecycleQueryPort.Revisions = new ServiceRevisionCatalogSnapshot(
- "scope-a:default:default:member-a",
- [
- new ServiceRevisionSnapshot(
- "rev-1",
- "workflow",
- "Published",
- "hash-1",
- string.Empty,
- [],
- DateTimeOffset.UtcNow.AddHours(-1),
- DateTimeOffset.UtcNow.AddHours(-1),
- DateTimeOffset.UtcNow.AddHours(-1),
- null),
- ],
- DateTimeOffset.UtcNow,
- 5,
- "evt-5");
- host.ServingQueryPort.ServingSet = new ServiceServingSetSnapshot(
- "scope-a:default:default:member-a",
- 5,
- string.Empty,
- [
- new ServiceServingTargetSnapshot(
- "dep-member-a",
- "rev-1",
- "def-member-a",
- 100,
- ServiceServingState.Active.ToString(),
- []),
- ],
- DateTimeOffset.UtcNow);
-
- var response = await host.Client.GetFromJsonAsync(
- "/api/scopes/scope-a/members/member-a/binding");
-
- response.Should().NotBeNull();
- response!.Available.Should().BeTrue();
- response.ScopeId.Should().Be("scope-a");
- response.MemberId.Should().Be("member-a");
- response.PublishedServiceId.Should().Be("member-a");
- response.PublishedServiceKey.Should().Be("scope-a:default:default:member-a");
- response.Revisions.Should().ContainSingle();
- response.Revisions[0].DeploymentId.Should().Be("dep-member-a");
- response.CatalogStateVersion.Should().Be(5);
- response.CatalogLastEventId.Should().Be("evt-5");
- }
-
[Fact]
public async Task GetEndpointContractEndpoint_ShouldReturnWorkflowChatStreamContract()
{
diff --git a/test/Aevatar.GAgentService.Tests/Application/ScopeBindingCommandApplicationServiceTests.cs b/test/Aevatar.GAgentService.Tests/Application/ScopeBindingCommandApplicationServiceTests.cs
index ad2e5dda7..74e2cb8dc 100644
--- a/test/Aevatar.GAgentService.Tests/Application/ScopeBindingCommandApplicationServiceTests.cs
+++ b/test/Aevatar.GAgentService.Tests/Application/ScopeBindingCommandApplicationServiceTests.cs
@@ -681,6 +681,281 @@ public async Task UpsertAsync_ShouldHonorExplicitRevisionId()
result.RevisionId.Should().Be("rev-explicit");
}
+ [Fact]
+ public async Task UpsertAsync_ShouldRejectExistingWorkflowRevision_WhenReplayIsNotAllowed()
+ {
+ const string revisionId = "rev-platform-bind-1";
+ var commandPort = new RecordingServiceCommandPort();
+ var lifecyclePort = new FakeServiceLifecycleQueryPort(
+ new ServiceCatalogSnapshot(
+ "scope-a:default:default:default",
+ ScopeId,
+ DefaultOptions.ServiceAppId,
+ DefaultOptions.ServiceNamespace,
+ DefaultOptions.DefaultServiceId,
+ "main",
+ revisionId,
+ revisionId,
+ "dep-1",
+ "actor-1",
+ "Active",
+ [
+ new ServiceEndpointSnapshot(
+ "chat",
+ "chat",
+ ServiceEndpointKind.Chat.ToString(),
+ "type.googleapis.com/aevatar.ai.ChatRequestEvent",
+ "type.googleapis.com/aevatar.ai.ChatResponseEvent",
+ "Workflow chat endpoint."),
+ ],
+ [],
+ DateTimeOffset.UtcNow),
+ new ServiceRevisionCatalogSnapshot(
+ "scope-a:default:default:default",
+ [
+ new ServiceRevisionSnapshot(
+ revisionId,
+ ServiceImplementationKind.Workflow.ToString(),
+ ServiceRevisionStatus.Published.ToString(),
+ string.Empty,
+ string.Empty,
+ [],
+ DateTimeOffset.UtcNow.AddHours(-1),
+ DateTimeOffset.UtcNow.AddHours(-1),
+ DateTimeOffset.UtcNow.AddHours(-1),
+ null),
+ ],
+ DateTimeOffset.UtcNow));
+ var scopeScriptQueryPort = new FakeScopeScriptQueryPort();
+ var scriptDefinitionSnapshotPort = new FakeScriptDefinitionSnapshotPort();
+ var actorPort = new FakeWorkflowRunActorPort();
+ var service = CreateService(commandPort, lifecyclePort, scopeScriptQueryPort, scriptDefinitionSnapshotPort, actorPort);
+
+ var act = () => service.UpsertAsync(new ScopeBindingUpsertRequest(
+ ScopeId,
+ ScopeBindingImplementationKind.Workflow,
+ Workflow: new ScopeBindingWorkflowSpec([
+ "name: main\nsteps:\n - run: echo hello",
+ ]),
+ RevisionId: revisionId));
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*already exists*");
+ commandPort.Calls.Should().ContainSingle(call => call.Method == "UpdateServiceAsync");
+ commandPort.Calls.Should().NotContain(call => call.Method == "CreateRevisionAsync");
+ }
+
+ [Fact]
+ public async Task UpsertAsync_ShouldReuseExistingWorkflowRevision_WhenReplayRevisionMatches()
+ {
+ const string revisionId = "rev-platform-bind-1";
+ var commandPort = new RecordingServiceCommandPort();
+ var lifecyclePort = new FakeServiceLifecycleQueryPort(
+ new ServiceCatalogSnapshot(
+ "scope-a:default:default:default",
+ ScopeId,
+ DefaultOptions.ServiceAppId,
+ DefaultOptions.ServiceNamespace,
+ DefaultOptions.DefaultServiceId,
+ "main",
+ revisionId,
+ revisionId,
+ "dep-1",
+ "actor-1",
+ "Active",
+ [
+ new ServiceEndpointSnapshot(
+ "chat",
+ "chat",
+ ServiceEndpointKind.Chat.ToString(),
+ "type.googleapis.com/aevatar.ai.ChatRequestEvent",
+ "type.googleapis.com/aevatar.ai.ChatResponseEvent",
+ "Workflow chat endpoint."),
+ ],
+ [],
+ DateTimeOffset.UtcNow),
+ new ServiceRevisionCatalogSnapshot(
+ "scope-a:default:default:default",
+ [
+ new ServiceRevisionSnapshot(
+ revisionId,
+ ServiceImplementationKind.Workflow.ToString(),
+ ServiceRevisionStatus.Published.ToString(),
+ string.Empty,
+ string.Empty,
+ [],
+ DateTimeOffset.UtcNow.AddHours(-1),
+ DateTimeOffset.UtcNow.AddHours(-1),
+ DateTimeOffset.UtcNow.AddHours(-1),
+ null),
+ ],
+ DateTimeOffset.UtcNow));
+ var scopeScriptQueryPort = new FakeScopeScriptQueryPort();
+ var scriptDefinitionSnapshotPort = new FakeScriptDefinitionSnapshotPort();
+ var actorPort = new FakeWorkflowRunActorPort();
+ var service = CreateService(commandPort, lifecyclePort, scopeScriptQueryPort, scriptDefinitionSnapshotPort, actorPort);
+
+ var act = () => service.UpsertAsync(new ScopeBindingUpsertRequest(
+ ScopeId,
+ ScopeBindingImplementationKind.Workflow,
+ Workflow: new ScopeBindingWorkflowSpec([
+ "name: main\nsteps:\n - run: echo hello",
+ ]),
+ RevisionId: revisionId,
+ AllowExistingRevisionReplay: true,
+ ReplayRevisionId: revisionId));
+
+ var result = await act();
+
+ result.RevisionId.Should().Be(revisionId);
+ result.ImplementationKind.Should().Be(ScopeBindingImplementationKind.Workflow);
+ commandPort.Calls.Should().ContainSingle(call => call.Method == "UpdateServiceAsync");
+ commandPort.Calls.Should().NotContain(call => call.Method == "CreateRevisionAsync");
+ commandPort.Calls.Should().Contain(call => call.Method == "PrepareRevisionAsync");
+ commandPort.Calls.Should().Contain(call => call.Method == "PublishRevisionAsync");
+ commandPort.Calls.Should().Contain(call => call.Method == "SetDefaultServingRevisionAsync");
+ commandPort.Calls.Should().Contain(call => call.Method == "ActivateServiceRevisionAsync");
+ }
+
+ [Fact]
+ public async Task UpsertAsync_ShouldRejectExistingWorkflowRevision_WhenReplayRevisionDoesNotMatch()
+ {
+ const string revisionId = "rev-user-supplied";
+ var commandPort = new RecordingServiceCommandPort();
+ var lifecyclePort = new FakeServiceLifecycleQueryPort(
+ new ServiceCatalogSnapshot(
+ "scope-a:default:default:default",
+ ScopeId,
+ DefaultOptions.ServiceAppId,
+ DefaultOptions.ServiceNamespace,
+ DefaultOptions.DefaultServiceId,
+ "main",
+ revisionId,
+ revisionId,
+ "dep-1",
+ "actor-1",
+ "Active",
+ [
+ new ServiceEndpointSnapshot(
+ "chat",
+ "chat",
+ ServiceEndpointKind.Chat.ToString(),
+ "type.googleapis.com/aevatar.ai.ChatRequestEvent",
+ "type.googleapis.com/aevatar.ai.ChatResponseEvent",
+ "Workflow chat endpoint."),
+ ],
+ [],
+ DateTimeOffset.UtcNow),
+ new ServiceRevisionCatalogSnapshot(
+ "scope-a:default:default:default",
+ [
+ new ServiceRevisionSnapshot(
+ revisionId,
+ ServiceImplementationKind.Workflow.ToString(),
+ ServiceRevisionStatus.Published.ToString(),
+ string.Empty,
+ string.Empty,
+ [],
+ DateTimeOffset.UtcNow.AddHours(-1),
+ DateTimeOffset.UtcNow.AddHours(-1),
+ DateTimeOffset.UtcNow.AddHours(-1),
+ null),
+ ],
+ DateTimeOffset.UtcNow));
+ var scopeScriptQueryPort = new FakeScopeScriptQueryPort();
+ var scriptDefinitionSnapshotPort = new FakeScriptDefinitionSnapshotPort();
+ var actorPort = new FakeWorkflowRunActorPort();
+ var service = CreateService(commandPort, lifecyclePort, scopeScriptQueryPort, scriptDefinitionSnapshotPort, actorPort);
+
+ var act = () => service.UpsertAsync(new ScopeBindingUpsertRequest(
+ ScopeId,
+ ScopeBindingImplementationKind.Workflow,
+ Workflow: new ScopeBindingWorkflowSpec([
+ "name: main\nsteps:\n - run: echo hello",
+ ]),
+ RevisionId: revisionId,
+ AllowExistingRevisionReplay: true,
+ ReplayRevisionId: "rev-other-command"));
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*already exists*");
+ commandPort.Calls.Should().ContainSingle(call => call.Method == "UpdateServiceAsync");
+ commandPort.Calls.Should().NotContain(call => call.Method == "CreateRevisionAsync");
+ commandPort.Calls.Should().NotContain(call => call.Method == "PrepareRevisionAsync");
+ }
+
+ [Fact]
+ public async Task UpsertAsync_ShouldReuseExistingGAgentRevision_WhenReplayRevisionMatches()
+ {
+ const string revisionId = "rev-static-bind-1";
+ var commandPort = new RecordingServiceCommandPort();
+ var lifecyclePort = new FakeServiceLifecycleQueryPort(
+ new ServiceCatalogSnapshot(
+ "scope-a:default:default:default",
+ ScopeId,
+ DefaultOptions.ServiceAppId,
+ DefaultOptions.ServiceNamespace,
+ DefaultOptions.DefaultServiceId,
+ "main",
+ revisionId,
+ revisionId,
+ "dep-1",
+ "actor-1",
+ "Active",
+ [
+ new ServiceEndpointSnapshot(
+ "chat",
+ "chat",
+ ServiceEndpointKind.Chat.ToString(),
+ "type.googleapis.com/aevatar.ai.ChatRequestEvent",
+ "type.googleapis.com/aevatar.ai.ChatResponseEvent",
+ "Chat endpoint."),
+ ],
+ [],
+ DateTimeOffset.UtcNow),
+ new ServiceRevisionCatalogSnapshot(
+ "scope-a:default:default:default",
+ [
+ new ServiceRevisionSnapshot(
+ revisionId,
+ ServiceImplementationKind.Static.ToString(),
+ ServiceRevisionStatus.Published.ToString(),
+ string.Empty,
+ string.Empty,
+ [],
+ DateTimeOffset.UtcNow.AddHours(-1),
+ DateTimeOffset.UtcNow.AddHours(-1),
+ DateTimeOffset.UtcNow.AddHours(-1),
+ null),
+ ],
+ DateTimeOffset.UtcNow));
+ var scopeScriptQueryPort = new FakeScopeScriptQueryPort();
+ var scriptDefinitionSnapshotPort = new FakeScriptDefinitionSnapshotPort();
+ var actorPort = new FakeWorkflowRunActorPort();
+ var service = CreateService(commandPort, lifecyclePort, scopeScriptQueryPort, scriptDefinitionSnapshotPort, actorPort);
+
+ var act = () => service.UpsertAsync(new ScopeBindingUpsertRequest(
+ ScopeId,
+ ScopeBindingImplementationKind.GAgent,
+ GAgent: new ScopeBindingGAgentSpec(
+ typeof(TestStaticServiceAgent).AssemblyQualifiedName!,
+ []),
+ RevisionId: revisionId,
+ AllowExistingRevisionReplay: true,
+ ReplayRevisionId: revisionId));
+
+ var result = await act();
+
+ result.RevisionId.Should().Be(revisionId);
+ result.ImplementationKind.Should().Be(ScopeBindingImplementationKind.GAgent);
+ commandPort.Calls.Should().ContainSingle(call => call.Method == "UpdateServiceAsync");
+ commandPort.Calls.Should().NotContain(call => call.Method == "CreateRevisionAsync");
+ commandPort.Calls.Should().Contain(call => call.Method == "PrepareRevisionAsync");
+ commandPort.Calls.Should().Contain(call => call.Method == "PublishRevisionAsync");
+ commandPort.Calls.Should().Contain(call => call.Method == "SetDefaultServingRevisionAsync");
+ commandPort.Calls.Should().Contain(call => call.Method == "ActivateServiceRevisionAsync");
+ }
+
[Fact]
public async Task UpsertAsync_ShouldThrow_WhenWorkflowNamesAreDuplicated()
{
diff --git a/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs
index 3b8d245e9..b378737f3 100644
--- a/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs
+++ b/test/Aevatar.Studio.Tests/ActorDispatchStudioMemberCommandServiceTests.cs
@@ -16,8 +16,7 @@ namespace Aevatar.Studio.Tests;
/// 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.
+/// - Binding requests route through the run actor with a stable payload hash.
/// - Dispatch always goes through IStudioActorBootstrap before
/// IActorDispatchPort, so the projection scope is active before the
/// command lands on the inbox.
@@ -155,53 +154,167 @@ public async Task UpdateImplementationAsync_ShouldDispatchTypedRefForEachKind(st
}
[Fact]
- public async Task RecordBindingAsync_ShouldRejectEmptyPublishedServiceId()
+ public async Task StartBindingRunAsync_ShouldDispatchRequestedEventToRunActor()
{
- var service = new ActorDispatchStudioMemberCommandService(
- new RecordingBootstrap(), new RecordingDispatchPort());
+ var bootstrap = new RecordingBootstrap();
+ var dispatch = new RecordingDispatchPort();
+ var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch);
- var act = () => service.RecordBindingAsync(
- ScopeId, "m-1", "", "rev-1", MemberImplementationKindNames.Workflow, CancellationToken.None);
+ await service.StartBindingRunAsync(
+ new StudioMemberBindingRunStartRequest(
+ BindingRunId: "bind-1",
+ ScopeId: ScopeId,
+ MemberId: "m-1",
+ ImplementationKind: MemberImplementationKindNames.Script,
+ Binding: new UpdateStudioMemberBindingRequest(
+ Script: new StudioMemberScriptBindingSpec(
+ ScriptId: "script-1",
+ ScriptRevision: "rev-a"))),
+ CancellationToken.None);
- await act.Should().ThrowAsync()
- .WithMessage("*publishedServiceId is required*");
+ bootstrap.EnsuredActorIds.Should().Equal(
+ "studio-member-binding-run:bind-1",
+ "studio-member:scope-1:m-1");
+ dispatch.Dispatches.Should().ContainSingle();
+ var dispatched = dispatch.Dispatches[0];
+ dispatched.ActorId.Should().Be("studio-member-binding-run:bind-1");
+ dispatched.Envelope.Payload.Is(StudioMemberBindingRunRequested.Descriptor).Should().BeTrue();
+ var evt = dispatched.Envelope.Payload.Unpack();
+ evt.Request.BindingRunId.Should().Be("bind-1");
+ evt.Request.ScopeId.Should().Be(ScopeId);
+ evt.Request.MemberId.Should().Be("m-1");
+ evt.Request.RequestHash.Should().NotBeNullOrWhiteSpace();
+ evt.Request.RequestHash.Should().MatchRegex("^[0-9a-f]{64}$");
+ evt.Request.Script.ScriptId.Should().Be("script-1");
+ evt.Request.Script.ScriptRevision.Should().Be("rev-a");
}
[Fact]
- public async Task RecordBindingAsync_ShouldRejectEmptyRevisionId()
+ public async Task StartBindingRunAsync_ShouldDispatchWorkflowBindingPayload()
{
- var service = new ActorDispatchStudioMemberCommandService(
- new RecordingBootstrap(), new RecordingDispatchPort());
+ var bootstrap = new RecordingBootstrap();
+ var dispatch = new RecordingDispatchPort();
+ var service = new ActorDispatchStudioMemberCommandService(bootstrap, dispatch);
+
+ await service.StartBindingRunAsync(
+ new StudioMemberBindingRunStartRequest(
+ BindingRunId: "bind-workflow",
+ ScopeId: ScopeId,
+ MemberId: "m-1",
+ ImplementationKind: MemberImplementationKindNames.Workflow,
+ Binding: new UpdateStudioMemberBindingRequest(
+ RevisionId: "rev-explicit",
+ Workflow: new StudioMemberWorkflowBindingSpec([
+ "workflow:\n name: alpha",
+ "workflow:\n name: beta",
+ ]))),
+ CancellationToken.None);
- var act = () => service.RecordBindingAsync(
- ScopeId, "m-1", "member-m-1", "", MemberImplementationKindNames.Workflow, CancellationToken.None);
+ var evt = dispatch.Dispatches.Should().ContainSingle().Which
+ .Envelope.Payload.Unpack();
+ evt.Request.RevisionId.Should().Be("rev-explicit");
+ evt.Request.Workflow.WorkflowYamls.Should().Equal(
+ "workflow:\n name: alpha",
+ "workflow:\n name: beta");
+ }
- await act.Should().ThrowAsync()
- .WithMessage("*revisionId is required*");
+ [Fact]
+ public async Task StartBindingRunAsync_ShouldComputeStableHashFromPayload()
+ {
+ var firstDispatch = new RecordingDispatchPort();
+ var firstService = new ActorDispatchStudioMemberCommandService(new RecordingBootstrap(), firstDispatch);
+ await firstService.StartBindingRunAsync(NewScriptRunStartRequest("bind-1", "rev-a"), CancellationToken.None);
+
+ var repeatDispatch = new RecordingDispatchPort();
+ var repeatService = new ActorDispatchStudioMemberCommandService(new RecordingBootstrap(), repeatDispatch);
+ await repeatService.StartBindingRunAsync(NewScriptRunStartRequest("bind-1", "rev-a"), CancellationToken.None);
+
+ var changedDispatch = new RecordingDispatchPort();
+ var changedService = new ActorDispatchStudioMemberCommandService(new RecordingBootstrap(), changedDispatch);
+ await changedService.StartBindingRunAsync(NewScriptRunStartRequest("bind-1", "rev-b"), CancellationToken.None);
+
+ var firstHash = firstDispatch.Dispatches[0].Envelope.Payload
+ .Unpack().Request.RequestHash;
+ var repeatHash = repeatDispatch.Dispatches[0].Envelope.Payload
+ .Unpack().Request.RequestHash;
+ var changedHash = changedDispatch.Dispatches[0].Envelope.Payload
+ .Unpack().Request.RequestHash;
+
+ firstHash.Should().MatchRegex("^[0-9a-f]{64}$");
+ repeatHash.Should().Be(firstHash);
+ changedHash.Should().NotBe(firstHash);
}
[Fact]
- public async Task RecordBindingAsync_ShouldDispatchBoundEvent()
+ public async Task StartBindingRunAsync_ShouldDispatchGAgentBindingPayload()
{
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,
+ await service.StartBindingRunAsync(
+ new StudioMemberBindingRunStartRequest(
+ BindingRunId: "bind-gagent",
+ ScopeId: ScopeId,
+ MemberId: "m-1",
+ ImplementationKind: MemberImplementationKindNames.GAgent,
+ Binding: new UpdateStudioMemberBindingRequest(
+ GAgent: new StudioMemberGAgentBindingSpec(
+ ActorTypeName: "MyCompany.MyGAgent",
+ Endpoints: [
+ new StudioMemberGAgentEndpointSpec(
+ EndpointId: "chat",
+ DisplayName: "Chat",
+ Kind: "chat",
+ RequestTypeUrl: "type.googleapis.com/a.Request",
+ ResponseTypeUrl: "type.googleapis.com/a.Response",
+ Description: "chat endpoint")
+ ]))),
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);
+ var evt = dispatch.Dispatches.Should().ContainSingle().Which
+ .Envelope.Payload.Unpack();
+ evt.Request.Gagent.ActorTypeName.Should().Be("MyCompany.MyGAgent");
+ evt.Request.Gagent.Endpoints.Should().ContainSingle()
+ .Which.Should().BeEquivalentTo(new StudioMemberGAgentEndpointBindingRequest
+ {
+ EndpointId = "chat",
+ DisplayName = "Chat",
+ Kind = StudioMemberGAgentEndpointKind.Chat,
+ RequestTypeUrl = "type.googleapis.com/a.Request",
+ ResponseTypeUrl = "type.googleapis.com/a.Response",
+ Description = "chat endpoint",
+ });
+ }
+
+ [Fact]
+ public async Task StartBindingRunAsync_ShouldRejectMissingGAgentEndpointKind()
+ {
+ var service = new ActorDispatchStudioMemberCommandService(
+ new RecordingBootstrap(),
+ new RecordingDispatchPort());
+
+ var act = () => service.StartBindingRunAsync(
+ new StudioMemberBindingRunStartRequest(
+ BindingRunId: "bind-gagent",
+ ScopeId: ScopeId,
+ MemberId: "m-1",
+ ImplementationKind: MemberImplementationKindNames.GAgent,
+ Binding: new UpdateStudioMemberBindingRequest(
+ GAgent: new StudioMemberGAgentBindingSpec(
+ ActorTypeName: "MyCompany.MyGAgent",
+ Endpoints: [
+ new StudioMemberGAgentEndpointSpec(
+ EndpointId: "chat",
+ DisplayName: "Chat",
+ Kind: "",
+ RequestTypeUrl: "type.googleapis.com/a.Request",
+ ResponseTypeUrl: "type.googleapis.com/a.Response")
+ ]))),
+ CancellationToken.None);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*gagent endpoint kind is required*");
}
[Fact]
@@ -252,4 +365,17 @@ public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationTo
public sealed record DispatchedCommand(string ActorId, EventEnvelope Envelope);
}
+
+ private static StudioMemberBindingRunStartRequest NewScriptRunStartRequest(
+ string bindingRunId,
+ string scriptRevision) =>
+ new(
+ BindingRunId: bindingRunId,
+ ScopeId: ScopeId,
+ MemberId: "m-1",
+ ImplementationKind: MemberImplementationKindNames.Script,
+ Binding: new UpdateStudioMemberBindingRequest(
+ Script: new StudioMemberScriptBindingSpec(
+ ScriptId: "script-1",
+ ScriptRevision: scriptRevision)));
}
diff --git a/test/Aevatar.Studio.Tests/ScopeBindingStudioMemberPlatformBindingCommandServiceTests.cs b/test/Aevatar.Studio.Tests/ScopeBindingStudioMemberPlatformBindingCommandServiceTests.cs
new file mode 100644
index 000000000..b151a3c81
--- /dev/null
+++ b/test/Aevatar.Studio.Tests/ScopeBindingStudioMemberPlatformBindingCommandServiceTests.cs
@@ -0,0 +1,493 @@
+using Aevatar.Foundation.Abstractions;
+using Aevatar.GAgentService.Abstractions;
+using Aevatar.GAgentService.Abstractions.Ports;
+using Aevatar.GAgents.StudioMember;
+using Aevatar.Studio.Projection.CommandServices;
+using FluentAssertions;
+using Google.Protobuf.WellKnownTypes;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Aevatar.Studio.Tests;
+
+public sealed class ScopeBindingStudioMemberPlatformBindingCommandServiceTests
+{
+ [Fact]
+ public async Task StartAsync_ShouldOnlyAcceptWithoutRunningPlatformBinding()
+ {
+ var scopeBindingPort = new RecordingScopeBindingCommandPort();
+ var dispatchPort = new RecordingDispatchPort();
+ var service = new ScopeBindingStudioMemberPlatformBindingCommandService(
+ scopeBindingPort,
+ dispatchPort,
+ NullLogger.Instance);
+
+ var accepted = await service.StartAsync(
+ "studio-member-binding-run:bind-1",
+ NewScriptStartRequest());
+
+ accepted.BindingRunId.Should().Be("bind-1");
+ accepted.PlatformBindingCommandId.Should().Be("platform-bind-1");
+
+ scopeBindingPort.Requests.Should().BeEmpty();
+ dispatchPort.Dispatches.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task StartAsync_WhenCommandIdMissing_ShouldUseSharedFallbackConvention()
+ {
+ var scopeBindingPort = new RecordingScopeBindingCommandPort();
+ var dispatchPort = new RecordingDispatchPort();
+ var service = new ScopeBindingStudioMemberPlatformBindingCommandService(
+ scopeBindingPort,
+ dispatchPort,
+ NullLogger.Instance);
+ var request = NewScriptStartRequest();
+ request.PlatformBindingCommandId = "";
+
+ var accepted = await service.StartAsync(
+ "studio-member-binding-run:bind-1",
+ request);
+
+ accepted.PlatformBindingCommandId.Should().Be("platform-bind-1-1");
+ scopeBindingPort.Requests.Should().BeEmpty();
+ dispatchPort.Dispatches.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ShouldRunPlatformBindingAndDispatchSucceededContinuation()
+ {
+ var scopeBindingPort = new RecordingScopeBindingCommandPort();
+ var dispatchPort = new RecordingDispatchPort();
+ var service = new ScopeBindingStudioMemberPlatformBindingCommandService(
+ scopeBindingPort,
+ dispatchPort,
+ NullLogger.Instance);
+
+ await service.ExecuteAsync(
+ "studio-member-binding-run:bind-1",
+ "platform-bind-1",
+ NewScriptStartRequest());
+
+ var dispatch = await dispatchPort.NextDispatch.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ dispatch.ActorId.Should().Be("studio-member-binding-run:bind-1");
+ var succeeded = dispatch.Envelope.Payload.Unpack();
+ succeeded.BindingRunId.Should().Be("bind-1");
+ succeeded.PlatformBindingCommandId.Should().Be("platform-bind-1");
+ succeeded.Result.PublishedServiceId.Should().Be("member-m-1");
+ succeeded.Result.RevisionId.Should().Be("rev-platform-bind-1");
+ succeeded.Result.ImplementationKind.Should().Be(StudioMemberImplementationKind.Script);
+ succeeded.Result.ImplementationRef.Script.ScriptId.Should().Be("script-1");
+
+ scopeBindingPort.Requests.Should().ContainSingle();
+ scopeBindingPort.Requests[0].ScopeId.Should().Be("scope-1");
+ scopeBindingPort.Requests[0].ServiceId.Should().Be("member-m-1");
+ scopeBindingPort.Requests[0].DisplayName.Should().Be("Script member");
+ scopeBindingPort.Requests[0].ImplementationKind.Should().Be(ScopeBindingImplementationKind.Scripting);
+ scopeBindingPort.Requests[0].Script!.ScriptId.Should().Be("script-1");
+ scopeBindingPort.Requests[0].Script!.ScriptRevision.Should().Be("draft-1");
+ scopeBindingPort.Requests[0].RevisionId.Should().Be("rev-platform-bind-1");
+ scopeBindingPort.Requests[0].AllowExistingRevisionReplay.Should().BeTrue();
+ scopeBindingPort.Requests[0].ReplayRevisionId.Should().Be("rev-platform-bind-1");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ShouldBuildWorkflowBindingRequestAndDispatchWorkflowResult()
+ {
+ var scopeBindingPort = new RecordingScopeBindingCommandPort();
+ var dispatchPort = new RecordingDispatchPort();
+ var service = new ScopeBindingStudioMemberPlatformBindingCommandService(
+ scopeBindingPort,
+ dispatchPort,
+ NullLogger.Instance);
+
+ await service.ExecuteAsync(
+ "studio-member-binding-run:bind-1",
+ "platform-bind-1",
+ NewWorkflowStartRequest());
+
+ var dispatch = await dispatchPort.NextDispatch.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ var succeeded = dispatch.Envelope.Payload.Unpack();
+ succeeded.Result.ImplementationKind.Should().Be(StudioMemberImplementationKind.Workflow);
+ succeeded.Result.ImplementationRef.Workflow.WorkflowId.Should().Be("workflow-main");
+ succeeded.Result.ImplementationRef.Workflow.WorkflowRevision.Should().Be("rev-platform-bind-1");
+
+ var request = scopeBindingPort.Requests.Should().ContainSingle().Subject;
+ request.ImplementationKind.Should().Be(ScopeBindingImplementationKind.Workflow);
+ request.Workflow!.WorkflowYamls.Should().ContainSingle().Which.Should().Contain("name: workflow-main");
+ request.AllowExistingRevisionReplay.Should().BeTrue();
+ request.ReplayRevisionId.Should().Be("rev-platform-bind-1");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ShouldBuildGAgentBindingRequestAndDispatchGAgentResult()
+ {
+ var scopeBindingPort = new RecordingScopeBindingCommandPort();
+ var dispatchPort = new RecordingDispatchPort();
+ var service = new ScopeBindingStudioMemberPlatformBindingCommandService(
+ scopeBindingPort,
+ dispatchPort,
+ NullLogger.Instance);
+
+ await service.ExecuteAsync(
+ "studio-member-binding-run:bind-1",
+ "platform-bind-1",
+ NewGAgentStartRequest());
+
+ var dispatch = await dispatchPort.NextDispatch.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ var succeeded = dispatch.Envelope.Payload.Unpack();
+ succeeded.Result.ImplementationKind.Should().Be(StudioMemberImplementationKind.Gagent);
+ succeeded.Result.ImplementationRef.Gagent.ActorTypeName.Should().Be("Tests.JokerGAgent");
+
+ var request = scopeBindingPort.Requests.Should().ContainSingle().Subject;
+ request.ImplementationKind.Should().Be(ScopeBindingImplementationKind.GAgent);
+ request.GAgent!.ActorTypeName.Should().Be("Tests.JokerGAgent");
+ request.GAgent.Endpoints.Should().ContainSingle().Which.Kind.Should().Be(ServiceEndpointKind.Chat);
+ request.GAgent.Endpoints[0].EndpointId.Should().Be("chat");
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ShouldStartPlatformBindingAndReturnBeforeCompletion()
+ {
+ var releaseUpsert = new TaskCompletionSource