feat(studio): member-first endpoint contract + revision lifecycle APIs (#454)#457
feat(studio): member-first endpoint contract + revision lifecycle APIs (#454)#457
Conversation
#454) Closes #454. Adds three member-first HTTP routes the Studio frontend needs to finish removing serviceId from its Bind/Invoke/Observe paths: - GET /api/scopes/{scopeId}/members/{memberId}/endpoints/{endpointId}/contract - POST /api/scopes/{scopeId}/members/{memberId}/binding/revisions/{revisionId}:activate - POST /api/scopes/{scopeId}/members/{memberId}/binding/revisions/{revisionId}:retire All three resolve the member-owned publishedServiceId internally through the existing StudioMember authority (IStudioMemberQueryPort), then call platform IServiceLifecycleQueryPort / IServiceCommandPort against the resolved identity. ServiceId is never accepted as a user-facing input. The contract response mirrors the legacy ScopeServiceEndpointContractHttpResponse shape so the frontend can keep its rendering, but exposes the member-first InvokePath (/api/scopes/.../members/.../invoke/...). Activation/retire responses include both memberId and publishedServiceId for parity with the legacy serviceId-keyed shape during migration. Why this routes through Studio (not platform's IMemberPublishedServiceResolver): the Studio bind path persists publishedServiceId = "member-{memberId}" via the StudioMember authority (StudioMemberConventions). Going through StudioMemberQueryPort guarantees these new routes read the exact identity Studio's bind wrote, regardless of the platform resolver's convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
eanzhao
left a comment
There was a problem hiding this comment.
Reviewed the member-first contract/revision API changes. I found one functional mismatch that should be addressed before relying on the returned invoke path.
| return null; | ||
| } | ||
|
|
||
| private static string BuildMemberInvokePath(string scopeId, string memberId, string endpointId) => |
There was a problem hiding this comment.
这里返回的 InvokePath 会让前端调用现有 /members/{memberId}/invoke... handler,但那个 handler 仍通过 IMemberPublishedServiceResolver 解析成员服务;默认实现当前返回 PublishedServiceId = memberId,而 Studio bind/本方法解析到的是 member-{memberId}。结果是 contract 是按 member-{memberId} 构建的,实际 invoke 却会打到 {memberId} 服务,常见结果是 404 或调用错误服务。要么需要让 member invoke 路由同样走 IStudioMemberQueryPort/StudioMember authority,要么这里暂时不能返回现有 member invoke path。
PR Review: Key Issues1. [Medium] ~200 lines of contract-building logic duplicated from legacy
This is the biggest maintainability risk in the PR. A bug fix in one copy will not propagate to the other. Consider extracting the shared contract-building logic into a shared utility that both the Studio Application layer and the legacy Hosting layer reference. 2. [Medium] Activate is non-transactional —
|
Codecov Report❌ Patch coverage is @@ Coverage Diff @@
## dev #457 +/- ##
==========================================
+ Coverage 70.59% 71.25% +0.66%
==========================================
Files 1208 1211 +3
Lines 86806 87420 +614
Branches 11369 11446 +77
==========================================
+ Hits 61280 62292 +1012
+ Misses 21097 20648 -449
- Partials 4429 4480 +51
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 30 files with indirect coverage changes 🚀 New features to boost your workflow:
|
…ServiceId; share contract math Addresses PR #457 review. ## Functional fix (the inline review): InvokePath / invoke handler mismatch The contract returned by the new `GET /members/.../endpoints/.../contract` was telling the frontend to call `/members/{memberId}/invoke/...`, but the existing platform handler for that path resolves the member through `IMemberPublishedServiceResolver` which today returns `publishedServiceId == memberId`. Studio's bind path persists `publishedServiceId == "member-{memberId}"`. So the contract was built for `member-{memberId}` while invoke would target `{memberId}` → 404. Fix: register `StudioAwareMemberPublishedServiceResolver` from Studio's DI. It first asks `IStudioMemberQueryPort` for the member's stored `publishedServiceId`; if no Studio member exists, falls back to the legacy deterministic mapping (`memberId == publishedServiceId`) so direct platform binds keep working unchanged. Now contract / activate / retire / invoke / runs all resolve to the same identity. ## Refactors per the PR review - **#1 Duplicated contract-building logic**: extracted the pure helpers (`ResolveCurrentContractRevision`, `EnumeratePreferredContractRevisionIds`, `RevisionContainsEndpoint`, `IsChatEndpoint`, `ResolveStreamFrameFormat`, `BuildBase64PayloadPlaceholder`, `BuildTypedInvokeRequestExampleBody`) into `Aevatar.GAgentService.Abstractions.Services.ServiceEndpointContractMath`. Both `ScopeServiceEndpoints.cs` (legacy) and `StudioMemberService.cs` (member-first) funnel through it. A bug fix in one helper now propagates to both paths automatically. - **#3 / #4 Repeated resolve+verify pattern**: introduced `ResolveBoundServiceContextAsync` returning `(ScopeId, MemberId, PublishedServiceId, Identity, Service, Revisions)`. The three new methods now all share one query path; activate / retire dropped from 4 platform queries to 2. - **#2 Non-atomic activate**: documented with a `NOTE:` comment that `SetDefaultServingRevision` then `ActivateServiceRevision` is intentionally non-transactional, mirroring the legacy scope-default behavior, and that both commands are platform-side idempotent. - **#7 Hardcoded "retired" string**: introduced `MemberRevisionLifecycleStatusNames.Retired` next to the existing `MemberLifecycleStageNames` so future lifecycle verbs declare themselves alongside it instead of as scattered magic strings. - **#6 / #8 Input trimming**: collapsed the four ad-hoc trimming sites into a single `NormalizeRequired(value, fieldName)` helper applied at the service entry of every public method. Trimming now happens at exactly one boundary per call. ## Tests - 13 new tests pin the resolver's contract (Studio member → stored publishedServiceId; non-Studio member → legacy fallback; trim; reject malformed input; empty publishedServiceId degrades safely). - Existing tests unchanged: 327 Studio + 281 platform integration passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up Review: Fixes AssessmentAll 8 issues from the first review have been addressed. Here is the assessment: Fixed
Partially addressed
New additions reviewed
Test coverage — Verdict: LGTM. All significant issues are resolved. The remaining minor items (pre-existing scopeId normalization gap, 5-param constructor) are acceptable trade-offs. Ship-ready. |
| string scopeId, | ||
| string memberId, | ||
| string endpointId, | ||
| IStudioMemberService memberService, |
There was a problem hiding this comment.
This parameter needs an explicit [FromServices] (and the same applies to the other IStudioMemberService parameters in this file). When the app actually builds route endpoints, minimal API inspects IStudioMemberService and finds the instance method BindAsync(...); because it is not a valid static custom binder, host startup fails with BindAsync method found on IStudioMemberService with incorrect format. I reproduced it with dotnet test test/Aevatar.Hosting.Tests/Aevatar.Hosting.Tests.csproj --nologo --filter "FullyQualifiedName~MainnetHostCompositionTests|FullyQualifiedName~MainnetHealthEndpointsTests": both mainnet host startup tests fail before serving requests. Handler-only unit tests miss this because they call the methods directly instead of letting RequestDelegateFactory bind parameters.
…ervices] PR #457 follow-up: mainnet host startup fails with "BindAsync method found on IStudioMemberService with incorrect format" because Minimal API's RequestDelegateFactory probes every parameter type for a BindAsync custom-binder hook, and IStudioMemberService itself defines an instance method named BindAsync (the bind-revision write path). Without [FromServices] on the parameter, the binder matches the probe to that instance method, fails its shape check, and the whole composition tears down before serving any request. Reproducer (from the review): dotnet test test/Aevatar.Hosting.Tests/Aevatar.Hosting.Tests.csproj \ --filter "FullyQualifiedName~MainnetHostCompositionTests" Both MainnetHostCompositionTests and MainnetHealthEndpointsTests went red on this. The handler-only unit tests in StudioMemberEndpointsTests missed it because they call the static handlers via reflection and never exercise RequestDelegateFactory. Fix: decorate every IStudioMemberService parameter across all eight handlers with [FromServices]. Class doc-comment now explicitly names the failure mode so a future contributor doesn't strip the attribute back off "for tidiness." Regression guard: new StudioMemberEndpointsRouteBindingTests forces endpoint construction (the exact codepath from the failing stack trace) and asserts all 8 routes build. Without [FromServices] this test goes red at the Studio test layer instead of mainnet startup. Verified: test/Aevatar.Hosting.Tests — 32 passed (was 30 + 2 failing) test/Aevatar.Studio.Tests — 330 passed (+1 new regression test) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #454.
Summary
serviceIdfrom itsBind / Invoke / Observepaths:GET /api/scopes/{scopeId}/members/{memberId}/endpoints/{endpointId}/contractPOST /api/scopes/{scopeId}/members/{memberId}/binding/revisions/{revisionId}:activatePOST /api/scopes/{scopeId}/members/{memberId}/binding/revisions/{revisionId}:retirepublishedServiceIdinternally through the existing StudioMember authority (IStudioMemberQueryPort), then call platformIServiceLifecycleQueryPort/IServiceCommandPortagainst the resolved identity.serviceIdis never accepted as a user-facing input.Why route through Studio (not platform's resolver)
Studio's
BindAsync(PR #428) persistspublishedServiceId = "member-{memberId}"via the StudioMember authority (StudioMemberConventions.BuildPublishedServiceId). Going throughIStudioMemberQueryPortguarantees the new routes read the exact identity Studio's bind wrote, regardless ofDefaultMemberPublishedServiceResolver's deterministic-id convention.Response shapes (acceptance: stay stable with legacy where possible)
StudioMemberEndpointContractResponsemirrorsScopeServiceEndpointContractHttpResponsefield-for-field; addsMemberId, setsInvokePathto the member-first URL.StudioMemberBindingActivationResponse/StudioMemberBindingRevisionActionResponsemirror the legacy activation / revision-action payloads, withMemberId+PublishedServiceIdfor migration parity.Architecture notes
StudioMemberServiceconstructor addsIServiceLifecycleQueryPort+IServiceCommandPort. DI is unchanged for callers because both are already registered by the platform capability bundle.IStudioMemberService(same pattern as the existing member-first endpoints).STUDIO_MEMBER_NOT_FOUND; member exists but unbound / missing revision / retired-revision-activated → 400 with a stable code; endpoint-not-on-service → 404STUDIO_MEMBER_ENDPOINT_CONTRACT_NOT_FOUND.Acceptance criteria from #454
Test plan
dotnet build aevatar.slnx --nologo— 0 errors.dotnet test test/Aevatar.Studio.Tests/Aevatar.Studio.Tests.csproj— 314 passed (was 290; +24 new for [Blocker] Missing member contract and revision lifecycle APIs block final Studio de-serviceId cleanup #454 + the existing-test constructor updates).dotnet test test/Aevatar.GAgentService.Integration.Tests/...— 281 passed (no regressions; the platform side is untouched).bash tools/ci/workflow_binding_boundary_guard.shbash tools/ci/query_projection_priming_guard.shbash tools/ci/projection_state_version_guard.shbash tools/ci/projection_state_mirror_current_state_guard.shbash tools/ci/projection_route_mapping_guard.shbash tools/ci/test_stability_guards.shbash tools/ci/cqrs_eventsourcing_boundary_guard.shbash tools/ci/committed_state_projection_guard.shbash tools/ci/solution_split_guards.shplayground_asset_drift_guard(which requirespnpm exec vite buildand is not impacted by this PR — no playground/CLI assets touched).Non-goals
/binding/revisions/...,/services/{serviceId}/...) stay untouched for legacy callers.IMemberPublishedServiceResolverconvention divergence is out of scope.🤖 Generated with Claude Code