diff --git a/apps/aevatar-console-web/.env.example b/apps/aevatar-console-web/.env.example index fe83d1d64..c879fe5b1 100644 --- a/apps/aevatar-console-web/.env.example +++ b/apps/aevatar-console-web/.env.example @@ -14,7 +14,7 @@ # Keep these in sync with the backend ports below if you change them. AEVATAR_API_TARGET=http://127.0.0.1:5080 AEVATAR_CONFIGURATION_API_TARGET=http://127.0.0.1:6688 -AEVATAR_STUDIO_API_TARGET=http://127.0.0.1:6690 +AEVATAR_STUDIO_API_TARGET=http://127.0.0.1:5080 # NyxID login # Register a public client in NyxID and keep redirect URI aligned with the frontend port. @@ -27,12 +27,8 @@ NYXID_SCOPE="openid profile email roles groups" # Optional. Defaults to the public Ornn instance when omitted. ORNN_BASE_URL=https://ornn.chrono-ai.fun -# Local dev stack ports used by scripts/dev-stack.sh +# Local dev ports # FRONTEND_PORT affects the dev server bind port. -# API/CONFIG ports affect backend startup only; update the proxy targets above too. +# API port affects backend startup only; update the proxy targets above too. AEVATAR_CONSOLE_API_PORT=5080 -AEVATAR_CONSOLE_CONFIG_PORT=6688 -AEVATAR_CONSOLE_STUDIO_PORT=6690 AEVATAR_CONSOLE_FRONTEND_PORT=5173 -AEVATAR_CONSOLE_SCOPE_ID=aevatar -AEVATAR_CONSOLE_STUDIO_NYXID_ENABLED=true diff --git a/apps/aevatar-console-web/README.md b/apps/aevatar-console-web/README.md index 55e1593f7..a9c83d184 100644 --- a/apps/aevatar-console-web/README.md +++ b/apps/aevatar-console-web/README.md @@ -35,8 +35,6 @@ set +a If you change backend ports, also keep `AEVATAR_API_TARGET` and `AEVATAR_STUDIO_API_TARGET` aligned with those ports. -When starting the Studio sidecar manually, set `Cli__App__ScopeId=aevatar` and keep `Cli__App__NyxId__Enabled=true` unless you intentionally want to disable protected Studio APIs. Chrono-storage backed connector and role catalogs require both the scope and a valid Studio NyxID session. - For NyxID login, also set these values in `.env.local`: ```bash @@ -66,16 +64,11 @@ pnpm tsc ## Local stack -`aevatar-console-web` depends on two local backend services during development: +`aevatar-console-web` depends on the local Mainnet Host API during development. +Mainnet composes the runtime APIs and `Aevatar.Studio.Hosting`, including the +Team endpoints. - `Mainnet Host API` on `http://127.0.0.1:5080` -- `Studio sidecar` on `http://127.0.0.1:6690` - -The dedicated local configuration tool is still available when you need to edit -secrets, workflows, providers, MCP servers, or raw config files, but it is no -longer proxied through the console: - -- `aevatar config ui --no-browser` Start the required services in separate terminals: @@ -83,20 +76,17 @@ Start the required services in separate terminals: env ASPNETCORE_URLS=http://127.0.0.1:5080 \ dotnet run --project src/Aevatar.Mainnet.Host.Api -env Cli__App__NyxId__Enabled=true Cli__App__ScopeId=aevatar \ - dotnet run --project tools/Aevatar.Tools.Cli -- app --no-browser --port 6690 --api-base http://127.0.0.1:5080 - cd apps/aevatar-console-web AEVATAR_API_TARGET=http://127.0.0.1:5080 \ -AEVATAR_STUDIO_API_TARGET=http://127.0.0.1:6690 \ +AEVATAR_STUDIO_API_TARGET=http://127.0.0.1:5080 \ ORNN_BASE_URL=https://ornn.chrono-ai.fun \ pnpm dev ``` Current proxy split during local development: -- `/api/chat`, `/api/workflows/*`, `/api/actors/*`, `/api/runs/*`, `/api/primitives`, `/api/capabilities`, `/api/scopes/*` -> `Mainnet Host API` -- `/api/app/*`, `/api/auth/*`, `/api/workspace/*`, `/api/editor/*`, `/api/executions/*`, `/api/roles/*`, `/api/connectors/*`, `/api/settings/*` -> `Studio sidecar` +- `/api/chat`, `/api/workflows/*`, `/api/actors/*`, `/api/runs/*`, `/api/primitives`, `/api/capabilities`, most `/api/scopes/*` runtime routes -> `Mainnet Host API` +- `/api/app/*`, `/api/auth/*`, `/api/workspace/*`, `/api/editor/*`, `/api/executions/*`, `/api/roles/*`, `/api/connectors/*`, `/api/settings/*`, `/api/scopes/{scopeId}/teams*` -> `Studio Hosting API target` ## Current scope diff --git a/apps/aevatar-console-web/config/proxy.ts b/apps/aevatar-console-web/config/proxy.ts index e02668385..f0951abe9 100644 --- a/apps/aevatar-console-web/config/proxy.ts +++ b/apps/aevatar-console-web/config/proxy.ts @@ -45,6 +45,8 @@ const studioProxyEntries = [ }, {}); const studioScopeProxyEntries = { + '^/api/scopes/[^/]+/teams(?:/.*)?$': + buildProxyTarget(studioApiTarget), '^/api/scopes/[^/]+/scripts/draft-run$': buildProxyTarget(studioApiTarget), '^/api/scripts/validate$': buildProxyTarget(studioApiTarget), diff --git a/apps/aevatar-console-web/config/routes.ts b/apps/aevatar-console-web/config/routes.ts index ab7899472..c8d850ea4 100644 --- a/apps/aevatar-console-web/config/routes.ts +++ b/apps/aevatar-console-web/config/routes.ts @@ -40,6 +40,13 @@ export default [ hideInMenu: true, menuGroupKey: "teams", }, + { + path: "/teams/:scopeId/:teamId", + name: "Team Details", + component: "./teams/detail", + hideInMenu: true, + parentKeys: ["/teams"], + }, { path: "/teams/:scopeId", name: "Team Details", diff --git a/apps/aevatar-console-web/src/pages/runs/index.test.tsx b/apps/aevatar-console-web/src/pages/runs/index.test.tsx index f478aa1fc..9d303736e 100644 --- a/apps/aevatar-console-web/src/pages/runs/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/runs/index.test.tsx @@ -141,50 +141,50 @@ jest.mock("./components/RunsLaunchRail", () => { const normalizePatch = ( value: Record = {} ): Partial => ({ - ...(Object.prototype.hasOwnProperty.call(value, "actorId") + ...(Object.hasOwn(value, "actorId") ? { actorId: normalizeValues({ actorId: value.actorId }).actorId } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "endpointId") + ...(Object.hasOwn(value, "endpointId") ? { endpointId: normalizeValues({ endpointId: value.endpointId }).endpointId } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "endpointKind") + ...(Object.hasOwn(value, "endpointKind") ? { endpointKind: normalizeValues({ endpointKind: value.endpointKind, }).endpointKind, } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "payloadBase64") + ...(Object.hasOwn(value, "payloadBase64") ? { payloadBase64: normalizeValues({ payloadBase64: value.payloadBase64, }).payloadBase64, } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "payloadTypeUrl") + ...(Object.hasOwn(value, "payloadTypeUrl") ? { payloadTypeUrl: normalizeValues({ payloadTypeUrl: value.payloadTypeUrl, }).payloadTypeUrl, } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "prompt") + ...(Object.hasOwn(value, "prompt") ? { prompt: normalizeValues({ prompt: value.prompt }).prompt } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "routeName") + ...(Object.hasOwn(value, "routeName") ? { routeName: normalizeValues({ routeName: value.routeName }).routeName } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "scopeId") + ...(Object.hasOwn(value, "scopeId") ? { scopeId: normalizeValues({ scopeId: value.scopeId }).scopeId } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "serviceOverrideId") + ...(Object.hasOwn(value, "serviceOverrideId") ? { serviceOverrideId: normalizeValues({ serviceOverrideId: value.serviceOverrideId, }).serviceOverrideId, } : {}), - ...(Object.prototype.hasOwnProperty.call(value, "transport") + ...(Object.hasOwn(value, "transport") ? { transport: normalizeValues({ transport: value.transport }).transport } : {}), }); @@ -372,10 +372,10 @@ describe("RunsPage", () => { await screen.findByRole("button", { name: "返回团队高级编辑" }) ); - expect(window.location.pathname).toBe("/teams/scope-1"); - expect(new URLSearchParams(window.location.search).get("tab")).toBe( - "advanced" - ); + expect(window.location.pathname).toBe("/teams"); + const params = new URLSearchParams(window.location.search); + expect(params.get("scopeId")).toBe("scope-1"); + expect(params.get("tab")).toBeNull(); }); it("returns to the originating studio route when a return target is provided", async () => { diff --git a/apps/aevatar-console-web/src/pages/teams/components/TeamDetailChrome.tsx b/apps/aevatar-console-web/src/pages/teams/components/TeamDetailChrome.tsx index 7b53b89ff..30ee0e9fb 100644 --- a/apps/aevatar-console-web/src/pages/teams/components/TeamDetailChrome.tsx +++ b/apps/aevatar-console-web/src/pages/teams/components/TeamDetailChrome.tsx @@ -56,9 +56,9 @@ const topActionButtonStyle: React.CSSProperties = { }; export const TeamDetailEmptyState: React.FC = () => ( - + - + ); diff --git a/apps/aevatar-console-web/src/pages/teams/detail.test.tsx b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx index febd62e2b..ca17ed14c 100644 --- a/apps/aevatar-console-web/src/pages/teams/detail.test.tsx +++ b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx @@ -3,11 +3,15 @@ import { message } from "antd"; import React from "react"; import { scopesApi } from "@/shared/api/scopesApi"; import { scopeRuntimeApi } from "@/shared/api/scopeRuntimeApi"; +import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; import { runtimeGAgentApi } from "@/shared/api/runtimeGAgentApi"; import { history } from "@/shared/navigation/history"; import { studioApi } from "@/shared/studio/api"; import { loadDraftRunPayload } from "@/shared/runs/draftRunSession"; -import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; +import { + createTestQueryClient, + renderWithQueryClient, +} from "../../../tests/reactQueryTestUtils"; import TeamDetailPage from "./detail"; jest.mock("@/shared/graphs/GraphCanvas", () => ({ @@ -498,6 +502,11 @@ jest.mock("@/shared/api/scopeRuntimeApi", () => ({ })); jest.mock("@/shared/studio/api", () => ({ + isStudioApiStatus: (error: unknown, status: number) => + typeof error === "object" && + error !== null && + "status" in error && + (error as { status?: unknown }).status === status, studioApi: { getScopeBinding: jest.fn(async () => ({ available: true, @@ -683,9 +692,19 @@ jest.mock("@/shared/studio/api", () => ({ }, })); +function createStudioApiStatusError(message: string, status: number): Error & { status: number } { + const error = new Error(message) as Error & { status: number }; + error.status = status; + return error; +} + describe("TeamDetailPage", () => { beforeEach(() => { - window.history.replaceState({}, "", "/teams/scope-1?scopeId=scope-1"); + window.history.replaceState({}, "", "/teams/scope-1/t-alpha"); + (scopesApi.listWorkflows as jest.Mock).mockClear(); + (scopesApi.listScripts as jest.Mock).mockClear(); + (runtimeGAgentApi.listActors as jest.Mock).mockClear(); + (runtimeActorsApi.getActorGraphEnriched as jest.Mock).mockClear(); (scopeRuntimeApi.getServiceRevisions as jest.Mock).mockReset(); (scopeRuntimeApi.getServiceRevisions as jest.Mock).mockImplementation( async () => mockCreateServiceRevisionCatalog(), @@ -733,6 +752,36 @@ describe("TeamDetailPage", () => { ); }); + it("renders no-team-selected state without detail data flows for scope-only links", async () => { + window.history.replaceState({}, "", "/teams/scope-1"); + + renderWithQueryClient(React.createElement(TeamDetailPage)); + + expect(await screen.findByText("未选择团队")).toBeTruthy(); + expect( + screen.getByText("当前链接只有工作区上下文,没有具体 Team 标识。返回团队列表后选择一个团队。"), + ).toBeTruthy(); + expect(screen.queryByText("Team authority")).toBeNull(); + + await waitFor(() => { + expect(studioApi.getTeam).not.toHaveBeenCalled(); + expect(studioApi.listTeamMembers).not.toHaveBeenCalled(); + expect(studioApi.getWorkspaceSettings).not.toHaveBeenCalled(); + expect(studioApi.getConnectorCatalog).not.toHaveBeenCalled(); + expect(studioApi.listMembers).not.toHaveBeenCalled(); + expect(scopesApi.listWorkflows).not.toHaveBeenCalled(); + expect(scopesApi.listScripts).not.toHaveBeenCalled(); + expect(scopeRuntimeApi.listServices).not.toHaveBeenCalled(); + expect(runtimeGAgentApi.listActors).not.toHaveBeenCalled(); + expect(scopeRuntimeApi.getServiceRevisions).not.toHaveBeenCalled(); + expect(scopeRuntimeApi.listServiceRuns).not.toHaveBeenCalled(); + expect(scopeRuntimeApi.listMemberRuns).not.toHaveBeenCalled(); + expect(scopeRuntimeApi.getServiceRunAudit).not.toHaveBeenCalled(); + expect(scopeRuntimeApi.getMemberRunAudit).not.toHaveBeenCalled(); + expect(runtimeActorsApi.getActorGraphEnriched).not.toHaveBeenCalled(); + }); + }); + it("renders the chinese team-first overview shell", async () => { renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -776,7 +825,7 @@ describe("TeamDetailPage", () => { expect(screen.getByRole("button", { name: "本次对话" })).toBeTruthy(); expect(screen.getByRole("button", { name: "服务映射" })).toBeTruthy(); expect(screen.getByRole("button", { name: "高级编辑" })).toBeTruthy(); - expect(studioApi.getTeam).not.toHaveBeenCalled(); + expect(studioApi.getTeam).toHaveBeenCalledWith("scope-1", "t-alpha"); }); it("keeps compare honest when no successful baseline exists", async () => { @@ -800,7 +849,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?scopeId=scope-1&runId=run-good", + "/teams/scope-1/t-alpha?runId=run-good", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -821,13 +870,13 @@ describe("TeamDetailPage", () => { }); }); - it("prefers the explicit workflow display name for the team heading", async () => { + it("uses the Team authority display name for the team heading", async () => { renderWithQueryClient(React.createElement(TeamDetailPage)); expect( await screen.findByRole("heading", { level: 1, - name: "Support Escalation Triage", + name: "Alpha Support Team", }), ).toBeTruthy(); }); @@ -838,7 +887,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - `/teams/${longScopeId}?scopeId=${longScopeId}`, + `/teams/${longScopeId}/t-alpha`, ); (scopesApi.listWorkflows as jest.Mock).mockResolvedValueOnce([]); (studioApi.getScopeBinding as jest.Mock).mockResolvedValueOnce(null); @@ -853,7 +902,11 @@ describe("TeamDetailPage", () => { expect(screen.getByText("1626c177...71b0d6")).toBeTruthy(); }); - it("falls back to workflowName when the scope display name is only the workflow id", async () => { + it("falls back to workflowName when Team display name is unavailable and the workflow display name is only the workflow id", async () => { + (studioApi.getTeam as jest.Mock).mockResolvedValueOnce({ + ...mockCreateTeamSummary(), + displayName: "", + }); (scopesApi.listWorkflows as jest.Mock).mockResolvedValueOnce([ { scopeId: "scope-1", @@ -976,7 +1029,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?scopeId=scope-1&serviceId=default&tab=events", + "/teams/scope-1/t-alpha?serviceId=default&tab=events", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1047,7 +1100,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?scopeId=scope-1&teamId=t-alpha&tab=members", + "/teams/scope-1/t-alpha?tab=members", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1067,7 +1120,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?scopeId=scope-1&teamId=t-alpha", + "/teams/scope-1/t-alpha", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1093,7 +1146,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?scopeId=scope-1&teamId=t-alpha", + "/teams/scope-1/t-alpha", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1132,7 +1185,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?scopeId=scope-1&teamId=t-alpha", + "/teams/scope-1/t-alpha", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1160,7 +1213,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?scopeId=scope-1&teamId=t-alpha&tab=advanced", + "/teams/scope-1/t-alpha?tab=advanced", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1198,7 +1251,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?scopeId=scope-1&teamId=t-alpha&tab=advanced", + "/teams/scope-1/t-alpha?tab=advanced", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1216,7 +1269,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?scopeId=scope-1&teamId=t-alpha", + "/teams/scope-1/t-alpha", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1235,6 +1288,37 @@ describe("TeamDetailPage", () => { }); }); + it("treats a just-created Team 404 as projection syncing and retries", async () => { + jest.useFakeTimers(); + window.history.replaceState({}, "", "/teams/scope-1/t-alpha?tab=members"); + (studioApi.getTeam as jest.Mock) + .mockRejectedValueOnce(createStudioApiStatusError("Not Found", 404)) + .mockResolvedValueOnce(mockCreateTeamSummary()); + (studioApi.listTeamMembers as jest.Mock) + .mockRejectedValueOnce(createStudioApiStatusError("Not Found", 404)) + .mockResolvedValueOnce(mockCreateTeamMembersCatalog()); + + const queryClient = createTestQueryClient(); + queryClient.setQueryData( + ["teams", "team-summary", "scope-1", "t-alpha"], + mockCreateTeamSummary(), + ); + renderWithQueryClient(React.createElement(TeamDetailPage), queryClient); + + expect(await screen.findByText("Alpha Support Team")).toBeTruthy(); + expect(await screen.findByText("Team roster 正在同步")).toBeTruthy(); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(await screen.findByText("Team Alpha Operator")).toBeTruthy(); + expect(studioApi.getTeam).toHaveBeenCalledTimes(2); + expect(studioApi.listTeamMembers).toHaveBeenCalledTimes(2); + + jest.useRealTimers(); + }); + it("shows a team-first event stream with member mapping", async () => { renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1244,7 +1328,7 @@ describe("TeamDetailPage", () => { expect(await screen.findByText("当前任务事件流")).toBeTruthy(); expect(screen.getByText("本次 Run 成员映射")).toBeTruthy(); expect(await screen.findByText("切换 Run")).toBeTruthy(); - expect(screen.getAllByRole("button", { name: "运行记录" }).length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "打开完整审计" })).toBeTruthy(); expect((await screen.findAllByText(/risk_review/)).length).toBeGreaterThan(0); }); @@ -1269,7 +1353,7 @@ describe("TeamDetailPage", () => { await screen.findByRole("button", { name: "服务映射" }); act(() => { - history.push("/teams/scope-1?scopeId=scope-1&tab=events&runId=run-good"); + history.push("/teams/scope-1/t-alpha?tab=events&runId=run-good"); }); expect(await screen.findByText("当前任务事件流")).toBeTruthy(); @@ -1431,7 +1515,7 @@ describe("TeamDetailPage", () => { expect(window.location.search).toContain("serviceId=default"); cleanup(); - window.history.replaceState({}, "", "/teams/scope-1?scopeId=scope-1"); + window.history.replaceState({}, "", "/teams/scope-1/t-alpha"); renderWithQueryClient(React.createElement(TeamDetailPage)); await screen.findByRole("button", { name: "服务映射" }); @@ -1447,24 +1531,38 @@ describe("TeamDetailPage", () => { }); it("opens Studio in the current team context from the top actions", async () => { + window.history.replaceState( + {}, + "", + "/teams/scope-1/t-alpha?workflowId=workflow-1", + ); + renderWithQueryClient(React.createElement(TeamDetailPage)); await screen.findByRole("button", { name: "服务映射" }); await screen.findByRole("heading", { level: 1, - name: "Support Escalation Triage", + name: "Alpha Support Team", }); - fireEvent.click(screen.getByRole("button", { name: "高级编辑" })); - await waitFor(() => { - expect(window.location.pathname).toBe("/studio"); + expect(scopesApi.getWorkflowDetail).toHaveBeenCalledWith("scope-1", "workflow-1"); }); - const params = new URLSearchParams(window.location.search); + + const pushSpy = jest.spyOn(history, "push").mockImplementation(() => undefined); + fireEvent.click(screen.getByRole("button", { name: "高级编辑" })); + + expect(pushSpy).toHaveBeenCalled(); + const pushedHref = pushSpy.mock.calls.at(-1)?.[0] ?? ""; + const pushedUrl = new URL(pushedHref, window.location.origin); + expect(pushedUrl.pathname).toBe("/studio"); + const params = pushedUrl.searchParams; expect(params.get("scopeId")).toBe("scope-1"); + expect(params.get("teamId")).toBe("t-alpha"); expect(params.get("member")).toBe("workflow:workflow-1"); expect(params.get("memberId")).toBeNull(); expect(params.get("focus")).toBeNull(); expect(params.get("tab")).toBe("studio"); + pushSpy.mockRestore(); }); it("opens workflow and script Studio deep links from assets with scope context", async () => { @@ -1486,7 +1584,7 @@ describe("TeamDetailPage", () => { ); cleanup(); - window.history.replaceState({}, "", "/teams/scope-1?scopeId=scope-1&tab=assets"); + window.history.replaceState({}, "", "/teams/scope-1/t-alpha?tab=assets"); renderWithQueryClient(React.createElement(TeamDetailPage)); await screen.findByText("当前 Team 资产"); @@ -1505,7 +1603,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?workflowId=workflow-1&serviceId=stale-service&runId=stale-run", + "/teams/scope-1/t-alpha?workflowId=workflow-1&serviceId=stale-service&runId=stale-run", ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -1513,7 +1611,7 @@ describe("TeamDetailPage", () => { expect( await screen.findByRole("heading", { level: 1, - name: "Support Escalation Triage", + name: "Alpha Support Team", }), ).toBeTruthy(); expect(screen.queryByText("路由上下文已自动校正")).toBeNull(); @@ -1523,7 +1621,7 @@ describe("TeamDetailPage", () => { window.history.replaceState( {}, "", - "/teams/scope-1?workflowId=workflow-missing", + "/teams/scope-1/t-alpha?workflowId=workflow-missing", ); renderWithQueryClient(React.createElement(TeamDetailPage)); diff --git a/apps/aevatar-console-web/src/pages/teams/detail.tsx b/apps/aevatar-console-web/src/pages/teams/detail.tsx index 77b7afe01..b9a10c8d3 100644 --- a/apps/aevatar-console-web/src/pages/teams/detail.tsx +++ b/apps/aevatar-console-web/src/pages/teams/detail.tsx @@ -39,7 +39,7 @@ import { buildRuntimeRunsHref, } from "@/shared/navigation/runtimeRoutes"; import { saveObservedRunSessionPayload } from "@/shared/runs/draftRunSession"; -import { studioApi } from "@/shared/studio/api"; +import { isStudioApiStatus, studioApi } from "@/shared/studio/api"; import { buildStudioWorkflowMemberKey, buildStudioScriptsWorkspaceRoute, @@ -51,6 +51,7 @@ import { formatStudioTeamLifecycleStage, type StudioWorkflowDocument, } from "@/shared/studio/models"; +import type { StudioTeamSummary } from "@/shared/studio/models"; import { AevatarCompactText } from "@/shared/ui/compactText"; import { describeError } from "@/shared/ui/errorText"; import { @@ -84,6 +85,21 @@ import { useTeamRuntimeLens } from "./runtime/useTeamRuntimeLens"; type ObservationStatus = "live" | "delayed" | "partial" | "unavailable"; +const teamProjectionRetryLimit = 5; +const teamProjectionRetryBaseMs = 500; +const teamProjectionRetryMaxMs = 3_000; + +function isProjectionSyncing404(error: unknown): boolean { + return isStudioApiStatus(error, 404); +} + +function projectionRetryDelay(attemptIndex: number): number { + return Math.min( + teamProjectionRetryBaseMs * 2 ** attemptIndex, + teamProjectionRetryMaxMs, + ); +} + type ObservationBadge = { label: string; status: ObservationStatus; @@ -1098,6 +1114,21 @@ const TeamDetailPage: React.FC = () => { }, [locationSnapshot]); const scopeId = routeState.scopeId.trim(); const selectedTeamId = trimText(routeState.teamId); + const hasTeamIdentity = scopeId.length > 0 && selectedTeamId.length > 0; + const teamSummaryQueryKey = React.useMemo( + () => ["teams", "team-summary", scopeId, selectedTeamId] as const, + [scopeId, selectedTeamId], + ); + const teamMembersQueryKey = React.useMemo( + () => ["teams", "team-members", scopeId, selectedTeamId] as const, + [scopeId, selectedTeamId], + ); + const cachedTeamSummary = queryClient.getQueryData( + teamSummaryQueryKey, + ); + const shouldRetryProjectionSync = Boolean( + hasTeamIdentity && cachedTeamSummary?.teamId === selectedTeamId, + ); const teamsListHref = React.useMemo( () => buildScopeHref("/teams", { scopeId }), [scopeId], @@ -1151,6 +1182,7 @@ const TeamDetailPage: React.FC = () => { servicesQuery, workflowsQuery, } = useTeamRuntimeLens(scopeId, { + enabled: hasTeamIdentity, graphDepth, preferredActorId: selectedActorId || undefined, preferredMemberId, @@ -1159,31 +1191,49 @@ const TeamDetailPage: React.FC = () => { }); const workspaceSettingsQuery = useQuery({ - enabled: scopeId.length > 0, + enabled: hasTeamIdentity, queryFn: () => studioApi.getWorkspaceSettings(), queryKey: ["teams", "workspace-settings"], retry: false, }); const connectorCatalogQuery = useQuery({ - enabled: scopeId.length > 0, + enabled: hasTeamIdentity, queryFn: () => studioApi.getConnectorCatalog(), queryKey: ["teams", "connector-catalog"], retry: false, }); const teamMembersQuery = useQuery({ - enabled: scopeId.length > 0 && selectedTeamId.length > 0, + enabled: hasTeamIdentity, queryFn: () => studioApi.listTeamMembers(scopeId, selectedTeamId), - queryKey: ["teams", "team-members", scopeId, selectedTeamId], - retry: false, + queryKey: teamMembersQueryKey, + retry: (failureCount, error) => + shouldRetryProjectionSync && + isProjectionSyncing404(error) && + failureCount < teamProjectionRetryLimit, + retryDelay: projectionRetryDelay, }); const teamSummaryQuery = useQuery({ - enabled: scopeId.length > 0 && selectedTeamId.length > 0, + enabled: hasTeamIdentity, queryFn: () => studioApi.getTeam(scopeId, selectedTeamId), - queryKey: ["teams", "team-summary", scopeId, selectedTeamId], - retry: false, + queryKey: teamSummaryQueryKey, + retry: (failureCount, error) => + shouldRetryProjectionSync && + isProjectionSyncing404(error) && + failureCount < teamProjectionRetryLimit, + retryDelay: projectionRetryDelay, }); + const isTeamSummaryProjectionSyncing = + shouldRetryProjectionSync && + ((teamSummaryQuery.failureCount > 0 && + isProjectionSyncing404(teamSummaryQuery.failureReason)) || + (teamSummaryQuery.isError && isProjectionSyncing404(teamSummaryQuery.error))); + const isTeamMembersProjectionSyncing = + shouldRetryProjectionSync && + ((teamMembersQuery.failureCount > 0 && + isProjectionSyncing404(teamMembersQuery.failureReason)) || + (teamMembersQuery.isError && isProjectionSyncing404(teamMembersQuery.error))); React.useEffect(() => { if (!teamEditorOpen || !teamSummaryQuery.data) { @@ -1197,11 +1247,11 @@ const TeamDetailPage: React.FC = () => { const refreshTeamAuthority = React.useCallback(async () => { await Promise.all([ queryClient.invalidateQueries({ - queryKey: ["teams", "team-summary", scopeId, selectedTeamId], + queryKey: teamSummaryQueryKey, }), queryClient.invalidateQueries({ queryKey: ["teams", "roster", scopeId] }), ]); - }, [queryClient, scopeId, selectedTeamId]); + }, [queryClient, scopeId, teamSummaryQueryKey]); const fallbackWorkflowSummary = React.useMemo(() => { if (lens.activeRevision?.implementationKind !== "workflow") { @@ -1298,7 +1348,7 @@ const TeamDetailPage: React.FC = () => { const teamWorkflowDetailQuery = useQuery({ enabled: - scopeId.length > 0 && + hasTeamIdentity && lens.activeRevision?.implementationKind === "workflow" && trimText(activeWorkflowSummary?.workflowId).length > 0, queryFn: () => @@ -1315,6 +1365,7 @@ const TeamDetailPage: React.FC = () => { const teamWorkflowDocumentsQuery = useQuery({ enabled: + hasTeamIdentity && lens.activeRevision?.implementationKind === "workflow" && Boolean(teamWorkflowDetailQuery.data?.available) && trimText(teamWorkflowDetailQuery.data?.source?.workflowYaml).length > 0, @@ -1350,11 +1401,13 @@ const TeamDetailPage: React.FC = () => { }); const teamScopedRoleLoading = + hasTeamIdentity && lens.activeRevision?.implementationKind === "workflow" && (workflowsQuery.isLoading || teamWorkflowDetailQuery.isLoading || teamWorkflowDocumentsQuery.isLoading); const teamScopedRoleUnavailable = + hasTeamIdentity && lens.activeRevision?.implementationKind === "workflow" && !teamScopedRoleLoading && (!activeWorkflowSummary || @@ -1395,6 +1448,11 @@ const TeamDetailPage: React.FC = () => { ""; React.useEffect(() => { + if (!hasTeamIdentity) { + setSelectedConnectorKey(""); + return; + } + if (integrations.items.length === 0) { setSelectedConnectorKey(""); return; @@ -1406,7 +1464,12 @@ const TeamDetailPage: React.FC = () => { ) { setSelectedConnectorKey(defaultSelectedConnectorKey); } - }, [defaultSelectedConnectorKey, integrations.items, selectedConnectorKey]); + }, [ + defaultSelectedConnectorKey, + hasTeamIdentity, + integrations.items, + selectedConnectorKey, + ]); const runtimeServiceId = focusedOperationalUnit?.matchedService?.serviceId || @@ -1418,7 +1481,11 @@ const TeamDetailPage: React.FC = () => { trimText(preferredMemberId); React.useEffect(() => { const canonicalMemberId = trimText(currentMemberId); - if (!scopeId || !canonicalMemberId || trimText(routeState.memberId)) { + if ( + !hasTeamIdentity || + !canonicalMemberId || + trimText(routeState.memberId) + ) { return; } @@ -1441,6 +1508,7 @@ const TeamDetailPage: React.FC = () => { routeState.tab, routeState.workflowId, selectedTeamId, + hasTeamIdentity, scopeId, ]); const currentPlatformService = @@ -1503,6 +1571,11 @@ const TeamDetailPage: React.FC = () => { lens.graph.focusActorId || lens.members[0]?.actorId || ""; React.useEffect(() => { + if (!hasTeamIdentity) { + setSelectedActorId(""); + return; + } + if (availableActorIds.length === 0) { setSelectedActorId(""); return; @@ -1510,7 +1583,7 @@ const TeamDetailPage: React.FC = () => { if (!selectedActorId || !availableActorIds.includes(selectedActorId)) { setSelectedActorId(defaultSelectedActorId || availableActorIds[0]); } - }, [availableActorIds, defaultSelectedActorId, selectedActorId]); + }, [availableActorIds, defaultSelectedActorId, hasTeamIdentity, selectedActorId]); const effectiveActorId = selectedActorId || defaultSelectedActorId; const localizedFocusReason = formatTopologyFocusReason(lens.graph.focusReason); @@ -2600,6 +2673,11 @@ const TeamDetailPage: React.FC = () => { [topologyGraph.nodes], ); React.useEffect(() => { + if (!hasTeamIdentity) { + setSelectedTopologyNodeId(""); + return; + } + if (topologyNodeIds.length === 0) { setSelectedTopologyNodeId(""); return; @@ -2611,7 +2689,7 @@ const TeamDetailPage: React.FC = () => { : topologyNodeIds[0], ); } - }, [effectiveActorId, selectedTopologyNodeId, topologyNodeIds]); + }, [effectiveActorId, hasTeamIdentity, selectedTopologyNodeId, topologyNodeIds]); const selectedTopologyEntity = topologyGraph.entityMap.get(selectedTopologyNodeId) ?? topologyGraph.entityMap.get(effectiveActorId) ?? @@ -2974,6 +3052,8 @@ const TeamDetailPage: React.FC = () => { })); const teamAuthorityStatusLabel = teamSummaryQuery.data ? teamLifecycleLabel + : isTeamSummaryProjectionSyncing + ? "Projection 同步中" : teamSummaryQuery.isError ? "Team summary 不可用" : selectedTeamId @@ -2981,16 +3061,22 @@ const TeamDetailPage: React.FC = () => { : "未绑定 Team"; const teamAuthorityStatusStyle = teamSummaryQuery.data ? resolveStatusPillStyle(token, teamLifecycleStatus) + : isTeamSummaryProjectionSyncing + ? resolveTonePillStyle(token, "warning") : teamSummaryQuery.isError ? resolveTonePillStyle(token, "warning") : resolveTonePillStyle(token, "neutral"); const teamAuthorityTitle = teamSummaryQuery.data ? teamTitle + : isTeamSummaryProjectionSyncing + ? "Team projection 正在同步" : selectedTeamId ? "Team summary 暂不可用" : teamTitle; const teamAuthorityDescription = teamSummaryQuery.data ? teamSummaryDescription || "Team authority 当前没有描述。" + : isTeamSummaryProjectionSyncing + ? "Team 已创建,后端正在把 committed state 物化到查询用 read model。这里会自动刷新。" : selectedTeamId ? "当前仍会显示运行时视图;Team authority summary 暂时无法读取。" : "当前详情来自运行时上下文,还没有明确的 Team authority 绑定。"; @@ -3790,8 +3876,9 @@ const TeamDetailPage: React.FC = () => { onOpenRuntimeExplorer={handleOpenServiceMapping} onOpenServices={handleOpenServices} onSelectActor={setSelectedActorId} - rosterError={teamMembersQuery.isError} + rosterError={teamMembersQuery.isError && !isTeamMembersProjectionSyncing} rosterLoading={teamMembersQuery.isLoading} + rosterSyncing={isTeamMembersProjectionSyncing} rosterRows={teamRosterRows} rosterTeamId={selectedTeamId} /> @@ -3945,7 +4032,7 @@ const TeamDetailPage: React.FC = () => { break; } - if (!scopeId) { + if (!hasTeamIdentity) { return ; } diff --git a/apps/aevatar-console-web/src/pages/teams/home.test.tsx b/apps/aevatar-console-web/src/pages/teams/home.test.tsx index 0b19fd44d..f33b62526 100644 --- a/apps/aevatar-console-web/src/pages/teams/home.test.tsx +++ b/apps/aevatar-console-web/src/pages/teams/home.test.tsx @@ -239,11 +239,11 @@ describe("TeamsHomePage", () => { fireEvent.click(await screen.findByRole("button", { name: "查看团队" })); await waitFor(() => { - expect(window.location.pathname).toBe("/teams/scope-a"); + expect(window.location.pathname).toBe("/teams/scope-a/t-support"); }); const params = new URLSearchParams(window.location.search); - expect(params.get("teamId")).toBe("t-support"); + expect(params.get("teamId")).toBeNull(); expect(params.get("memberId")).toBe("member-alpha"); expect(params.get("serviceId")).toBe("service-alpha"); expect(params.get("runId")).toBe("run-latest"); diff --git a/apps/aevatar-console-web/src/pages/teams/home.tsx b/apps/aevatar-console-web/src/pages/teams/home.tsx index d07fdfea5..f7d980466 100644 --- a/apps/aevatar-console-web/src/pages/teams/home.tsx +++ b/apps/aevatar-console-web/src/pages/teams/home.tsx @@ -1217,7 +1217,9 @@ const TeamsHomePage: React.FC = () => {