From a157fdcf6835e22458befe3152e8628c09d6f1cc Mon Sep 17 00:00:00 2001 From: AbigailDeng Date: Mon, 27 Apr 2026 14:01:26 +0800 Subject: [PATCH 1/6] Refine Studio invoke and observe flow --- .../studio/scripts/ScriptsWorkbenchPage.tsx | 8 +- .../src/pages/chat/chatAdvancedConsole.tsx | 4 +- .../src/pages/chat/index.test.tsx | 5 + .../src/pages/chat/index.tsx | 28 +- .../src/pages/gagents/index.test.tsx | 14 +- .../src/pages/gagents/index.tsx | 6 +- .../src/pages/scopes/assets.test.tsx | 12 + .../src/pages/scopes/assets.tsx | 69 +- .../ScopeServiceRuntimeWorkbench.tsx | 26 +- .../src/pages/scopes/invoke.test.tsx | 14 + .../src/pages/scopes/invoke.tsx | 87 +- .../components/StudioExecutionPage.test.tsx | 115 +- .../StudioMemberInvokePanel.test.tsx | 16 + .../components/StudioMemberInvokePanel.tsx | 96 +- .../components/StudioWorkbenchSections.tsx | 507 +++++-- .../components/bind/StudioMemberBindPanel.tsx | 12 +- .../studio/components/bind/bindContract.ts | 4 +- .../src/pages/studio/index.test.tsx | 431 +++++- .../src/pages/studio/index.tsx | 1267 ++++++++++++++--- .../src/pages/teams/LegacyTeamsHome.tsx | 10 +- .../src/pages/teams/TeamsHomeRosterV0.tsx | 4 +- .../src/pages/teams/detail.test.tsx | 52 +- .../src/pages/teams/detail.tsx | 25 +- .../src/pages/teams/home.test.tsx | 140 +- .../src/pages/teams/home.tsx | 475 ++---- .../pages/teams/runtime/teamIntegrations.ts | 10 +- .../teams/runtime/teamRuntimeLens.test.ts | 4 +- .../pages/teams/runtime/teamRuntimeLens.ts | 140 +- .../pages/teams/runtime/useTeamRuntimeLens.ts | 75 +- .../src/pages/teams/tabs/TeamBindingsTab.tsx | 2 +- .../src/pages/teams/teamsHomeShared.tsx | 13 +- .../teams/workflowOperationalUnits.test.ts | 46 - .../pages/teams/workflowOperationalUnits.ts | 46 +- .../src/shared/api/runtimeGAgentApi.ts | 18 + .../shared/models/runtime/scopeServices.ts | 6 +- .../src/shared/studio/api.ts | 4 + .../src/shared/studio/execution.ts | 40 +- .../src/shared/studio/models.ts | 42 + .../src/shared/studio/navigation.test.ts | 2 +- .../src/shared/studio/navigation.ts | 5 - .../src/shared/studio/observeSession.ts | 137 ++ .../src/shared/ui/AevatarAppFlowGuide.tsx | 10 +- 42 files changed, 3035 insertions(+), 992 deletions(-) create mode 100644 apps/aevatar-console-web/src/shared/studio/observeSession.ts diff --git a/apps/aevatar-console-web/src/modules/studio/scripts/ScriptsWorkbenchPage.tsx b/apps/aevatar-console-web/src/modules/studio/scripts/ScriptsWorkbenchPage.tsx index 33f14eb75..b95da59b5 100644 --- a/apps/aevatar-console-web/src/modules/studio/scripts/ScriptsWorkbenchPage.tsx +++ b/apps/aevatar-console-web/src/modules/studio/scripts/ScriptsWorkbenchPage.tsx @@ -1501,12 +1501,12 @@ const ScriptsWorkbenchPage: React.FC = ({ await queryClient.invalidateQueries({ queryKey: ['studio-scripts-catalogs', appContext.scopeId], }); - const bindingScopeIds = Array.from( + const defaultRouteScopeIds = Array.from( new Set([appContext.scopeId, resolvedScopeId].filter(Boolean)), ); - for (const scopeId of bindingScopeIds) { + for (const scopeId of defaultRouteScopeIds) { await queryClient.invalidateQueries({ - queryKey: ['studio-scope-binding', scopeId], + queryKey: ['studio-default-route', scopeId], }); } }, [appContext.scopeId, queryClient, resolvedScopeId]); @@ -2672,7 +2672,7 @@ const ScriptsWorkbenchPage: React.FC = ({ className="console-scripts-solid-action console-scripts-header-text-action" onClick={handleOpenBindScope} disabled={!canBindScope} - aria-label="Bind scope" + aria-label="Update default route" > Bind diff --git a/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx b/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx index c143da909..8d59c9c53 100644 --- a/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx +++ b/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx @@ -124,7 +124,7 @@ const consoleFlows: readonly ConsoleFlow[] = [ { badge: "Recommended first", description: - "Check the scope binding, published services, deployed workflows, or inspect an actor directly.", + "Check the default route target, published services, deployed workflows, or inspect an actor directly.", group: "understand", id: "query", label: "Query", @@ -702,7 +702,7 @@ export function ChatAdvancedConsole({ let result: unknown; switch (queryTarget) { case "binding": - result = await studioApi.getScopeBinding(scopeId); + result = await studioApi.getDefaultRouteTarget(scopeId); break; case "services": result = await servicesApi.listServices({ diff --git a/apps/aevatar-console-web/src/pages/chat/index.test.tsx b/apps/aevatar-console-web/src/pages/chat/index.test.tsx index c51f7f6e0..478d238c2 100644 --- a/apps/aevatar-console-web/src/pages/chat/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/chat/index.test.tsx @@ -84,6 +84,11 @@ jest.mock("@/shared/studio/api", () => ({ scopeId: "scope-a", serviceId: "support-service", })), + getDefaultRouteTarget: jest.fn(async () => ({ + available: true, + scopeId: "scope-a", + serviceId: "support-service", + })), getUserConfig: jest.fn(async () => ({ defaultModel: "", preferredLlmRoute: "", diff --git a/apps/aevatar-console-web/src/pages/chat/index.tsx b/apps/aevatar-console-web/src/pages/chat/index.tsx index 5cb68da69..4941aa8bc 100644 --- a/apps/aevatar-console-web/src/pages/chat/index.tsx +++ b/apps/aevatar-console-web/src/pages/chat/index.tsx @@ -386,10 +386,10 @@ const ChatPage: React.FC = () => { queryFn: () => studioApi.getUserConfigModels(), }); - const bindingQuery = useQuery({ + const defaultRouteTargetQuery = useQuery({ enabled: scopeId.length > 0, - queryKey: ["chat", "binding", scopeId], - queryFn: () => studioApi.getScopeBinding(scopeId), + queryKey: ["chat", "default-route-target", scopeId], + queryFn: () => studioApi.getDefaultRouteTarget(scopeId), }); const servicesQuery = useQuery({ enabled: scopeId.length > 0, @@ -407,13 +407,19 @@ const ChatPage: React.FC = () => { createOnboardingServiceOption(), ...buildScopeConsoleServiceOptions( servicesQuery.data ?? [], - bindingQuery.data?.available ? bindingQuery.data.serviceId : undefined, + defaultRouteTargetQuery.data?.available + ? defaultRouteTargetQuery.data.serviceId + : undefined, { chatOnly: true, } ).map(mapChatServiceOption), ], - [bindingQuery.data?.available, bindingQuery.data?.serviceId, servicesQuery.data] + [ + defaultRouteTargetQuery.data?.available, + defaultRouteTargetQuery.data?.serviceId, + servicesQuery.data, + ] ); const providerConfigured = useMemo( () => hasConfiguredProviders(settingsQuery.data?.providers ?? []), @@ -575,14 +581,16 @@ const ChatPage: React.FC = () => { const onboardingPreferredServiceId = settingsQuery.isSuccess && !providerConfigured && - !bindingQuery.data?.available && + !defaultRouteTargetQuery.data?.available && services.some((service) => service.id === onboardingServiceId) ? onboardingServiceId : ""; const preferredServiceId = routePreferredServiceId || onboardingPreferredServiceId || - (bindingQuery.data?.available ? bindingQuery.data.serviceId : "") || + (defaultRouteTargetQuery.data?.available + ? defaultRouteTargetQuery.data.serviceId + : "") || services.find((service) => service.id === nyxIdChatServiceId)?.id || services[0]?.id || ""; @@ -609,8 +617,8 @@ const ChatPage: React.FC = () => { } }, [ activeConversationId, - bindingQuery.data?.available, - bindingQuery.data?.serviceId, + defaultRouteTargetQuery.data?.available, + defaultRouteTargetQuery.data?.serviceId, isStreaming, messages.length, providerConfigured, @@ -2197,7 +2205,7 @@ const ChatPage: React.FC = () => { "Open Tools only when you need audit evidence or protocol-level detail.", ] : [ - "Ask NyxID to inspect services, credentials, or scope bindings.", + "Ask NyxID to inspect services, credentials, or default route targets.", "Use natural-language prompts first, then open Tools for deeper runtime evidence.", "Keep model and route overrides in the composer footer when you need a specific provider path.", ] diff --git a/apps/aevatar-console-web/src/pages/gagents/index.test.tsx b/apps/aevatar-console-web/src/pages/gagents/index.test.tsx index 433d97458..8936c5751 100644 --- a/apps/aevatar-console-web/src/pages/gagents/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/gagents/index.test.tsx @@ -34,9 +34,12 @@ jest.mock("@/shared/api/runtimeGAgentApi", () => ({ listTypes: jest.fn(), listActors: jest.fn(), getScopeBinding: jest.fn(), + getDefaultRouteTarget: jest.fn(), bindScopeGAgent: jest.fn(), activateScopeBindingRevision: jest.fn(), + activateMemberBindingRevision: jest.fn(), retireScopeBindingRevision: jest.fn(), + retireMemberBindingRevision: jest.fn(), addActor: jest.fn(), removeActor: jest.fn(), streamDraftRun: jest.fn(), @@ -106,9 +109,12 @@ describe("GAgentsPage", () => { listTypes: jest.Mock; listActors: jest.Mock; getScopeBinding: jest.Mock; + getDefaultRouteTarget: jest.Mock; bindScopeGAgent: jest.Mock; activateScopeBindingRevision: jest.Mock; + activateMemberBindingRevision: jest.Mock; retireScopeBindingRevision: jest.Mock; + retireMemberBindingRevision: jest.Mock; addActor: jest.Mock; removeActor: jest.Mock; streamDraftRun: jest.Mock; @@ -181,13 +187,13 @@ describe("GAgentsPage", () => { preferredActorId: "orders-1", }, }); - mockedRuntimeGAgentApi.activateScopeBindingRevision.mockResolvedValue({ + mockedRuntimeGAgentApi.activateMemberBindingRevision.mockResolvedValue({ scopeId: "scope-a", serviceId: "service-orders", displayName: "Orders Assistant", revisionId: "rev-2", }); - mockedRuntimeGAgentApi.retireScopeBindingRevision.mockResolvedValue({ + mockedRuntimeGAgentApi.retireMemberBindingRevision.mockResolvedValue({ scopeId: "scope-a", serviceId: "service-orders", revisionId: "rev-2", @@ -625,7 +631,7 @@ describe("GAgentsPage", () => { fireEvent.click(await screen.findByRole("button", { name: "Activate" })); await waitFor(() => { expect( - mockedRuntimeGAgentApi.activateScopeBindingRevision + mockedRuntimeGAgentApi.activateMemberBindingRevision ).toHaveBeenCalledWith("scope-a", "rev-2"); }); expect( @@ -640,7 +646,7 @@ describe("GAgentsPage", () => { await waitFor(() => { expect( - mockedRuntimeGAgentApi.retireScopeBindingRevision + mockedRuntimeGAgentApi.retireMemberBindingRevision ).toHaveBeenCalledWith("scope-a", "rev-2"); }); expect( diff --git a/apps/aevatar-console-web/src/pages/gagents/index.tsx b/apps/aevatar-console-web/src/pages/gagents/index.tsx index 764951064..ad62ef585 100644 --- a/apps/aevatar-console-web/src/pages/gagents/index.tsx +++ b/apps/aevatar-console-web/src/pages/gagents/index.tsx @@ -453,7 +453,7 @@ const GAgentsPage: React.FC = () => { const bindingQuery = useQuery({ enabled: normalizedScopeId.length > 0, queryKey: ['runtime-gagents', 'binding', normalizedScopeId], - queryFn: () => runtimeGAgentApi.getScopeBinding(normalizedScopeId), + queryFn: () => runtimeGAgentApi.getDefaultRouteTarget(normalizedScopeId), retry: false, }); @@ -1119,7 +1119,7 @@ const GAgentsPage: React.FC = () => { setBindingPendingKey(`activate:${revisionId}`); setBindingNotice(null); try { - const result = await runtimeGAgentApi.activateScopeBindingRevision( + const result = await runtimeGAgentApi.activateMemberBindingRevision( normalizedScopeId, revisionId, ); @@ -1149,7 +1149,7 @@ const GAgentsPage: React.FC = () => { setBindingPendingKey(`retire:${revisionId}`); setBindingNotice(null); try { - const result = await runtimeGAgentApi.retireScopeBindingRevision( + const result = await runtimeGAgentApi.retireMemberBindingRevision( normalizedScopeId, revisionId, ); diff --git a/apps/aevatar-console-web/src/pages/scopes/assets.test.tsx b/apps/aevatar-console-web/src/pages/scopes/assets.test.tsx index cbd21a23f..f3adc8358 100644 --- a/apps/aevatar-console-web/src/pages/scopes/assets.test.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/assets.test.tsx @@ -103,6 +103,18 @@ jest.mock('@/shared/studio/api', () => ({ : 'actor://scope-a/default', revisions: [], })), + getDefaultRouteTarget: jest.fn(async (scopeId: string) => ({ + available: true, + scopeId: scopeId?.trim() || 'scope-a', + serviceId: scopeId?.trim() === 'scope-b' ? 'ops' : 'default', + displayName: scopeId?.trim() === 'scope-b' ? 'Workspace Beta' : 'Workspace Demo', + serviceKey: scopeId?.trim() === 'scope-b' ? 'scope-b:ops' : 'scope-a:default', + primaryActorId: + scopeId?.trim() === 'scope-b' + ? 'actor://scope-b/ops' + : 'actor://scope-a/default', + revisions: [], + })), }, })); diff --git a/apps/aevatar-console-web/src/pages/scopes/assets.tsx b/apps/aevatar-console-web/src/pages/scopes/assets.tsx index a6848a7b7..cfece2a5c 100644 --- a/apps/aevatar-console-web/src/pages/scopes/assets.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/assets.tsx @@ -37,10 +37,10 @@ import type { } from "@/shared/models/scopes"; import { studioApi } from "@/shared/studio/api"; import { - describeStudioScopeBindingRevisionContext, - describeStudioScopeBindingRevisionTarget, - formatStudioScopeBindingImplementationKind, - getStudioScopeBindingCurrentRevision, + describeStudioDefaultRouteTargetRevisionContext, + describeStudioDefaultRouteTargetRevisionTarget, + formatStudioMemberBindingImplementationKind, + getStudioDefaultRouteTargetCurrentRevision, } from "@/shared/studio/models"; import { buildStudioScriptsWorkspaceRoute, @@ -369,10 +369,10 @@ const TeamAssetsPage: React.FC = () => { queryFn: () => scopesApi.listScripts(activeDraft.scopeId), queryKey: ["scopes", "scripts", activeDraft.scopeId], }); - const bindingQuery = useQuery({ + const defaultRouteQuery = useQuery({ enabled: activeDraft.scopeId.trim().length > 0, - queryFn: () => studioApi.getScopeBinding(activeDraft.scopeId), - queryKey: ["scopes", "binding", activeDraft.scopeId], + queryFn: () => studioApi.getDefaultRouteTarget(activeDraft.scopeId), + queryKey: ["scopes", "default-route", activeDraft.scopeId], }); const workflowDetailQuery = useQuery({ enabled: @@ -423,27 +423,27 @@ const TeamAssetsPage: React.FC = () => { (item) => item.capabilityStatus === "active", ).length; const draftCapabilityCount = workflowCount + scriptCount - activeCapabilityCount; - const currentBindingRevision = getStudioScopeBindingCurrentRevision( - bindingQuery.data, + const currentDefaultRouteRevision = getStudioDefaultRouteTargetCurrentRevision( + defaultRouteQuery.data, ); - const currentBindingLabel = bindingQuery.data?.available - ? currentBindingRevision - ? describeStudioScopeBindingRevisionTarget(currentBindingRevision) - : bindingQuery.data.displayName || bindingQuery.data.serviceId + const currentDefaultRouteLabel = defaultRouteQuery.data?.available + ? currentDefaultRouteRevision + ? describeStudioDefaultRouteTargetRevisionTarget(currentDefaultRouteRevision) + : defaultRouteQuery.data.displayName || defaultRouteQuery.data.serviceId : "Not bound"; - const currentBindingKind = currentBindingRevision - ? formatStudioScopeBindingImplementationKind( - currentBindingRevision.implementationKind, + const currentDefaultRouteKind = currentDefaultRouteRevision + ? formatStudioMemberBindingImplementationKind( + currentDefaultRouteRevision.implementationKind, ) - : bindingQuery.data?.available + : defaultRouteQuery.data?.available ? "Published" : "Unknown"; - const currentBindingContext = describeStudioScopeBindingRevisionContext( - currentBindingRevision, + const currentDefaultRouteContext = describeStudioDefaultRouteTargetRevisionContext( + currentDefaultRouteRevision, ); - const currentBindingActor = - currentBindingRevision?.primaryActorId || - bindingQuery.data?.primaryActorId || + const currentDefaultRouteActor = + currentDefaultRouteRevision?.primaryActorId || + defaultRouteQuery.data?.primaryActorId || ""; const selectedWorkflow = useMemo( @@ -740,8 +740,9 @@ const TeamAssetsPage: React.FC = () => { history.push( buildRuntimeGAgentsHref({ scopeId: activeDraft.scopeId.trim(), - actorId: currentBindingRevision?.primaryActorId || undefined, - actorTypeName: currentBindingRevision?.staticActorTypeName || undefined, + actorId: currentDefaultRouteRevision?.primaryActorId || undefined, + actorTypeName: + currentDefaultRouteRevision?.staticActorTypeName || undefined, }), ) } @@ -830,8 +831,14 @@ const TeamAssetsPage: React.FC = () => { }} > - - + + { value="Stage capability posture first. Open the inspector only when you need source, schema, or catalog detail." /> @@ -609,8 +609,8 @@ const ScopeServiceRuntimeWorkbench: React.FC {revision.retiredAt ? : null} - {describeStudioScopeBindingRevisionTarget(revision)} ·{" "} - {describeStudioScopeBindingRevisionContext(revision) || "No detail"} + {describeStudioMemberBindingRevisionTarget(revision)} ·{" "} + {describeStudioMemberBindingRevisionContext(revision) || "No detail"} Serving {revision.servingState || revision.status} · Published{" "} @@ -856,12 +856,12 @@ const ScopeServiceRuntimeWorkbench: React.FC title={ bindingsQuery.error instanceof Error ? bindingsQuery.error.message - : "Failed to load scope bindings." + : "Failed to load default route revisions." } type="error" /> ) : bindingsQuery.isLoading ? ( - + ) : ( bindingCards )} @@ -915,22 +915,22 @@ const ScopeServiceRuntimeWorkbench: React.FC /> - {describeStudioScopeBindingRevisionContext(currentRevision) ? ( + {describeStudioMemberBindingRevisionContext(currentRevision) ? ( title={ bindingEditorState?.mode === "edit" ? `Edit binding ${bindingEditorState.bindingId || ""}` - : "Create scope binding" + : "Create default route" } > diff --git a/apps/aevatar-console-web/src/pages/scopes/invoke.test.tsx b/apps/aevatar-console-web/src/pages/scopes/invoke.test.tsx index 88bd907ef..36a5bca55 100644 --- a/apps/aevatar-console-web/src/pages/scopes/invoke.test.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/invoke.test.tsx @@ -121,6 +121,20 @@ jest.mock('@/shared/studio/api', () => ({ updatedAt: '2026-03-26T08:00:00Z', revisions: [], })), + getDefaultRouteTarget: jest.fn(async () => ({ + available: true, + scopeId: 'scope-a', + serviceId: 'default', + displayName: 'Workspace Demo', + serviceKey: 'scope-a:default:default:default', + defaultServingRevisionId: 'rev-2', + activeServingRevisionId: 'rev-2', + deploymentId: 'deploy-2', + deploymentStatus: 'Active', + primaryActorId: 'actor://scope-a/default', + updatedAt: '2026-03-26T08:00:00Z', + revisions: [], + })), }, })); diff --git a/apps/aevatar-console-web/src/pages/scopes/invoke.tsx b/apps/aevatar-console-web/src/pages/scopes/invoke.tsx index 91933c89f..6fffeafd2 100644 --- a/apps/aevatar-console-web/src/pages/scopes/invoke.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/invoke.tsx @@ -57,9 +57,9 @@ import { import { studioApi } from '@/shared/studio/api'; import type { ServiceCatalogSnapshot } from '@/shared/models/services'; import { - describeStudioScopeBindingRevisionContext, - describeStudioScopeBindingRevisionTarget, - getStudioScopeBindingCurrentRevision, + describeStudioDefaultRouteTargetRevisionContext, + describeStudioDefaultRouteTargetRevisionTarget, + getStudioDefaultRouteTargetCurrentRevision, } from '@/shared/studio/models'; import { buildStudioWorkflowWorkspaceRoute } from '@/shared/studio/navigation'; import { @@ -527,10 +527,10 @@ const ScopeInvokePage: React.FC = () => { }, [chatMessages]); const scopeId = activeDraft.scopeId.trim(); - const bindingQuery = useQuery({ + const defaultRouteQuery = useQuery({ enabled: scopeId.length > 0, - queryKey: ['scopes', 'binding', scopeId], - queryFn: () => studioApi.getScopeBinding(scopeId), + queryKey: ['scopes', 'default-route', scopeId], + queryFn: () => studioApi.getDefaultRouteTarget(scopeId), }); const scopeServicesQuery = useQuery({ enabled: scopeId.length > 0, @@ -551,11 +551,13 @@ const ScopeInvokePage: React.FC = () => { () => buildPublishedServiceCatalog( scopeServicesQuery.data ?? [], - bindingQuery.data?.available ? bindingQuery.data.serviceId : undefined, + defaultRouteQuery.data?.available + ? defaultRouteQuery.data.serviceId + : undefined, ), [ - bindingQuery.data?.available, - bindingQuery.data?.serviceId, + defaultRouteQuery.data?.available, + defaultRouteQuery.data?.serviceId, scopeServicesQuery.data, ], ); @@ -564,8 +566,8 @@ const ScopeInvokePage: React.FC = () => { scopeId ? buildScopeConsoleServiceOptions( publishedServices, - bindingQuery.data?.available - ? bindingQuery.data.serviceId + defaultRouteQuery.data?.available + ? defaultRouteQuery.data.serviceId : undefined, { sortBy: 'serviceId', @@ -573,8 +575,8 @@ const ScopeInvokePage: React.FC = () => { ) : [], [ - bindingQuery.data?.available, - bindingQuery.data?.serviceId, + defaultRouteQuery.data?.available, + defaultRouteQuery.data?.serviceId, publishedServices, scopeId, ], @@ -597,7 +599,9 @@ const ScopeInvokePage: React.FC = () => { const preferredServiceId = getPreferredScopeConsoleServiceId( services, - bindingQuery.data?.available ? bindingQuery.data.serviceId : undefined, + defaultRouteQuery.data?.available + ? defaultRouteQuery.data.serviceId + : undefined, ); const hasSelectedService = selectedServiceId && @@ -617,7 +621,7 @@ const ScopeInvokePage: React.FC = () => { setSelectedServiceId(preferredServiceId); } }, [ - bindingQuery.data?.serviceId, + defaultRouteQuery.data?.serviceId, preserveEmptySelection, selectedServiceId, services, @@ -657,18 +661,18 @@ const ScopeInvokePage: React.FC = () => { const isChatPlayground = Boolean( selectedEndpoint && isChatServiceEndpoint(selectedEndpoint), ); - const currentBindingRevision = getStudioScopeBindingCurrentRevision( - bindingQuery.data, + const currentDefaultRouteRevision = getStudioDefaultRouteTargetCurrentRevision( + defaultRouteQuery.data, ); - const currentBindingTarget = describeStudioScopeBindingRevisionTarget( - currentBindingRevision, + const currentDefaultRouteTarget = describeStudioDefaultRouteTargetRevisionTarget( + currentDefaultRouteRevision, ); - const currentBindingContext = describeStudioScopeBindingRevisionContext( - currentBindingRevision, + const currentDefaultRouteContext = describeStudioDefaultRouteTargetRevisionContext( + currentDefaultRouteRevision, ); - const currentBindingActor = - currentBindingRevision?.primaryActorId || - bindingQuery.data?.primaryActorId || + const currentDefaultRouteActor = + currentDefaultRouteRevision?.primaryActorId || + defaultRouteQuery.data?.primaryActorId || ''; useEffect(() => { @@ -1143,9 +1147,9 @@ const ScopeInvokePage: React.FC = () => { ); const observedEventCount = observedEvents.length; - const bindingStatus = - bindingQuery.data?.deploymentStatus || - (bindingQuery.data?.available ? 'ready' : 'missing'); + const defaultRouteStatus = + defaultRouteQuery.data?.deploymentStatus || + (defaultRouteQuery.data?.available ? 'ready' : 'missing'); return ( @@ -1308,15 +1312,16 @@ const ScopeInvokePage: React.FC = () => { title={ } - subtitle="Published binding visible beside the playground." - title="Current Binding" + subtitle="Current default route visible beside the playground." + title="Current Default Route" /> } > - {!bindingQuery.data?.available || !currentBindingRevision ? ( + {!defaultRouteQuery.data?.available || + !currentDefaultRouteRevision ? ( ) : ( @@ -1325,31 +1330,31 @@ const ScopeInvokePage: React.FC = () => { - {bindingQuery.data.displayName || - bindingQuery.data.serviceId} + {defaultRouteQuery.data.displayName || + defaultRouteQuery.data.serviceId} } label="Target" - value={currentBindingTarget} + value={currentDefaultRouteTarget} /> } label="Revision" - value={currentBindingRevision.revisionId} + value={currentDefaultRouteRevision.revisionId} /> } label="Actor" - value={currentBindingActor || 'n/a'} + value={currentDefaultRouteActor || 'n/a'} /> - {currentBindingContext ? ( + {currentDefaultRouteContext ? ( - {currentBindingContext} + {currentDefaultRouteContext} ) : null} + ) : activeExecutionInteraction.kind === 'wait_signal' ? ( + ) : ( } - description={`其中 ${draftUnits.length} 个行为定义还停留在草稿阶段,尚未形成首页团队入口。`} + description={`其中 ${draftUnits.length} 个行为定义还停留在草稿阶段,团队概览还不完整。`} showIcon title="还有草稿待整理" type="info" @@ -1279,11 +1108,11 @@ const TeamsHomePage: React.FC = () => { ) : null} {workflowsQuery.isLoading ? ( - + ) : workflowsQuery.isError ? ( ) : scopePreviewTeam ? ( @@ -1310,10 +1139,10 @@ const TeamsHomePage: React.FC = () => { margin: 0, }} > - 团队入口 + 当前团队 - 当前 Scope 下已经形成首页入口的团队。 + 当前 Scope 下这个团队的成员与运行概况。 {visibleTeamCount > 1 ? ( diff --git a/apps/aevatar-console-web/src/pages/teams/runtime/teamIntegrations.ts b/apps/aevatar-console-web/src/pages/teams/runtime/teamIntegrations.ts index 65b59e099..c937acda4 100644 --- a/apps/aevatar-console-web/src/pages/teams/runtime/teamIntegrations.ts +++ b/apps/aevatar-console-web/src/pages/teams/runtime/teamIntegrations.ts @@ -1,12 +1,12 @@ import type { StudioConnectorCatalog, StudioConnectorDefinition, - StudioScopeBindingImplementationKind, + StudioMemberBindingImplementationKind, StudioWorkflowDocument, StudioWorkflowRoleDocument, StudioWorkspaceSettings, } from "@/shared/studio/models"; -import { formatStudioScopeBindingImplementationKind } from "@/shared/studio/models"; +import { formatStudioMemberBindingImplementationKind } from "@/shared/studio/models"; export type TeamIntegrationItem = { key: string; @@ -19,7 +19,7 @@ export type TeamIntegrationItem = { export type TeamIntegrationsSummary = { available: boolean; - bindingKind: StudioScopeBindingImplementationKind; + bindingKind: StudioMemberBindingImplementationKind; connectorCount: number; directoryCount: number; items: TeamIntegrationItem[]; @@ -157,7 +157,7 @@ export function deriveTeamWorkflowRoleBindings( } export function deriveTeamIntegrationsSummary(input: { - bindingKind: StudioScopeBindingImplementationKind; + bindingKind: StudioMemberBindingImplementationKind; connectorCatalog: StudioConnectorCatalog | null; teamWorkflowRoles: TeamWorkflowRoleBinding[] | null | undefined; workflowDocumentCount?: number; @@ -239,7 +239,7 @@ export function deriveTeamIntegrationsSummary(input: { connectors.length > 0 || teamWorkflowRoles.length > 0; - const bindingKindLabel = formatStudioScopeBindingImplementationKind( + const bindingKindLabel = formatStudioMemberBindingImplementationKind( input.bindingKind, ); const teamRoleUsageStatus: TeamIntegrationsSummary["teamRoleUsageStatus"] = diff --git a/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.test.ts b/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.test.ts index f4b553fe4..96256b9f3 100644 --- a/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.test.ts +++ b/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.test.ts @@ -74,7 +74,7 @@ describe("teamRuntimeLens", () => { it("derives a blocked health state and compare summary from runtime facts", () => { const lens = deriveTeamRuntimeLens({ scopeId: "scope-team", - binding: { + defaultRouteTargetStatus: { available: true, scopeId: "scope-team", serviceId: "default", @@ -427,7 +427,7 @@ describe("teamRuntimeLens", () => { it("keeps health at attention when serving is active but no recent run exists", () => { const lens = deriveTeamRuntimeLens({ scopeId: "scope-team", - binding: { + defaultRouteTargetStatus: { available: true, scopeId: "scope-team", serviceId: "default", diff --git a/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.ts b/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.ts index 36a2e8771..08af8de71 100644 --- a/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.ts +++ b/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.ts @@ -1,17 +1,13 @@ import type { WorkflowActorGraphEnrichedSnapshot } from "@/shared/models/runtime/actors"; import type { RuntimeGAgentActorGroup } from "@/shared/models/runtime/gagents"; import type { + ScopeServiceRevisionCatalogSnapshot, ScopeServiceRunAuditSnapshot, ScopeServiceRunSummary, } from "@/shared/models/runtime/scopeServices"; +import { getScopeServiceCurrentRevision } from "@/shared/models/runtime/scopeServices"; import type { ServiceCatalogSnapshot } from "@/shared/models/services"; -import { - describeStudioScopeBindingRevisionContext, - describeStudioScopeBindingRevisionTarget, - getStudioScopeBindingCurrentRevision, - type StudioScopeBindingRevision, - type StudioScopeBindingStatus, -} from "@/shared/studio/models"; +import type { StudioMemberBindingRevision } from "@/shared/studio/models"; export type TeamHealthStatus = | "healthy" @@ -129,8 +125,8 @@ export type TeamRuntimeLens = { scopeId: string; title: string; subtitle: string; - activeRevision: StudioScopeBindingRevision | null; - previousRevision: StudioScopeBindingRevision | null; + activeRevision: StudioMemberBindingRevision | null; + previousRevision: StudioMemberBindingRevision | null; currentService: ServiceCatalogSnapshot | null; currentRun: ScopeServiceRunSummary | null; baselineRun: ScopeServiceRunSummary | null; @@ -150,14 +146,13 @@ export type TeamRuntimeLens = { serviceCount: number; recentRunCount: number; partialSignals: string[]; - currentBindingTarget: string; - currentBindingContext: string; humanInterventionDetected: boolean; }; export type TeamRuntimeLensInput = { scopeId: string; - binding: StudioScopeBindingStatus | null; + focusedServiceId: string | null; + serviceRevisionCatalog: ScopeServiceRevisionCatalogSnapshot | null; services: readonly ServiceCatalogSnapshot[]; actors: readonly RuntimeGAgentActorGroup[]; runs: readonly ScopeServiceRunSummary[]; @@ -196,6 +191,28 @@ function sortRuns(runs: readonly ScopeServiceRunSummary[]): ScopeServiceRunSumma }); } +function sortServices( + services: readonly ServiceCatalogSnapshot[], +): ServiceCatalogSnapshot[] { + return [...services].sort((left, right) => { + const leftDisplayName = trimOptional(left.displayName); + const rightDisplayName = trimOptional(right.displayName); + if (leftDisplayName && rightDisplayName && leftDisplayName !== rightDisplayName) { + return leftDisplayName.localeCompare(rightDisplayName); + } + + if (leftDisplayName && !rightDisplayName) { + return -1; + } + + if (!leftDisplayName && rightDisplayName) { + return 1; + } + + return trimOptional(left.serviceId).localeCompare(trimOptional(right.serviceId)); + }); +} + function isSuccessfulRun(run: ScopeServiceRunSummary | null | undefined): boolean { if (!run) { return false; @@ -347,20 +364,24 @@ function deriveFocusActorId(input: TeamRuntimeLensInput, currentRun: ScopeServic }; } - const activeRevision = getStudioScopeBindingCurrentRevision(input.binding); + const activeRevision = getScopeServiceCurrentRevision(input.serviceRevisionCatalog); const activeRevisionActorId = trimOptional(activeRevision?.primaryActorId); if (activeRevisionActorId) { return { actorId: activeRevisionActorId, - reason: "Focused on the currently serving revision actor because no active run was selected.", + reason: "Focused on the currently selected service revision actor because no active run was selected.", }; } - const bindingActorId = trimOptional(input.binding?.primaryActorId); - if (bindingActorId) { + const focusedServiceActorId = trimOptional( + input.services.find( + (service) => trimOptional(service.serviceId) === trimOptional(input.focusedServiceId), + )?.primaryActorId, + ); + if (focusedServiceActorId) { return { - actorId: bindingActorId, - reason: "Focused on the team primary actor from the current binding.", + actorId: focusedServiceActorId, + reason: "Focused on the currently selected service actor because no stronger runtime signal was available.", }; } @@ -590,7 +611,7 @@ function deriveCompareSummary(input: { } function deriveHealth(input: { - binding: StudioScopeBindingStatus | null; + activeRevision: StudioMemberBindingRevision | null; currentRun: ScopeServiceRunSummary | null; currentRunAudit: ScopeServiceRunAuditSnapshot | null; currentService: ServiceCatalogSnapshot | null; @@ -603,33 +624,40 @@ function deriveHealth(input: { const details: string[] = []; const humanInterventionDetected = hasHumanIntervention(input.currentRunAudit); - if (!input.binding?.available) { - return { - status: "attention", - tone: "warning", - summary: "The current team binding is unavailable, so runtime truth is incomplete.", - details: ["Binding data is missing or not yet ready for this team."], - }; - } - if (humanInterventionDetected) { details.push("A human-in-the-loop step is visible in the current run."); } + if (trimOptional(input.activeRevision?.revisionId)) { + details.push(`Focused revision is ${input.activeRevision?.revisionId}.`); + } + if (trimOptional(input.currentService?.deploymentStatus)) { details.push(`Service deployment is ${input.currentService?.deploymentStatus}.`); } + if (!input.currentService && !input.activeRevision && !input.currentRun) { + return { + status: "attention", + tone: "warning", + summary: + "The current team does not have a published member service or visible run yet.", + details: [ + "No published member-scoped service is visible for the current team focus.", + ], + }; + } + if (!input.currentRun) { if (normalizeStatus(input.currentService?.deploymentStatus) === "active") { return { status: "attention", tone: "info", summary: - "The current team has an active serving deployment, but no recent run is available to prove runtime health.", + "The current team has a published member service, but no recent run is available to prove runtime health.", details: [ ...details, - "No recent team activity is available to verify the active deployment.", + "No recent team activity is available to verify the current member service.", ], }; } @@ -976,15 +1004,12 @@ function derivePlaybackSummary( export function deriveTeamRuntimeLens( input: TeamRuntimeLensInput, ): TeamRuntimeLens { - const activeRevision = getStudioScopeBindingCurrentRevision(input.binding); + const activeRevision = getScopeServiceCurrentRevision(input.serviceRevisionCatalog); const previousRevision = - input.binding?.revisions.find( + input.serviceRevisionCatalog?.revisions.find( (revision) => revision.revisionId !== activeRevision?.revisionId, ) || null; - const currentService = - input.services.find((service) => service.serviceId === input.binding?.serviceId) || - input.services[0] || - null; + const sortedServices = sortServices(input.services); const selectedRuns = input.currentRun !== undefined || input.baselineRun !== undefined ? { @@ -993,8 +1018,26 @@ export function deriveTeamRuntimeLens( } : selectTeamCompareRuns(input.runs); const { baselineRun, currentRun } = selectedRuns; + const currentService = + sortedServices.find( + (service) => trimOptional(service.serviceId) === trimOptional(input.focusedServiceId), + ) || + sortedServices.find( + (service) => + trimOptional(service.serviceId) === + trimOptional(input.serviceRevisionCatalog?.serviceId), + ) || + sortedServices.find( + (service) => trimOptional(service.serviceId) === trimOptional(currentRun?.serviceId), + ) || + sortedServices[0] || + null; const focus = deriveFocusActorId(input, currentRun); - const members = deriveMembers(input.actors, focus.actorId, trimOptional(input.binding?.primaryActorId)); + const members = deriveMembers( + input.actors, + focus.actorId, + trimOptional(currentService?.primaryActorId) || trimOptional(activeRevision?.primaryActorId), + ); const compare = deriveCompareSummary({ baselineRun, baselineRunAudit: input.baselineRunAudit, @@ -1003,7 +1046,7 @@ export function deriveTeamRuntimeLens( }); const playback = derivePlaybackSummary(input.currentRunAudit); const health = deriveHealth({ - binding: input.binding, + activeRevision, currentRun, currentRunAudit: input.currentRunAudit, currentService, @@ -1018,14 +1061,19 @@ export function deriveTeamRuntimeLens( if (!currentRun) { partialSignals.push("No recent runs"); } + const subtitleParts = [ + input.workflowCount > 0 ? `${input.workflowCount} 个 workflow` : "", + input.scriptCount > 0 ? `${input.scriptCount} 个 script` : "", + input.services.length > 0 ? `${input.services.length} 个 service` : "", + ].filter(Boolean); return { scopeId: input.scopeId, - title: - trimOptional(input.binding?.displayName) || `Team ${input.scopeId}`, + title: "当前团队", subtitle: - trimOptional(input.binding?.serviceKey) || - "Team-first runtime workspace", + subtitleParts.length > 0 + ? `团队容器 · ${subtitleParts.join(" / ")}` + : "团队容器,成员绑定与运行信号会在这里汇总。", activeRevision, previousRevision, currentService, @@ -1046,7 +1094,11 @@ export function deriveTeamRuntimeLens( compare, playback, governance: { - servingRevision: activeRevision?.revisionId || "Unknown", + servingRevision: + activeRevision?.revisionId || + trimOptional(currentService?.activeServingRevisionId) || + trimOptional(currentService?.defaultServingRevisionId) || + "Unknown", traceability: currentRun ? `Recent run ${currentRun.runId} is traceable through team activity.` : "No recent run is available yet.", @@ -1065,8 +1117,6 @@ export function deriveTeamRuntimeLens( serviceCount: input.services.length, recentRunCount: input.runs.length, partialSignals, - currentBindingTarget: describeStudioScopeBindingRevisionTarget(activeRevision), - currentBindingContext: describeStudioScopeBindingRevisionContext(activeRevision), humanInterventionDetected: hasHumanIntervention(input.currentRunAudit), }; } diff --git a/apps/aevatar-console-web/src/pages/teams/runtime/useTeamRuntimeLens.ts b/apps/aevatar-console-web/src/pages/teams/runtime/useTeamRuntimeLens.ts index f7190cad0..dab9129f1 100644 --- a/apps/aevatar-console-web/src/pages/teams/runtime/useTeamRuntimeLens.ts +++ b/apps/aevatar-console-web/src/pages/teams/runtime/useTeamRuntimeLens.ts @@ -5,7 +5,7 @@ import { runtimeGAgentApi } from "@/shared/api/runtimeGAgentApi"; import { scopeRuntimeApi } from "@/shared/api/scopeRuntimeApi"; import { scopesApi } from "@/shared/api/scopesApi"; import { servicesApi } from "@/shared/api/servicesApi"; -import { studioApi } from "@/shared/studio/api"; +import { getScopeServiceCurrentRevision } from "@/shared/models/runtime/scopeServices"; import { deriveTeamRuntimeLens, selectTeamCompareRuns } from "./teamRuntimeLens"; const scopeServiceAppId = "default"; @@ -19,6 +19,31 @@ type UseTeamRuntimeLensOptions = { preferredServiceId?: string; }; +function trimOptional(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +function compareServices( + left: { displayName?: string | null; serviceId: string }, + right: { displayName?: string | null; serviceId: string }, +): number { + const leftDisplayName = trimOptional(left.displayName); + const rightDisplayName = trimOptional(right.displayName); + if (leftDisplayName && rightDisplayName && leftDisplayName !== rightDisplayName) { + return leftDisplayName.localeCompare(rightDisplayName); + } + + if (leftDisplayName && !rightDisplayName) { + return -1; + } + + if (!leftDisplayName && rightDisplayName) { + return 1; + } + + return trimOptional(left.serviceId).localeCompare(trimOptional(right.serviceId)); +} + export function useTeamRuntimeLens( scopeId: string, options?: UseTeamRuntimeLensOptions, @@ -30,12 +55,6 @@ export function useTeamRuntimeLens( const preferredServiceId = options?.preferredServiceId?.trim() ?? ""; const preferredRunId = options?.preferredRunId?.trim() ?? ""; - const bindingQuery = useQuery({ - enabled: normalizedScopeId.length > 0, - queryKey: ["teams", "binding", normalizedScopeId], - queryFn: () => studioApi.getScopeBinding(normalizedScopeId), - retry: false, - }); const workflowsQuery = useQuery({ enabled: normalizedScopeId.length > 0 && includeCatalogSignals, queryKey: ["teams", "workflows", normalizedScopeId], @@ -66,14 +85,20 @@ export function useTeamRuntimeLens( retry: false, }); + const services = useMemo( + () => [...(servicesQuery.data ?? [])].sort(compareServices), + [servicesQuery.data], + ); const serviceId = - servicesQuery.data?.find((service) => service.serviceId === preferredServiceId) - ?.serviceId || - servicesQuery.data?.find( - (service) => service.serviceId === bindingQuery.data?.serviceId, - )?.serviceId || - servicesQuery.data?.[0]?.serviceId || + services.find((service) => service.serviceId === preferredServiceId)?.serviceId || + services[0]?.serviceId || ""; + const serviceRevisionsQuery = useQuery({ + enabled: normalizedScopeId.length > 0 && serviceId.length > 0, + queryKey: ["teams", "service-revisions", normalizedScopeId, serviceId], + queryFn: () => scopeRuntimeApi.getServiceRevisions(normalizedScopeId, serviceId), + retry: false, + }); const runsQuery = useQuery({ enabled: normalizedScopeId.length > 0 && serviceId.length > 0, queryKey: ["teams", "runs", normalizedScopeId, serviceId], @@ -93,11 +118,17 @@ export function useTeamRuntimeLens( ); const currentRunId = compareRuns.currentRun?.runId?.trim() || ""; const baselineRunId = compareRuns.baselineRun?.runId?.trim() || ""; + const activeServiceRevision = useMemo( + () => getScopeServiceCurrentRevision(serviceRevisionsQuery.data ?? null), + [serviceRevisionsQuery.data], + ); const focusActorId = preferredActorId || compareRuns.currentRun?.actorId?.trim() || - bindingQuery.data?.primaryActorId?.trim() || + activeServiceRevision?.primaryActorId?.trim() || + serviceRevisionsQuery.data?.primaryActorId?.trim() || + services.find((service) => service.serviceId === serviceId)?.primaryActorId?.trim() || actorsQuery.data?.flatMap((group) => group.actorIds)[0] || ""; @@ -165,8 +196,9 @@ export function useTeamRuntimeLens( () => deriveTeamRuntimeLens({ scopeId: normalizedScopeId, - binding: bindingQuery.data ?? null, - services: servicesQuery.data ?? [], + focusedServiceId: serviceId || null, + serviceRevisionCatalog: serviceRevisionsQuery.data ?? null, + services, actors: actorsQuery.data ?? [], runs: runsQuery.data?.runs ?? [], currentRun: compareRuns.currentRun, @@ -181,18 +213,15 @@ export function useTeamRuntimeLens( actorGraphQuery.data, actorsQuery.data, baselineRunAuditQuery.data, - baselineRunId, - bindingQuery.data, compareRuns.baselineRun, compareRuns.currentRun, currentRunAuditQuery.data, - currentRunId, - graphDepth, normalizedScopeId, - preferredActorId, runsQuery.data?.runs, + serviceId, + serviceRevisionsQuery.data, + services, scriptsQuery.data?.length, - servicesQuery.data, workflowsQuery.data?.length, ], ); @@ -201,10 +230,10 @@ export function useTeamRuntimeLens( actorGraphQuery, actorsQuery, baselineRunAuditQuery, - bindingQuery, currentRunAuditQuery, lens, runsQuery, + serviceRevisionsQuery, scriptsQuery, servicesQuery, workflowsQuery, diff --git a/apps/aevatar-console-web/src/pages/teams/tabs/TeamBindingsTab.tsx b/apps/aevatar-console-web/src/pages/teams/tabs/TeamBindingsTab.tsx index ca011a63d..6cef6df99 100644 --- a/apps/aevatar-console-web/src/pages/teams/tabs/TeamBindingsTab.tsx +++ b/apps/aevatar-console-web/src/pages/teams/tabs/TeamBindingsTab.tsx @@ -272,7 +272,7 @@ const TeamBindingsTab: React.FC = ({ diff --git a/apps/aevatar-console-web/src/pages/teams/teamsHomeShared.tsx b/apps/aevatar-console-web/src/pages/teams/teamsHomeShared.tsx index 1062fa39f..0958e86e8 100644 --- a/apps/aevatar-console-web/src/pages/teams/teamsHomeShared.tsx +++ b/apps/aevatar-console-web/src/pages/teams/teamsHomeShared.tsx @@ -120,7 +120,6 @@ export function formatFreshnessAge(timestamp: string | null): string { } export function deriveRosterReason(lens: TeamRuntimeLens): RosterReason { - const bindingContext = lens.currentBindingContext.trim(); const compareSummary = lens.compare.summary.trim(); const missingSignals = lens.partialSignals.length > 0 @@ -134,7 +133,7 @@ export function deriveRosterReason(lens: TeamRuntimeLens): RosterReason { return { detail: lens.playback.prompt || lens.healthSummary, label: "A recent run is waiting on human input", - support: [bindingContext, compareSummary].filter(Boolean).slice(0, 2), + support: [compareSummary].filter(Boolean).slice(0, 2), }; } @@ -142,7 +141,7 @@ export function deriveRosterReason(lens: TeamRuntimeLens): RosterReason { return { detail: lens.healthSummary, label: "Current runtime health still needs attention", - support: [bindingContext, compareSummary].filter(Boolean).slice(0, 2), + support: [compareSummary].filter(Boolean).slice(0, 2), }; } @@ -150,14 +149,6 @@ export function deriveRosterReason(lens: TeamRuntimeLens): RosterReason { return { detail: missingSignals, label: "Some runtime signals are incomplete", - support: [bindingContext, compareSummary].filter(Boolean).slice(0, 2), - }; - } - - if (bindingContext) { - return { - detail: bindingContext, - label: "Serving context changed recently", support: [compareSummary].filter(Boolean).slice(0, 2), }; } diff --git a/apps/aevatar-console-web/src/pages/teams/workflowOperationalUnits.test.ts b/apps/aevatar-console-web/src/pages/teams/workflowOperationalUnits.test.ts index aafc937f2..c9a561b49 100644 --- a/apps/aevatar-console-web/src/pages/teams/workflowOperationalUnits.test.ts +++ b/apps/aevatar-console-web/src/pages/teams/workflowOperationalUnits.test.ts @@ -5,48 +5,6 @@ import { } from "./workflowOperationalUnits"; describe("workflowOperationalUnits", () => { - const binding = { - available: true, - scopeId: "scope-a", - serviceId: "service-alpha", - displayName: "Alpha Team", - serviceKey: "scope-a:alpha", - defaultServingRevisionId: "rev-alpha", - activeServingRevisionId: "rev-alpha", - deploymentId: "dep-alpha", - deploymentStatus: "Active", - primaryActorId: "actor://alpha", - updatedAt: "2026-04-13T09:00:00Z", - revisions: [ - { - revisionId: "rev-alpha", - implementationKind: "workflow", - status: "Published", - artifactHash: "hash-alpha", - failureReason: "", - isDefaultServing: true, - isActiveServing: true, - isServingTarget: true, - allocationWeight: 100, - servingState: "Active", - deploymentId: "dep-alpha", - primaryActorId: "actor://alpha", - createdAt: "2026-04-13T08:00:00Z", - preparedAt: "2026-04-13T08:05:00Z", - publishedAt: "2026-04-13T08:10:00Z", - retiredAt: null, - workflowName: "customer-support-triage", - workflowDefinitionActorId: "definition://customer-support-triage", - inlineWorkflowCount: 1, - scriptId: "", - scriptRevision: "", - scriptDefinitionActorId: "", - scriptSourceHash: "", - staticActorTypeName: "", - }, - ], - } as const; - const workflows = [ { scopeId: "scope-a", @@ -159,7 +117,6 @@ describe("workflowOperationalUnits", () => { it("collects the deduped matched service ids for the roster queries", () => { expect( collectWorkflowOperationalServiceIds({ - binding, services, workflows, }), @@ -168,7 +125,6 @@ describe("workflowOperationalUnits", () => { it("ignores stale service and run hints when a workflow-backed match exists", () => { const unit = resolveWorkflowOperationalUnit({ - binding, preferredRunId: "run-does-not-exist", preferredServiceId: "service-stale", runs, @@ -191,7 +147,6 @@ describe("workflowOperationalUnits", () => { it("marks a workflow with no bound service and no service key as draft only", () => { const unit = resolveWorkflowOperationalUnit({ - binding, services, signals: { servicesAvailable: true, @@ -206,7 +161,6 @@ describe("workflowOperationalUnits", () => { it("sorts attention-first roster cards before healthy or draft cards", () => { const units = buildWorkflowOperationalUnits({ - binding, workflows: [ ...workflows, { diff --git a/apps/aevatar-console-web/src/pages/teams/workflowOperationalUnits.ts b/apps/aevatar-console-web/src/pages/teams/workflowOperationalUnits.ts index 7d12fc37d..5a3f90a8d 100644 --- a/apps/aevatar-console-web/src/pages/teams/workflowOperationalUnits.ts +++ b/apps/aevatar-console-web/src/pages/teams/workflowOperationalUnits.ts @@ -1,10 +1,6 @@ import type { ScopeWorkflowSummary } from "@/shared/models/scopes"; import type { ScopeServiceRunSummary } from "@/shared/models/runtime/scopeServices"; import type { ServiceCatalogSnapshot } from "@/shared/models/services"; -import { - getStudioScopeBindingCurrentRevision, - type StudioScopeBindingStatus, -} from "@/shared/studio/models"; export const WORKFLOW_RUNTIME_GUARDRAIL = 12; @@ -40,7 +36,6 @@ type WorkflowOperationalSignals = { }; type ResolveWorkflowOperationalUnitInput = { - readonly binding?: StudioScopeBindingStatus | null; readonly preferredRunId?: string; readonly preferredServiceId?: string; readonly runs?: readonly ScopeServiceRunSummary[]; @@ -50,7 +45,6 @@ type ResolveWorkflowOperationalUnitInput = { }; type BuildWorkflowOperationalUnitsInput = { - readonly binding?: StudioScopeBindingStatus | null; readonly runsByServiceId?: Readonly>; readonly services?: readonly ServiceCatalogSnapshot[]; readonly signals?: WorkflowOperationalSignals; @@ -159,29 +153,9 @@ function isFailedRun(run: ScopeServiceRunSummary | null | undefined): boolean { ); } -function workflowMatchesBindingRevision( - workflow: ScopeWorkflowSummary, - binding: StudioScopeBindingStatus | null | undefined, -): boolean { - const activeRevision = getStudioScopeBindingCurrentRevision(binding); - if (!activeRevision) { - return false; - } - - const workflowName = trimOptional(workflow.workflowName); - const revisionId = trimOptional(workflow.activeRevisionId); - return ( - (workflowName.length > 0 && - trimOptional(activeRevision.workflowName) === workflowName) || - (revisionId.length > 0 && - trimOptional(activeRevision.revisionId) === revisionId) - ); -} - function workflowMatchesService( workflow: ScopeWorkflowSummary, service: ServiceCatalogSnapshot, - binding: StudioScopeBindingStatus | null | undefined, ): boolean { const workflowServiceKey = trimOptional(workflow.serviceKey); const workflowRevisionId = trimOptional(workflow.activeRevisionId); @@ -200,20 +174,15 @@ function workflowMatchesService( ) { return true; } - - return ( - trimOptional(binding?.serviceId) === trimOptional(service.serviceId) && - workflowMatchesBindingRevision(workflow, binding ?? null) - ); + return false; } function matchWorkflowOperationalService(input: { - readonly binding: StudioScopeBindingStatus | null | undefined; readonly preferredServiceId?: string; readonly services: readonly ServiceCatalogSnapshot[]; readonly workflow: ScopeWorkflowSummary; }): ServiceMatchResult { - const { binding, services, workflow } = input; + const { services, workflow } = input; const preferredServiceId = trimOptional(input.preferredServiceId); const preferredService = preferredServiceId.length > 0 @@ -223,7 +192,7 @@ function matchWorkflowOperationalService(input: { : null; if ( preferredService && - workflowMatchesService(workflow, preferredService, binding ?? null) + workflowMatchesService(workflow, preferredService) ) { return { matchedService: preferredService, @@ -233,9 +202,7 @@ function matchWorkflowOperationalService(input: { const matchedService = services - .filter((service) => - workflowMatchesService(workflow, service, binding ?? null), - ) + .filter((service) => workflowMatchesService(workflow, service)) .sort(compareServices)[0] ?? null; return { @@ -397,7 +364,6 @@ function resolveRuntimeAvailability(input: { } export function collectWorkflowOperationalServiceIds(input: { - readonly binding?: StudioScopeBindingStatus | null; readonly services?: readonly ServiceCatalogSnapshot[]; readonly workflows: readonly ScopeWorkflowSummary[]; }): string[] { @@ -406,7 +372,6 @@ export function collectWorkflowOperationalServiceIds(input: { input.workflows.forEach((workflow) => { const matchedService = matchWorkflowOperationalService({ - binding: input.binding ?? null, services, workflow, }).matchedService; @@ -426,7 +391,6 @@ export function resolveWorkflowOperationalUnit( const servicesAvailable = input.signals?.servicesAvailable ?? true; const serviceMatch = servicesAvailable ? matchWorkflowOperationalService({ - binding: input.binding ?? null, preferredServiceId: input.preferredServiceId, services, workflow: input.workflow, @@ -526,7 +490,6 @@ export function buildWorkflowOperationalUnits( ): WorkflowOperationalUnit[] { const units = input.workflows.map((workflow) => { const unitWithoutRuns = resolveWorkflowOperationalUnit({ - binding: input.binding ?? null, services: input.services ?? [], signals: input.signals, workflow, @@ -534,7 +497,6 @@ export function buildWorkflowOperationalUnits( const serviceId = trimOptional(unitWithoutRuns.matchedService?.serviceId); return resolveWorkflowOperationalUnit({ - binding: input.binding ?? null, runs: serviceId.length > 0 ? input.runsByServiceId?.[serviceId] ?? [] diff --git a/apps/aevatar-console-web/src/shared/api/runtimeGAgentApi.ts b/apps/aevatar-console-web/src/shared/api/runtimeGAgentApi.ts index d1d53c01c..e806dc73b 100644 --- a/apps/aevatar-console-web/src/shared/api/runtimeGAgentApi.ts +++ b/apps/aevatar-console-web/src/shared/api/runtimeGAgentApi.ts @@ -283,6 +283,10 @@ export const runtimeGAgentApi = { ); }, + getDefaultRouteTarget(scopeId: string): Promise { + return this.getScopeBinding(scopeId); + }, + bindScopeGAgent( input: RuntimeScopeGAgentBindingRequest ): Promise { @@ -335,6 +339,13 @@ export const runtimeGAgentApi = { ); }, + activateMemberBindingRevision( + scopeId: string, + revisionId: string + ): Promise { + return this.activateScopeBindingRevision(scopeId, revisionId); + }, + retireScopeBindingRevision( scopeId: string, revisionId: string @@ -353,6 +364,13 @@ export const runtimeGAgentApi = { ); }, + retireMemberBindingRevision( + scopeId: string, + revisionId: string + ): Promise { + return this.retireScopeBindingRevision(scopeId, revisionId); + }, + async addActor( scopeId: string, gAgentType: string, diff --git a/apps/aevatar-console-web/src/shared/models/runtime/scopeServices.ts b/apps/aevatar-console-web/src/shared/models/runtime/scopeServices.ts index ad5e41989..0d7c8a4ca 100644 --- a/apps/aevatar-console-web/src/shared/models/runtime/scopeServices.ts +++ b/apps/aevatar-console-web/src/shared/models/runtime/scopeServices.ts @@ -1,5 +1,5 @@ import type { ServiceBindingSnapshot } from "@/shared/models/governance"; -import type { StudioScopeBindingRevision } from "@/shared/studio/models"; +import type { StudioMemberBindingRevision } from "@/shared/studio/models"; export interface ScopeServiceBindingInput { readonly bindingId: string; @@ -32,7 +32,7 @@ export interface ScopeServiceRevisionCatalogSnapshot { readonly catalogStateVersion: number; readonly catalogLastEventId: string; readonly updatedAt: string | null; - readonly revisions: readonly StudioScopeBindingRevision[]; + readonly revisions: readonly StudioMemberBindingRevision[]; } export interface ScopeServiceRevisionActionResult { @@ -189,7 +189,7 @@ export type ScopeServiceBindingCatalogSnapshot = { export function getScopeServiceCurrentRevision( catalog: ScopeServiceRevisionCatalogSnapshot | null | undefined, -): StudioScopeBindingRevision | null { +): StudioMemberBindingRevision | null { if (!catalog?.revisions.length) { return null; } diff --git a/apps/aevatar-console-web/src/shared/studio/api.ts b/apps/aevatar-console-web/src/shared/studio/api.ts index bd6b26220..9b7d2d411 100644 --- a/apps/aevatar-console-web/src/shared/studio/api.ts +++ b/apps/aevatar-console-web/src/shared/studio/api.ts @@ -1251,6 +1251,10 @@ export const studioApi = { ); }, + getDefaultRouteTarget(scopeId: string): Promise { + return this.getScopeBinding(scopeId); + }, + getScopeScriptBinding( scopeId: string ): Promise { diff --git a/apps/aevatar-console-web/src/shared/studio/execution.ts b/apps/aevatar-console-web/src/shared/studio/execution.ts index d2be9ccc2..fb4936515 100644 --- a/apps/aevatar-console-web/src/shared/studio/execution.ts +++ b/apps/aevatar-console-web/src/shared/studio/execution.ts @@ -31,12 +31,13 @@ export type StepExecutionState = { }; export type ExecutionInteractionState = { - readonly kind: 'human_input' | 'human_approval'; + readonly kind: 'human_input' | 'human_approval' | 'wait_signal'; readonly runId: string; readonly stepId: string; readonly prompt: string; readonly timeoutSeconds: number | null; readonly variableName: string; + readonly signalName: string; }; export type ExecutionTrace = { @@ -113,7 +114,11 @@ function normalizeExecutionInteractionKind( value: unknown, ): ExecutionInteractionState['kind'] | null { const text = String(value || '').trim().toLowerCase(); - if (text === 'human_input' || text === 'human_approval') { + if ( + text === 'human_input' || + text === 'human_approval' || + text === 'wait_signal' + ) { return text; } @@ -254,12 +259,16 @@ export function buildExecutionTrace( continue; } - if (customName === 'aevatar.human_input.request') { + if ( + customName === 'aevatar.human_input.request' || + customName === 'aevatar.wait_signal.request' + ) { const stepId = String(customPayload?.stepId || '').trim(); const runId = String(customPayload?.runId || '').trim(); - const interactionKind = normalizeExecutionInteractionKind( - customPayload?.suspensionType, - ); + const interactionKind = + customName === 'aevatar.wait_signal.request' + ? 'wait_signal' + : normalizeExecutionInteractionKind(customPayload?.suspensionType); if (!stepId || !runId || !interactionKind) { continue; } @@ -277,6 +286,7 @@ export function buildExecutionTrace( prompt: String(customPayload?.prompt || '').trim(), timeoutSeconds, variableName: String(customPayload?.variableName || '').trim(), + signalName: String(customPayload?.signalName || '').trim(), }; logs.push({ @@ -284,9 +294,17 @@ export function buildExecutionTrace( title: interactionKind === 'human_approval' ? `${stepId} waiting for approval` + : interactionKind === 'wait_signal' + ? `${stepId} waiting for signal` : `${stepId} waiting for input`, meta: [ - interactionKind === 'human_approval' ? 'human approval' : 'human input', + interactionKind === 'human_approval' + ? 'human approval' + : interactionKind === 'wait_signal' + ? `wait signal${ + interaction.signalName ? ` ${interaction.signalName}` : '' + }` + : 'human input', interaction.variableName ? `variable ${interaction.variableName}` : null, timeoutSeconds ? `timeout ${timeoutSeconds}s` : null, ] @@ -367,10 +385,18 @@ export function buildExecutionTrace( title: interactionKind === 'human_approval' ? `${stepId} ${approved ? 'approved' : 'rejected'}` + : interactionKind === 'wait_signal' + ? `${stepId} signal sent` : `${stepId} input submitted`, meta: interactionKind === 'human_approval' ? `human approval · ${approved ? 'approved' : 'rejected'}` + : interactionKind === 'wait_signal' + ? `wait signal${ + String(customPayload?.signalName || '').trim() + ? ` · ${String(customPayload?.signalName || '').trim()}` + : '' + }` : 'human input submitted', previewText: buildExecutionLogPreview(customPayload?.userInput), clipboardText: buildExecutionLogText(customPayload?.userInput), diff --git a/apps/aevatar-console-web/src/shared/studio/models.ts b/apps/aevatar-console-web/src/shared/studio/models.ts index 146a70b3e..9e52bee2b 100644 --- a/apps/aevatar-console-web/src/shared/studio/models.ts +++ b/apps/aevatar-console-web/src/shared/studio/models.ts @@ -159,6 +159,18 @@ export interface StudioExecutionSummary { readonly completedAtUtc: string | null; readonly actorId: string | null; readonly error: string | null; + readonly serviceId?: string | null; + readonly revisionId?: string | null; + readonly definitionActorId?: string | null; + readonly stateVersion?: number | null; + readonly lastEventId?: string | null; + readonly updatedAtUtc?: string | null; + readonly totalSteps?: number | null; + readonly completedSteps?: number | null; + readonly roleReplyCount?: number | null; + readonly output?: string | null; + readonly auditUpdatedAtUtc?: string | null; + readonly auditSource?: 'service-run-summary' | 'run-audit' | 'invoke-session'; } export interface StudioExecutionFrame { @@ -377,6 +389,36 @@ export function getStudioScopeBindingCurrentRevision( ); } +export type StudioMemberBindingImplementationKind = + StudioScopeBindingImplementationKind; +export type StudioMemberBindingTargetKind = StudioScopeBindingTargetKind; +export type StudioMemberBindingResult = StudioScopeBindingResult; +export type StudioMemberBindingRevision = StudioScopeBindingRevision; +export type StudioMemberBindingStatus = StudioScopeBindingStatus; +export type StudioMemberBindingActivationResult = + StudioScopeBindingActivationResult; +export type StudioMemberBindingRetirementResult = + StudioScopeBindingRetirementResult; +export const normalizeStudioMemberBindingImplementationKind = + normalizeStudioScopeBindingImplementationKind; +export const formatStudioMemberBindingImplementationKind = + formatStudioScopeBindingImplementationKind; +export const describeStudioMemberBindingRevisionTarget = + describeStudioScopeBindingRevisionTarget; +export const describeStudioMemberBindingRevisionContext = + describeStudioScopeBindingRevisionContext; +export const getStudioMemberBindingCurrentRevision = + getStudioScopeBindingCurrentRevision; + +export type StudioDefaultRouteTargetRevision = StudioScopeBindingRevision; +export type StudioDefaultRouteTargetStatus = StudioScopeBindingStatus; +export const describeStudioDefaultRouteTargetRevisionTarget = + describeStudioScopeBindingRevisionTarget; +export const describeStudioDefaultRouteTargetRevisionContext = + describeStudioScopeBindingRevisionContext; +export const getStudioDefaultRouteTargetCurrentRevision = + getStudioScopeBindingCurrentRevision; + export interface StudioScopeScriptBindingInput { readonly scopeId: string; readonly displayName?: string | null; diff --git a/apps/aevatar-console-web/src/shared/studio/navigation.test.ts b/apps/aevatar-console-web/src/shared/studio/navigation.test.ts index c4ed78e91..fba84f38c 100644 --- a/apps/aevatar-console-web/src/shared/studio/navigation.test.ts +++ b/apps/aevatar-console-web/src/shared/studio/navigation.test.ts @@ -109,7 +109,7 @@ describe('buildStudioRoute', () => { memberKey: 'workflow:workflow-1', step: 'bind', }), - ).toBe('/studio?scopeId=scope-1&member=workflow%3Aworkflow-1&step=bind&tab=bindings'); + ).toBe('/studio?scopeId=scope-1&member=workflow%3Aworkflow-1&step=bind'); }); it('builds dedicated workflow and script workspace routes', () => { diff --git a/apps/aevatar-console-web/src/shared/studio/navigation.ts b/apps/aevatar-console-web/src/shared/studio/navigation.ts index 7f2c9f7aa..3c4c93e77 100644 --- a/apps/aevatar-console-web/src/shared/studio/navigation.ts +++ b/apps/aevatar-console-web/src/shared/studio/navigation.ts @@ -99,10 +99,6 @@ function resolveStudioTab(options?: StudioRouteOptions): StudioTab | undefined { return options.tab.trim() as StudioTab; } - if (options?.step === 'bind') { - return 'bindings'; - } - if (options?.step === 'invoke') { return 'invoke'; } @@ -217,7 +213,6 @@ export function buildStudioBindingWorkspaceRoute(options?: { return buildStudioRoute({ ...options, step: 'bind', - tab: 'bindings', }); } diff --git a/apps/aevatar-console-web/src/shared/studio/observeSession.ts b/apps/aevatar-console-web/src/shared/studio/observeSession.ts new file mode 100644 index 000000000..339d0110e --- /dev/null +++ b/apps/aevatar-console-web/src/shared/studio/observeSession.ts @@ -0,0 +1,137 @@ +import type { RuntimeEvent } from '@/shared/agui/runtimeEventSemantics'; + +export type StudioObserveSessionSeed = { + readonly actorId: string; + readonly assistantText: string; + readonly commandId: string; + readonly completedAtUtc: string | null; + readonly endpointId: string; + readonly error: string; + readonly events: RuntimeEvent[]; + readonly finalOutput: string; + readonly mode: 'stream' | 'invoke'; + readonly payloadBase64: string; + readonly payloadTypeUrl: string; + readonly prompt: string; + readonly runId: string; + readonly serviceId: string; + readonly serviceLabel: string; + readonly startedAtUtc: string; + readonly status: 'running' | 'success' | 'error'; +}; + +const STORAGE_PREFIX = 'aevatar-console:studio:observe-session:'; + +function trimOptional(value: string | null | undefined): string { + return value?.trim() ?? ''; +} + +function buildStorageKey(scopeId: string, serviceId: string): string { + return `${STORAGE_PREFIX}${scopeId}::${serviceId}`; +} + +export function isStudioObserveSessionSeedFresh( + seed: StudioObserveSessionSeed | null | undefined, + maxAgeMs = 5 * 60 * 1000, +): boolean { + if (!seed) { + return false; + } + + const timestamp = + Date.parse(trimOptional(seed.completedAtUtc) || trimOptional(seed.startedAtUtc)); + return Number.isFinite(timestamp) && Date.now() - timestamp <= maxAgeMs; +} + +export function saveStudioObserveSessionSeed(input: { + scopeId: string; + session: StudioObserveSessionSeed; +}): void { + if (typeof window === 'undefined') { + return; + } + + const scopeId = trimOptional(input.scopeId); + const serviceId = trimOptional(input.session.serviceId); + if (!scopeId || !serviceId) { + return; + } + + window.sessionStorage.setItem( + buildStorageKey(scopeId, serviceId), + JSON.stringify(input.session), + ); +} + +export function loadStudioObserveSessionSeed(input: { + scopeId: string; + serviceId: string; +}): StudioObserveSessionSeed | null { + if (typeof window === 'undefined') { + return null; + } + + const scopeId = trimOptional(input.scopeId); + const serviceId = trimOptional(input.serviceId); + if (!scopeId || !serviceId) { + return null; + } + + const raw = window.sessionStorage.getItem(buildStorageKey(scopeId, serviceId)); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + const normalizedSession: StudioObserveSessionSeed = { + actorId: trimOptional(parsed.actorId), + assistantText: trimOptional(parsed.assistantText), + commandId: trimOptional(parsed.commandId), + completedAtUtc: trimOptional(parsed.completedAtUtc) || null, + endpointId: trimOptional(parsed.endpointId), + error: trimOptional(parsed.error), + events: Array.isArray(parsed.events) ? (parsed.events as RuntimeEvent[]) : [], + finalOutput: trimOptional(parsed.finalOutput), + mode: parsed.mode === 'invoke' ? 'invoke' : 'stream', + payloadBase64: trimOptional(parsed.payloadBase64), + payloadTypeUrl: trimOptional(parsed.payloadTypeUrl), + prompt: trimOptional(parsed.prompt), + runId: trimOptional(parsed.runId), + serviceId: trimOptional(parsed.serviceId), + serviceLabel: trimOptional(parsed.serviceLabel), + startedAtUtc: trimOptional(parsed.startedAtUtc), + status: + parsed.status === 'error' + ? 'error' + : parsed.status === 'success' + ? 'success' + : 'running', + }; + + return normalizedSession.endpointId && + normalizedSession.serviceId && + normalizedSession.startedAtUtc + ? normalizedSession + : null; + } catch { + return null; + } +} + +export function clearStudioObserveSessionSeed(input: { + scopeId: string; + serviceId: string; +}): void { + if (typeof window === 'undefined') { + return; + } + + const scopeId = trimOptional(input.scopeId); + const serviceId = trimOptional(input.serviceId); + if (!scopeId || !serviceId) { + return; + } + + window.sessionStorage.removeItem(buildStorageKey(scopeId, serviceId)); +} diff --git a/apps/aevatar-console-web/src/shared/ui/AevatarAppFlowGuide.tsx b/apps/aevatar-console-web/src/shared/ui/AevatarAppFlowGuide.tsx index 4c35ecbc6..4029c8f80 100644 --- a/apps/aevatar-console-web/src/shared/ui/AevatarAppFlowGuide.tsx +++ b/apps/aevatar-console-web/src/shared/ui/AevatarAppFlowGuide.tsx @@ -150,15 +150,15 @@ const flowPaths: FlowPath[] = [ }, { id: 'bind-scope', - label: 'Bind scope', + label: 'Update default route', description: - 'Bind scope updates the default project service so /invoke points at the published active revision.', + 'Updating the default route points /invoke at the published active revision without changing member-owned bind facts.', }, { id: 'invoke-services', label: 'Project Invoke', description: - 'Invoke reads the scope binding and service catalog, resolves the active serving revision, and starts a new run actor.', + 'Invoke reads the default route and service catalog, resolves the active serving revision, and starts a new run actor.', }, { id: 'open-in-runs', @@ -173,9 +173,9 @@ const flowPaths: FlowPath[] = [ const distinctionCards: DistinctionCard[] = [ { id: 'save-vs-bind', - title: 'Save is not Bind scope', + title: 'Save is not Update default route', description: - 'Save updates named workflow assets. Bind scope updates the default project service that backs /invoke.', + 'Save updates named workflow assets. Updating the default route switches the project service that backs /invoke.', }, { id: 'draft-vs-invoke', From ad8e2e3c3a1b5f2a84514feb61ddf815c322286e Mon Sep 17 00:00:00 2001 From: AbigailDeng Date: Mon, 27 Apr 2026 17:43:26 +0800 Subject: [PATCH 2/6] Advance Studio member-first frontend flows --- .../src/pages/studio/index.test.tsx | 171 +++++++++- .../src/pages/studio/index.tsx | 313 ++++++++++++++---- .../src/shared/studio/api.test.ts | 208 ++++++++++++ .../src/shared/studio/api.ts | 237 ++++++++++++- .../src/shared/studio/models.ts | 81 +++++ 5 files changed, 939 insertions(+), 71 deletions(-) diff --git a/apps/aevatar-console-web/src/pages/studio/index.test.tsx b/apps/aevatar-console-web/src/pages/studio/index.test.tsx index af75b6f93..9cb00084b 100644 --- a/apps/aevatar-console-web/src/pages/studio/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/studio/index.test.tsx @@ -303,6 +303,7 @@ function mockBuildServiceRunAuditSnapshot( let mockParsedDocument = mockCloneValue(mockWorkflowDocument); let mockWorkflowFile: any; let mockWorkflowSummaries: any[]; +let mockStudioMembers: any[]; let mockConnectorCatalog: any; let mockConnectorDraftResponse: any; let mockRoleCatalog: any; @@ -362,6 +363,23 @@ function mockCreateDefaultWorkflowSummaries() { ]; } +function mockCreateDefaultStudioMembers() { + return [ + { + memberId: "workspace-demo", + scopeId: "scope-1", + displayName: "workspace-demo", + description: "Workspace workflow member", + implementationKind: "workflow", + lifecycleStage: "bind_ready", + publishedServiceId: "default", + lastBoundRevisionId: "rev-2", + createdAt: "2026-04-27T08:00:00Z", + updatedAt: "2026-04-27T08:05:00Z", + }, + ]; +} + async function mockAuthorWorkflowSuccess( _input: { prompt: string }, options?: { @@ -377,6 +395,7 @@ async function mockAuthorWorkflowSuccess( function resetMockState(): void { mockParsedDocument = mockCloneValue(mockWorkflowDocument); mockWorkflowSummaries = mockCreateDefaultWorkflowSummaries(); + mockStudioMembers = mockCreateDefaultStudioMembers(); mockWorkflowFile = { workflowId: "workflow-1", name: "workspace-demo", @@ -560,6 +579,13 @@ jest.mock("@/shared/api/scopeRuntimeApi", () => ({ getServiceRevisions: jest.fn(async (_scopeId: string, serviceId: string) => mockBuildServiceRevisionCatalog({ serviceId }) ), + listMemberRuns: jest.fn(async (_scopeId: string, memberId: string) => ({ + scopeId: "scope-1", + serviceId: memberId, + serviceKey: `scope-1:default:default:${memberId}`, + displayName: "workspace-demo", + runs: [mockBuildServiceRunSummary({ serviceId: memberId })], + })), listServiceRuns: jest.fn(async (_scopeId: string, serviceId: string) => ({ scopeId: "scope-1", serviceId, @@ -567,6 +593,10 @@ jest.mock("@/shared/api/scopeRuntimeApi", () => ({ displayName: "workspace-demo", runs: [mockBuildServiceRunSummary({ serviceId })], })), + getMemberRunAudit: jest.fn( + async (_scopeId: string, memberId: string, runId: string) => + mockBuildServiceRunAuditSnapshot({ serviceId: memberId, runId }) + ), getServiceRunAudit: jest.fn( async (_scopeId: string, serviceId: string, runId: string) => mockBuildServiceRunAuditSnapshot({ serviceId, runId }) @@ -607,7 +637,9 @@ const mockServicesApi = servicesApi as unknown as { }; const mockScopeRuntimeApi = scopeRuntimeApi as unknown as { getServiceRevisions: jest.Mock; + listMemberRuns: jest.Mock; listServiceRuns: jest.Mock; + getMemberRunAudit: jest.Mock; getServiceRunAudit: jest.Mock; }; const mockRuntimeRunsApi = runtimeRunsApi as unknown as { @@ -648,6 +680,71 @@ jest.mock("@/shared/studio/api", () => ({ gatewayUrl: "https://nyx-api.example/gateway", supportedModels: ["gpt-4.1-mini", "gpt-5.4-mini"], })), + listMembers: jest.fn(async () => ({ + scopeId: "scope-1", + members: mockStudioMembers, + nextPageToken: null, + })), + getMember: jest.fn(async (_scopeId: string, memberId: string) => { + const matchedMember = + mockStudioMembers.find((member) => member.memberId === memberId) ?? + mockStudioMembers[0]; + return { + summary: matchedMember, + implementationRef: + matchedMember?.implementationKind === "workflow" + ? { + implementationKind: "workflow", + workflowId: matchedMember.displayName, + workflowRevision: matchedMember.lastBoundRevisionId, + } + : matchedMember?.implementationKind === "script" + ? { + implementationKind: "script", + scriptId: matchedMember.displayName, + scriptRevision: matchedMember.lastBoundRevisionId, + } + : { + implementationKind: "gagent", + actorTypeName: matchedMember?.displayName || "", + }, + lastBinding: matchedMember?.lastBoundRevisionId + ? { + publishedServiceId: matchedMember.publishedServiceId, + revisionId: matchedMember.lastBoundRevisionId, + implementationKind: matchedMember.implementationKind, + boundAt: matchedMember.updatedAt, + } + : null, + }; + }), + createMember: jest.fn( + async (input: { + scopeId: string; + displayName: string; + implementationKind: "workflow" | "script" | "gagent"; + description?: string | null; + memberId?: string | null; + }) => { + const nextMemberId = + input.memberId?.trim() || + input.displayName.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"); + const nextMember = { + memberId: nextMemberId, + scopeId: input.scopeId, + displayName: input.displayName.trim(), + description: input.description?.trim() || "", + implementationKind: input.implementationKind, + lifecycleStage: "created", + publishedServiceId: `member-${nextMemberId}`, + lastBoundRevisionId: null, + createdAt: "2026-04-27T08:10:00Z", + updatedAt: "2026-04-27T08:10:00Z", + }; + mockStudioMembers = [nextMember, ...mockStudioMembers]; + return nextMember; + } + ), getSkillsHealth: jest.fn(async () => ({ baseUrl: "https://ornn.chrono-ai.fun", reachable: true, @@ -922,6 +1019,35 @@ jest.mock("@/shared/studio/api", () => ({ definitionActorIdPrefix: "scope-workflow:scope-1:default", expectedActorId: "scope-workflow:scope-1:default:dep-1", })), + bindMemberWorkflow: jest.fn(async (input: { + scopeId: string; + memberId: string; + displayName?: string; + workflowYamls: string[]; + }) => { + mockStudioMembers = mockStudioMembers.map((member) => + member.memberId === input.memberId + ? { + ...member, + lifecycleStage: "bind_ready", + lastBoundRevisionId: "rev-2", + updatedAt: "2026-04-27T08:15:00Z", + } + : member + ); + + return { + scopeId: input.scopeId, + serviceId: "default", + displayName: input.displayName || "workspace-demo", + targetKind: "workflow", + targetName: input.displayName || "workspace-demo", + revisionId: "rev-2", + workflowName: input.displayName || "workspace-demo", + definitionActorIdPrefix: "scope-workflow:scope-1:default", + expectedActorId: "scope-workflow:scope-1:default:dep-1", + }; + }), bindScopeGAgent: jest.fn(async (input: { scopeId: string; displayName?: string; @@ -2658,6 +2784,16 @@ describe("StudioPage", () => { async (_scopeId: string, serviceId: string) => mockBuildServiceRevisionCatalog({ serviceId }) ); + mockScopeRuntimeApi.listMemberRuns.mockReset(); + mockScopeRuntimeApi.listMemberRuns.mockImplementation( + async (_scopeId: string, memberId: string) => ({ + scopeId: "scope-1", + serviceId: memberId, + serviceKey: `scope-1:default:default:${memberId}`, + displayName: "workspace-demo", + runs: [mockBuildServiceRunSummary({ serviceId: memberId })], + }) + ); mockScopeRuntimeApi.listServiceRuns.mockReset(); mockScopeRuntimeApi.listServiceRuns.mockImplementation( async (_scopeId: string, serviceId: string) => ({ @@ -2668,6 +2804,11 @@ describe("StudioPage", () => { runs: [mockBuildServiceRunSummary({ serviceId })], }) ); + mockScopeRuntimeApi.getMemberRunAudit.mockReset(); + mockScopeRuntimeApi.getMemberRunAudit.mockImplementation( + async (_scopeId: string, memberId: string, runId: string) => + mockBuildServiceRunAuditSnapshot({ serviceId: memberId, runId }) + ); mockScopeRuntimeApi.getServiceRunAudit.mockReset(); mockScopeRuntimeApi.getServiceRunAudit.mockImplementation( async (_scopeId: string, serviceId: string, runId: string) => @@ -3175,7 +3316,7 @@ describe("StudioPage", () => { }); it("creates a workflow draft from the create-member inventory flow", async () => { - renderStudioPage("/studio?focus=workflow%3Aworkflow-1&tab=studio"); + renderStudioPage("/studio?scopeId=scope-1&focus=workflow%3Aworkflow-1&tab=studio"); const createButton = await screen.findByLabelText("Create member"); await waitFor(() => { @@ -3208,9 +3349,17 @@ describe("StudioPage", () => { ); }); + await waitFor(() => { + expect(studioApi.createMember).toHaveBeenCalledWith({ + scopeId: "scope-1", + displayName: "orders-draft", + implementationKind: "workflow", + }); + }); + await waitFor(() => { expect(message.success).toHaveBeenCalledWith( - "Created workflow draft for member orders-draft.", + "Created member orders-draft and opened its workflow draft.", ); }); }); @@ -3248,7 +3397,7 @@ describe("StudioPage", () => { expect(scriptChip).toHaveAttribute("aria-pressed", "true"); expect( screen.getByText( - "Script member creation still relies on the upcoming member API. For now, continue in Build > Script to inspect or edit script implementations.", + "Script member authority exists on backend, but this modal still hands off through Build > Script for implementation editing.", ), ).toBeTruthy(); expect(within(createDialog).getByRole("button", { name: "Create member" })).toBeDisabled(); @@ -3261,7 +3410,7 @@ describe("StudioPage", () => { expect(gagentChip).toHaveAttribute("aria-pressed", "true"); expect( screen.getByText( - "GAgent member creation still relies on the upcoming member API. For now, continue in Build > GAgent to inspect or edit GAgent implementations.", + "GAgent member authority exists on backend, but this modal still hands off through Build > GAgent for implementation editing.", ), ).toBeTruthy(); }); @@ -3500,9 +3649,10 @@ describe("StudioPage", () => { }); await waitFor(() => { - expect(studioApi.bindScopeWorkflow).toHaveBeenCalledWith( + expect(studioApi.bindMemberWorkflow).toHaveBeenCalledWith( expect.objectContaining({ scopeId: "scope-1", + memberId: "workspace-demo", displayName: "workspace-demo", workflowYamls: expect.arrayContaining([expect.stringContaining("name: workspace-demo")]), }), @@ -4977,6 +5127,7 @@ describe("StudioPage", () => { reason: "user requested stop", }, { + memberId: "workspace-demo", serviceId: "default", } ); @@ -5068,7 +5219,7 @@ describe("StudioPage", () => { }); it("pins Observe to the selected member service and corrects stale run selection", async () => { - mockScopeRuntimeApi.listServiceRuns.mockResolvedValueOnce({ + mockScopeRuntimeApi.listMemberRuns.mockResolvedValueOnce({ scopeId: "scope-1", serviceId: "default", serviceKey: "scope-1:default:default:default", @@ -5089,9 +5240,9 @@ describe("StudioPage", () => { expect(await screen.findByText("Logs")).toBeTruthy(); await waitFor(() => { - expect(mockScopeRuntimeApi.listServiceRuns).toHaveBeenCalledWith( + expect(mockScopeRuntimeApi.listMemberRuns).toHaveBeenCalledWith( "scope-1", - "default", + "workspace-demo", { take: 12, } @@ -5106,7 +5257,7 @@ describe("StudioPage", () => { }); it("keeps Observe populated with the latest invoke session while runtime runs warm up", async () => { - mockScopeRuntimeApi.listServiceRuns.mockResolvedValue({ + mockScopeRuntimeApi.listMemberRuns.mockResolvedValue({ scopeId: "scope-1", serviceId: "default", serviceKey: "scope-1:default:default:default", @@ -5132,7 +5283,7 @@ describe("StudioPage", () => { it("rehydrates Observe from the persisted invoke session after refresh", async () => { const now = Date.now(); - mockScopeRuntimeApi.listServiceRuns.mockResolvedValue({ + mockScopeRuntimeApi.listMemberRuns.mockResolvedValue({ scopeId: "scope-1", serviceId: "default", serviceKey: "scope-1:default:default:default", diff --git a/apps/aevatar-console-web/src/pages/studio/index.tsx b/apps/aevatar-console-web/src/pages/studio/index.tsx index 25784da25..f04b399a7 100644 --- a/apps/aevatar-console-web/src/pages/studio/index.tsx +++ b/apps/aevatar-console-web/src/pages/studio/index.tsx @@ -116,6 +116,7 @@ import type { StudioWorkflowDirectory, } from '@/shared/studio/models'; import { + formatStudioMemberLifecycleStage, normalizeStudioMemberBindingImplementationKind, } from '@/shared/studio/models'; import { @@ -2163,6 +2164,12 @@ const StudioPage: React.FC = () => { tenantId: resolvedStudioScopeId, }), }); + const studioMembersQuery = useQuery({ + queryKey: ['studio-scope-members', resolvedStudioScopeId], + enabled: studioHostReady && Boolean(resolvedStudioScopeId), + retry: false, + queryFn: () => studioApi.listMembers(resolvedStudioScopeId), + }); const selectedWorkflowQuery = useQuery({ queryKey: ['studio-workflow', workflowWorkspaceContextKey, selectedWorkflowId], enabled: studioHostReady && Boolean(selectedWorkflowId), @@ -2217,6 +2224,23 @@ const StudioPage: React.FC = () => { () => scopeServicesQuery.data ?? [], [scopeServicesQuery.data], ); + const studioScopeMembers = useMemo( + () => studioMembersQuery.data?.members ?? [], + [studioMembersQuery.data?.members], + ); + const studioMemberByPublishedServiceId = useMemo(() => { + const members = new Map(); + for (const member of studioScopeMembers) { + const publishedServiceId = trimOptional(member.publishedServiceId); + if (!publishedServiceId) { + continue; + } + + members.set(publishedServiceId, member); + } + + return members; + }, [studioScopeMembers]); const availableScopeScripts = useMemo( () => (scopeScriptsQuery.data ?? []).filter( @@ -2275,6 +2299,9 @@ const StudioPage: React.FC = () => { const publishedScopeMembers = useMemo(() => { return publishedScopeServices.map((service) => { const serviceId = trimOptional(service.serviceId); + const memberSummary = serviceId + ? studioMemberByPublishedServiceId.get(serviceId) ?? null + : null; const revision = serviceId ? currentServiceRevisionByServiceId.get(serviceId) ?? null : null; @@ -2299,6 +2326,7 @@ const StudioPage: React.FC = () => { : null; return { + memberSummary, service, revision, matchedWorkflow, @@ -2309,6 +2337,7 @@ const StudioPage: React.FC = () => { availableScopeScripts, currentServiceRevisionByServiceId, publishedScopeServices, + studioMemberByPublishedServiceId, visibleWorkflowSummaries, ]); const serviceBackedWorkflowIds = useMemo( @@ -3290,6 +3319,59 @@ const StudioPage: React.FC = () => { draftYaml, resolvedStudioScopeId, ]); + const buildPendingMemberSummary = useMemo(() => { + if (buildPendingBindCandidate?.kind !== 'workflow') { + return null; + } + + const candidateWorkflowId = trimOptional( + selectedWorkflowId || activeWorkflowFile?.workflowId, + ); + const normalizedCandidateName = normalizeComparableText( + buildPendingBindCandidate.displayName, + ); + + const publishedMatch = publishedScopeMembers.find( + ({ matchedWorkflow, memberSummary }) => { + if ( + candidateWorkflowId && + trimOptional(matchedWorkflow?.workflowId) === candidateWorkflowId + ) { + return true; + } + + const workflowName = trimOptional(matchedWorkflow?.name); + if ( + workflowName && + normalizeComparableText(workflowName) === normalizedCandidateName + ) { + return true; + } + + const memberDisplayName = trimOptional(memberSummary?.displayName); + return ( + Boolean(memberDisplayName) && + normalizeComparableText(memberDisplayName) === normalizedCandidateName + ); + }, + )?.memberSummary; + if (publishedMatch) { + return publishedMatch; + } + + const rosterMatches = studioScopeMembers.filter( + (member) => + member.implementationKind === 'workflow' && + normalizeComparableText(member.displayName) === normalizedCandidateName, + ); + return rosterMatches.length === 1 ? rosterMatches[0] : null; + }, [ + activeWorkflowFile?.workflowId, + buildPendingBindCandidate, + publishedScopeMembers, + selectedWorkflowId, + studioScopeMembers, + ]); const handleBindPendingCandidate = useCallback(async () => { if (!buildPendingBindCandidate || !resolvedStudioScopeId) { throw new Error('Resolve the current scope before binding this member.'); @@ -3301,13 +3383,25 @@ const StudioPage: React.FC = () => { ); } - const result = await studioApi.bindScopeWorkflow({ - scopeId: resolvedStudioScopeId, - displayName: buildPendingBindCandidate.displayName, - workflowYamls: await buildWorkflowYamlBundle(), + const resolvedBuildMemberId = trimOptional(buildPendingMemberSummary?.memberId); + const result = resolvedBuildMemberId + ? await studioApi.bindMemberWorkflow({ + scopeId: resolvedStudioScopeId, + memberId: resolvedBuildMemberId, + displayName: buildPendingBindCandidate.displayName, + workflowYamls: await buildWorkflowYamlBundle(), + }) + : await studioApi.bindScopeWorkflow({ + scopeId: resolvedStudioScopeId, + displayName: buildPendingBindCandidate.displayName, + workflowYamls: await buildWorkflowYamlBundle(), + }); + await queryClient.invalidateQueries({ + queryKey: ['studio-scope-members', resolvedStudioScopeId], }); const servicesResult = await scopeServicesQuery.refetch(); const optimisticBoundServiceId = + trimOptional(buildPendingMemberSummary?.publishedServiceId) || trimOptional(buildPendingBindCandidate.displayName) || trimOptional(result.displayName) || trimOptional(result.targetName) || @@ -3365,8 +3459,10 @@ const StudioPage: React.FC = () => { } }, [ activeBuildFocusKey, + buildPendingMemberSummary, buildWorkflowYamlBundle, buildPendingBindCandidate, + queryClient, resolvedStudioScopeId, routeState.memberId, routeState.memberKey, @@ -3651,8 +3747,8 @@ const StudioPage: React.FC = () => { if (createMemberKind !== 'workflow') { void message.info( createMemberKind === 'script' - ? 'Script member creation will move into this modal after the member API lands. For now, continue in Build > Script.' - : 'GAgent member creation will move into this modal after the member API lands. For now, continue in Build > GAgent.', + ? 'Script member authority exists on the backend now, but this modal still continues through Build > Script.' + : 'GAgent member authority exists on the backend now, but this modal still continues through Build > GAgent.', ); return; } @@ -3680,6 +3776,16 @@ const StudioPage: React.FC = () => { return; } + if ( + studioScopeMembers.some( + (member) => + normalizeComparableText(member.displayName) === workflowName.toLowerCase(), + ) + ) { + void message.warning('A team member with the same name already exists.'); + return; + } + setInventoryBusyKey('create'); setInventoryBusyAction('create'); @@ -3695,7 +3801,33 @@ const StudioPage: React.FC = () => { await applySavedWorkflowSelection(savedWorkflow); setCreateMemberModalOpen(false); - void message.success(`Created workflow draft for member ${workflowName}.`); + + if (!resolvedStudioScopeId) { + void message.success( + `Created workflow draft for member ${workflowName}. Connect a scope to register the backend member authority.`, + ); + return; + } + + try { + await studioApi.createMember({ + scopeId: resolvedStudioScopeId, + displayName: workflowName, + implementationKind: 'workflow', + }); + await queryClient.invalidateQueries({ + queryKey: ['studio-scope-members', resolvedStudioScopeId], + }); + void message.success( + `Created member ${workflowName} and opened its workflow draft.`, + ); + } catch (memberError) { + void message.error( + memberError instanceof Error + ? `Workflow draft created, but Studio could not register the member authority: ${memberError.message}` + : 'Workflow draft created, but Studio could not register the member authority.', + ); + } } catch (error) { void message.error( error instanceof Error @@ -3712,7 +3844,9 @@ const StudioPage: React.FC = () => { createMemberDirectoryId, createMemberName, inventoryDirectoryId, + queryClient, resolvedStudioScopeId, + studioScopeMembers, visibleWorkflowSummaries, ]); @@ -4217,23 +4351,16 @@ const StudioPage: React.FC = () => { reason: 'user requested stop', }, { + memberId: workbenchStudioMemberId || undefined, serviceId: workbenchPublishedServiceId, }, ); await Promise.all([ queryClient.invalidateQueries({ - queryKey: [ - 'studio-observe-runs', - resolvedStudioScopeId, - workbenchPublishedServiceId, - ], + queryKey: ['studio-observe-runs', resolvedStudioScopeId], }), queryClient.invalidateQueries({ - queryKey: [ - 'studio-observe-run-audit', - resolvedStudioScopeId, - workbenchPublishedServiceId, - ], + queryKey: ['studio-observe-run-audit', resolvedStudioScopeId], }), ]); setExecutionNotice({ @@ -4291,6 +4418,7 @@ const StudioPage: React.FC = () => { payload: userInput.trim() || undefined, }, { + memberId: workbenchStudioMemberId || undefined, serviceId: workbenchPublishedServiceId, }, ); @@ -4308,24 +4436,17 @@ const StudioPage: React.FC = () => { userInput: userInput.trim() || undefined, }, { + memberId: workbenchStudioMemberId || undefined, serviceId: workbenchPublishedServiceId, }, ); } await Promise.all([ queryClient.invalidateQueries({ - queryKey: [ - 'studio-observe-runs', - resolvedStudioScopeId, - workbenchPublishedServiceId, - ], + queryKey: ['studio-observe-runs', resolvedStudioScopeId], }), queryClient.invalidateQueries({ - queryKey: [ - 'studio-observe-run-audit', - resolvedStudioScopeId, - workbenchPublishedServiceId, - ], + queryKey: ['studio-observe-run-audit', resolvedStudioScopeId], }), ]); setExecutionNotice({ @@ -5016,6 +5137,34 @@ const StudioPage: React.FC = () => { ), [publishedScopeMembers, workbenchMemberKey], ); + const workbenchStudioMemberSummary = useMemo( + () => + workbenchPublishedServiceId + ? studioMemberByPublishedServiceId.get(workbenchPublishedServiceId) ?? null + : null, + [studioMemberByPublishedServiceId, workbenchPublishedServiceId], + ); + const workbenchStudioMemberId = useMemo( + () => trimOptional(workbenchStudioMemberSummary?.memberId), + [workbenchStudioMemberSummary?.memberId], + ); + const workbenchStudioMemberDetailQuery = useQuery({ + queryKey: ['studio-scope-member', resolvedStudioScopeId, workbenchStudioMemberId], + enabled: + studioHostReady && + Boolean(resolvedStudioScopeId) && + Boolean(workbenchStudioMemberId), + retry: false, + queryFn: () => studioApi.getMember(resolvedStudioScopeId, workbenchStudioMemberId), + }); + const workbenchStudioMember = useMemo( + () => workbenchStudioMemberDetailQuery.data?.summary ?? workbenchStudioMemberSummary, + [workbenchStudioMemberDetailQuery.data?.summary, workbenchStudioMemberSummary], + ); + const workbenchStudioMemberBinding = useMemo( + () => workbenchStudioMemberDetailQuery.data?.lastBinding ?? null, + [workbenchStudioMemberDetailQuery.data?.lastBinding], + ); const workbenchPublishedService = useMemo( () => workbenchPublishedServiceId @@ -5102,21 +5251,30 @@ const StudioPage: React.FC = () => { queryKey: [ 'studio-observe-runs', resolvedStudioScopeId, + workbenchStudioMemberId, workbenchPublishedServiceId, ], enabled: studioSurface === 'observe' && studioHostReady && Boolean(resolvedStudioScopeId) && - Boolean(workbenchPublishedServiceId), + Boolean(workbenchStudioMemberId || workbenchPublishedServiceId), queryFn: () => - scopeRuntimeApi.listServiceRuns( - resolvedStudioScopeId, - workbenchPublishedServiceId, - { - take: 12, - }, - ), + workbenchStudioMemberId + ? scopeRuntimeApi.listMemberRuns( + resolvedStudioScopeId, + workbenchStudioMemberId, + { + take: 12, + }, + ) + : scopeRuntimeApi.listServiceRuns( + resolvedStudioScopeId, + workbenchPublishedServiceId, + { + take: 12, + }, + ), retry: false, }); const observeServiceRuns = useMemo(() => { @@ -5158,6 +5316,7 @@ const StudioPage: React.FC = () => { queryKey: [ 'studio-observe-run-audit', resolvedStudioScopeId, + workbenchStudioMemberId, workbenchPublishedServiceId, selectedExecutionId, trimOptional(selectedObserveRunSummary?.actorId), @@ -5166,19 +5325,29 @@ const StudioPage: React.FC = () => { studioSurface === 'observe' && studioHostReady && Boolean(resolvedStudioScopeId) && - Boolean(workbenchPublishedServiceId) && + Boolean(workbenchStudioMemberId || workbenchPublishedServiceId) && Boolean(selectedExecutionId) && Boolean(selectedObserveBackendRunSummary), queryFn: () => - scopeRuntimeApi.getServiceRunAudit( - resolvedStudioScopeId, - workbenchPublishedServiceId, - selectedExecutionId, - { - actorId: - trimOptional(selectedObserveBackendRunSummary?.actorId) || undefined, - }, - ), + workbenchStudioMemberId + ? scopeRuntimeApi.getMemberRunAudit( + resolvedStudioScopeId, + workbenchStudioMemberId, + selectedExecutionId, + { + actorId: + trimOptional(selectedObserveBackendRunSummary?.actorId) || undefined, + }, + ) + : scopeRuntimeApi.getServiceRunAudit( + resolvedStudioScopeId, + workbenchPublishedServiceId, + selectedExecutionId, + { + actorId: + trimOptional(selectedObserveBackendRunSummary?.actorId) || undefined, + }, + ), retry: false, }); useEffect(() => { @@ -5285,10 +5454,14 @@ const StudioPage: React.FC = () => { ? 'Select a member' : workbenchMemberKey.startsWith('workflow:') ? trimOptional(activeWorkflowName) || 'Workflow member' - : workbenchMemberKey.startsWith('script:') - ? trimOptional(selectedScriptId) || 'Script member' + : workbenchMemberKey.startsWith('script:') + ? trimOptional(selectedScriptId) || 'Script member' : workbenchMemberKey.startsWith('member:') - ? trimOptional(workbenchPublishedService?.displayName) || + ? trimOptional(workbenchPublishedServiceRevision?.workflowName) || + trimOptional(workbenchPublishedServiceRevision?.scriptId) || + trimOptional(workbenchPublishedServiceRevision?.staticActorTypeName) || + trimOptional(workbenchPublishedService?.displayName) || + trimOptional(workbenchStudioMember?.displayName) || trimOptional(workbenchPublishedService?.serviceId) || trimOptional(routeState.memberId) || 'Current member' @@ -5325,12 +5498,20 @@ const StudioPage: React.FC = () => { primary: currentMemberImplementationLabel, secondary: trimOptional(selectedScriptId) || 'Current script member', }) || 'Studio is tracking the current script-backed member.' - : workbenchMemberKey.startsWith('member:') + : workbenchMemberKey.startsWith('member:') ? formatStudioAssetMeta({ primary: currentMemberImplementationLabel, secondary: + trimOptional(workbenchStudioMemberBinding?.publishedServiceId) || + trimOptional(workbenchStudioMember?.publishedServiceId) || trimOptional(workbenchPublishedService?.serviceId) || trimOptional(routeState.memberId) || + (workbenchStudioMember + ? formatStudioMemberLifecycleStage( + workbenchStudioMember.lifecycleStage, + ) + : '') || + trimOptional(workbenchStudioMemberBinding?.revisionId) || trimOptional(workbenchPublishedServiceRevision?.revisionId) || trimOptional(workbenchPublishedService?.deploymentStatus) || 'Published member', @@ -5356,7 +5537,9 @@ const StudioPage: React.FC = () => { ? currentMemberImplementationLabel || 'Member focus' : '', secondary: hasSelectedMemberFocus - ? trimOptional(workbenchPublishedServiceRevision?.revisionId) || + ? trimOptional(workbenchStudioMemberBinding?.revisionId) || + trimOptional(workbenchStudioMember?.lastBoundRevisionId) || + trimOptional(workbenchPublishedServiceRevision?.revisionId) || trimOptional(workbenchPublishedService?.serviceId) || trimOptional(routeState.memberId) || activeBuildFocusKey @@ -5894,24 +6077,30 @@ const StudioPage: React.FC = () => { }; for (const { + memberSummary, service, revision: serviceRevision, matchedWorkflow, matchedScript, } of publishedScopeMembers) { + const memberLifecycleLabel = memberSummary + ? formatStudioMemberLifecycleStage(memberSummary.lifecycleStage) + : ''; addItem({ - key: `member:${service.serviceId}`, + key: `member:${trimOptional(memberSummary?.publishedServiceId) || service.serviceId}`, label: trimOptional(matchedWorkflow?.name) || trimOptional(matchedScript?.script?.scriptId) || + trimOptional(memberSummary?.displayName) || trimOptional(service.displayName) || trimOptional(service.serviceId) || 'Member', description: formatStudioAssetMeta({ primary: describeMemberImplementationLabel( - serviceRevision?.implementationKind, + memberSummary?.implementationKind || serviceRevision?.implementationKind, ), secondary: + trimOptional(memberSummary?.description) || trimOptional(matchedWorkflow?.description) || trimOptional(matchedWorkflow?.fileName) || trimOptional(matchedScript?.script?.definitionActorId) || @@ -5932,7 +6121,9 @@ const StudioPage: React.FC = () => { meta: formatStudioAssetMeta({ primary: trimOptional(service.serviceId) || 'Published service', secondary: + trimOptional(memberSummary?.lastBoundRevisionId) || trimOptional(serviceRevision?.revisionId) || + trimOptional(memberLifecycleLabel) || trimOptional(service.activeServingRevisionId) || trimOptional(service.defaultServingRevisionId) || trimOptional(service.deploymentStatus), @@ -6504,8 +6695,8 @@ const StudioPage: React.FC = () => { ) : (
- Create a member here. Workflow entry is available now, and Script / GAgent - creation will move into the same flow next. + Create a member here. Workflow now registers backend member authority; + Script / GAgent will move into the same flow next.
)} @@ -6791,6 +6982,7 @@ const StudioPage: React.FC = () => { : null } initialEndpointId={bindInitialEndpointId} + memberId={workbenchStudioMemberId || undefined} initialServiceId={bindSelectedMemberServiceId} onBindPendingCandidate={handleBindPendingCandidate} onContinueToInvoke={handleUseBindingEndpoint} @@ -6804,6 +6996,7 @@ const StudioPage: React.FC = () => { ) : isInvokeSurface ? ( { ))}
- Choose the implementation kind first. Workflow entry is - available now; Script and GAgent member creation will move into - this modal when the member API lands. + Choose the implementation kind first. Workflow entry now + registers a backend member authority; Script and GAgent will + move into this modal once their Build handoff is ready.
{createMemberKind === 'workflow' - ? 'Workflow members currently start from a blank workflow draft with an empty canvas, so you can name it first and then continue editing inside Build.' + ? 'Workflow members currently start from a blank workflow draft with an empty canvas, and Studio also registers the member authority in backend once the draft is created.' : createMemberKind === 'script' - ? 'Script member creation still relies on the upcoming member API. For now, continue in Build > Script to inspect or edit script implementations.' - : 'GAgent member creation still relies on the upcoming member API. For now, continue in Build > GAgent to inspect or edit GAgent implementations.'} + ? 'Script member authority exists on backend, but this modal still hands off through Build > Script for implementation editing.' + : 'GAgent member authority exists on backend, but this modal still hands off through Build > GAgent for implementation editing.'}
{createMemberKind === 'workflow' ? (
- 查看团队 + {preview.primaryActionLabel} @@ -720,8 +678,8 @@ const ScopeBackedTeamCard: React.FC<{ ); }; -const ScopeBackedTeamRow: React.FC<{ - readonly preview: ScopeBackedTeamPreview; +const MemberRosterRow: React.FC<{ + readonly preview: MemberRosterPreview; }> = ({ preview }) => { const { token } = theme.useToken(); @@ -793,7 +751,7 @@ const ScopeBackedTeamRow: React.FC<{ fontSize: 13, }} > - 默认入口:{preview.entryLabel} + 成员标识:{preview.entryLabel}
@@ -812,7 +770,7 @@ const ScopeBackedTeamRow: React.FC<{ onClick={stopEvent(() => history.push(preview.detailHref))} type="primary" > - 查看团队 + {preview.primaryActionLabel}
@@ -887,16 +845,10 @@ const TeamsHomePage: React.FC = () => { history.replace(buildScopeHref("/teams", activeDraft)); }, [activeDraft]); - const bindingQuery = useQuery({ + const membersQuery = useQuery({ enabled: scopeId.length > 0, - queryKey: ["teams", "binding", scopeId], - queryFn: () => studioApi.getScopeBinding(scopeId), - retry: false, - }); - const workflowsQuery = useQuery({ - enabled: scopeId.length > 0, - queryKey: ["teams", "workflows", scopeId], - queryFn: () => scopesApi.listWorkflows(scopeId), + queryKey: ["teams", "members", scopeId], + queryFn: () => studioApi.listMembers(scopeId), retry: false, }); const servicesQuery = useQuery({ @@ -907,150 +859,120 @@ const TeamsHomePage: React.FC = () => { tenantId: scopeId, appId: scopeServiceAppId, namespace: scopeServiceNamespace, - }), + }), retry: false, }); - const matchedServiceIds = React.useMemo( - () => - collectWorkflowOperationalServiceIds({ - services: servicesQuery.data ?? [], - workflows: workflowsQuery.data ?? [], - }), - [bindingQuery.data, servicesQuery.data, workflowsQuery.data], + const studioMembers = React.useMemo( + () => [...(membersQuery.data?.members ?? [])].sort(compareMembers), + [membersQuery.data?.members], ); - const scopePreviewServiceId = React.useMemo( + const runtimeTrackableMembers = React.useMemo( () => - bindingQuery.data?.available - ? trimOptional(bindingQuery.data?.serviceId) - : "", - [bindingQuery.data?.available, bindingQuery.data?.serviceId], + studioMembers.filter( + (member) => + Boolean(trimOptional(member.publishedServiceId)) || + Boolean(trimOptional(member.lastBoundRevisionId)), + ), + [studioMembers], ); - const runtimeServiceIds = React.useMemo(() => { - const normalizedScopePreviewServiceId = trimOptional(scopePreviewServiceId); - const ordered = normalizedScopePreviewServiceId - ? [ - normalizedScopePreviewServiceId, - ...matchedServiceIds.filter((serviceId) => serviceId !== normalizedScopePreviewServiceId), - ] - : matchedServiceIds; - return ordered; - }, [matchedServiceIds, scopePreviewServiceId]); - const runtimeSampleServiceIds = runtimeServiceIds.slice( - 0, - WORKFLOW_RUNTIME_GUARDRAIL, + const runtimeSampleMembers = React.useMemo( + () => runtimeTrackableMembers.slice(0, WORKFLOW_RUNTIME_GUARDRAIL), + [runtimeTrackableMembers], ); - const guardrailedServiceIds = React.useMemo( - () => new Set(runtimeServiceIds.slice(WORKFLOW_RUNTIME_GUARDRAIL)), - [runtimeServiceIds], + const guardrailedMemberIds = React.useMemo( + () => + new Set( + runtimeTrackableMembers + .slice(WORKFLOW_RUNTIME_GUARDRAIL) + .map((member) => trimOptional(member.memberId)) + .filter(Boolean), + ), + [runtimeTrackableMembers], ); - const serviceRunQueries = useQueries({ - queries: runtimeSampleServiceIds.map((serviceId) => ({ - enabled: scopeId.length > 0 && servicesQuery.isSuccess, - queryKey: ["teams", "runs", scopeId, serviceId], + const memberRunQueries = useQueries({ + queries: runtimeSampleMembers.map((member) => ({ + enabled: scopeId.length > 0 && membersQuery.isSuccess, + queryKey: ["teams", "member-runs", scopeId, member.memberId], queryFn: () => - scopeRuntimeApi.listServiceRuns(scopeId, serviceId, { + scopeRuntimeApi.listMemberRuns(scopeId, member.memberId, { take: 12, }), retry: false, })), }); - const runtimeAvailableByServiceId = React.useMemo(() => { + const runtimeAvailableByMemberId = React.useMemo(() => { const available = new Set(); - serviceRunQueries.forEach((query, index) => { + memberRunQueries.forEach((query, index) => { if (query.isSuccess) { - available.add(runtimeSampleServiceIds[index] ?? ""); + available.add(trimOptional(runtimeSampleMembers[index]?.memberId)); } }); return available; - }, [runtimeSampleServiceIds, serviceRunQueries]); - const runsByServiceId = React.useMemo( + }, [memberRunQueries, runtimeSampleMembers]); + const runsByMemberId = React.useMemo( () => Object.fromEntries( - runtimeSampleServiceIds.map((serviceId, index) => [ - serviceId, - serviceRunQueries[index]?.data?.runs ?? [], + runtimeSampleMembers.map((member, index) => [ + trimOptional(member.memberId), + memberRunQueries[index]?.data?.runs ?? [], ]), ) as Record, - [runtimeSampleServiceIds, serviceRunQueries], - ); - const units = React.useMemo( - () => - buildWorkflowOperationalUnits({ - runsByServiceId, - services: servicesQuery.data ?? [], - signals: { - runtimeAvailableByServiceId, - runtimeGuardrailedServiceIds: guardrailedServiceIds, - servicesAvailable: servicesQuery.isSuccess, - }, - workflows: workflowsQuery.data ?? [], - }), - [ - bindingQuery.data, - guardrailedServiceIds, - runsByServiceId, - runtimeAvailableByServiceId, - servicesQuery.data, - servicesQuery.isSuccess, - workflowsQuery.data, - ], + [memberRunQueries, runtimeSampleMembers], ); - const scopePreviewTeam = React.useMemo( + const memberPreviews = React.useMemo( () => - buildScopeBackedTeamPreview({ - binding: bindingQuery.data ?? null, - guardrailedServiceIds, - runsByServiceId, - runtimeAvailableByServiceId, - scopeId, - services: servicesQuery.data ?? [], - workflows: workflowsQuery.data ?? [], - }), + studioMembers.map((member) => + buildMemberRosterPreview({ + guardrailedMemberIds, + member, + runsByMemberId, + runtimeAvailableByMemberId, + scopeId, + services: servicesQuery.data ?? [], + }), + ), [ - bindingQuery.data, - guardrailedServiceIds, - runsByServiceId, - runtimeAvailableByServiceId, + guardrailedMemberIds, + runsByMemberId, + runtimeAvailableByMemberId, scopeId, servicesQuery.data, - workflowsQuery.data, + studioMembers, ], ); - - const draftUnits = units.filter( - (unit) => unit.isDraftOnly || (!unit.matchedService && !unit.latestRun), + const membersPendingBindingCount = React.useMemo( + () => + studioMembers.filter( + (member) => + !trimOptional(member.publishedServiceId) || + !trimOptional(member.lastBoundRevisionId), + ).length, + [studioMembers], ); - const visibleTeamCount = scopePreviewTeam ? 1 : 0; - const scopeBindingUnavailableNotice = - scopeId.length > 0 && - bindingQuery.isSuccess && - bindingQuery.data?.available === false - ? { - description: - "没有找到已发布的默认入口服务,所以首页暂时没有运行信号。去 Studio 发布团队后,这里会自动出现。", - title: "当前 Scope 还没有默认团队入口", - } - : null; + const visibleTeamCount = memberPreviews.length; const resolvedRosterView = manualRosterView ?? (visibleTeamCount >= compactTeamRosterThreshold ? "list" : "cards"); const useCompactRoster = resolvedRosterView === "list"; - const healthyTeamCount = scopePreviewTeam?.attention === "healthy" ? 1 : 0; - const attentionTeamCount = - scopePreviewTeam && scopePreviewTeam.attention !== "healthy" ? 1 : 0; - const draftHint = - draftUnits.length > 0 - ? `当前 Scope 里还有 ${draftUnits.length} 个已保存的行为定义,但它们还没有形成首页团队入口。` - : "当前 Scope 里还没有形成首页团队入口。"; + const healthyTeamCount = memberPreviews.filter( + (preview) => preview.attention === "healthy", + ).length; + const attentionTeamCount = memberPreviews.filter( + (preview) => preview.attention !== "healthy", + ).length; + const emptyRosterHint = + scopeId.length > 0 + ? "当前 Scope 下还没有创建任何 member。进入 Studio 创建成员后,这里会按成员逐个展示。" + : "先导入一个 Scope,首页才能渲染出这组成员卡片。"; const partialIssues = [ servicesQuery.isError ? "服务目录暂时不可见。" : null, - bindingQuery.isError ? "当前 Scope 的团队绑定信息暂时不可见。" : null, - ...serviceRunQueries.map((query) => - query.isError ? "部分运行信号暂时不可见。" : null, + membersQuery.isError ? "当前 Scope 的成员清单暂时不可见。" : null, + ...memberRunQueries.map((query) => + query.isError ? "部分成员运行信号暂时不可见。" : null, ), - guardrailedServiceIds.size > 0 - ? `当前首页只采样前 ${WORKFLOW_RUNTIME_GUARDRAIL} 个服务的运行信号。` + guardrailedMemberIds.size > 0 + ? `当前首页只采样前 ${WORKFLOW_RUNTIME_GUARDRAIL} 个已绑定成员的运行信号。` : null, ].filter((issue): issue is string => Boolean(issue)); @@ -1204,7 +1126,7 @@ const TeamsHomePage: React.FC = () => { {scopeId}
- 首页按这个 Scope 汇总已经形成入口的团队,Scope 只做上下文,不再直接当团队名展示。 + 首页按这个 Scope 汇总成员本身的绑定与运行状态,Scope 只做上下文,不再直接当团队名展示。 @@ -1213,7 +1135,7 @@ const TeamsHomePage: React.FC = () => { {!scopeId ? ( ) : null} @@ -1244,15 +1166,6 @@ const TeamsHomePage: React.FC = () => { /> ) : null} - {scopeBindingUnavailableNotice ? ( - - ) : null} - {scopeId ? ( <>
{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", }} > - +
- {draftUnits.length > 0 ? ( + {membersPendingBindingCount > 0 ? ( { 打开 Studio } - description={`其中 ${draftUnits.length} 个行为定义还停留在草稿阶段,尚未形成首页团队入口。`} + description={`其中 ${membersPendingBindingCount} 个成员还没有完成独立绑定,或还没有形成稳定的可调用入口。`} showIcon - title="还有草稿待整理" + title="还有成员待整理" type="info" /> ) : null} - {workflowsQuery.isLoading ? ( - - ) : workflowsQuery.isError ? ( + {membersQuery.isLoading ? ( + + ) : membersQuery.isError ? ( - ) : scopePreviewTeam ? ( + ) : memberPreviews.length > 0 ? ( <>
{ margin: 0, }} > - 团队入口 + 团队成员 - 当前 Scope 下已经形成首页入口的团队。 + 当前 Scope 下已经登记的成员,以及它们各自的绑定和运行状态。
{visibleTeamCount > 1 ? ( @@ -1359,7 +1272,9 @@ const TeamsHomePage: React.FC = () => { gap: 14, }} > - + {memberPreviews.map((preview) => ( + + ))} ) : (
{ gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))", }} > - + {memberPreviews.map((preview) => ( + + ))}
)} ) : (