diff --git a/.gitignore b/.gitignore index 979fc40f..77a63874 100644 --- a/.gitignore +++ b/.gitignore @@ -497,3 +497,4 @@ docs/agents-working-space/* # Docs triage working directory docs/.triage/ +.gstack/ diff --git a/apps/aevatar-console-web/config/config.ts b/apps/aevatar-console-web/config/config.ts index fc68edbe..1f8353d9 100644 --- a/apps/aevatar-console-web/config/config.ts +++ b/apps/aevatar-console-web/config/config.ts @@ -155,6 +155,10 @@ const config: ReturnType = defineConfig({ process.env.NYXID_REDIRECT_URI, ), 'process.env.NYXID_SCOPE': JSON.stringify(process.env.NYXID_SCOPE), + 'process.env.ORNN_BASE_URL': JSON.stringify(process.env.ORNN_BASE_URL), + 'process.env.AEVATAR_CONSOLE_TEAM_FIRST_ENABLED': JSON.stringify( + process.env.AEVATAR_CONSOLE_TEAM_FIRST_ENABLED, + ), 'process.env.AEVATAR_CONSOLE_PUBLIC_PATH': JSON.stringify( process.env.AEVATAR_CONSOLE_PUBLIC_PATH, ), diff --git a/apps/aevatar-console-web/config/routes.ts b/apps/aevatar-console-web/config/routes.ts index 9f2fef38..0e8c0f48 100644 --- a/apps/aevatar-console-web/config/routes.ts +++ b/apps/aevatar-console-web/config/routes.ts @@ -23,82 +23,88 @@ export default [ }, { path: "/overview", - redirect: "/scopes/overview", + redirect: "/teams", hideInMenu: true, }, + { + path: "/teams", + name: "我的团队", + component: "./scopes/overview", + menuGroupKey: "teams", + }, + { + path: "/teams/new", + name: "组建团队", + component: "./teams/new", + menuGroupKey: "teams", + }, + { + path: "/teams/:scopeId", + name: "团队详情", + component: "./teams", + hideInMenu: true, + parentKeys: ["/teams"], + }, { path: "/scopes/assets", - name: "Assets", component: "./scopes/assets", - // postMenuData in app.tsx regroups flat routes into lifecycle menu sections. - menuGroupKey: "build", - menuBadgeKey: "build.assets", + hideInMenu: true, }, { path: "/studio", - name: "Studio", component: "./studio", - menuGroupKey: "build", + hideInMenu: true, }, { path: "/runtime/workflows", - name: "Workflows", component: "./workflows", - menuGroupKey: "build", + hideInMenu: true, }, { path: "/runtime/primitives", - name: "Capabilities", + name: "连接器", component: "./primitives", - menuGroupKey: "build", + hideInMenu: true, }, { path: "/chat", - name: "Chat", component: "./chat", - menuGroupKey: "chat", + hideInMenu: true, }, { path: "/scopes/invoke", - name: "Invoke Lab", component: "./scopes/invoke", - menuGroupKey: "live", - menuBadgeKey: "live.invoke", + hideInMenu: true, }, { path: "/runtime/runs", - name: "Runs", + name: "事件流", component: "./runs", - menuGroupKey: "live", - menuBadgeKey: "live.runs", + hideInMenu: true, }, { path: "/runtime/mission-control", name: "Mission Control", component: "./MissionControl", hideInMenu: true, - menuGroupKey: "live", - menuBadgeKey: "live.attention", }, { path: "/runtime/explorer", name: "Topology", component: "./actors", - menuGroupKey: "live", - menuBadgeKey: "live.topology", + menuGroupKey: "platform", }, { path: "/runtime/gagents", - name: "GAgents", + name: "成员", component: "./gagents", - menuGroupKey: "live", + hideInMenu: true, }, { path: "/services", name: "Services", component: "./services", - menuGroupKey: "governance", - menuBadgeKey: "governance.services", + menuGroupKey: "platform", }, { path: "/services/:serviceId", @@ -110,15 +116,13 @@ export default [ path: "/deployments", name: "Deployments", component: "./Deployments", - menuGroupKey: "governance", - menuBadgeKey: "governance.deployments", + menuGroupKey: "platform", }, { path: "/governance", name: "Governance", component: "./governance", - menuGroupKey: "governance", - menuBadgeKey: "governance.audit", + menuGroupKey: "platform", }, { path: "/governance/policies", @@ -146,29 +150,28 @@ export default [ }, { path: "/scopes/overview", - name: "Projects", - component: "./scopes/overview", - menuGroupKey: "settings", + redirect: "/teams", + hideInMenu: true, }, { path: "/settings", - name: "Account", + name: "设置", component: "./settings/account", menuGroupKey: "settings", }, { path: "/scopes", - redirect: "/scopes/overview", + redirect: "/teams", hideInMenu: true, }, { path: "/scopes/workflows", - component: "./scopes/workflows", + redirect: "/runtime/workflows", hideInMenu: true, }, { path: "/scopes/scripts", - component: "./scopes/scripts", + redirect: "/studio?tab=scripts", hideInMenu: true, }, { @@ -208,7 +211,7 @@ export default [ }, { path: "/", - redirect: "/scopes/overview", + redirect: "/teams", }, { component: "404", diff --git a/apps/aevatar-console-web/src/app.navigation.test.ts b/apps/aevatar-console-web/src/app.navigation.test.ts new file mode 100644 index 00000000..a63692c4 --- /dev/null +++ b/apps/aevatar-console-web/src/app.navigation.test.ts @@ -0,0 +1,25 @@ +describe("app navigation groups", () => { + function loadNavigationGroups(): ReturnType { + let groups!: ReturnType; + jest.isolateModules(() => { + groups = require("./shared/navigation/navigationGroups").getNavigationGroupOrder() as ReturnType< + typeof import("./shared/navigation/navigationGroups").getNavigationGroupOrder + >; + }); + return groups; + } + + beforeEach(() => { + jest.resetModules(); + }); + + it("uses the Team-first group model by default", () => { + const groups = loadNavigationGroups(); + + expect(groups.map((group) => group.label)).toEqual([ + "Teams", + "Platform", + "Settings", + ]); + }); +}); diff --git a/apps/aevatar-console-web/src/app.tsx b/apps/aevatar-console-web/src/app.tsx index 3e3a06ca..3996cc88 100644 --- a/apps/aevatar-console-web/src/app.tsx +++ b/apps/aevatar-console-web/src/app.tsx @@ -8,8 +8,6 @@ import { DashboardOutlined, DownOutlined, LogoutOutlined, - MessageOutlined, - SafetyCertificateOutlined, SettingOutlined, UserOutlined, } from "@ant-design/icons"; @@ -126,26 +124,16 @@ const LIVE_OPS_ATTENTION_REFRESH_MS = 30_000; const NAVIGATION_GROUP_ORDER: readonly NavigationGroup[] = [ { icon: , - key: "build", - label: "Build / Studio", - }, - { - flattenSingleItem: true, - icon: , - key: "chat", - label: "Chat", + key: "teams", + label: "Teams", }, { icon: , - key: "live", - label: "Live Ops", - }, - { - icon: , - key: "governance", - label: "Governance", + key: "platform", + label: "Platform", }, { + flattenSingleItem: true, icon: , key: "settings", label: "Settings", diff --git a/apps/aevatar-console-web/src/modules/studio/scripts/ScriptsWorkbenchPage.test.tsx b/apps/aevatar-console-web/src/modules/studio/scripts/ScriptsWorkbenchPage.test.tsx index 009f2910..5a7a114c 100644 --- a/apps/aevatar-console-web/src/modules/studio/scripts/ScriptsWorkbenchPage.test.tsx +++ b/apps/aevatar-console-web/src/modules/studio/scripts/ScriptsWorkbenchPage.test.tsx @@ -499,14 +499,14 @@ public sealed class DraftBehavior : ScriptBehavior = ({ type: 'success', message: `Updated scope ${result.scopeId} to serve script ${result.targetName} on revision ${result.revisionId}.`, description: - 'Review the active binding, revision rollout, and saved script assets from the scope views.', + 'Review the active binding, revision rollout, and saved script assets from the team views.', actions: [ { - label: 'Open Scope Scripts', - href: buildScopePageHref('/scopes/scripts', resolvedScopeId, { + label: 'Open Team Assets', + href: buildScopePageHref('/scopes/assets', resolvedScopeId, { + tab: 'scripts', scriptId, }), }, { - label: 'Open Scope Overview', - href: buildScopePageHref('/scopes/overview', resolvedScopeId), + label: 'Open Team Workspace', + href: buildTeamWorkspaceRoute(resolvedScopeId), }, ], }); diff --git a/apps/aevatar-console-web/src/pages/actors/index.test.tsx b/apps/aevatar-console-web/src/pages/actors/index.test.tsx index 0e05c48e..6910226a 100644 --- a/apps/aevatar-console-web/src/pages/actors/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/actors/index.test.tsx @@ -30,6 +30,7 @@ jest.mock('@/shared/graphs/GraphCanvas', () => ({ describe('ActorsPage', () => { beforeEach(() => { window.localStorage.clear(); + window.history.replaceState({}, '', '/runtime/explorer'); }); it('renders the runtime explorer shell and navigation actions', async () => { @@ -111,4 +112,24 @@ describe('ActorsPage', () => { 'Completed successfully.', ); }); + + it('preserves playback explorer context from the incoming route', async () => { + window.history.replaceState( + {}, + '', + '/runtime/explorer?actorId=actor-route-a&runId=run-current&scopeId=scope-route-a&serviceId=default', + ); + + renderWithQueryClient(React.createElement(ActorsPage)); + + await waitFor(() => { + expect(window.location.pathname).toBe('/runtime/explorer'); + }); + + const params = new URLSearchParams(window.location.search); + expect(params.get('actorId')).toBe('actor-route-a'); + expect(params.get('runId')).toBe('run-current'); + expect(params.get('scopeId')).toBe('scope-route-a'); + expect(params.get('serviceId')).toBe('default'); + }); }); diff --git a/apps/aevatar-console-web/src/pages/actors/index.tsx b/apps/aevatar-console-web/src/pages/actors/index.tsx index 021bb2b3..27d2de5b 100644 --- a/apps/aevatar-console-web/src/pages/actors/index.tsx +++ b/apps/aevatar-console-web/src/pages/actors/index.tsx @@ -24,17 +24,37 @@ import { AevatarWorkbenchLayout, } from "@/shared/ui/aevatarPageShells"; -function readActorSelection(): string { +type ExplorerRouteSelection = { + actorId: string; + runId: string; + scopeId: string; + serviceId: string; +}; + +function readExplorerSelection(): ExplorerRouteSelection { if (typeof window === "undefined") { - return ""; + return { + actorId: "", + runId: "", + scopeId: "", + serviceId: "", + }; } - return new URLSearchParams(window.location.search).get("actorId")?.trim() ?? ""; + const searchParams = new URLSearchParams(window.location.search); + return { + actorId: searchParams.get("actorId")?.trim() ?? "", + runId: searchParams.get("runId")?.trim() ?? "", + scopeId: searchParams.get("scopeId")?.trim() ?? "", + serviceId: searchParams.get("serviceId")?.trim() ?? "", + }; } const ActorsPage: React.FC = () => { const [actorKeyword, setActorKeyword] = useState(""); - const [selectedActorId, setSelectedActorId] = useState(readActorSelection()); + const [selectedActorId, setSelectedActorId] = useState( + readExplorerSelection().actorId, + ); const actorsQuery = useQuery({ queryKey: ["runtime-agents"], @@ -61,9 +81,16 @@ const ActorsPage: React.FC = () => { }); useEffect(() => { + const routeSelection = readExplorerSelection(); history.replace( buildRuntimeExplorerHref({ actorId: selectedActorId || undefined, + runId: + routeSelection.actorId && routeSelection.actorId === selectedActorId + ? routeSelection.runId || undefined + : undefined, + scopeId: routeSelection.scopeId || undefined, + serviceId: routeSelection.serviceId || undefined, }), ); }, [selectedActorId]); 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 433d9745..f3914bbb 100644 --- a/apps/aevatar-console-web/src/pages/gagents/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/gagents/index.test.tsx @@ -518,7 +518,7 @@ describe("GAgentsPage", () => { fireEvent.click( screen.getByRole("checkbox", { - name: "I understand this changes the scope's published default service.", + name: "I understand this changes the team's published default service.", }) ); fireEvent.click(screen.getByRole("button", { name: "Publish binding" })); @@ -537,7 +537,7 @@ describe("GAgentsPage", () => { requestTypeUrl: "type.googleapis.com/google.protobuf.StringValue", responseTypeUrl: undefined, - description: "Run the published GAgent.", + description: "Run the published team entry.", }, ], }); @@ -629,7 +629,7 @@ describe("GAgentsPage", () => { ).toHaveBeenCalledWith("scope-a", "rev-2"); }); expect( - await screen.findByText("Scope scope-a is now serving revision rev-2.") + await screen.findByText("Team scope-a is now serving revision rev-2.") ).toBeTruthy(); const retireButton = screen diff --git a/apps/aevatar-console-web/src/pages/gagents/index.tsx b/apps/aevatar-console-web/src/pages/gagents/index.tsx index e3881271..818bdb07 100644 --- a/apps/aevatar-console-web/src/pages/gagents/index.tsx +++ b/apps/aevatar-console-web/src/pages/gagents/index.tsx @@ -2795,8 +2795,8 @@ const GAgentsPage: React.FC = () => { } - title="GAgents" - titleHelp="Discover runtime GAgent types, publish scope bindings, reuse actors, and verify draft and serving paths from one workbench." + title="团队成员" + titleHelp="这里保留原有 GAgent runtime 能力,但统一对外表述为团队成员管理与绑定工作台。" > { setIsActorRegistryDrawerOpen(false)} open={isActorRegistryDrawerOpen} - title="Actor Registry" + title="成员注册表" width={screens.xl ? 680 : 520} > {actorRegistryPanel} @@ -2820,7 +2820,7 @@ const GAgentsPage: React.FC = () => { ? describeRuntimeGAgentBindingRevisionTarget(selectedRevision) : undefined } - title="Revision Details" + title="版本详情" width={screens.xl ? 620 : 480} > {selectedRevisionPanel} diff --git a/apps/aevatar-console-web/src/pages/login/index.tsx b/apps/aevatar-console-web/src/pages/login/index.tsx index dae71a26..3ab15f0c 100644 --- a/apps/aevatar-console-web/src/pages/login/index.tsx +++ b/apps/aevatar-console-web/src/pages/login/index.tsx @@ -111,7 +111,7 @@ const LoginPage: React.FC = () => {
@@ -121,7 +121,7 @@ const LoginPage: React.FC = () => { Aevatar Console
- + Sign in with NyxID diff --git a/apps/aevatar-console-web/src/pages/overview/index.test.tsx b/apps/aevatar-console-web/src/pages/overview/index.test.tsx deleted file mode 100644 index c9f39444..00000000 --- a/apps/aevatar-console-web/src/pages/overview/index.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { screen, waitFor } from "@testing-library/react"; -import React from "react"; -import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; -import { runtimeQueryApi } from "@/shared/api/runtimeQueryApi"; -import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; -import OverviewPage from "./index"; - -jest.mock("@/shared/api/runtimeCatalogApi", () => ({ - runtimeCatalogApi: { - listWorkflowNames: jest.fn(async () => []), - listWorkflowCatalog: jest.fn(async () => []), - }, -})); - -jest.mock("@/shared/api/runtimeQueryApi", () => ({ - runtimeQueryApi: { - listAgents: jest.fn(async () => []), - getCapabilities: jest.fn(async () => ({ - schemaVersion: "capabilities.v1", - generatedAtUtc: "2026-03-12T00:00:00Z", - primitives: [], - connectors: [], - workflows: [], - })), - }, -})); - -describe("OverviewPage", () => { - it("renders the overview title", async () => { - const { container } = renderWithQueryClient( - React.createElement(OverviewPage) - ); - - expect(container.textContent).toContain("Overview"); - expect( - screen.queryByText( - "A single command-center view from login to runtime: project-first actions on the left, ecosystem health in the center, and detail only when you ask for it." - ) - ).toBeNull(); - expect(screen.getAllByRole("button", { name: "Show help" }).length).toBeGreaterThan(0); - expect(container.textContent).toContain("Command Path"); - expect(container.textContent).toContain("Operator Shortcuts"); - expect(container.textContent).toContain( - "Anchor work to a project" - ); - expect(container.textContent).toContain( - "Promote a capability" - ); - expect(container.textContent).toContain( - "Operate the runtime" - ); - expect(container.textContent).toContain("State Board"); - expect(container.textContent).toContain("Human Loop"); - expect(container.textContent).toContain("Live Actors"); - expect(container.textContent).toContain("Open Projects"); - expect(container.textContent).toContain("Open workflow workspace"); - expect(container.textContent).toContain("Open Runs"); - expect(container.textContent).toContain("Open Invoke Lab"); - expect(container.textContent).toContain("Open governance"); - expect(container.textContent).toContain("Workflow sources"); - expect(container.textContent).toContain("Runtime attention"); - expect(container.textContent).not.toContain("Start preferred workflow"); - expect(container.textContent).not.toContain("Preferred workflow"); - expect(container.textContent).not.toContain("Open Runtime Observability"); - expect(container.textContent).not.toContain("Capability surfaces"); - await waitFor(() => { - expect(runtimeCatalogApi.listWorkflowNames).toHaveBeenCalled(); - expect(runtimeQueryApi.listAgents).toHaveBeenCalled(); - expect(runtimeQueryApi.getCapabilities).toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/overview/index.tsx b/apps/aevatar-console-web/src/pages/overview/index.tsx deleted file mode 100644 index ba6d84bd..00000000 --- a/apps/aevatar-console-web/src/pages/overview/index.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { - ApartmentOutlined, - ControlOutlined, - DeploymentUnitOutlined, - EyeOutlined, - ThunderboltOutlined, -} from "@ant-design/icons"; -import { ProCard } from "@ant-design/pro-components"; -import { Alert, Button, Empty, Space, Typography } from "antd"; -import React, { useMemo, useState } from "react"; -import { history } from "@/shared/navigation/history"; -import { - buildRuntimeExplorerHref, - buildRuntimePrimitivesHref, - buildRuntimeRunsHref, - buildRuntimeWorkflowsHref, -} from "@/shared/navigation/runtimeRoutes"; -import { buildStudioWorkflowWorkspaceRoute } from "@/shared/studio/navigation"; -import { - AevatarContextDrawer, - AevatarInspectorEmpty, - AevatarPageShell, - AevatarPanel, - AevatarStatusTag, - AevatarWorkbenchLayout, -} from "@/shared/ui/aevatarPageShells"; -import { - buildAevatarMetricCardStyle, - resolveAevatarMetricVisual, - type AevatarThemeSurfaceToken, -} from "@/shared/ui/aevatarWorkbench"; -import { theme } from "antd"; -import { useOverviewData } from "./useOverviewData"; - -type OverviewFocus = - | "human-workflows" - | "live-actors" - | "connectors" - | "primitives" - | "catalog" - | null; - -type CommandTile = { - description: string; - icon: React.ReactNode; - id: Exclude; - label: string; - tone?: "default" | "error" | "info" | "success" | "warning"; - value: string; -}; - -const quickStartSteps = [ - { - description: - "Start from the project workspace so every later action stays tied to a project scope instead of raw platform pages.", - label: "Open Projects", - onClick: () => history.push("/scopes/overview"), - secondary: { - label: "Open workflow workspace", - onClick: () => history.push(buildStudioWorkflowWorkspaceRoute()), - }, - step: "01", - title: "Anchor work to a project", - }, - { - description: - "Draft or revise a capability in Studio, then move it into an active binding when it is ready to serve.", - label: "Open workflow workspace", - onClick: () => history.push(buildStudioWorkflowWorkspaceRoute()), - secondary: { - label: "Open Assets", - onClick: () => history.push("/scopes/assets"), - }, - step: "02", - title: "Promote a capability", - }, - { - description: - "Open Runs first to attach to a real execution. Mission Control only becomes useful after a live run context exists.", - label: "Open Runs", - onClick: () => history.push(buildRuntimeRunsHref()), - secondary: { - label: "Open Invoke Lab", - onClick: () => history.push("/scopes/invoke"), - }, - step: "03", - title: "Operate the runtime", - }, -] as const; - -const OverviewPage: React.FC = () => { - const { token } = theme.useToken(); - const { - agentsQuery, - capabilitiesQuery, - humanFocusedWorkflows, - liveActors, - visibleCatalogItems, - workflowsQuery, - capabilityConnectorSummary, - capabilityPrimitiveCategorySummary, - capabilityWorkflowSourceSummary, - } = useOverviewData(); - const [focus, setFocus] = useState(null); - - const tiles = useMemo( - () => [ - { - description: "Workflows that already expose approval, input, or wait-signal paths.", - icon: , - id: "human-workflows", - label: "Human Loop", - tone: "warning", - value: String(humanFocusedWorkflows.length), - }, - { - description: "Live or recently observed actors that can be reopened in runtime explorer.", - icon: , - id: "live-actors", - label: "Live Actors", - tone: "info", - value: String(liveActors.length), - }, - { - description: "Connector readiness across the runtime capability surface.", - icon: , - id: "connectors", - label: "Connectors", - tone: "success", - value: capabilityConnectorSummary, - }, - { - description: "Top primitive categories visible in the current capability catalog.", - icon: , - id: "primitives", - label: "Primitive Surface", - tone: "info", - value: capabilityPrimitiveCategorySummary.join(" · ") || "No primitives", - }, - { - description: "Visible catalog items that can move from design to runtime.", - icon: , - id: "catalog", - label: "Catalog", - tone: "default", - value: `${visibleCatalogItems.length} visible`, - }, - ], - [ - capabilityConnectorSummary, - capabilityPrimitiveCategorySummary, - humanFocusedWorkflows.length, - liveActors.length, - visibleCatalogItems.length, - ], - ); - - const focusTitle = - focus === "human-workflows" - ? "Human-loop workflows" - : focus === "live-actors" - ? "Live actor shortcuts" - : focus === "connectors" - ? "Connector readiness" - : focus === "primitives" - ? "Primitive surface" - : focus === "catalog" - ? "Workflow catalog" - : "Overview"; - - return ( - - - -
- {quickStartSteps.map((step) => ( -
- {step.step} - {step.title} - - {step.description} - - - - - -
- ))} -
-
- - - - - - - - - - } - stage={ -
- {workflowsQuery.error || agentsQuery.error || capabilitiesQuery.error ? ( - - ) : null} - - -
- {tiles.map((tile) => { - const visual = resolveAevatarMetricVisual( - token as AevatarThemeSurfaceToken, - tile.tone || "default", - ); - - return ( - - ); - })} -
-
- - -
- setFocus("catalog")} - title="Workflow sources" - /> - item.name) - .slice(0, 3) - .join(" · ") || "No human-loop workflows discovered." - } - onOpen={() => setFocus("human-workflows")} - title="Human-loop focus" - /> - item.id).slice(0, 3).join(" · ") || - "No live actors returned by the backend." - } - onOpen={() => setFocus("live-actors")} - title="Runtime attention" - /> -
-
-
- } - /> - - setFocus(null)} - open={Boolean(focus)} - subtitle="Focused command-center detail" - title={focusTitle} - > - {!focus ? ( - - ) : focus === "human-workflows" ? ( - ({ - action: () => - history.push( - buildRuntimeRunsHref({ - workflow: item.name, - }), - ), - actionLabel: "Open runs", - description: item.description || "Workflow ready for human approval or signal choreography.", - label: item.name, - status: item.requiresLlmProvider ? "active" : "draft", - }))} - /> - ) : focus === "live-actors" ? ( - ({ - action: () => - history.push( - buildRuntimeExplorerHref({ - actorId: item.id, - }), - ), - actionLabel: "Open explorer", - description: item.description || item.type, - label: item.id, - status: "live", - }))} - /> - ) : focus === "connectors" ? ( - ({ - action: () => history.push(buildRuntimePrimitivesHref()), - actionLabel: "Open primitives", - description: `${item.type} · ${item.allowedOperations.join(", ") || "No operations declared"}`, - label: item.name, - status: item.enabled ? "ready" : "disabled", - }))} - /> - ) : focus === "primitives" ? ( - ({ - action: () => - history.push( - buildRuntimePrimitivesHref({ - primitive: item.name, - }), - ), - actionLabel: "Inspect primitive", - description: item.description || item.category, - label: item.name, - status: item.closedWorldBlocked ? "blocked" : "ready", - }))} - /> - ) : ( - ({ - action: () => - history.push( - buildRuntimeWorkflowsHref({ - workflow: item.name, - }), - ), - actionLabel: "Open workflow", - description: item.description || item.groupLabel, - label: item.name, - status: item.requiresLlmProvider ? "active" : "draft", - }))} - /> - )} - -
- ); -}; - -const GhostBoardCard: React.FC<{ - description: string; - onOpen: () => void; - title: string; -}> = ({ description, onOpen, title }) => ( - - - {title} - {description} - - - -); - -const FocusList: React.FC<{ - emptyText: string; - items: Array<{ - action: () => void; - actionLabel: string; - description: string; - label: string; - status: string; - }>; -}> = ({ emptyText, items }) => - items.length === 0 ? ( - - ) : ( -
- {items.map((item) => ( -
- - {item.label} - - - {item.description} - -
- ))} -
- ); - -export default OverviewPage; diff --git a/apps/aevatar-console-web/src/pages/overview/useOverviewData.ts b/apps/aevatar-console-web/src/pages/overview/useOverviewData.ts deleted file mode 100644 index 7616c064..00000000 --- a/apps/aevatar-console-web/src/pages/overview/useOverviewData.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import React, { useEffect, useMemo, useState } from "react"; -import { runtimeCatalogApi } from "@/shared/api/runtimeCatalogApi"; -import { runtimeQueryApi } from "@/shared/api/runtimeQueryApi"; -import { listVisibleWorkflowCatalogItems } from "@/shared/workflows/catalogVisibility"; - -export function useOverviewData() { - const [deferredDetailsEnabled, setDeferredDetailsEnabled] = useState(false); - - useEffect(() => { - const requestIdle = window.requestIdleCallback?.bind(window); - if (requestIdle) { - const handle = requestIdle(() => setDeferredDetailsEnabled(true)); - return () => window.cancelIdleCallback?.(handle); - } - - const handle = window.setTimeout(() => setDeferredDetailsEnabled(true), 0); - return () => window.clearTimeout(handle); - }, []); - - const workflowsQuery = useQuery({ - queryKey: ["overview-workflows"], - queryFn: () => runtimeCatalogApi.listWorkflowNames(), - staleTime: 60_000, - }); - const catalogQuery = useQuery({ - queryKey: ["overview-catalog"], - queryFn: () => runtimeCatalogApi.listWorkflowCatalog(), - staleTime: 60_000, - }); - const agentsQuery = useQuery({ - queryKey: ["overview-agents"], - queryFn: () => runtimeQueryApi.listAgents(), - enabled: deferredDetailsEnabled, - staleTime: 30_000, - }); - const capabilitiesQuery = useQuery({ - queryKey: ["overview-capabilities"], - queryFn: () => runtimeQueryApi.getCapabilities(), - enabled: deferredDetailsEnabled, - staleTime: 30_000, - }); - - const visibleCatalogItems = useMemo( - () => listVisibleWorkflowCatalogItems(catalogQuery.data ?? []), - [catalogQuery.data] - ); - - const humanFocusedWorkflows = useMemo( - () => - visibleCatalogItems - .filter((item) => - item.primitives.some((primitive) => - ["human_input", "human_approval", "wait_signal"].includes(primitive) - ) - ) - .slice(0, 6), - [visibleCatalogItems] - ); - - const capabilityPrimitiveCategorySummary = useMemo(() => { - const categoryCounts = new Map(); - - for (const primitive of capabilitiesQuery.data?.primitives ?? []) { - categoryCounts.set( - primitive.category, - (categoryCounts.get(primitive.category) ?? 0) + 1 - ); - } - - return Array.from(categoryCounts.entries()) - .sort((left, right) => right[1] - left[1]) - .slice(0, 3) - .map(([category, count]) => `${count} ${category}`); - }, [capabilitiesQuery.data]); - - const capabilityConnectorEnabledCount = useMemo( - () => - (capabilitiesQuery.data?.connectors ?? []).filter( - (connector) => connector.enabled - ).length, - [capabilitiesQuery.data] - ); - - const capabilityConnectorSummary = useMemo(() => { - const connectors = capabilitiesQuery.data?.connectors ?? []; - - if (connectors.length === 0) { - return "No connectors exposed"; - } - - return `${capabilityConnectorEnabledCount}/${connectors.length} enabled`; - }, [capabilitiesQuery.data, capabilityConnectorEnabledCount]); - - const capabilityWorkflowSourceSummary = useMemo(() => { - const workflows = capabilitiesQuery.data?.workflows ?? []; - const sourceCounts = new Map(); - - for (const workflow of workflows) { - const source = workflow.source || "runtime"; - sourceCounts.set(source, (sourceCounts.get(source) ?? 0) + 1); - } - - return Array.from(sourceCounts.entries()) - .sort((left, right) => right[1] - left[1]) - .slice(0, 3) - .map(([source, count]) => `${count} ${source}`); - }, [capabilitiesQuery.data]); - - const liveActors = useMemo( - () => (agentsQuery.data ?? []).slice(0, 6), - [agentsQuery.data] - ); - - return { - agentsQuery, - capabilitiesQuery, - catalogQuery, - humanFocusedWorkflows, - liveActors, - visibleCatalogItems, - workflowsQuery, - capabilityConnectorSummary, - capabilityPrimitiveCategorySummary, - capabilityWorkflowSourceSummary, - }; -} diff --git a/apps/aevatar-console-web/src/pages/primitives/index.test.tsx b/apps/aevatar-console-web/src/pages/primitives/index.test.tsx index 48acad56..b818b735 100644 --- a/apps/aevatar-console-web/src/pages/primitives/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/primitives/index.test.tsx @@ -34,15 +34,15 @@ describe("PrimitivesPage", () => { React.createElement(PrimitivesPage), ); - expect(container.textContent).toContain("Primitive Library"); + expect(container.textContent).toContain("连接器目录"); expect( screen.queryByText( "Primitive definitions are now managed as a runtime library workbench. The main stage stays dedicated to discovery while parameter contracts and example workflows live in the inspector.", ), ).toBeNull(); expect(screen.getAllByRole("button", { name: "Show help" }).length).toBeGreaterThan(0); - expect(container.textContent).toContain("Runtime Primitives"); - expect(container.textContent).toContain("Filter Library"); + expect(container.textContent).toContain("可用连接器"); + expect(container.textContent).toContain("筛选连接器"); expect(container.textContent).not.toContain("Legacy draft"); expect(container.textContent).not.toContain("Studio"); @@ -56,7 +56,7 @@ describe("PrimitivesPage", () => { React.createElement(PrimitivesPage), ); - expect(await screen.findByText("Library Digest")).toBeTruthy(); + expect(await screen.findByText("目录摘要")).toBeTruthy(); expect(container.querySelector(".ant-select")).toHaveStyle({ width: "100%" }); }); @@ -64,13 +64,13 @@ describe("PrimitivesPage", () => { renderWithQueryClient(React.createElement(PrimitivesPage)); expect(await screen.findByText("Ready")).toBeTruthy(); - expect(screen.getByText("Category")).toBeTruthy(); - expect(screen.getByText("Parameters")).toBeTruthy(); - expect(screen.getByText("Examples")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Inspect" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Example workflow" })).toBeTruthy(); + expect(screen.getByText("分类")).toBeTruthy(); + expect(screen.getByText("参数")).toBeTruthy(); + expect(screen.getByText("示例")).toBeTruthy(); + expect(screen.getByRole("button", { name: "查看" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "示例行为定义" })).toBeTruthy(); expect( - screen.getByRole("button", { name: "Inspect primitive human_input" }), + screen.getByRole("button", { name: "查看连接器 human_input" }), ).toHaveStyle({ width: "100%", }); @@ -80,9 +80,9 @@ describe("PrimitivesPage", () => { renderWithQueryClient(React.createElement(PrimitivesPage)); fireEvent.click( - await screen.findByRole("button", { name: "Inspect primitive human_input" }), + await screen.findByRole("button", { name: "查看连接器 human_input" }), ); - expect(await screen.findByText("Primitive contract")).toBeTruthy(); + expect(await screen.findByText("连接器契约")).toBeTruthy(); }); }); diff --git a/apps/aevatar-console-web/src/pages/primitives/index.tsx b/apps/aevatar-console-web/src/pages/primitives/index.tsx index 6b58acbf..7dfc4097 100644 --- a/apps/aevatar-console-web/src/pages/primitives/index.tsx +++ b/apps/aevatar-console-web/src/pages/primitives/index.tsx @@ -48,8 +48,8 @@ function buildPrimitiveSummary(primitive: WorkflowPrimitiveDescriptor): string { } return primitive.aliases.length > 0 - ? `Aliases: ${primitive.aliases.join(", ")}` - : "Ready to inspect parameter contracts and example workflow coverage."; + ? `别名:${primitive.aliases.join(", ")}` + : "已就绪,可继续查看参数契约和示例行为定义。"; } const PrimitiveSummaryMetric: React.FC<{ @@ -72,7 +72,7 @@ const PrimitiveCatalogCard: React.FC<{ return (
{ if (event.key === "Enter" || event.key === " ") { @@ -138,30 +138,30 @@ const PrimitiveCatalogCard: React.FC<{ width: "100%", }} > - +
@@ -237,8 +237,8 @@ const PrimitivesPage: React.FC = () => { return ( {
{ > setKeyword(event.target.value)} - placeholder="Search primitive, category, or alias" + placeholder="搜索连接器、分类或别名" style={{ width: "100%" }} value={keyword} /> @@ -267,7 +267,7 @@ const PrimitivesPage: React.FC = () => { mode="multiple" onChange={setSelectedCategories} options={categoryOptions} - placeholder="Filter categories" + placeholder="筛选分类" style={{ width: "100%" }} value={selectedCategories} /> @@ -278,23 +278,23 @@ const PrimitivesPage: React.FC = () => { setSelectedPrimitiveName(""); }} > - Reset filters + 重置筛选
- + - {filteredRows.length} primitives in view + {filteredRows.length} 个连接器能力 - {categoryOptions.length} categories ·{" "} + {categoryOptions.length} 个分类 ·{" "} {filteredRows.reduce( (count, primitive) => count + primitive.parameters.length, 0, )}{" "} - parameters surfaced + 个已暴露参数 @@ -303,8 +303,8 @@ const PrimitivesPage: React.FC = () => { stage={ dataSource={filteredRows} @@ -312,7 +312,7 @@ const PrimitivesPage: React.FC = () => { locale={{ emptyText: ( ), @@ -343,16 +343,16 @@ const PrimitivesPage: React.FC = () => { setSelectedPrimitiveName("")} open={Boolean(selectedPrimitiveName)} - subtitle="Primitive contract" - title={selectedPrimitive?.name || selectedPrimitiveName || "Primitive"} + subtitle="连接器契约" + title={selectedPrimitive?.name || selectedPrimitiveName || "连接器"} > {!selectedPrimitive ? ( - + ) : ( <> @@ -362,20 +362,20 @@ const PrimitivesPage: React.FC = () => { - {selectedPrimitive.description || "No primitive description."} + {selectedPrimitive.description || "当前连接器还没有描述。"} - Aliases:{" "} + 别名: {selectedPrimitive.aliases.length > 0 ? selectedPrimitive.aliases.join(", ") - : "None"} + : "无"} {selectedPrimitive.parameters.length > 0 ? (
@@ -395,7 +395,7 @@ const PrimitivesPage: React.FC = () => { {parameter.name} @@ -403,25 +403,25 @@ const PrimitivesPage: React.FC = () => { - {parameter.description || "No parameter description."} + {parameter.description || "当前参数还没有描述。"} - Default: {parameter.default || "n/a"} + 默认值:{parameter.default || "n/a"}
))}
) : ( )} {selectedPrimitive.exampleWorkflows.length > 0 ? ( @@ -447,14 +447,14 @@ const PrimitivesPage: React.FC = () => { ) } > - Open workflow + 打开行为定义 ))} ) : ( )} diff --git a/apps/aevatar-console-web/src/pages/runs/components/RunsTracePane.test.tsx b/apps/aevatar-console-web/src/pages/runs/components/RunsTracePane.test.tsx index 46a23f21..1cb90f9f 100644 --- a/apps/aevatar-console-web/src/pages/runs/components/RunsTracePane.test.tsx +++ b/apps/aevatar-console-web/src/pages/runs/components/RunsTracePane.test.tsx @@ -1,4 +1,4 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import RunsTracePane from "./RunsTracePane"; @@ -31,4 +31,37 @@ describe("RunsTracePane", () => { overflow: "hidden", }); }); + + it("shows only the active trace pane when switching tabs", () => { + const Harness = () => { + const [view, setView] = React.useState<"timeline" | "messages" | "events">( + "timeline" + ); + + return ( + events panel} + eventCount={3} + hasPendingInteraction={false} + messageConsoleView={
messages panel
} + messageCount={2} + onConsoleViewChange={(key) => setView(key)} + timelineView={
timeline panel
} + /> + ); + }; + + const { queryByText } = render(); + + expect(screen.getByText("timeline panel")).toBeTruthy(); + expect(queryByText("messages panel")).toBeNull(); + expect(queryByText("events panel")).toBeNull(); + + fireEvent.click(screen.getByRole("tab", { name: "Messages" })); + + expect(screen.getByText("messages panel")).toBeTruthy(); + expect(queryByText("timeline panel")).toBeNull(); + expect(queryByText("events panel")).toBeNull(); + }); }); diff --git a/apps/aevatar-console-web/src/pages/runs/components/RunsTracePane.tsx b/apps/aevatar-console-web/src/pages/runs/components/RunsTracePane.tsx index 2fdfd139..cfc2c32f 100644 --- a/apps/aevatar-console-web/src/pages/runs/components/RunsTracePane.tsx +++ b/apps/aevatar-console-web/src/pages/runs/components/RunsTracePane.tsx @@ -77,6 +77,7 @@ const RunsTracePane: React.FC = ({ ({ }, })); -function getButtonByText(label: string): HTMLButtonElement { - const button = screen - .getAllByText((_, element) => element?.textContent?.trim() === label) - .map((element) => - element instanceof HTMLButtonElement ? element : element.closest("button") - ) - .find((element): element is HTMLButtonElement => element instanceof HTMLButtonElement); - - if (!button) { - throw new Error(`Unable to find button with text '${label}'.`); - } - - return button; -} - describe("RunsPage", () => { const mockedRuntimeCatalogApi = runtimeCatalogApi as unknown as { listWorkflowCatalog: jest.Mock; @@ -109,6 +95,7 @@ describe("RunsPage", () => { signal: jest.Mock; stop: jest.Mock; }; + const mockedParseBackendSSEStream = parseBackendSSEStream as jest.Mock; beforeEach(() => { window.history.replaceState({}, "", "/runtime/runs"); @@ -136,23 +123,101 @@ describe("RunsPage", () => { }); mockedRuntimeRunsApi.streamDraftRun.mockResolvedValue({}); mockedRuntimeCatalogApi.listWorkflowCatalog.mockResolvedValue([]); + mockedParseBackendSSEStream.mockImplementation( + () => (async function* () {})() + ); }); it("renders the runtime run console header and navigation actions", async () => { const { container } = renderWithQueryClient(React.createElement(RunsPage)); expect(container.textContent).toContain("Runtime endpoint console"); - expect(screen.getByLabelText("Open runtime console guide")).toBeTruthy(); - expect(screen.getByText("Catalog")).toBeTruthy(); - expect(screen.getByText("Explorer")).toBeTruthy(); - expect(screen.queryByLabelText("Open observability hub")).toBeNull(); - expect(screen.getByText("Inspector")).toBeTruthy(); - expect(screen.getByLabelText("Scope ID")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Open runtime console guide" }) + ); + expect( + screen.getByRole("button", { name: "Catalog" }) + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "返回团队高级编辑" }) + ).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Explorer" }) + ).toBeTruthy(); + expect( + screen.queryByRole("button", { name: "Open observability hub" }) + ).toBeNull(); + expect(screen.getByRole("button", { name: "Inspector" })).toBeTruthy(); + expect( + screen.getByPlaceholderText("Describe the task to run.") + ).toBeTruthy(); expect(container.textContent).toContain("Launch rail"); expect(container.textContent).toContain("Run trace"); expect(container.textContent).toContain("Inspector"); }); + it("navigates back to the team advanced tab from the runs console", async () => { + window.history.replaceState( + {}, + "", + "/runtime/runs?scopeId=scope-1" + ); + + renderWithQueryClient(React.createElement(RunsPage)); + + fireEvent.click( + await screen.findByRole("button", { name: "返回团队高级编辑" }) + ); + + expect(window.location.pathname).toBe("/teams/scope-1"); + expect(new URLSearchParams(window.location.search).get("tab")).toBe( + "advanced" + ); + }); + + it("returns to the originating studio route when a return target is provided", async () => { + window.history.replaceState( + {}, + "", + "/runtime/runs?scopeId=scope-1&returnTo=%2Fstudio%3FscopeId%3Dscope-1%26tab%3Dstudio%26template%3Dhello-chat" + ); + + renderWithQueryClient(React.createElement(RunsPage)); + + fireEvent.click( + await screen.findByRole("button", { name: "返回团队高级编辑" }) + ); + + expect(window.location.pathname).toBe("/studio"); + expect(new URLSearchParams(window.location.search).get("scopeId")).toBe( + "scope-1" + ); + expect(new URLSearchParams(window.location.search).get("tab")).toBe( + "studio" + ); + expect(new URLSearchParams(window.location.search).get("template")).toBe( + "hello-chat" + ); + }); + + it("keeps the trace workspace viewport stretchable so the inner console can scroll", async () => { + const { container } = renderWithQueryClient(React.createElement(RunsPage)); + + const tabs = container.querySelectorAll(".ant-tabs"); + expect(tabs[0]).toHaveStyle({ + flex: "1", + minHeight: "0", + }); + + const contentHolder = tabs[0]?.querySelector(".ant-tabs-content-holder"); + expect(contentHolder).not.toBeNull(); + expect(contentHolder).toHaveStyle({ + flex: "1", + minHeight: "0", + overflow: "hidden", + }); + }); + it("uses the generic invoke path for prepared service invocation drafts", async () => { const draftKey = saveEndpointInvocationDraftPayload({ endpointId: "aevatar.tools.cli.hosting.AppScriptCommand", @@ -168,13 +233,8 @@ describe("RunsPage", () => { renderWithQueryClient(React.createElement(RunsPage)); - const promptInput = await screen.findByDisplayValue("script payload"); - // ProForm's custom submitter buttons don't render in jsdom; submit via the - // form element which exercises the same onFinish path as the button's - // onClick={() => props.form?.submit?.()) wiring. - const form = promptInput.closest("form"); - expect(form).toBeTruthy(); - fireEvent.submit(form!); + await screen.findByDisplayValue("script payload"); + fireEvent.click(screen.getByRole("button", { name: "Start run" })); await waitFor(() => { expect(mockedRuntimeRunsApi.invokeEndpoint).toHaveBeenCalledWith( @@ -237,6 +297,120 @@ describe("RunsPage", () => { expect(loadDraftRunPayload(draftKey)).toBeNull(); }); + it("retries chat runs against the scope default binding when a stale service id is missing", async () => { + window.history.replaceState( + {}, + "", + "/runtime/runs?scopeId=scope-1&route=hello-chat&serviceOverrideId=scope-1:default:default:hello-chat&prompt=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E8%AF%B7%E5%81%9A%E4%B8%AA%E8%87%AA%E6%88%91%E4%BB%8B%E7%BB%8D" + ); + + mockedRuntimeRunsApi.streamChat + .mockRejectedValueOnce( + new Error( + "Service 'scope-1:default:default:hello-chat' was not found." + ) + ) + .mockResolvedValueOnce({ + ok: true, + body: {}, + }); + + renderWithQueryClient(React.createElement(RunsPage)); + + await screen.findByDisplayValue("scope-1"); + fireEvent.click(screen.getByRole("button", { name: "Start run" })); + + await waitFor(() => { + expect(mockedRuntimeRunsApi.streamChat).toHaveBeenCalledTimes(2); + }); + + expect(mockedRuntimeRunsApi.streamChat).toHaveBeenNthCalledWith( + 1, + "scope-1", + expect.objectContaining({ + prompt: "你好,请做个自我介绍", + }), + expect.any(AbortSignal), + { + serviceId: "scope-1:default:default:hello-chat", + } + ); + + expect(mockedRuntimeRunsApi.streamChat).toHaveBeenNthCalledWith( + 2, + "scope-1", + expect.objectContaining({ + prompt: "你好,请做个自我介绍", + }), + expect.any(AbortSignal), + { + serviceId: undefined, + } + ); + }); + + it("retries streamed chat runs against the scope default binding when the stream reports a missing service", async () => { + window.history.replaceState( + {}, + "", + "/runtime/runs?scopeId=scope-1&route=hello-chat&serviceOverrideId=scope-1:default:default:hello-chat&prompt=%E4%BD%A0%E5%A5%BD%EF%BC%8C%E8%AF%B7%E5%81%9A%E4%B8%AA%E8%87%AA%E6%88%91%E4%BB%8B%E7%BB%8D" + ); + + mockedRuntimeRunsApi.streamChat + .mockResolvedValueOnce({ + ok: true, + body: {}, + }) + .mockResolvedValueOnce({ + ok: true, + body: {}, + }); + mockedParseBackendSSEStream + .mockImplementationOnce( + () => + (async function* () { + yield { + type: "RUN_ERROR", + message: "Service 'scope-1:default:default:hello-chat' was not found.", + }; + })() + ) + .mockImplementationOnce(() => (async function* () {})()); + + renderWithQueryClient(React.createElement(RunsPage)); + + await screen.findByDisplayValue("scope-1"); + fireEvent.click(screen.getByRole("button", { name: "Start run" })); + + await waitFor(() => { + expect(mockedRuntimeRunsApi.streamChat).toHaveBeenCalledTimes(2); + }); + + expect(mockedRuntimeRunsApi.streamChat).toHaveBeenNthCalledWith( + 1, + "scope-1", + expect.objectContaining({ + prompt: "你好,请做个自我介绍", + }), + expect.any(AbortSignal), + { + serviceId: "scope-1:default:default:hello-chat", + } + ); + + expect(mockedRuntimeRunsApi.streamChat).toHaveBeenNthCalledWith( + 2, + "scope-1", + expect.objectContaining({ + prompt: "你好,请做个自我介绍", + }), + expect.any(AbortSignal), + { + serviceId: undefined, + } + ); + }); + it("hydrates observed run sessions without starting a new invoke", async () => { const draftKey = saveObservedRunSessionPayload({ scopeId: "scope-1", @@ -336,8 +510,7 @@ describe("RunsPage", () => { mockDispatch.mockClear(); mockReset.mockClear(); - fireEvent.click(screen.getByText("Recent (1)")); - fireEvent.click(getButtonByText("Restore")); + fireEvent.click(screen.getAllByRole("button", { name: "Restore" })[0]); await waitFor(() => { expect(mockReset).toHaveBeenCalled(); @@ -389,13 +562,8 @@ describe("RunsPage", () => { renderWithQueryClient(React.createElement(RunsPage)); - const promptInput = await screen.findByDisplayValue("Run it"); - // ProForm's custom submitter buttons don't render in jsdom; submit via the - // form element which exercises the same onFinish path as the button's - // onClick={() => props.form?.submit?.()) wiring. - const form = promptInput.closest("form"); - expect(form).toBeTruthy(); - fireEvent.submit(form!); + await screen.findByDisplayValue("Run it"); + fireEvent.click(screen.getByRole("button", { name: "Start run" })); await waitFor(() => { expect(mockedRuntimeRunsApi.streamChat).toHaveBeenCalledWith( diff --git a/apps/aevatar-console-web/src/pages/runs/index.tsx b/apps/aevatar-console-web/src/pages/runs/index.tsx index b8742665..fa58a0df 100644 --- a/apps/aevatar-console-web/src/pages/runs/index.tsx +++ b/apps/aevatar-console-web/src/pages/runs/index.tsx @@ -21,6 +21,8 @@ import { } from "@ant-design/pro-components"; import { useQuery } from "@tanstack/react-query"; import { history } from "@/shared/navigation/history"; +import { sanitizeReturnTo } from "@/shared/auth/session"; +import { buildTeamDetailHref } from "@/shared/navigation/teamRoutes"; import { buildRuntimeExplorerHref, buildRuntimeWorkflowsHref, @@ -90,6 +92,7 @@ import RunsTimelineView from "./components/RunsTimelineView"; import { buildTimelineGroups, buildEventRows, + resolveRunMessageFallback, isHumanApprovalSuspension, type RunEventRow, type RunTimelineGroup, @@ -130,6 +133,8 @@ import { workbenchEventRowStyle, workbenchMessageListStyle, workbenchOverviewGridStyle, + workbenchTraceTabsStyle, + workbenchTraceTabsStyles, } from "./runWorkbenchConfig"; const runsWorkbenchHeaderBarStyle: React.CSSProperties = { @@ -159,6 +164,25 @@ const runsWorkbenchHeaderActionStyle: React.CSSProperties = { justifyContent: "flex-end", }; +const runsWorkspaceTabsClassName = "runs-workspace-tabs"; +const runsWorkspaceTabsCss = ` +.${runsWorkspaceTabsClassName} { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.${runsWorkspaceTabsClassName} .ant-tabs-content-holder, +.${runsWorkspaceTabsClassName} .ant-tabs-content, +.${runsWorkspaceTabsClassName} .ant-tabs-tabpane-active { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} +`; + function resolveRequestedServiceId( request: Pick< RunFormValues, @@ -183,6 +207,15 @@ function resolveRequestedServiceId( return normalizedServiceOverrideId || trimOptional(request.routeName) || ""; } +function extractMissingServiceId(messageText: string): string { + const match = messageText.match(/Service '([^']+)' was not found/i); + return match?.[1]?.trim() ?? ""; +} + +function isMissingScopeServiceError(messageText: string): boolean { + return extractMissingServiceId(messageText).length > 0; +} + const RunsPage: React.FC = () => { const [messageApi, messageContextHolder] = message.useMessage(); const urlInitialFormValues = useMemo(() => readInitialRunFormValues(), []); @@ -193,6 +226,14 @@ const RunsPage: React.FC = () => { return new URLSearchParams(window.location.search).get("draftKey") ?? ""; }, []); + const requestedReturnTo = useMemo(() => { + if (typeof window === "undefined") { + return ""; + } + + const returnTo = new URLSearchParams(window.location.search).get("returnTo"); + return returnTo ? sanitizeReturnTo(returnTo) : ""; + }, []); const draftRunPayload = useMemo( () => loadQueuedDraftRunPayload(draftRunKey), [draftRunKey] @@ -430,145 +471,224 @@ const RunsPage: React.FC = () => { scopeId: string, request: RunFormValues ) => { - const normalizedScopeId = scopeId.trim(); - const normalizedEndpointKind = normalizeRunEndpointKind( - request.endpointKind, - request.endpointId - ); - const normalizedEndpointId = resolveStoredRunEndpointId( - normalizedEndpointKind, - request.endpointId - ); - const resolvedServiceId = resolveRequestedServiceId( - request, - Boolean(scopeDraftPayload) - ); - const requestedPayloadTypeUrl = request.payloadTypeUrl?.trim() ?? ""; - const requestedPayloadBase64 = request.payloadBase64?.trim() ?? ""; - if (!normalizedScopeId) { - throw new Error("Scope ID is required."); - } - if (normalizedEndpointKind === "command" && !normalizedEndpointId) { - throw new Error("Endpoint ID is required for command invokes."); - } - if ( - requestedPayloadTypeUrl && - !requestedPayloadBase64 && - !isAutoEncodableTextPayloadTypeUrl(requestedPayloadTypeUrl) - ) { - throw new Error( - `payloadBase64 is required for payloadTypeUrl '${requestedPayloadTypeUrl}'.` + const runAttempt = async ( + requestedRun: RunFormValues, + allowMissingServiceRecovery: boolean + ): Promise => { + const normalizedScopeId = scopeId.trim(); + const normalizedEndpointKind = normalizeRunEndpointKind( + requestedRun.endpointKind, + requestedRun.endpointId ); - } - - abortRun(); - reset(); - setTransportIssue(undefined); - setActiveTransport(request.transport); - setActiveScopeId(normalizedScopeId); - setActiveServiceOverrideId(resolvedServiceId); - setActiveEndpointKind(normalizedEndpointKind); - setActiveEndpointId(normalizedEndpointId); - setRunStartedAtMs(Date.now()); - setStreaming(true); + const normalizedEndpointId = resolveStoredRunEndpointId( + normalizedEndpointKind, + requestedRun.endpointId + ); + const resolvedServiceId = resolveRequestedServiceId( + requestedRun, + Boolean(scopeDraftPayload) + ); + const requestedPayloadTypeUrl = + requestedRun.payloadTypeUrl?.trim() ?? ""; + const requestedPayloadBase64 = + requestedRun.payloadBase64?.trim() ?? ""; - try { - const controller = new AbortController(); - stopActiveRunRef.current = () => controller.abort(); + if (!normalizedScopeId) { + throw new Error("Scope ID is required."); + } + if (normalizedEndpointKind === "command" && !normalizedEndpointId) { + throw new Error("Endpoint ID is required for command invokes."); + } + if ( + requestedPayloadTypeUrl && + !requestedPayloadBase64 && + !isAutoEncodableTextPayloadTypeUrl(requestedPayloadTypeUrl) + ) { + throw new Error( + `payloadBase64 is required for payloadTypeUrl '${requestedPayloadTypeUrl}'.` + ); + } - const response = scopeDraftPayload - ? await runtimeRunsApi.streamDraftRun( - normalizedScopeId, - { - prompt: request.prompt, - workflowYamls: scopeDraftPayload.bundleYamls, - }, - controller.signal - ) - : normalizedEndpointKind === "chat" && - !request.payloadTypeUrl?.trim() && - !request.payloadBase64?.trim() - ? await runtimeRunsApi.streamChat( + abortRun(); + reset(); + setTransportIssue(undefined); + setActiveTransport(requestedRun.transport); + setActiveScopeId(normalizedScopeId); + setActiveServiceOverrideId(resolvedServiceId); + setActiveEndpointKind(normalizedEndpointKind); + setActiveEndpointId(normalizedEndpointId); + setRunStartedAtMs(Date.now()); + setStreaming(true); + + try { + const controller = new AbortController(); + stopActiveRunRef.current = () => controller.abort(); + + const response = scopeDraftPayload + ? await runtimeRunsApi.streamDraftRun( normalizedScopeId, { - prompt: request.prompt, - metadata: undefined, + prompt: requestedRun.prompt, + workflowYamls: scopeDraftPayload.bundleYamls, }, - controller.signal, - { - serviceId: resolvedServiceId || undefined, - } + controller.signal ) - : null; - - if (response) { - for await (const event of parseBackendSSEStream(response, { - signal: controller.signal, - })) { - if (controller.signal.aborted) { - break; - } + : normalizedEndpointKind === "chat" && + !requestedRun.payloadTypeUrl?.trim() && + !requestedRun.payloadBase64?.trim() + ? await runtimeRunsApi.streamChat( + normalizedScopeId, + { + prompt: requestedRun.prompt, + metadata: undefined, + }, + controller.signal, + { + serviceId: resolvedServiceId || undefined, + } + ) + : null; + + if (response) { + for await (const event of parseBackendSSEStream(response, { + signal: controller.signal, + })) { + if (controller.signal.aborted) { + break; + } - dispatch(event); - } - } else { - const receipt = await runtimeRunsApi.invokeEndpoint( - normalizedScopeId, - { - endpointId: normalizedEndpointId, - prompt: request.prompt, - commandId: undefined, - payloadTypeUrl: request.payloadTypeUrl || undefined, - payloadBase64: request.payloadBase64 || undefined, - }, - { - serviceId: resolvedServiceId || undefined, + if ( + allowMissingServiceRecovery && + normalizedEndpointKind === "chat" && + resolvedServiceId && + event.type === AGUIEventType.RUN_ERROR && + isMissingScopeServiceError(event.message ?? "") + ) { + composerFormRef.current?.setFieldsValue({ + routeName: undefined, + serviceOverrideId: undefined, + }); + setSelectedRouteName(""); + setActiveServiceOverrideId(""); + messageApi.warning( + `Selected service '${extractMissingServiceId( + event.message ?? "" + )}' is no longer available. Retrying with the scope default binding.` + ); + await runAttempt( + { + ...requestedRun, + routeName: undefined, + serviceOverrideId: undefined, + }, + false + ); + return; + } + + dispatch(event); } - ); - const receiptRunId = - String(receipt.request_id ?? receipt.requestId ?? receipt.commandId ?? "").trim() || - `${normalizedEndpointId}-${Date.now().toString(36)}`; - const receiptActorId = - String( - receipt.target_actor_id ?? receipt.targetActorId ?? receipt.actorId ?? "" + } else { + const receipt = await runtimeRunsApi.invokeEndpoint( + normalizedScopeId, + { + endpointId: normalizedEndpointId, + prompt: requestedRun.prompt, + commandId: undefined, + payloadTypeUrl: requestedRun.payloadTypeUrl || undefined, + payloadBase64: requestedRun.payloadBase64 || undefined, + }, + { + serviceId: resolvedServiceId || undefined, + } + ); + const receiptRunId = + String( + receipt.request_id ?? + receipt.requestId ?? + receipt.commandId ?? + "" + ).trim() || `${normalizedEndpointId}-${Date.now().toString(36)}`; + const receiptActorId = String( + receipt.target_actor_id ?? + receipt.targetActorId ?? + receipt.actorId ?? + "" ).trim(); - const receiptCorrelationId = - String(receipt.correlation_id ?? receipt.correlationId ?? receiptRunId).trim() || - receiptRunId; - - dispatch({ - type: AGUIEventType.RUN_STARTED, - threadId: receiptCorrelationId, - runId: receiptRunId, - }); - dispatch({ - type: AGUIEventType.CUSTOM, - name: CustomEventName.RunContext, - value: { - actorId: receiptActorId || undefined, - workflowName: normalizedEndpointId, - commandId: - String(receipt.command_id ?? receipt.commandId ?? "").trim() || undefined, - }, - }); - messageApi.success( - `Endpoint ${normalizedEndpointId} accepted with request ${receiptRunId}.` - ); - } - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - return; + const receiptCorrelationId = + String( + receipt.correlation_id ?? + receipt.correlationId ?? + receiptRunId + ).trim() || receiptRunId; + + dispatch({ + type: AGUIEventType.RUN_STARTED, + threadId: receiptCorrelationId, + runId: receiptRunId, + }); + dispatch({ + type: AGUIEventType.CUSTOM, + name: CustomEventName.RunContext, + value: { + actorId: receiptActorId || undefined, + workflowName: normalizedEndpointId, + commandId: + String( + receipt.command_id ?? receipt.commandId ?? "" + ).trim() || undefined, + }, + }); + messageApi.success( + `Endpoint ${normalizedEndpointId} accepted with request ${receiptRunId}.` + ); + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return; + } + + const text = error instanceof Error ? error.message : String(error); + if ( + allowMissingServiceRecovery && + normalizedEndpointKind === "chat" && + resolvedServiceId && + isMissingScopeServiceError(text) + ) { + composerFormRef.current?.setFieldsValue({ + routeName: undefined, + serviceOverrideId: undefined, + }); + setSelectedRouteName(""); + setActiveServiceOverrideId(""); + messageApi.warning( + `Selected service '${extractMissingServiceId( + text + )}' is no longer available. Retrying with the scope default binding.` + ); + await runAttempt( + { + ...requestedRun, + routeName: undefined, + serviceOverrideId: undefined, + }, + false + ); + return; + } + + reportTransportError(text); + } finally { + stopActiveRunRef.current = undefined; + setStreaming(false); } + }; - const text = error instanceof Error ? error.message : String(error); - reportTransportError(text); - } finally { - stopActiveRunRef.current = undefined; - setStreaming(false); - } + await runAttempt(request, true); }, [ abortRun, + composerFormRef, dispatch, messageApi, reportTransportError, @@ -585,6 +705,23 @@ const RunsPage: React.FC = () => { ); }, [activeScopeId]); + const teamAdvancedHref = useMemo(() => { + if (requestedReturnTo) { + return requestedReturnTo; + } + + const scopeId = resolveRunScopeId(); + if (!scopeId) { + return ""; + } + + return buildTeamDetailHref({ + scopeId, + tab: "advanced", + runId: session.runId || undefined, + }); + }, [requestedReturnTo, resolveRunScopeId, session.runId]); + const resolveRunServiceOverrideId = useCallback(() => { return ( activeServiceOverrideId.trim() || @@ -946,12 +1083,56 @@ const RunsPage: React.FC = () => { return builtInPresets.filter((preset) => available.has(preset.routeName)); }, [workflowCatalogQuery.data]); + const snapshotMessageFallback = useMemo(() => { + const snapshot = actorSnapshotQuery.data; + if (!snapshot || session.status !== "finished") { + return ""; + } + + const snapshotCommandId = snapshot.lastCommandId?.trim() ?? ""; + if (commandId && snapshotCommandId && snapshotCommandId !== commandId) { + return ""; + } + + return snapshot.lastOutput?.trim() ?? ""; + }, [actorSnapshotQuery.data, commandId, session.status]); + + const displayedMessages = useMemo(() => { + if (session.messages.length > 0) { + return session.messages; + } + + const fallbackContent = resolveRunMessageFallback( + session.events, + snapshotMessageFallback + ); + if (!fallbackContent) { + return session.messages; + } + + return [ + { + messageId: `final-output:${session.runId || commandId || actorId || "latest"}`, + role: "assistant", + content: fallbackContent, + complete: true, + }, + ] as typeof session.messages; + }, [ + actorId, + commandId, + session.events, + session.messages, + session.runId, + snapshotMessageFallback, + ]); + const latestMessagePreview = useMemo(() => { - const lastWithContent = [...session.messages] + const lastWithContent = [...displayedMessages] .reverse() .find((item) => item.content?.trim()); return lastWithContent?.content?.trim() ?? ""; - }, [session.messages]); + }, [displayedMessages]); const recentRunRows = useMemo( () => @@ -1256,7 +1437,7 @@ const RunsPage: React.FC = () => { focusStatus: runFocus.status, focusLabel: runFocus.label, lastEventAt, - messageCount: session.messages.length, + messageCount: displayedMessages.length, eventCount: session.events.length, activeSteps: [...session.activeSteps], }), @@ -1269,7 +1450,7 @@ const RunsPage: React.FC = () => { runFocus.status, session.activeSteps, session.events.length, - session.messages.length, + displayedMessages.length, session.runId, session.status, endpointKind, @@ -1398,9 +1579,9 @@ const RunsPage: React.FC = () => { Message stream
- {session.messages.length > 0 ? ( + {displayedMessages.length > 0 ? (
- {session.messages.map((record) => ( + {displayedMessages.map((record) => (
{
+ {teamAdvancedHref ? ( + + ) : null}
+ { eventCount={eventRows.length} hasPendingInteraction={hasPendingInteraction} messageConsoleView={messageConsoleView} - messageCount={session.messages.length} + messageCount={displayedMessages.length} onConsoleViewChange={setConsoleView} timelineView={ { ), }, ]} + styles={workbenchTraceTabsStyles} />
diff --git a/apps/aevatar-console-web/src/pages/runs/runEventPresentation.test.ts b/apps/aevatar-console-web/src/pages/runs/runEventPresentation.test.ts index e36f024e..c084b9d5 100644 --- a/apps/aevatar-console-web/src/pages/runs/runEventPresentation.test.ts +++ b/apps/aevatar-console-web/src/pages/runs/runEventPresentation.test.ts @@ -6,7 +6,9 @@ import { import { buildTimelineGroups, buildEventRows, + extractRunFinishedOutput, filterEventRows, + resolveRunMessageFallback, type EventFilterValues, } from './runEventPresentation'; @@ -149,4 +151,35 @@ describe('runEventPresentation', () => { expect(groups[2].label).toBe('Step · triage'); expect(groups[0].key).not.toBe(groups[2].key); }); + + it('extracts final output from the run finished result payload', () => { + const events: AGUIEvent[] = [ + { + type: AGUIEventType.RUN_FINISHED, + threadId: 'actor-1', + runId: 'run-1', + result: { + '@type': + 'type.googleapis.com/aevatar.workflow.application.abstractions.runs.WorkflowRunResultPayload', + output: '你好,我是 hello-chat。', + }, + }, + ]; + + expect(extractRunFinishedOutput(events)).toBe('你好,我是 hello-chat。'); + }); + + it('falls back to snapshot output when no message frames were emitted', () => { + const events: AGUIEvent[] = [ + { + type: AGUIEventType.RUN_FINISHED, + threadId: 'actor-1', + runId: 'run-1', + }, + ]; + + expect(resolveRunMessageFallback(events, '最终输出来自 snapshot')).toBe( + '最终输出来自 snapshot', + ); + }); }); diff --git a/apps/aevatar-console-web/src/pages/runs/runEventPresentation.ts b/apps/aevatar-console-web/src/pages/runs/runEventPresentation.ts index 56a353b2..3e26b5cf 100644 --- a/apps/aevatar-console-web/src/pages/runs/runEventPresentation.ts +++ b/apps/aevatar-console-web/src/pages/runs/runEventPresentation.ts @@ -58,6 +58,8 @@ export type RunTimelineGroup = { const PAYLOAD_PREVIEW_LIMIT = 180; +type JsonRecord = Record; + export const eventStatusValueEnum = { processing: { text: 'Processing', status: 'Processing' }, success: { text: 'Completed', status: 'Success' }, @@ -126,6 +128,60 @@ function readOptionalEventString(event: AGUIEvent, key: string): string { return typeof candidate === 'string' ? candidate : ''; } +function asRecord(value: unknown): JsonRecord | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + return value as JsonRecord; +} + +function readOptionalRecordString( + record: JsonRecord | undefined, + key: string, +): string { + if (!record) { + return ''; + } + + const candidate = record[key]; + return typeof candidate === 'string' ? candidate : ''; +} + +export function extractRunFinishedOutput(events: AGUIEvent[]): string { + const latestRunFinished = [...events] + .reverse() + .find((event) => event.type === AGUIEventType.RUN_FINISHED); + + if (!latestRunFinished) { + return ''; + } + + const result = asRecord( + (latestRunFinished as unknown as Record).result, + ); + + return ( + readOptionalRecordString(result, 'output') || + readOptionalRecordString(result, 'Output') || + '' + ); +} + +export function resolveRunMessageFallback( + events: AGUIEvent[], + snapshotLastOutput?: string | null, +): string { + const runFinishedOutput = extractRunFinishedOutput(events).trim(); + if (runFinishedOutput) { + return runFinishedOutput; + } + + return typeof snapshotLastOutput === 'string' + ? snapshotLastOutput.trim() + : ''; +} + function mergeTimelineStatus( current: RunEventStatus, incoming: RunEventStatus, diff --git a/apps/aevatar-console-web/src/pages/runs/runWorkbenchConfig.tsx b/apps/aevatar-console-web/src/pages/runs/runWorkbenchConfig.tsx index 235490e0..0d820836 100644 --- a/apps/aevatar-console-web/src/pages/runs/runWorkbenchConfig.tsx +++ b/apps/aevatar-console-web/src/pages/runs/runWorkbenchConfig.tsx @@ -462,8 +462,11 @@ export const workbenchHudBodyStyle = { } as const; export const workbenchOverviewGridStyle = { + display: "flex", flex: 1, + flexDirection: "column", minHeight: 0, + overflow: "hidden", } as const; export const workbenchOverviewCardStyle = { 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 82e820cc..bcfdfd01 100644 --- a/apps/aevatar-console-web/src/pages/scopes/assets.test.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/assets.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { renderWithQueryClient } from '../../../tests/reactQueryTestUtils'; -import ProjectAssetsPage from './assets'; +import TeamAssetsPage from './assets'; jest.mock('@/shared/api/scopesApi', () => ({ scopesApi: { @@ -74,18 +74,18 @@ jest.mock('@/shared/studio/api', () => ({ }, })); -describe('ProjectAssetsPage', () => { +describe('TeamAssetsPage', () => { beforeEach(() => { window.history.replaceState({}, '', '/scopes/assets?scopeId=scope-a'); }); it('shows the unified project asset summary and workflow detail drawer', async () => { - renderWithQueryClient(React.createElement(ProjectAssetsPage)); + renderWithQueryClient(React.createElement(TeamAssetsPage)); - expect(await screen.findByText('Project asset summary')).toBeTruthy(); + expect(await screen.findByText('Team asset summary')).toBeTruthy(); expect(await screen.findByText('Workspace Demo')).toBeTruthy(); expect(await screen.findByText('Workflow Alpha')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Manage GAgents' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Open Member Runtime' })).toBeTruthy(); fireEvent.click(screen.getAllByRole('button', { name: 'Inspect' })[0]); @@ -100,9 +100,9 @@ describe('ProjectAssetsPage', () => { }); it('switches to script assets and opens catalog detail in the same workspace', async () => { - renderWithQueryClient(React.createElement(ProjectAssetsPage)); + renderWithQueryClient(React.createElement(TeamAssetsPage)); - expect(await screen.findByText('Project asset summary')).toBeTruthy(); + expect(await screen.findByText('Team asset summary')).toBeTruthy(); fireEvent.click(await screen.findByRole('tab', { name: 'Scripts (1)' })); fireEvent.click(screen.getAllByRole('button', { name: 'Inspect' })[0]); @@ -117,7 +117,7 @@ describe('ProjectAssetsPage', () => { }); it('opens a selected workflow in the Studio editor route', async () => { - renderWithQueryClient(React.createElement(ProjectAssetsPage)); + renderWithQueryClient(React.createElement(TeamAssetsPage)); fireEvent.click(await screen.findByRole('button', { name: 'Open workflow editor' })); diff --git a/apps/aevatar-console-web/src/pages/scopes/assets.tsx b/apps/aevatar-console-web/src/pages/scopes/assets.tsx index 259354d8..e84c405d 100644 --- a/apps/aevatar-console-web/src/pages/scopes/assets.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/assets.tsx @@ -22,6 +22,7 @@ import { } from "antd"; import React, { useEffect, useMemo, useState } from "react"; import { history } from "@/shared/navigation/history"; +import { buildTeamWorkspaceRoute } from "@/shared/navigation/scopeRoutes"; import { scopesApi } from "@/shared/api/scopesApi"; import { formatDateTime } from "@/shared/datetime/dateTime"; import { buildRuntimeGAgentsHref } from "@/shared/navigation/runtimeRoutes"; @@ -271,7 +272,7 @@ function buildScriptWorkspaceItems( .sort((left, right) => left.title.localeCompare(right.title)); } -const ProjectAssetsPage: React.FC = () => { +const TeamAssetsPage: React.FC = () => { const { token } = theme.useToken(); const surfaceToken = token as AevatarThemeSurfaceToken; @@ -685,9 +686,9 @@ const ProjectAssetsPage: React.FC = () => { , , , ]} - onBack={() => history.push(buildScopeHref("/scopes/overview", activeDraft))} + onBack={() => history.push(buildTeamWorkspaceRoute(activeDraft.scopeId))} title={ } > @@ -723,7 +724,7 @@ const ProjectAssetsPage: React.FC = () => { > { const nextDraft = normalizeScopeDraft(draft); @@ -763,7 +764,7 @@ const ProjectAssetsPage: React.FC = () => { {!activeDraft.scopeId.trim() ? ( ) : ( @@ -771,7 +772,7 @@ const ProjectAssetsPage: React.FC = () => {
{ gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", }} > - + { /> { Capability inventory - One working surface for project-owned assets. Inspectors carry the heavy detail, not the list. + One working surface for team-owned assets. Inspectors carry the heavy detail, not the list. { }, }} locale={{ - emptyText: "No workflow assets were found for this project.", + emptyText: "No workflow assets were found for this team.", }} metas={workflowListMetas} pagination={{ pageSize: 6, showSizeChanger: false }} @@ -921,7 +922,7 @@ const ProjectAssetsPage: React.FC = () => { }, }} locale={{ - emptyText: "No script assets were found for this project.", + emptyText: "No script assets were found for this team.", }} metas={scriptListMetas} pagination={{ pageSize: 6, showSizeChanger: false }} @@ -1164,4 +1165,4 @@ const ScopeScriptCatalogSummary: React.FC<{ catalog: ScopeScriptCatalog }> = ({
); -export default ProjectAssetsPage; +export default TeamAssetsPage; diff --git a/apps/aevatar-console-web/src/pages/scopes/components/ScopeQueryCard.tsx b/apps/aevatar-console-web/src/pages/scopes/components/ScopeQueryCard.tsx index 998b15f1..1359eb32 100644 --- a/apps/aevatar-console-web/src/pages/scopes/components/ScopeQueryCard.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/components/ScopeQueryCard.tsx @@ -5,10 +5,12 @@ import { moduleCardProps } from '@/shared/ui/proComponents'; import type { ScopeQueryDraft } from './scopeQuery'; type ScopeQueryCardProps = { + activeScopeId?: string | null; draft: ScopeQueryDraft; onChange: (draft: ScopeQueryDraft) => void; onLoad: () => void; onReset?: () => void; + resetDisabled?: boolean; loadLabel?: string; resolvedScopeId?: string | null; resolvedScopeSource?: string | null; @@ -16,21 +18,32 @@ type ScopeQueryCardProps = { }; const ScopeQueryCard: React.FC = ({ + activeScopeId, draft, onChange, onLoad, onReset, + resetDisabled, loadLabel = 'Load scope', resolvedScopeId, resolvedScopeSource, onUseResolvedScope, }) => { + const normalizedDraftScopeId = draft.scopeId.trim(); + const normalizedActiveScopeId = activeScopeId?.trim() ?? ''; const normalizedResolvedScopeId = resolvedScopeId?.trim() ?? ''; const normalizedResolvedScopeSource = resolvedScopeSource?.trim() ?? ''; const canUseResolvedScope = normalizedResolvedScopeId.length > 0 && - draft.scopeId.trim() !== normalizedResolvedScopeId && + normalizedDraftScopeId !== normalizedResolvedScopeId && onUseResolvedScope; + const loadIsNoOp = + normalizedDraftScopeId.length > 0 && + normalizedDraftScopeId === normalizedActiveScopeId; + const computedResetDisabled = + normalizedDraftScopeId === normalizedResolvedScopeId && + normalizedActiveScopeId === normalizedResolvedScopeId; + const resetIsNoOp = (resetDisabled ?? computedResetDisabled) === true; const { token } = theme.useToken(); const helperLabelStyle = { color: token.colorTextSecondary, @@ -68,7 +81,7 @@ const ScopeQueryCard: React.FC = ({ > @@ -78,10 +91,14 @@ const ScopeQueryCard: React.FC = ({ } onPressEnter={onLoad} /> - - {onReset ? : null} + {onReset ? ( + + ) : null}
= ({ {normalizedResolvedScopeId ? ( <> - Resolved project + 已解析团队 = ({ wordBreak: 'break-word', }} > - Resolved from the current session via {normalizedResolvedScopeSource} + 当前会话已通过 {normalizedResolvedScopeSource} 解析出这个团队 + + ) : null} + {loadIsNoOp ? ( + + 当前已加载这个团队,所以“{loadLabel}”不会再触发变化。 + + ) : null} + {resetIsNoOp ? ( + + 当前已经回到会话解析出的团队,所以“重置”不会再触发变化。 ) : null} {canUseResolvedScope ? (
) : null} @@ -135,9 +162,7 @@ const ScopeQueryCard: React.FC = ({ wordBreak: 'break-word', }} > - No project scope was resolved from the current session. Enter a - scopeId manually. tenantId and appId stay platform-managed and - hidden in this flow. + 当前会话里没有自动解析出团队。请手动输入一个 scopeId。 )}
diff --git a/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.ts b/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.ts index 8d0aa1fe..f663d52f 100644 --- a/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.ts +++ b/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.ts @@ -1,24 +1,2 @@ -import type { StudioAuthSession } from "@/shared/studio/models"; - -export type ResolvedScopeContext = { - scopeId: string; - scopeSource: string; -}; - -function trimOptional(value: string | null | undefined): string { - return value?.trim() ?? ""; -} - -export function resolveStudioScopeContext( - authSession?: StudioAuthSession | null -): ResolvedScopeContext | null { - const authScopeId = trimOptional(authSession?.scopeId); - if (authScopeId) { - return { - scopeId: authScopeId, - scopeSource: trimOptional(authSession?.scopeSource), - }; - } - - return null; -} +export type { ResolvedScopeContext } from "@/shared/scope/context"; +export { resolveStudioScopeContext } from "@/shared/scope/context"; diff --git a/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.test.ts b/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.test.ts index 23902a28..6e41ae52 100644 --- a/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.test.ts +++ b/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.test.ts @@ -24,10 +24,10 @@ describe('scopeQuery', () => { it('builds scope routes with preserved scope and selection context', () => { expect( buildScopeHref( - '/scopes/workflows', + '/scopes/assets', { scopeId: 'scope-alpha' }, - { workflowId: 'wf-1' }, + { tab: 'workflows', workflowId: 'wf-1' }, ), - ).toBe('/scopes/workflows?scopeId=scope-alpha&workflowId=wf-1'); + ).toBe('/scopes/assets?scopeId=scope-alpha&tab=workflows&workflowId=wf-1'); }); }); diff --git a/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.ts b/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.ts index 62c785f0..d6d93915 100644 --- a/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.ts +++ b/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.ts @@ -1,51 +1,9 @@ -export type ScopeQueryDraft = { - scopeId: string; -}; - -function readString(value: string | null): string { - return value?.trim() ?? ''; -} - -export function normalizeScopeDraft(draft: ScopeQueryDraft): ScopeQueryDraft { - return { - scopeId: draft.scopeId.trim(), - }; -} - -export function readScopeQueryDraft( - search = typeof window === 'undefined' ? '' : window.location.search, -): ScopeQueryDraft { - const params = new URLSearchParams(search); - return { - scopeId: readString(params.get('scopeId')), - }; -} - -function buildScopeParams( - draft: ScopeQueryDraft, - extras?: Record, -): URLSearchParams { - const params = new URLSearchParams(); - - if (draft.scopeId.trim()) { - params.set('scopeId', draft.scopeId.trim()); - } - - for (const [key, value] of Object.entries(extras ?? {})) { - const normalized = value?.trim(); - if (normalized) { - params.set(key, normalized); - } - } - - return params; -} - -export function buildScopeHref( - path: string, - draft: ScopeQueryDraft, - extras?: Record, -): string { - const suffix = buildScopeParams(draft, extras).toString(); - return suffix ? `${path}?${suffix}` : path; -} +export type { ScopeQueryDraft } from "@/shared/navigation/scopeRoutes"; +export { + buildScopeHref, + buildScopeOverviewHref, + buildTeamWorkspaceRoute, + normalizeScopeDraft, + readScopeQueryDraft, + resolveScopeOverviewPath, +} from "@/shared/navigation/scopeRoutes"; 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 9833969d..68017ff3 100644 --- a/apps/aevatar-console-web/src/pages/scopes/invoke.test.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/invoke.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { loadDraftRunPayload } from '@/shared/runs/draftRunSession'; import { renderWithQueryClient } from '../../../tests/reactQueryTestUtils'; -import ScopeInvokePage from './invoke'; +import ScopeInvokePage, { buildServiceOptions } from './invoke'; jest.mock('@ant-design/pro-components', () => { const mockReact = require('react'); @@ -408,6 +408,80 @@ describe('ScopeInvokePage', () => { }); }); + it('deduplicates repeated service ids before building picker options', () => { + expect( + buildServiceOptions( + [ + { + serviceKey: 'scope-a:default:default:hello-chat:older', + tenantId: 'scope-a', + appId: 'default', + namespace: 'default', + serviceId: 'hello-chat', + displayName: 'hello-chat', + defaultServingRevisionId: 'rev-1', + activeServingRevisionId: 'rev-1', + deploymentId: 'deploy-1', + primaryActorId: 'actor://scope-a/hello-chat/1', + deploymentStatus: 'Active', + endpoints: [ + { + endpointId: 'chat', + displayName: 'chat', + kind: 'chat', + requestTypeUrl: 'type.googleapis.com/aevatar.ChatRequestEvent', + responseTypeUrl: 'type.googleapis.com/aevatar.ChatResponseEvent', + description: 'Chat endpoint.', + }, + ], + policyIds: [], + updatedAt: '2026-04-09T09:00:00Z', + }, + { + serviceKey: 'scope-a:default:default:hello-chat:newer', + tenantId: 'scope-a', + appId: 'default', + namespace: 'default', + serviceId: 'hello-chat', + displayName: 'hello-chat', + defaultServingRevisionId: 'rev-2', + activeServingRevisionId: 'rev-2', + deploymentId: 'deploy-2', + primaryActorId: 'actor://scope-a/hello-chat/2', + deploymentStatus: 'Active', + endpoints: [ + { + endpointId: 'chat', + displayName: 'chat', + kind: 'chat', + requestTypeUrl: 'type.googleapis.com/aevatar.ChatRequestEvent', + responseTypeUrl: 'type.googleapis.com/aevatar.ChatResponseEvent', + description: 'Chat endpoint.', + }, + { + endpointId: 'health', + displayName: 'health', + kind: 'command', + requestTypeUrl: 'type.googleapis.com/google.protobuf.Empty', + responseTypeUrl: 'type.googleapis.com/google.protobuf.StringValue', + description: 'Health endpoint.', + }, + ], + policyIds: [], + updatedAt: '2026-04-09T09:30:00Z', + }, + ], + 'hello-chat', + ), + ).toEqual([ + expect.objectContaining({ + serviceId: 'hello-chat', + serviceKey: 'scope-a:default:default:hello-chat:newer', + activeServingRevisionId: 'rev-2', + }), + ]); + }); + it('invokes a non-chat endpoint for the selected scope service', async () => { (servicesApi.listServices as jest.Mock).mockResolvedValue([ { @@ -625,6 +699,57 @@ describe('ScopeInvokePage', () => { ); }); + it('keeps the invoke lab workspace constrained so the chat composer stays visible', async () => { + (servicesApi.listServices as jest.Mock).mockResolvedValue([ + { + serviceKey: 'scope-a:default:default:default', + tenantId: 'scope-a', + appId: 'default', + namespace: 'default', + serviceId: 'default', + displayName: 'Workspace Demo', + defaultServingRevisionId: 'rev-2', + activeServingRevisionId: 'rev-2', + deploymentId: 'deploy-2', + primaryActorId: 'actor://scope-a/default', + deploymentStatus: 'Active', + endpoints: [ + { + endpointId: 'chat', + displayName: 'chat', + kind: 'chat', + requestTypeUrl: 'type.googleapis.com/aevatar.ChatRequestEvent', + responseTypeUrl: 'type.googleapis.com/aevatar.ChatResponseEvent', + description: 'Chat with the published scope service.', + }, + ], + policyIds: [], + updatedAt: '2026-03-26T08:00:00Z', + }, + ]); + + renderWithQueryClient(React.createElement(ScopeInvokePage)); + + const viewport = await screen.findByTestId('invoke-lab-workspace-viewport'); + const grid = await screen.findByTestId('invoke-lab-workspace-grid'); + + expect(viewport).toHaveStyle({ + flex: '1 1 auto', + minHeight: '0', + overflow: 'hidden', + }); + expect(grid).toHaveStyle({ + alignItems: 'stretch', + height: '100%', + minHeight: '0', + }); + expect( + await screen.findByPlaceholderText( + 'Describe the task, ask a question, or paste the next operator instruction.', + ), + ).toBeTruthy(); + }); + it('renders semantic chat output for reasoning, steps, and tool activity', async () => { (servicesApi.listServices as jest.Mock).mockResolvedValue([ { @@ -785,7 +910,7 @@ describe('ScopeInvokePage', () => { await waitFor(() => { expect( - screen.getByText('No published project service is selected yet.'), + screen.getByText('No published team service is selected yet.'), ).toBeTruthy(); }); diff --git a/apps/aevatar-console-web/src/pages/scopes/invoke.tsx b/apps/aevatar-console-web/src/pages/scopes/invoke.tsx index c8d82741..4ff2bd8c 100644 --- a/apps/aevatar-console-web/src/pages/scopes/invoke.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/invoke.tsx @@ -38,6 +38,7 @@ import { parseBackendSSEStream } from '@/shared/agui/sseFrameNormalizer'; import { runtimeRunsApi } from '@/shared/api/runtimeRunsApi'; import { servicesApi } from '@/shared/api/servicesApi'; import { history } from '@/shared/navigation/history'; +import { buildTeamWorkspaceRoute } from '@/shared/navigation/scopeRoutes'; import { buildRuntimeGAgentsHref, buildRuntimeRunsHref, @@ -143,15 +144,16 @@ const pageHeaderStyle: React.CSSProperties = { }; const workspaceViewportStyle: React.CSSProperties = { display: 'flex', - flex: '1 0 auto', + flex: '1 1 auto', minHeight: 0, - overflow: 'visible', + overflow: 'hidden', }; const workspaceGridStyle: React.CSSProperties = { - alignItems: 'start', + alignItems: 'stretch', display: 'grid', gap: 12, gridTemplateColumns: '250px minmax(0, 1fr) 300px', + height: '100%', minHeight: 0, minWidth: 0, width: '100%', @@ -281,7 +283,7 @@ const playgroundChatComposerStyle: React.CSSProperties = { background: '#ffffff', borderTop: '1px solid #e7e5e4', flexShrink: 0, - padding: '14px 20px', + padding: '14px 20px 18px', }; const consoleShellStyle: React.CSSProperties = { background: 'var(--ant-color-bg-container)', @@ -328,20 +330,63 @@ function createClientId(): string { : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } -function buildServiceOptions( +function compareServicePriority( + left: ServiceCatalogSnapshot, + right: ServiceCatalogSnapshot, + defaultServiceId?: string, +): number { + const leftIsDefault = left.serviceId === defaultServiceId ? 1 : 0; + const rightIsDefault = right.serviceId === defaultServiceId ? 1 : 0; + + if (leftIsDefault !== rightIsDefault) { + return rightIsDefault - leftIsDefault; + } + + const endpointDelta = right.endpoints.length - left.endpoints.length; + if (endpointDelta !== 0) { + return endpointDelta; + } + + const leftHasDisplayName = left.displayName.trim() ? 1 : 0; + const rightHasDisplayName = right.displayName.trim() ? 1 : 0; + if (leftHasDisplayName !== rightHasDisplayName) { + return rightHasDisplayName - leftHasDisplayName; + } + + const updatedAtDelta = right.updatedAt.localeCompare(left.updatedAt); + if (updatedAtDelta !== 0) { + return updatedAtDelta; + } + + const serviceIdDelta = left.serviceId.localeCompare(right.serviceId); + if (serviceIdDelta !== 0) { + return serviceIdDelta; + } + + return left.serviceKey.localeCompare(right.serviceKey); +} + +export function buildServiceOptions( services: readonly ServiceCatalogSnapshot[], defaultServiceId?: string, ): ServiceCatalogSnapshot[] { - return [...services].sort((left, right) => { - const leftIsDefault = left.serviceId === defaultServiceId ? 1 : 0; - const rightIsDefault = right.serviceId === defaultServiceId ? 1 : 0; + const deduped = new Map(); - if (leftIsDefault !== rightIsDefault) { - return rightIsDefault - leftIsDefault; + services.forEach((service) => { + const current = deduped.get(service.serviceId); + if (!current) { + deduped.set(service.serviceId, service); + return; } - return left.serviceId.localeCompare(right.serviceId); + if (compareServicePriority(service, current, defaultServiceId) < 0) { + deduped.set(service.serviceId, service); + } }); + + return [...deduped.values()].sort((left, right) => + compareServicePriority(left, right, defaultServiceId), + ); } function isChatEndpoint( @@ -985,11 +1030,11 @@ const ScopeInvokePage: React.FC = () => { const recommendedNextStep = !scopeId ? { - action: () => history.push('/scopes/overview'), - actionLabel: 'Open projects', + action: () => history.push(buildTeamWorkspaceRoute('')), + actionLabel: 'Open Teams', description: - 'Invoke Lab only becomes useful after you anchor the console to a scope.', - title: 'Load a project first', + 'Invoke Lab only becomes useful after you anchor the console to a team.', + title: 'Load a team first', } : services.length === 0 ? { @@ -1003,9 +1048,9 @@ const ScopeInvokePage: React.FC = () => { currentBindingRevision?.staticActorTypeName || undefined, }), ), - actionLabel: 'Open GAgents', + actionLabel: 'Open Member Runtime', description: - 'No published scope services were discovered. Manage the current binding before invoking.', + 'No published team services were discovered. Manage the current binding before invoking.', title: 'Publish or switch the default binding', } : invokeResult.status === 'success' @@ -1065,9 +1110,7 @@ const ScopeInvokePage: React.FC = () => {
-
-
+
+
{ title={ } - subtitle="Scope selector and reset controls." + subtitle="Team selector and reset controls." title="Invocation Controls" /> } @@ -1126,11 +1169,11 @@ const ScopeInvokePage: React.FC = () => {
- Scope ID + Team ID setDraft({ @@ -1149,7 +1192,7 @@ const ScopeInvokePage: React.FC = () => {
- Resolved project + Resolved team {resolvedScope?.scopeId ? ( <> @@ -1163,14 +1206,14 @@ const ScopeInvokePage: React.FC = () => { {draft.scopeId.trim() !== resolvedScope.scopeId ? (
) : null} ) : ( - No project scope was resolved from the current session. + No team context was resolved from the current session. )}
@@ -1193,7 +1236,7 @@ const ScopeInvokePage: React.FC = () => { {!bindingQuery.data?.available || !currentBindingRevision ? ( ) : ( @@ -1244,7 +1287,7 @@ const ScopeInvokePage: React.FC = () => { ) } > - Manage in GAgents + Open Member Runtime
@@ -1303,7 +1346,7 @@ const ScopeInvokePage: React.FC = () => { {!scopeId ? ( ) : !selectedService || !selectedEndpoint ? ( @@ -1503,13 +1546,13 @@ const ScopeInvokePage: React.FC = () => { {!scopeId ? ( ) : !selectedService ? ( ) : ( diff --git a/apps/aevatar-console-web/src/pages/scopes/overview.test.tsx b/apps/aevatar-console-web/src/pages/scopes/overview.test.tsx index 55350e4b..bb7b9bcc 100644 --- a/apps/aevatar-console-web/src/pages/scopes/overview.test.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/overview.test.tsx @@ -173,30 +173,30 @@ describe('ScopeOverviewPage', () => { it('renders the scope status board and asset summaries', async () => { renderWithQueryClient(React.createElement(ScopeOverviewPage)); - expect(await screen.findByText('Scope Status Board')).toBeTruthy(); + expect(await screen.findByText('团队状态')).toBeTruthy(); expect( screen.queryByText( 'Project Overview is now a true scope-level status board: binding posture, asset surface, and next-step actions all live on one stage.', ), ).toBeNull(); expect(screen.getAllByRole('button', { name: 'Show help' }).length).toBeGreaterThan(0); - expect(await screen.findByText('Current Binding')).toBeTruthy(); - expect(await screen.findByText('Revision Rollout')).toBeTruthy(); - expect(screen.getByText('Revision Rollout')).toBeTruthy(); + expect(await screen.findByText('当前默认成员')).toBeTruthy(); + expect(await screen.findByText('版本发布')).toBeTruthy(); + expect(screen.getByText('版本发布')).toBeTruthy(); expect(await screen.findByText('Workflow Alpha')).toBeTruthy(); expect(await screen.findByText('script-alpha')).toBeTruthy(); expect( - screen.getByRole('button', { name: 'Open workflow workspace' }) + screen.getByRole('button', { name: '打开行为定义' }) ).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Open assets' })).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Open invoke lab' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '打开团队资产' })).toBeTruthy(); + expect(screen.getAllByRole('button', { name: '打开测试入口' }).length).toBeGreaterThan(0); expect(screen.queryByRole('button', { name: 'Invoke Services' })).toBeNull(); }); it('activates a historical revision from the overview page', async () => { renderWithQueryClient(React.createElement(ScopeOverviewPage)); - expect(await screen.findByText('Revision Rollout')).toBeTruthy(); + expect(await screen.findByText('版本发布')).toBeTruthy(); const activateButtons = await screen.findAllByRole('button', { name: 'Activate', }); @@ -213,7 +213,7 @@ describe('ScopeOverviewPage', () => { it('retires a historical revision from the overview page', async () => { renderWithQueryClient(React.createElement(ScopeOverviewPage)); - expect(await screen.findByText('Revision Rollout')).toBeTruthy(); + expect(await screen.findByText('版本发布')).toBeTruthy(); const retireButtons = await screen.findAllByRole('button', { name: 'Retire', }); diff --git a/apps/aevatar-console-web/src/pages/scopes/overview.tsx b/apps/aevatar-console-web/src/pages/scopes/overview.tsx index 5d506215..e2770ecd 100644 --- a/apps/aevatar-console-web/src/pages/scopes/overview.tsx +++ b/apps/aevatar-console-web/src/pages/scopes/overview.tsx @@ -12,6 +12,7 @@ import { scopesApi } from "@/shared/api/scopesApi"; import { servicesApi } from "@/shared/api/servicesApi"; import { formatDateTime } from "@/shared/datetime/dateTime"; import { history } from "@/shared/navigation/history"; +import { buildTeamDetailHref } from "@/shared/navigation/teamRoutes"; import { buildRuntimeGAgentsHref, buildRuntimeRunsHref, @@ -188,7 +189,7 @@ const ScopeOverviewPage: React.FC = () => { useEffect(() => { history.replace( - buildScopeHref("/scopes/overview", activeDraft, { + buildScopeHref("/teams", activeDraft, { revisionId: focus?.kind === "revision" ? focus.id : "", workflowId: focus?.kind === "workflow" ? focus.id : "", scriptId: focus?.kind === "script" ? focus.id : "", @@ -223,7 +224,7 @@ const ScopeOverviewPage: React.FC = () => { onClick={() => setFocus({ kind: "workflow", id: workflow.workflowId })} type="link" > - Inspect + 查看 , , ], }, description: { render: (_, workflow) => workflow.serviceKey - ? `Entrypoint ${workflow.serviceKey}` - : "Workflow asset is not yet published as a project entrypoint.", + ? `入口 ${workflow.serviceKey}` + : "该行为定义还没有发布为团队默认入口。", }, subTitle: { render: (_, workflow) => ( @@ -277,7 +278,7 @@ const ScopeOverviewPage: React.FC = () => { onClick={() => setFocus({ kind: "script", id: script.scriptId })} type="link" > - Inspect + 查看 , , ], }, description: { render: (_, script) => script.activeSourceHash - ? `Source hash ${script.activeSourceHash}` - : "Script asset is waiting for a committed source hash.", + ? `源码哈希 ${script.activeSourceHash}` + : "该脚本行为正在等待已提交的源码哈希。", }, subTitle: { render: (_, script) => ( @@ -324,8 +327,8 @@ const ScopeOverviewPage: React.FC = () => { return ( {
{ const nextDraft = normalizeScopeDraft(draft); @@ -353,6 +357,12 @@ const ScopeOverviewPage: React.FC = () => { setActiveDraft(nextDraft); setFocus(null); }} + resetDisabled={ + normalizeScopeDraft(draft).scopeId === + (resolvedScope?.scopeId?.trim() ?? "") && + scopeId === (resolvedScope?.scopeId?.trim() ?? "") && + focus == null + } onUseResolvedScope={() => { if (!resolvedScope?.scopeId) { return; @@ -369,15 +379,45 @@ const ScopeOverviewPage: React.FC = () => { /> - + + + @@ -418,7 +463,7 @@ const ScopeOverviewPage: React.FC = () => {
{!scopeId ? ( @@ -427,8 +472,8 @@ const ScopeOverviewPage: React.FC = () => { {scopeId ? ( <>
{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", }} > - + - +
{!binding?.available || !activeRevision ? ( @@ -487,24 +532,24 @@ const ScopeOverviewPage: React.FC = () => { gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", }} > - - + +
{currentBindingContext ? ( - + ) : null} @@ -540,8 +585,8 @@ const ScopeOverviewPage: React.FC = () => {
{revisions.length > 0 ? (
@@ -576,7 +621,7 @@ const ScopeOverviewPage: React.FC = () => {
) : ( )} @@ -590,8 +635,8 @@ const ScopeOverviewPage: React.FC = () => { }} > {scopeServicesQuery.error ? ( { title={ scopeServicesQuery.error instanceof Error ? scopeServicesQuery.error.message - : "Failed to load published services." + : "加载已发布成员失败。" } type="error" /> ) : scopeServicesQuery.isLoading ? ( - + ) : scopeServicesQuery.data && scopeServicesQuery.data.length > 0 ? (
{scopeServicesQuery.data.map((service) => ( @@ -628,13 +673,13 @@ const ScopeOverviewPage: React.FC = () => {
) : ( )}
- + dataSource={workflowsQuery.data ?? []} grid={{ gutter: 16, column: 1 }} @@ -645,7 +690,7 @@ const ScopeOverviewPage: React.FC = () => { locale={{ emptyText: ( ), @@ -657,7 +702,7 @@ const ScopeOverviewPage: React.FC = () => { /> - + dataSource={scriptsQuery.data ?? []} grid={{ gutter: 16, column: 1 }} @@ -668,7 +713,7 @@ const ScopeOverviewPage: React.FC = () => { locale={{ emptyText: ( ), @@ -689,18 +734,18 @@ const ScopeOverviewPage: React.FC = () => { setFocus(null)} open={Boolean(focus)} - subtitle="Scope inspector" + subtitle="团队检查器" title={ focus?.kind === "revision" - ? focusedRevision?.revisionId || focus?.id || "Revision" - : focus?.id || "Scope detail" + ? focusedRevision?.revisionId || focus?.id || "版本" + : focus?.id || "团队详情" } > {!focus ? ( - + ) : focus.kind === "revision" && focusedRevision ? (
{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", }} > - + - +
@@ -733,7 +778,7 @@ const ScopeOverviewPage: React.FC = () => { focusedRevision, )} showIcon - title="Binding detail" + title="绑定详情" type="info" /> ) : null} @@ -770,14 +815,14 @@ const ScopeOverviewPage: React.FC = () => { ) } > - Open in GAgents + 在成员页查看
) : focus.kind === "workflow" ? ( workflowDetailQuery.data?.available && workflowDetailQuery.data.workflow ? ( <> - + { {workflowDetailQuery.data.workflow.displayName || workflowDetailQuery.data.workflow.workflowId} - {workflowDetailQuery.data.workflow.serviceKey || "No published entrypoint"} + {workflowDetailQuery.data.workflow.serviceKey || "当前还没有已发布入口"} - +
-                  {workflowDetailQuery.data.source?.workflowYaml || "No workflow YAML available."}
+                  {workflowDetailQuery.data.source?.workflowYaml || "当前还没有可查看的行为定义 YAML。"}
                 
) : ( - + ) ) : focus.kind === "script" ? ( scriptDetailQuery.data?.available && scriptDetailQuery.data.script ? ( <> - + { {scriptDetailQuery.data.script.scriptId} - Revision {scriptDetailQuery.data.script.activeRevision || "n/a"} · Hash{" "} + 版本 {scriptDetailQuery.data.script.activeRevision || "n/a"} · 哈希{" "} {scriptDetailQuery.data.script.activeSourceHash || "n/a"} - +
-                  {scriptDetailQuery.data.source?.sourceText || "No script source available."}
+                  {scriptDetailQuery.data.source?.sourceText || "当前还没有可查看的脚本源码。"}
                 
) : ( - + ) ) : ( - + )}
@@ -903,19 +948,19 @@ const ScopeServiceCard: React.FC<{ /> - {service.endpoints.length} endpoints · Revision{" "} + {service.endpoints.length} 个端点 · 版本{" "} {service.activeServingRevisionId || service.defaultServingRevisionId || "n/a"} - Actor {service.primaryActorId || "n/a"} · Updated {formatDateTime(service.updatedAt)} + 实例 {service.primaryActorId || "n/a"} · 更新时间 {formatDateTime(service.updatedAt)} - +
); @@ -1007,12 +1052,12 @@ const RevisionCard: React.FC<{ wordBreak: "break-word", }} > - Actor {revision.primaryActorId || "n/a"} · Deployment{" "} + 实例 {revision.primaryActorId || "n/a"} · 部署{" "} {revision.deploymentId || "draft"} } @@ -4077,8 +4169,8 @@ export const StudioEditorPage: React.FC = ({ @@ -4088,9 +4180,9 @@ export const StudioEditorPage: React.FC = ({ loading={publishPending} disabled={!canPublishWorkflow} > - Bind project + Bind team entry - + } />, @@ -4100,15 +4192,15 @@ export const StudioEditorPage: React.FC = ({ - + ) : workspacePage === 'scripts' ? ( ) : undefined; + const studioContextScopeLabel = initialState.scopeLabel || resolvedStudioScopeId; + const studioContextMemberLabel = initialState.memberLabel; + const currentStudioReturnTo = + typeof window === 'undefined' + ? '' + : sanitizeReturnTo( + `${window.location.pathname}${window.location.search}${window.location.hash}`, + ); + const studioContextAlert = + resolvedStudioScopeId || studioContextMemberLabel ? ( + + {resolvedStudioScopeId ? ( + + ) : null} + + + } + description={ + studioContextMemberLabel + ? `${studioContextScopeLabel || '当前团队'} / ${studioContextMemberLabel}` + : `${studioContextScopeLabel || '当前团队'}` + } + message="团队构建器上下文" + showIcon + type="info" + /> + ) : null; + const workspaceAlerts = ( - + <> + {studioContextAlert} + + ); const inspectorContent = ( @@ -4160,14 +4293,17 @@ const StudioPage: React.FC = () => { data-testid="studio-workflows-viewport" style={{ display: 'flex', - flex: 1, flexDirection: 'column', - minHeight: 0, - overflow: 'hidden', + minWidth: 0, }} > { route: workflowName || undefined, prompt: runPrompt || undefined, draftKey, + returnTo: currentStudioReturnTo || undefined, }), ); } catch { @@ -4387,6 +4524,7 @@ const StudioPage: React.FC = () => { scopeId: scopeId || undefined, route: workflowName || undefined, prompt: runPrompt || undefined, + returnTo: currentStudioReturnTo || undefined, }), ); } @@ -4404,8 +4542,12 @@ const StudioPage: React.FC = () => { onOpenProjectOverview={() => { history.push( resolvedStudioScopeId - ? `/scopes/overview?scopeId=${encodeURIComponent(resolvedStudioScopeId)}` - : '/scopes/overview', + ? buildTeamDetailHref({ + scopeId: resolvedStudioScopeId, + tab: 'advanced', + serviceId: scopeBindingQuery.data?.serviceId || undefined, + }) + : buildTeamsHref(), ); }} onOpenProjectInvoke={() => { diff --git a/apps/aevatar-console-web/src/pages/teams/detail.test.tsx b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx new file mode 100644 index 00000000..28bc20a9 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx @@ -0,0 +1,602 @@ +import { Grid } from "antd"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { scopeRuntimeApi } from "@/shared/api/scopeRuntimeApi"; +import { loadDraftRunPayload } from "@/shared/runs/draftRunSession"; +import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; +import TeamDetailPage from "./detail"; + +function mockCreateRunsCatalog() { + return { + scopeId: "scope-1", + serviceId: "default", + serviceKey: "scope-1:default", + displayName: "Support Runtime", + runs: [ + { + scopeId: "scope-1", + serviceId: "default", + runId: "run-current", + actorId: "actor-intake", + definitionActorId: "definition://support-triage", + revisionId: "rev-2", + deploymentId: "dep-2", + workflowName: "support-triage", + completionStatus: "waiting_approval", + stateVersion: 2, + lastEventId: "evt-2", + lastUpdatedAt: "2026-04-09T09:05:00Z", + boundAt: "2026-04-09T09:00:00Z", + bindingUpdatedAt: "2026-04-09T09:00:00Z", + lastSuccess: false, + totalSteps: 4, + completedSteps: 2, + roleReplyCount: 1, + lastOutput: "", + lastError: "Waiting on approval", + }, + { + scopeId: "scope-1", + serviceId: "default", + runId: "run-good", + actorId: "actor-intake-v1", + definitionActorId: "definition://support-triage-v1", + revisionId: "rev-1", + deploymentId: "dep-1", + workflowName: "support-triage-v1", + completionStatus: "completed", + stateVersion: 1, + lastEventId: "evt-1", + lastUpdatedAt: "2026-04-09T08:55:00Z", + boundAt: "2026-04-09T08:50:00Z", + bindingUpdatedAt: "2026-04-09T08:50:00Z", + lastSuccess: true, + totalSteps: 3, + completedSteps: 3, + roleReplyCount: 1, + lastOutput: "Resolved", + lastError: "", + }, + ], + }; +} + +function mockCreateRunAudit(scopeId: string, runId: string) { + return { + summary: { + scopeId, + serviceId: "default", + runId, + actorId: "actor-intake", + definitionActorId: "definition://support-triage", + revisionId: runId === "run-current" ? "rev-2" : "rev-1", + deploymentId: runId === "run-current" ? "dep-2" : "dep-1", + workflowName: "support-triage", + completionStatus: runId === "run-current" ? "waiting_approval" : "completed", + stateVersion: 2, + lastEventId: "evt-2", + lastUpdatedAt: "2026-04-09T09:05:00Z", + boundAt: "2026-04-09T09:00:00Z", + bindingUpdatedAt: "2026-04-09T09:00:00Z", + lastSuccess: runId !== "run-current", + totalSteps: 4, + completedSteps: runId === "run-current" ? 2 : 4, + roleReplyCount: 1, + lastOutput: runId === "run-current" ? "" : "Resolved", + lastError: runId === "run-current" ? "Waiting on approval" : "", + }, + audit: { + reportVersion: "1", + projectionScope: "service", + topologySource: "audit", + completionStatus: runId === "run-current" ? "waiting_approval" : "completed", + workflowName: "support-triage", + rootActorId: "actor-intake", + commandId: "cmd-1", + stateVersion: 2, + lastEventId: "evt-2", + createdAt: "2026-04-09T09:00:00Z", + updatedAt: "2026-04-09T09:05:00Z", + startedAt: "2026-04-09T09:00:00Z", + endedAt: null, + durationMs: 1000, + success: runId !== "run-current", + input: "hello", + finalOutput: runId === "run-current" ? "" : "Resolved", + finalError: runId === "run-current" ? "Waiting on approval" : "", + topology: + runId === "run-current" + ? [ + { + parent: "actor-intake", + child: "actor-risk", + }, + { + parent: "actor-risk", + child: "actor-ops", + }, + ] + : [ + { + parent: "actor-intake-v1", + child: "actor-risk", + }, + ], + steps: [ + { + stepId: "risk_review", + stepType: runId === "run-current" ? "human_approval" : "llm_call", + targetRole: "operator", + requestedAt: "2026-04-09T09:01:00Z", + completedAt: runId === "run-current" ? null : "2026-04-09T09:02:00Z", + success: runId !== "run-current", + workerId: "actor-intake", + outputPreview: "", + error: "", + requestParameters: {}, + completionAnnotations: {}, + nextStepId: "", + branchKey: "", + assignedVariable: "", + assignedValue: "", + suspensionType: runId === "run-current" ? "human_approval" : "", + suspensionPrompt: runId === "run-current" ? "Approve escalation" : "", + suspensionTimeoutSeconds: null, + requestedVariableName: "", + durationMs: null, + }, + ], + roleReplies: + runId === "run-current" + ? [ + { + timestamp: "2026-04-09T09:02:30Z", + roleId: "operator", + sessionId: "session-1", + content: "Escalation needs approval from on-call.", + contentLength: 39, + }, + ] + : [], + timeline: + runId === "run-current" + ? [ + { + timestamp: "2026-04-09T09:01:30Z", + stage: "human_gate", + message: "Approval requested from operator", + agentId: "actor-intake", + stepId: "risk_review", + stepType: "human_approval", + eventType: "suspension_requested", + data: {}, + }, + ] + : [], + summary: { + totalSteps: 4, + requestedSteps: 2, + completedSteps: runId === "run-current" ? 2 : 4, + roleReplyCount: 1, + stepTypeCounts: {}, + }, + }, + }; +} + +jest.mock("@/shared/api/scopesApi", () => ({ + scopesApi: { + listWorkflows: jest.fn(async () => [ + { + workflowId: "workflow-1", + }, + { + workflowId: "workflow-2", + }, + ]), + listScripts: jest.fn(async () => [ + { + scriptId: "script-1", + }, + ]), + }, +})); + +jest.mock("@/shared/api/servicesApi", () => ({ + servicesApi: { + listServices: jest.fn(async () => [ + { + serviceKey: "scope-1:default", + tenantId: "scope-1", + appId: "default", + namespace: "default", + serviceId: "default", + displayName: "Support Runtime", + defaultServingRevisionId: "rev-2", + activeServingRevisionId: "rev-2", + deploymentId: "dep-2", + primaryActorId: "actor-intake", + deploymentStatus: "Active", + endpoints: [], + policyIds: [], + updatedAt: "2026-04-09T09:00:00Z", + }, + ]), + }, +})); + +jest.mock("@/shared/api/runtimeGAgentApi", () => ({ + runtimeGAgentApi: { + listActors: jest.fn(async () => [ + { + gAgentType: "IntakeAgent", + actorIds: ["actor-intake"], + }, + { + gAgentType: "RiskReviewAgent", + actorIds: ["actor-risk"], + }, + ]), + }, +})); + +jest.mock("@/shared/api/runtimeActorsApi", () => ({ + runtimeActorsApi: { + getActorGraphEnriched: jest.fn(async () => ({ + snapshot: { + actorId: "actor-intake", + workflowName: "support-triage", + lastCommandId: "cmd-1", + completionStatusValue: 1, + stateVersion: 2, + lastEventId: "evt-2", + lastUpdatedAt: "2026-04-09T09:05:00Z", + lastSuccess: false, + lastOutput: "", + lastError: "Waiting on approval", + totalSteps: 4, + requestedSteps: 2, + completedSteps: 2, + roleReplyCount: 1, + }, + subgraph: { + rootNodeId: "actor-intake", + nodes: [ + { + nodeId: "actor-intake", + nodeType: "actor", + updatedAt: "2026-04-09T09:05:00Z", + properties: { + role: "triage lead", + }, + }, + { + nodeId: "actor-risk", + nodeType: "actor", + updatedAt: "2026-04-09T09:05:00Z", + properties: { + role: "risk review", + }, + }, + ], + edges: [ + { + edgeId: "edge-1", + fromNodeId: "actor-intake", + toNodeId: "actor-risk", + edgeType: "handoff", + updatedAt: "2026-04-09T09:05:00Z", + properties: {}, + }, + ], + }, + })), + }, +})); + +jest.mock("@/shared/api/scopeRuntimeApi", () => ({ + scopeRuntimeApi: { + listServiceRuns: jest.fn(async () => mockCreateRunsCatalog()), + getServiceRunAudit: jest.fn(async (scopeId: string, _serviceId: string, runId: string) => + mockCreateRunAudit(scopeId, runId), + ), + }, +})); + +jest.mock("@/shared/studio/api", () => ({ + studioApi: { + getScopeBinding: jest.fn(async () => ({ + available: true, + scopeId: "scope-1", + serviceId: "default", + displayName: "Support Escalation Triage", + serviceKey: "scope-1:default", + defaultServingRevisionId: "rev-2", + activeServingRevisionId: "rev-2", + deploymentId: "dep-2", + deploymentStatus: "Active", + primaryActorId: "actor-intake", + updatedAt: "2026-04-09T09:00:00Z", + revisions: [ + { + revisionId: "rev-2", + implementationKind: "workflow", + status: "Published", + artifactHash: "hash-2", + failureReason: "", + isDefaultServing: true, + isActiveServing: true, + isServingTarget: true, + allocationWeight: 100, + servingState: "Active", + deploymentId: "dep-2", + primaryActorId: "actor-intake", + createdAt: "2026-04-09T08:00:00Z", + preparedAt: "2026-04-09T08:01:00Z", + publishedAt: "2026-04-09T08:02:00Z", + retiredAt: null, + workflowName: "support-triage", + workflowDefinitionActorId: "definition://support-triage", + inlineWorkflowCount: 1, + scriptId: "", + scriptRevision: "", + scriptDefinitionActorId: "", + scriptSourceHash: "", + staticActorTypeName: "", + }, + { + revisionId: "rev-1", + implementationKind: "workflow", + status: "Published", + artifactHash: "hash-1", + failureReason: "", + isDefaultServing: false, + isActiveServing: false, + isServingTarget: false, + allocationWeight: 0, + servingState: "", + deploymentId: "", + primaryActorId: "actor-intake-v1", + createdAt: "2026-04-08T08:00:00Z", + preparedAt: "2026-04-08T08:01:00Z", + publishedAt: "2026-04-08T08:02:00Z", + retiredAt: null, + workflowName: "support-triage-v1", + workflowDefinitionActorId: "definition://support-triage-v1", + inlineWorkflowCount: 1, + scriptId: "", + scriptRevision: "", + scriptDefinitionActorId: "", + scriptSourceHash: "", + staticActorTypeName: "", + }, + ], + })), + getWorkspaceSettings: jest.fn(async () => ({ + runtimeBaseUrl: "https://runtime.aevatar.test", + directories: [ + { + directoryId: "default", + label: "Default", + path: "/tmp/workflows", + isBuiltIn: false, + }, + ], + })), + getConnectorCatalog: jest.fn(async () => ({ + homeDirectory: "/tmp/.aevatar", + filePath: "/tmp/.aevatar/connectors.json", + fileExists: true, + connectors: [ + { + name: "web-search", + type: "http", + enabled: true, + timeoutMs: 30000, + retry: 1, + http: { + baseUrl: "https://search.example.com", + allowedMethods: ["GET"], + allowedPaths: ["/search"], + allowedInputKeys: ["query"], + defaultHeaders: {}, + }, + }, + { + name: "ops-terminal", + type: "cli", + enabled: false, + timeoutMs: 30000, + retry: 0, + cli: { + command: "opsctl", + fixedArguments: ["tickets"], + allowedOperations: ["lookup"], + allowedInputKeys: ["ticket"], + workingDirectory: "/tmp", + environment: {}, + }, + }, + ], + })), + getRoleCatalog: jest.fn(async () => ({ + homeDirectory: "/tmp/.aevatar", + filePath: "/tmp/.aevatar/roles.json", + fileExists: true, + roles: [ + { + id: "triage_operator", + name: "triage_operator", + systemPrompt: "", + provider: "openai", + model: "gpt-4.1", + connectors: ["web-search", "crm-sync"], + }, + ], + })), + }, +})); + +describe("TeamDetailPage", () => { + let useBreakpointSpy: jest.SpyInstance | null = null; + + beforeEach(() => { + window.history.replaceState({}, "", "/teams/scope-1?scopeId=scope-1"); + useBreakpointSpy = jest.spyOn(Grid, "useBreakpoint").mockReturnValue({ + xs: false, + sm: true, + md: true, + lg: true, + xl: true, + xxl: true, + } as any); + (scopeRuntimeApi.listServiceRuns as jest.Mock).mockImplementation( + async () => mockCreateRunsCatalog(), + ); + (scopeRuntimeApi.getServiceRunAudit as jest.Mock).mockImplementation( + async (scopeId: string, _serviceId: string, runId: string) => + mockCreateRunAudit(scopeId, runId), + ); + }); + + afterEach(() => { + useBreakpointSpy?.mockRestore(); + useBreakpointSpy = null; + }); + + it("renders the Team-first shell with health, compare, and governance modules", async () => { + renderWithQueryClient(React.createElement(TeamDetailPage)); + + expect(await screen.findByText("Health / Trust Rail")).toBeTruthy(); + expect(await screen.findByText("Run Compare / Change Diff")).toBeTruthy(); + expect(await screen.findByText("Human Escalation Playback")).toBeTruthy(); + expect(await screen.findByText("Integrations Inspector")).toBeTruthy(); + expect(await screen.findByText("Governance Snapshot")).toBeTruthy(); + expect(await screen.findByText("Collaboration Canvas")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open Team Builder" })).toBeTruthy(); + await waitFor(() => { + expect(screen.getAllByText("Blocked").length).toBeGreaterThan(0); + expect(screen.getByText("Human intervention is visible in the current run.")).toBeTruthy(); + expect(screen.getByText("Step deltas")).toBeTruthy(); + expect(screen.getByText("Approve escalation")).toBeTruthy(); + expect(screen.getByText("Recent runtime events")).toBeTruthy(); + expect(screen.getByText("From focus")).toBeTruthy(); + expect(screen.getByText("web-search")).toBeTruthy(); + expect(screen.getByText("Referenced but undefined")).toBeTruthy(); + expect(screen.getByText("crm-sync")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open current run replay" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Inspect root actor" })).toBeTruthy(); + expect(screen.getAllByText("Delayed").length).toBeGreaterThan(0); + expect(screen.getAllByText("Live").length).toBeGreaterThan(0); + }); + }); + + it("keeps the collaboration canvas ahead of the auxiliary segmented panel on narrow screens", async () => { + useBreakpointSpy?.mockRestore(); + useBreakpointSpy = jest.spyOn(Grid, "useBreakpoint").mockReturnValue({ + xs: true, + sm: true, + md: false, + lg: false, + xl: false, + xxl: false, + } as any); + + renderWithQueryClient(React.createElement(TeamDetailPage)); + + const collaborationHeading = await screen.findByText("Collaboration Canvas"); + const segmentedActivity = await screen.findByText("Activity · Delayed"); + + expect( + collaborationHeading.compareDocumentPosition(segmentedActivity) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByText("Team Activity")).toBeTruthy(); + expect(screen.queryByText("Health / Trust Rail")).toBeNull(); + + fireEvent.click(screen.getByText("Details · Delayed")); + + expect(await screen.findByText("Health / Trust Rail")).toBeTruthy(); + expect(screen.queryByText("Team Activity")).toBeNull(); + }); + + it("surfaces team signal failures without leaking raw runtime errors", async () => { + (scopeRuntimeApi.listServiceRuns as jest.Mock).mockRejectedValueOnce( + new Error("No stub for /api/scopes/scope-1/services/default/runs"), + ); + + renderWithQueryClient(React.createElement(TeamDetailPage)); + + expect( + await screen.findByText("Some team signals are currently unavailable"), + ).toBeTruthy(); + expect( + screen.getByText("Recent team activity could not be loaded."), + ).toBeTruthy(); + expect(screen.getByText("Activity unavailable")).toBeTruthy(); + expect( + screen.getByText("Recent team activity could not be loaded for this team."), + ).toBeTruthy(); + expect( + screen.queryByText("No stub for /api/scopes/scope-1/services/default/runs"), + ).toBeNull(); + }); + + it("opens a playback run replay with observed session context", async () => { + renderWithQueryClient(React.createElement(TeamDetailPage)); + + await screen.findByText("Approve escalation"); + fireEvent.click(screen.getByRole("button", { name: "Open current run replay" })); + + await waitFor(() => { + expect(window.location.pathname).toBe("/runtime/runs"); + }); + const draftKey = new URLSearchParams(window.location.search).get("draftKey"); + expect(draftKey).toBeTruthy(); + expect(loadDraftRunPayload(draftKey)).toMatchObject({ + kind: "observed_run_session", + actorId: "actor-intake", + endpointId: "chat", + routeName: "support-triage", + runId: "run-current", + scopeId: "scope-1", + serviceOverrideId: "default", + }); + }); + + it("opens runtime explorer from the playback root actor action", async () => { + renderWithQueryClient(React.createElement(TeamDetailPage)); + + await screen.findByText("Approve escalation"); + fireEvent.click(screen.getByRole("button", { name: "Inspect root actor" })); + + await waitFor(() => { + expect(window.location.pathname).toBe("/runtime/explorer"); + }); + const params = new URLSearchParams(window.location.search); + expect(params.get("actorId")).toBe("actor-intake"); + expect(params.get("runId")).toBe("run-current"); + expect(params.get("scopeId")).toBe("scope-1"); + expect(params.get("serviceId")).toBe("default"); + }); + + it("lets member selection drive the inspector and explorer focus", async () => { + renderWithQueryClient(React.createElement(TeamDetailPage)); + + await screen.findByText("Selected Member"); + await screen.findByText("actor-risk"); + fireEvent.click( + screen.getByRole("button", { + name: "Focus member RiskReviewAgent actor-risk", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Open Explorer" })); + + await waitFor(() => { + expect(window.location.pathname).toBe("/runtime/explorer"); + }); + const params = new URLSearchParams(window.location.search); + expect(params.get("actorId")).toBe("actor-risk"); + expect(params.get("scopeId")).toBe("scope-1"); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/teams/detail.tsx b/apps/aevatar-console-web/src/pages/teams/detail.tsx new file mode 100644 index 00000000..df404c9a --- /dev/null +++ b/apps/aevatar-console-web/src/pages/teams/detail.tsx @@ -0,0 +1,1553 @@ +import { + type AGUIEvent, + AGUIEventType, + CustomEventName, +} from "@aevatar-react-sdk/types"; +import { + ApartmentOutlined, + BranchesOutlined, + ClockCircleOutlined, + DeploymentUnitOutlined, + EyeOutlined, + MessageOutlined, + PauseCircleOutlined, + SafetyCertificateOutlined, + SwapOutlined, +} from "@ant-design/icons"; +import { + Alert, + Button, + Empty, + Grid, + Segmented, + Space, + Tag, + Typography, + theme, +} from "antd"; +import { useQuery } from "@tanstack/react-query"; +import React from "react"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import { history } from "@/shared/navigation/history"; +import { + buildRuntimeExplorerHref, + buildRuntimeRunsHref, +} from "@/shared/navigation/runtimeRoutes"; +import { saveObservedRunSessionPayload } from "@/shared/runs/draftRunSession"; +import { readScopeQueryDraft } from "@/shared/navigation/scopeRoutes"; +import { studioApi } from "@/shared/studio/api"; +import { + AevatarInspectorEmpty, + AevatarPageShell, + AevatarPanel, + AevatarStatusTag, + AevatarWorkbenchLayout, +} from "@/shared/ui/aevatarPageShells"; +import { + buildAevatarMetricCardStyle, + resolveAevatarMetricVisual, + type AevatarSemanticTone, + type AevatarThemeSurfaceToken, +} from "@/shared/ui/aevatarWorkbench"; +import { + buildStudioWorkflowWorkspaceRoute, +} from "@/shared/studio/navigation"; +import { deriveTeamIntegrationsSummary } from "./runtime/teamIntegrations"; +import type { TeamPlaybackSummary } from "./runtime/teamRuntimeLens"; +import { useTeamRuntimeLens } from "./runtime/useTeamRuntimeLens"; + +type ObservationStatus = "live" | "delayed" | "partial" | "unavailable" | "seeded"; + +type ObservationBadge = { + label: string; + status: ObservationStatus; +}; + +type SignalCardProps = { + caption?: React.ReactNode; + icon?: React.ReactNode; + label: React.ReactNode; + tone?: AevatarSemanticTone; + value: React.ReactNode; +}; + +function renderHealthLabel(status: string): string { + switch (status) { + case "human-overridden": + return "Human Override"; + case "blocked": + return "Blocked"; + case "degraded": + return "Degraded"; + case "healthy": + return "Healthy"; + default: + return "Attention"; + } +} + +function renderPlaybackLabel(status: string): string { + switch (status) { + case "waiting": + return "Waiting"; + case "failed": + return "Failed"; + case "completed": + return "Completed"; + default: + return "Active"; + } +} + +function renderPlaybackTagColor(status: string): string { + switch (status) { + case "waiting": + return "warning"; + case "failed": + return "error"; + case "completed": + return "success"; + default: + return "processing"; + } +} + +function renderDirectionLabel(direction: string): string { + switch (direction) { + case "inbound": + return "Into focus"; + case "outbound": + return "From focus"; + default: + return "Peer"; + } +} + +function renderObservationLabel(status: ObservationStatus): string { + switch (status) { + case "live": + return "Live"; + case "delayed": + return "Delayed"; + case "partial": + return "Partial"; + case "seeded": + return "Seeded"; + default: + return "Unavailable"; + } +} + +function compactId(value: string): string { + const normalized = value.trim(); + if (!normalized) { + return "n/a"; + } + + const segment = normalized.split("/").pop() || normalized; + return segment.split(":").pop() || segment; +} + +function createObservedPlaybackEvents( + playback: Pick, +): AGUIEvent[] { + const events: AGUIEvent[] = []; + const runId = playback.currentRunId?.trim() || ""; + const actorId = playback.rootActorId?.trim() || ""; + const commandId = playback.commandId?.trim() || ""; + + if (runId) { + events.push({ + runId, + threadId: commandId || runId, + timestamp: Date.now(), + type: AGUIEventType.RUN_STARTED, + } as AGUIEvent); + } + + if (actorId || commandId) { + events.push({ + name: CustomEventName.RunContext, + timestamp: Date.now(), + type: AGUIEventType.CUSTOM, + value: { + actorId: actorId || undefined, + commandId: commandId || undefined, + }, + } as AGUIEvent); + } + + return events; +} + +const SignalCard: React.FC = ({ + caption, + icon, + label, + tone = "default", + value, +}) => { + const { token } = theme.useToken(); + const visual = resolveAevatarMetricVisual( + token as AevatarThemeSurfaceToken, + tone, + ); + + return ( +
+ + {icon ? ( + + {icon} + + ) : null} + + {label} + + + + {value} + + {caption ? ( + + {caption} + + ) : null} +
+ ); +}; + +const TeamDetailPage: React.FC = () => { + const requestedScope = readScopeQueryDraft(); + const scopeId = requestedScope.scopeId.trim(); + const screens = Grid.useBreakpoint(); + const isCompactTeamLayout = !screens.lg; + const { token } = theme.useToken(); + const [compactPanel, setCompactPanel] = React.useState<"activity" | "details">( + "activity", + ); + const { + actorGraphQuery, + actorsQuery, + baselineRunAuditQuery, + bindingQuery, + currentRunAuditQuery, + lens, + runsQuery, + scriptsQuery, + servicesQuery, + workflowsQuery, + } = useTeamRuntimeLens(scopeId); + const workspaceSettingsQuery = useQuery({ + enabled: scopeId.length > 0, + queryKey: ["teams", "workspace-settings"], + queryFn: () => studioApi.getWorkspaceSettings(), + retry: false, + }); + const connectorCatalogQuery = useQuery({ + enabled: scopeId.length > 0, + queryKey: ["teams", "connector-catalog"], + queryFn: () => studioApi.getConnectorCatalog(), + retry: false, + }); + const roleCatalogQuery = useQuery({ + enabled: scopeId.length > 0, + queryKey: ["teams", "role-catalog"], + queryFn: () => studioApi.getRoleCatalog(), + retry: false, + }); + const integrations = React.useMemo( + () => + deriveTeamIntegrationsSummary({ + workspaceSettings: workspaceSettingsQuery.data ?? null, + connectorCatalog: connectorCatalogQuery.data ?? null, + roleCatalog: roleCatalogQuery.data ?? null, + }), + [ + connectorCatalogQuery.data, + roleCatalogQuery.data, + workspaceSettingsQuery.data, + ], + ); + const initialLoading = + bindingQuery.isLoading || + servicesQuery.isLoading || + actorsQuery.isLoading || + workflowsQuery.isLoading || + scriptsQuery.isLoading; + const teamSignalIssues = [ + bindingQuery.isError ? "Team binding could not be loaded." : null, + servicesQuery.isError ? "Published services could not be loaded." : null, + actorsQuery.isError ? "Team members could not be loaded." : null, + workflowsQuery.isError ? "Workflow assets could not be loaded." : null, + scriptsQuery.isError ? "Scripts could not be loaded." : null, + runsQuery.isError ? "Recent team activity could not be loaded." : null, + currentRunAuditQuery.isError ? "Current run audit could not be loaded." : null, + baselineRunAuditQuery.isError ? "Baseline run audit could not be loaded." : null, + actorGraphQuery.isError ? "Collaboration graph could not be loaded." : null, + workspaceSettingsQuery.isError ? "Workspace settings could not be loaded." : null, + connectorCatalogQuery.isError ? "Connector catalog could not be loaded." : null, + roleCatalogQuery.isError ? "Role catalog could not be loaded." : null, + ].filter((issue): issue is string => Boolean(issue)); + const activityProvenance: ObservationBadge = + runsQuery.isError || currentRunAuditQuery.isError + ? { label: "Unavailable", status: "unavailable" } + : lens.currentRun + ? { label: "Delayed", status: "delayed" } + : { label: "Partial", status: "partial" }; + const compareProvenance: ObservationBadge = + currentRunAuditQuery.isError || baselineRunAuditQuery.isError + ? { label: "Unavailable", status: "unavailable" } + : lens.baselineRun + ? { label: "Delayed", status: "delayed" } + : { label: "Partial", status: "partial" }; + const playbackProvenance: ObservationBadge = currentRunAuditQuery.isError + ? { label: "Unavailable", status: "unavailable" } + : lens.playback.available + ? { label: "Delayed", status: "delayed" } + : { label: "Partial", status: "partial" }; + const graphProvenance: ObservationBadge = actorGraphQuery.isError + ? { label: "Unavailable", status: "unavailable" } + : lens.graph.available + ? { label: "Live", status: "live" } + : { label: "Partial", status: "partial" }; + const contextProvenance: ObservationBadge = + teamSignalIssues.length > 0 || lens.partialSignals.length > 0 + ? { label: "Partial", status: "partial" } + : { label: "Delayed", status: "delayed" }; + const currentServingProvenance: ObservationBadge = + bindingQuery.isError || servicesQuery.isError + ? { label: "Unavailable", status: "unavailable" } + : lens.currentBindingContext + ? { label: "Live", status: "live" } + : { label: "Partial", status: "partial" }; + const integrationsSignalIssues = [ + workspaceSettingsQuery.isError + ? "Workspace settings are unavailable." + : null, + connectorCatalogQuery.isError + ? "Connector catalog is unavailable." + : null, + roleCatalogQuery.isError ? "Role catalog is unavailable." : null, + ].filter((issue): issue is string => Boolean(issue)); + const integrationsProvenance: ObservationBadge = + workspaceSettingsQuery.isError && + connectorCatalogQuery.isError && + roleCatalogQuery.isError + ? { label: "Unavailable", status: "unavailable" } + : integrationsSignalIssues.length > 0 + ? { label: "Partial", status: "partial" } + : integrations.available + ? { label: "Delayed", status: "delayed" } + : { label: "Partial", status: "partial" }; + const runtimeServiceId = + lens.currentService?.serviceId || lens.currentRun?.serviceId || undefined; + const availableActorIds = React.useMemo( + () => + Array.from( + new Set([ + ...lens.members.map((member) => member.actorId), + ...lens.graph.nodes.map((node) => node.actorId), + ]), + ).filter(Boolean), + [lens.graph.nodes, lens.members], + ); + const defaultSelectedActorId = + lens.graph.focusActorId || lens.members[0]?.actorId || ""; + const [selectedActorId, setSelectedActorId] = React.useState(""); + + React.useEffect(() => { + if (availableActorIds.length === 0) { + if (selectedActorId) { + setSelectedActorId(""); + } + return; + } + + if (!selectedActorId || !availableActorIds.includes(selectedActorId)) { + setSelectedActorId(defaultSelectedActorId || availableActorIds[0]); + } + }, [availableActorIds, defaultSelectedActorId, selectedActorId]); + + const effectiveActorId = selectedActorId || defaultSelectedActorId; + const selectedMember = + lens.members.find((member) => member.actorId === effectiveActorId) || null; + const selectedGraphNodes = lens.graph.nodes.map((node) => ({ + ...node, + isFocused: effectiveActorId + ? node.actorId === effectiveActorId + : node.isFocused, + })); + const selectedGraphRelationships = effectiveActorId + ? lens.graph.relationships.filter( + (relationship) => + relationship.fromActorId === effectiveActorId || + relationship.toActorId === effectiveActorId, + ) + : lens.graph.relationships; + const visibleGraphRelationships = + selectedGraphRelationships.length > 0 + ? selectedGraphRelationships + : lens.graph.relationships; + const selectedFocusReason = + effectiveActorId && effectiveActorId !== lens.graph.focusActorId + ? `Inspector focus is pinned to ${compactId(effectiveActorId)}. ${lens.graph.focusReason}` + : lens.graph.focusReason; + const selectedPlaybackSteps = effectiveActorId + ? lens.playback.steps.filter((step) => step.actorId === effectiveActorId) + : lens.playback.steps; + const visiblePlaybackSteps = + selectedPlaybackSteps.length > 0 ? selectedPlaybackSteps : lens.playback.steps; + const selectedPlaybackEvents = effectiveActorId + ? lens.playback.events.filter((event) => event.actorId === effectiveActorId) + : lens.playback.events; + const visiblePlaybackEvents = + selectedPlaybackEvents.length > 0 + ? selectedPlaybackEvents + : lens.playback.events; + const selectedPlaybackSummary = + effectiveActorId && selectedPlaybackSteps.length === 0 && selectedPlaybackEvents.length === 0 + ? `No actor-specific playback facts are visible for ${compactId( + effectiveActorId, + )} yet, so the rail is showing the latest team-wide activity.` + : effectiveActorId + ? `The rail is focused on ${compactId( + effectiveActorId, + )} whenever actor-specific playback is available.` + : ""; + const handleOpenPlaybackRun = React.useCallback( + (preferredActorId?: string | null) => { + const runId = lens.playback.currentRunId?.trim() || ""; + if (!scopeId || !runId) { + return; + } + + const actorId = + preferredActorId?.trim() || + lens.playback.rootActorId?.trim() || + lens.currentRun?.actorId?.trim() || + ""; + const observedDraftKey = saveObservedRunSessionPayload({ + actorId: actorId || undefined, + commandId: lens.playback.commandId || undefined, + endpointId: "chat", + endpointKind: "chat", + events: createObservedPlaybackEvents(lens.playback), + prompt: + lens.playback.launchPrompt || + lens.playback.prompt || + lens.playback.summary, + routeName: lens.playback.workflowName || undefined, + runId, + scopeId, + serviceOverrideId: runtimeServiceId, + }); + + history.push( + buildRuntimeRunsHref({ + actorId: actorId || undefined, + draftKey: observedDraftKey || undefined, + endpointId: "chat", + endpointKind: "chat", + prompt: lens.playback.launchPrompt || undefined, + route: lens.playback.workflowName || undefined, + scopeId, + serviceId: runtimeServiceId, + }), + ); + }, + [ + lens.currentRun?.actorId, + lens.playback, + runtimeServiceId, + scopeId, + ], + ); + const handleOpenPlaybackActor = React.useCallback( + (actorId?: string | null, runId?: string | null) => { + const resolvedActorId = actorId?.trim() || lens.playback.rootActorId?.trim() || ""; + if (!scopeId || !resolvedActorId) { + return; + } + + history.push( + buildRuntimeExplorerHref({ + actorId: resolvedActorId, + runId: runId?.trim() || lens.playback.currentRunId || undefined, + scopeId, + serviceId: runtimeServiceId, + }), + ); + }, + [lens.playback.currentRunId, lens.playback.rootActorId, runtimeServiceId, scopeId], + ); + + const activityRail = ( +
+ + } + title="Team Activity" + titleHelp="Recent service runs are the shortest path from the team shell to real operational truth." + > + {runsQuery.isLoading ? ( + + ) : runsQuery.isError ? ( + + ) : lens.currentRun || lens.baselineRun ? ( +
+ {[lens.currentRun, ...[lens.baselineRun].filter(Boolean)].map((run, index) => { + if (!run) { + return null; + } + + return ( +
+ + + {index === 0 ? "Current run" : "Baseline run"} + + + {run.lastSuccess === true ? ( + prior good + ) : null} + + {run.runId} + + Revision {run.revisionId || "unknown"} · Actor {run.actorId || "n/a"} + + + Updated {formatDateTime(run.lastUpdatedAt)} + +
+ ); + })} +
+ ) : ( + + )} +
+ + + } + title="Run Compare / Change Diff" + titleHelp="Compare the latest visible team run with the closest prior good run so operators can explain what changed." + > + {!currentRunAuditQuery.isError && !baselineRunAuditQuery.isError && lens.compare.available ? ( +
+ + {lens.compare.sections.map((section) => ( +
+ + + {section.title} + +
+ {section.items.map((detail) => ( +
+ {detail} +
+ ))} +
+
+ ))} +
+ ) : ( + + )} +
+ + + } + title="Human Escalation Playback" + titleHelp="Playback keeps the current human gate, the recent step sequence, and the latest runtime events on one rail so operators can explain why the team is paused." + > + {!currentRunAuditQuery.isError && lens.playback.available ? ( +
+ + {selectedPlaybackSummary ? ( + + ) : null} + {lens.playback.currentRunId || lens.playback.rootActorId ? ( + + {lens.playback.currentRunId ? ( + + ) : null} + {lens.playback.rootActorId ? ( + + ) : null} + + ) : null} + {lens.playback.prompt ? ( +
+ + + + {lens.playback.interactionLabel || "Current gate"} + + {lens.playback.timeoutLabel ? ( + {lens.playback.timeoutLabel} + ) : null} + + {lens.playback.prompt} +
+ ) : null} +
+ {visiblePlaybackSteps.map((step) => ( +
+ + {step.stepId} + + {renderPlaybackLabel(step.status)} + + {step.stepType} + + {step.summary} + {step.detail} + {step.timestamp ? ( + + {formatDateTime(step.timestamp)} + + ) : null} + {step.runId || step.actorId ? ( + + {step.runId ? ( + + ) : null} + {step.actorId ? ( + + ) : null} + + ) : null} +
+ ))} +
+
+ + + Recent runtime events + + {visiblePlaybackEvents.map((event) => ( +
+ + + {event.stage} + + {event.timestamp ? ( + + {formatDateTime(event.timestamp)} + + ) : null} + + + {event.message} + + {event.detail} + {event.runId || event.actorId ? ( + + {event.runId ? ( + + ) : null} + {event.actorId ? ( + + ) : null} + + ) : null} +
+ ))} +
+ {lens.playback.roleReplies.length > 0 ? ( +
+ + + Recent replies + + {lens.playback.roleReplies.map((reply) => ( +
+ {reply} +
+ ))} +
+ ) : null} +
+ ) : ( + + )} +
+
+ ); + + const collaborationStage = ( +
+ + } + title="Collaboration Canvas" + titleHelp="The canvas stays focused on the actor implied by the latest run or current serving revision, then shows the nearby relationship surface around it." + > + {actorGraphQuery.isLoading ? ( + + ) : actorGraphQuery.isError ? ( + + ) : lens.graph.available ? ( +
+
+ } + label="Focused actor" + tone="info" + value={effectiveActorId || "n/a"} + caption={selectedFocusReason} + /> + } + label="Visible relations" + value={visibleGraphRelationships.length} + caption={`${selectedGraphNodes.length} nodes in the current focused subgraph`} + /> +
+ +
+
+ Focused actor + + {compactId(effectiveActorId)} + + + {selectedFocusReason} + + {selectedGraphNodes + .filter((node) => node.isFocused) + .slice(0, 1) + .map((node) => ( + + {node.actorType} + {node.relationCount} relations + {node.caption} + + ))} +
+
+ {selectedGraphNodes + .filter((node) => !node.isFocused) + .map((node) => ( +
+ + {compactId(node.actorId)} + {node.actorType} + + {node.caption} + + {node.relationCount} visible relations + +
+ ))} +
+
+
+ {visibleGraphRelationships.map((relationship) => ( +
+ + + + {compactId(relationship.fromActorId)} → {compactId(relationship.toActorId)} + + + + {renderDirectionLabel(relationship.direction)} + {relationship.edgeType} + +
+ ))} +
+
+ ) : ( + + )} +
+ + + } + title="Team Composition" + titleHelp="The team shell keeps members, service surface, and current binding context visible on one stage." + > +
+ } + label="Workflow assets" + value={lens.workflowCount} + caption="Visible workflow assets in the current team scope" + /> + } + label="Scripts" + value={lens.scriptCount} + caption="Scope-aware scripts currently visible" + /> + } + label="Services" + value={lens.serviceCount} + caption="Published service surfaces attached to this team" + /> +
+
+ {lens.members.length > 0 ? ( + lens.members.map((member) => ( + + )) + ) : ( + + )} +
+
+
+ ); + + const contextAside = ( +
+ + } + title="Selected Member" + titleHelp="Member selection should drive the canvas, playback rail, and inspector together, so the user never has to rebuild context across separate pages." + > + {selectedMember || effectiveActorId ? ( +
+ } + label="Inspector focus" + tone="info" + value={compactId(effectiveActorId)} + caption={selectedMember?.actorType || "Team member"} + /> + + {visibleGraphRelationships.length > 0 + ? `${visibleGraphRelationships.length} visible collaboration paths currently touch this member.` + : "No visible collaboration path is attached to this member yet."} + + + + {lens.playback.currentRunId ? ( + + ) : null} + +
+ ) : ( + + )} +
+ + + } + title="Health / Trust Rail" + titleHelp="This rail answers whether the team is healthy, blocked, degraded, human-overridden, or still missing critical runtime signals." + > +
+ } + label="Current state" + tone={lens.healthTone} + value={renderHealthLabel(lens.healthStatus)} + caption={lens.healthSummary} + /> + {lens.healthDetails.length > 0 ? ( + lens.healthDetails.map((detail) => ( +
+ {detail} +
+ )) + ) : ( + + No extra health detail is currently available. + + )} +
+
+ + + } + title="Current Serving" + titleHelp="This keeps the active target, revision, and service identity in one place so operators do not need to jump into Studio immediately." + > + + {lens.currentBindingTarget} + + Revision {lens.activeRevision?.revisionId || "unknown"} + + + Service {lens.currentService?.serviceId || lens.currentRun?.serviceId || "n/a"} + + {lens.currentBindingContext ? ( + + ) : null} + + + + + } + title="Integrations Inspector" + titleHelp="Integrations are external systems and connection capabilities around the team, not additional team members." + > + {workspaceSettingsQuery.isLoading && + connectorCatalogQuery.isLoading && + roleCatalogQuery.isLoading ? ( + + ) : !integrations.available && integrationsSignalIssues.length > 0 ? ( + + ) : ( +
+ {integrationsSignalIssues.length > 0 ? ( + + ) : null} + } + label="Runtime base" + tone="info" + value={integrations.runtimeHostLabel} + caption={integrations.workspaceSummary} + /> +
+ } + label="Connector definitions" + value={integrations.connectorCount} + caption="Workspace-visible connection capabilities" + /> + } + label="Role-linked connectors" + value={integrations.linkedConnectorCount} + caption={`${integrations.roleReferenceCount} saved connector references across team roles`} + /> +
+ + {integrations.summary} + + {integrations.items.length > 0 ? ( +
+ {integrations.items.map((connector) => ( +
+ + {connector.name} + {connector.type.toUpperCase()} + + {connector.enabled ? "enabled" : "disabled"} + + + + {connector.summary} + + {connector.usedByRoles.length > 0 ? ( + + Used by {connector.usedByRoles.join(", ")} + + ) : ( + + No saved role explicitly references this connector yet. + + )} +
+ ))} +
+ ) : ( + + )} + {integrations.unresolvedReferences.length > 0 ? ( +
+ Referenced but undefined + {integrations.unresolvedReferences.map((connectorName) => ( +
+ {connectorName} +
+ ))} +
+ ) : null} +
+ )} +
+ + + } + title="Governance Snapshot" + titleHelp="This is the buyer-readable trust summary, not a replacement for the full Governance console." + > + +
+ Who is serving now + + {lens.currentBindingTarget} on revision {lens.governance.servingRevision} + +
+
+ What changed recently + + {lens.compare.summary} + +
+
+ Can we trace the current runtime + + {lens.governance.traceability} + +
+
+ Can a human intervene + + {lens.governance.humanIntervention} + +
+
+ Is there a fallback + + {lens.governance.fallback} + +
+
+ Rollout posture + + {lens.governance.rollout} + +
+
+
+
+ ); + + if (!scopeId) { + return ( + + + + + + ); + } + + return ( + + {lens.title} + + {scopeId} + + } + content={`${lens.subtitle}. Team-first keeps the team shell readable while proving runtime truth with runs, revisions, and focused collaboration context.`} + extra={ + + + + + + } + titleHelp="This workspace keeps the team as the surface-level story while grounding every module in current runtime, service, and binding truth." + > +
+ {teamSignalIssues.length > 0 ? ( + + ) : null} + {lens.partialSignals.length > 0 ? ( + + ) : null} + {isCompactTeamLayout ? ( +
+ {collaborationStage} +
+ + setCompactPanel(value as "activity" | "details") + } + options={[ + { + label: `Activity · ${renderObservationLabel( + activityProvenance.status, + )}`, + value: "activity", + }, + { + label: `Details · ${renderObservationLabel( + contextProvenance.status, + )}`, + value: "details", + }, + ]} + value={compactPanel} + /> +
+ {compactPanel === "activity" ? activityRail : contextAside} +
+ ) : ( + + )} + {initialLoading ? ( + + Loading team shell signals... + + ) : null} +
+
+ ); +}; + +export default TeamDetailPage; diff --git a/apps/aevatar-console-web/src/pages/teams/index.test.tsx b/apps/aevatar-console-web/src/pages/teams/index.test.tsx new file mode 100644 index 00000000..7890a3c1 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/teams/index.test.tsx @@ -0,0 +1,251 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { history } from "@/shared/navigation/history"; +import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; +import TeamDetailPage from "./index"; + +jest.mock("@/shared/api/runtimeGAgentApi", () => ({ + runtimeGAgentApi: { + getScopeBinding: jest.fn(), + listActors: jest.fn(), + }, +})); + +jest.mock("@/shared/api/servicesApi", () => ({ + servicesApi: { + listServices: jest.fn(), + }, +})); + +jest.mock("@/shared/api/scopesApi", () => ({ + scopesApi: { + listWorkflows: jest.fn(), + listScripts: jest.fn(), + }, +})); + +jest.mock("@/shared/api/runtimeActorsApi", () => ({ + runtimeActorsApi: { + getActorGraphEnriched: jest.fn(), + }, +})); + +jest.mock("@/shared/api/scopeRuntimeApi", () => ({ + scopeRuntimeApi: { + listServiceRuns: jest.fn(), + getServiceRunAudit: jest.fn(), + getServiceBindings: jest.fn(), + }, +})); + +jest.mock("@/shared/graphs/GraphCanvas", () => ({ + __esModule: true, + default: () => { + const mockReact = require("react"); + return mockReact.createElement("div", null, "GraphCanvas"); + }, +})); + +import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; +import { runtimeGAgentApi } from "@/shared/api/runtimeGAgentApi"; +import { scopeRuntimeApi } from "@/shared/api/scopeRuntimeApi"; +import { scopesApi } from "@/shared/api/scopesApi"; +import { servicesApi } from "@/shared/api/servicesApi"; + +describe("TeamDetailPage", () => { + beforeEach(() => { + window.history.replaceState( + {}, + "", + "/teams/scope-a?tab=advanced&serviceId=service-alpha&runId=run-1", + ); + jest.clearAllMocks(); + + (runtimeGAgentApi.getScopeBinding as jest.Mock).mockResolvedValue({ + available: true, + scopeId: "scope-a", + serviceId: "service-alpha", + displayName: "Alpha Team", + serviceKey: "scope-a/default", + defaultServingRevisionId: "rev-1", + activeServingRevisionId: "rev-1", + deploymentId: "deploy-1", + deploymentStatus: "Ready", + primaryActorId: "actor://team-alpha", + updatedAt: "2026-04-09T08:00:00Z", + revisions: [ + { + revisionId: "rev-1", + implementationKind: "workflow", + status: "Ready", + artifactHash: "artifact-1", + failureReason: "", + isDefaultServing: true, + isActiveServing: true, + isServingTarget: true, + allocationWeight: 100, + servingState: "Ready", + deploymentId: "deploy-1", + primaryActorId: "actor://team-alpha", + createdAt: "2026-04-09T07:00:00Z", + preparedAt: "2026-04-09T07:05:00Z", + publishedAt: "2026-04-09T07:10:00Z", + retiredAt: null, + workflowName: "workflow-alpha", + workflowDefinitionActorId: "definition://workflow-alpha", + inlineWorkflowCount: 1, + scriptId: "", + scriptRevision: "", + scriptDefinitionActorId: "", + scriptSourceHash: "", + staticActorTypeName: "", + }, + ], + }); + (runtimeGAgentApi.listActors as jest.Mock).mockResolvedValue([ + { + gAgentType: "Tests.WorkflowMember", + actorIds: ["actor://team-alpha", "actor://helper"], + }, + ]); + (servicesApi.listServices as jest.Mock).mockResolvedValue([ + { + serviceId: "service-alpha", + serviceKey: "scope-a/default", + displayName: "Alpha Assistant", + deploymentStatus: "Ready", + updatedAt: "2026-04-09T08:00:00Z", + endpoints: [{ endpointId: "chat" }], + primaryActorId: "actor://team-alpha", + activeServingRevisionId: "rev-1", + defaultServingRevisionId: "rev-1", + }, + ]); + (scopesApi.listWorkflows as jest.Mock).mockResolvedValue([ + { + scopeId: "scope-a", + workflowId: "workflow-alpha", + displayName: "Alpha Workflow", + workflowName: "workflow-alpha", + serviceKey: "scope-a/default", + actorId: "actor://team-alpha", + activeRevisionId: "rev-1", + deploymentStatus: "Published", + deploymentId: "deploy-1", + updatedAt: "2026-04-09T08:00:00Z", + }, + ]); + (scopesApi.listScripts as jest.Mock).mockResolvedValue([ + { + scopeId: "scope-a", + scriptId: "script-alpha", + catalogActorId: "catalog://script-alpha", + definitionActorId: "definition://script-alpha", + activeRevision: "rev-1", + activeSourceHash: "hash-1", + updatedAt: "2026-04-09T08:00:00Z", + }, + ]); + (runtimeActorsApi.getActorGraphEnriched as jest.Mock).mockResolvedValue({ + snapshot: { + actorId: "actor://team-alpha", + }, + subgraph: { + rootNodeId: "actor://team-alpha", + nodes: [ + { + nodeId: "actor://team-alpha", + nodeType: "WorkflowRun", + properties: { + workflowName: "workflow-alpha", + stepId: "", + stepType: "", + targetRole: "", + }, + }, + ], + edges: [], + }, + }); + (scopeRuntimeApi.listServiceRuns as jest.Mock).mockResolvedValue({ + runs: [ + { + runId: "run-1", + actorId: "actor://team-alpha", + completionStatus: "Completed", + }, + ], + }); + (scopeRuntimeApi.getServiceRunAudit as jest.Mock).mockResolvedValue({ + audit: { + summary: { + totalSteps: 3, + requestedSteps: 3, + completedSteps: 3, + roleReplyCount: 2, + }, + timeline: [ + { + timestamp: "2026-04-09T08:01:00Z", + eventType: "RunStarted", + agentId: "actor://team-alpha", + stepId: "step-1", + message: "Run started", + }, + ], + }, + }); + (scopeRuntimeApi.getServiceBindings as jest.Mock).mockResolvedValue({ + bindings: [ + { + bindingId: "binding-1", + displayName: "Search Connector", + retired: false, + connectorRef: { + connectorType: "http", + connectorId: "search", + }, + targetKind: "workflow", + targetName: "workflow-alpha", + }, + ], + }); + }); + + it("renders the team detail tabs and aggregated team content", async () => { + renderWithQueryClient(React.createElement(TeamDetailPage)); + + expect(await screen.findByText("高级编辑")).toBeTruthy(); + expect(screen.getByText("团队构建器入口")).toBeTruthy(); + expect( + screen.getAllByRole("button", { name: "打开团队构建器" }).length, + ).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "行为定义" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "脚本行为" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Agent 角色" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "集成" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "测试运行" })).toBeTruthy(); + + await waitFor(() => { + expect(runtimeGAgentApi.getScopeBinding).toHaveBeenCalledWith("scope-a"); + expect(servicesApi.listServices).toHaveBeenCalled(); + expect(scopeRuntimeApi.getServiceBindings).toHaveBeenCalledWith( + "scope-a", + "service-alpha", + ); + }); + }); + + it("opens Studio workflow definitions with preserved team context", async () => { + const pushSpy = jest.spyOn(history, "push"); + renderWithQueryClient(React.createElement(TeamDetailPage)); + + fireEvent.click(await screen.findByRole("button", { name: "行为定义" })); + + await waitFor(() => { + expect(pushSpy).toHaveBeenCalledWith( + "/studio?scopeId=scope-a&scopeLabel=scope-a&tab=workflows", + ); + }); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/teams/index.tsx b/apps/aevatar-console-web/src/pages/teams/index.tsx new file mode 100644 index 00000000..d1c49794 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/teams/index.tsx @@ -0,0 +1,1228 @@ +import { + ApiOutlined, + ApartmentOutlined, + BranchesOutlined, + BuildOutlined, + DeploymentUnitOutlined, + EyeOutlined, + LinkOutlined, + RocketOutlined, +} from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { + Alert, + Button, + Empty, + Select, + Space, + Table, + Tabs, + Typography, +} from "antd"; +import type { Edge, Node } from "@xyflow/react"; +import React, { useEffect, useMemo, useState } from "react"; +import GraphCanvas from "@/shared/graphs/GraphCanvas"; +import { history } from "@/shared/navigation/history"; +import { + buildTeamDetailHref, + buildTeamsHref, + type TeamDetailTab, +} from "@/shared/navigation/teamRoutes"; +import { + buildStudioRoute, + buildStudioScriptsWorkspaceRoute, + buildStudioWorkflowEditorRoute, +} from "@/shared/studio/navigation"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; +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 type { ScopeScriptSummary, ScopeWorkflowSummary } from "@/shared/models/scopes"; +import type { + WorkflowActorGraphEnrichedSnapshot, + WorkflowActorGraphNode, +} from "@/shared/models/runtime/actors"; +import type { + ScopeServiceRunAuditTimelineEvent, + ScopeServiceRunSummary, +} from "@/shared/models/runtime/scopeServices"; +import type { ServiceCatalogSnapshot } from "@/shared/models/services"; +import { + describeScopeServiceBindingTarget, +} from "@/shared/models/runtime/scopeServices"; +import { + describeRuntimeGAgentBindingRevisionTarget, + formatRuntimeGAgentBindingImplementationKind, + getRuntimeGAgentCurrentBindingRevision, + type RuntimeGAgentBindingRevision, +} from "@/shared/models/runtime/gagents"; +import { + AevatarInspectorEmpty, + AevatarPageShell, + AevatarPanel, + AevatarStatusTag, +} from "@/shared/ui/aevatarPageShells"; + +type TeamDetailRouteState = { + readonly runId: string; + readonly scopeId: string; + readonly serviceId: string; + readonly tab: TeamDetailTab; +}; + +type TeamConnectorRow = { + readonly bindingId: string; + readonly connectorLabel: string; + readonly displayName: string; + readonly retired: boolean; + readonly serviceDisplayName: string; + readonly serviceId: string; + readonly targetLabel: string; +}; + +const scopeServiceAppId = "default"; +const scopeServiceNamespace = "default"; + +function trimOptional(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +function parseTeamTab(value: string | null): TeamDetailTab { + switch (trimOptional(value).toLowerCase()) { + case "topology": + case "events": + case "members": + case "connectors": + case "advanced": + return trimOptional(value).toLowerCase() as TeamDetailTab; + default: + return "overview"; + } +} + +function readInitialTeamRouteState(): TeamDetailRouteState { + if (typeof window === "undefined") { + return { + runId: "", + scopeId: "", + serviceId: "", + tab: "overview", + }; + } + + const pathname = window.location.pathname.split("/").filter(Boolean); + const params = new URLSearchParams(window.location.search); + return { + runId: trimOptional(params.get("runId")), + scopeId: trimOptional(pathname[1]), + serviceId: trimOptional(params.get("serviceId")), + tab: parseTeamTab(params.get("tab")), + }; +} + +function buildScopedServiceHref(scopeId: string, serviceId: string): string { + const params = new URLSearchParams(); + params.set("tenantId", scopeId.trim()); + params.set("appId", scopeServiceAppId); + params.set("namespace", scopeServiceNamespace); + params.set("serviceId", serviceId.trim()); + return `/services?${params.toString()}`; +} + +function shortActorId(actorId: string): string { + const normalized = actorId.trim(); + if (!normalized) { + return "n/a"; + } + + if (normalized.length <= 24) { + return normalized; + } + + return `${normalized.slice(0, 10)}...${normalized.slice(-10)}`; +} + +function inferTeamNodeLabel(node: WorkflowActorGraphNode): { + readonly label: string; + readonly subtitle: string; +} { + const workflowName = trimOptional(node.properties.workflowName); + const stepId = trimOptional(node.properties.stepId); + const stepType = trimOptional(node.properties.stepType); + const targetRole = trimOptional(node.properties.targetRole); + + switch (node.nodeType) { + case "WorkflowRun": + return { + label: workflowName || "Workflow Run", + subtitle: shortActorId(node.nodeId), + }; + case "WorkflowStep": + return { + label: stepId || "Step", + subtitle: [stepType, targetRole].filter(Boolean).join(" · ") || shortActorId(node.nodeId), + }; + default: + return { + label: workflowName || shortActorId(node.nodeId), + subtitle: node.nodeType || "Actor", + }; + } +} + +function resolveNodeLevelMap( + graph: WorkflowActorGraphEnrichedSnapshot, +): Map { + const levels = new Map(); + const rootNodeId = + trimOptional(graph.subgraph.rootNodeId) || + trimOptional(graph.snapshot.actorId) || + trimOptional(graph.subgraph.nodes[0]?.nodeId); + + if (!rootNodeId) { + return levels; + } + + const queue = [rootNodeId]; + levels.set(rootNodeId, 0); + + while (queue.length > 0) { + const currentNodeId = queue.shift() ?? ""; + const currentLevel = levels.get(currentNodeId) ?? 0; + + graph.subgraph.edges + .filter((edge) => edge.fromNodeId === currentNodeId) + .forEach((edge) => { + if (levels.has(edge.toNodeId)) { + return; + } + + levels.set(edge.toNodeId, currentLevel + 1); + queue.push(edge.toNodeId); + }); + } + + let fallbackLevel = Math.max(...Array.from(levels.values()), 0) + 1; + graph.subgraph.nodes.forEach((node) => { + if (!levels.has(node.nodeId)) { + levels.set(node.nodeId, fallbackLevel); + fallbackLevel += 1; + } + }); + + return levels; +} + +function buildTopologyGraph( + graph: WorkflowActorGraphEnrichedSnapshot | undefined, +): { + readonly edges: Edge[]; + readonly nodes: Node[]; +} { + if (!graph) { + return { edges: [], nodes: [] }; + } + + const levelMap = resolveNodeLevelMap(graph); + const rowsByLevel = new Map(); + + const nodes = graph.subgraph.nodes.map((node) => { + const level = levelMap.get(node.nodeId) ?? 0; + const row = rowsByLevel.get(level) ?? 0; + rowsByLevel.set(level, row + 1); + const label = inferTeamNodeLabel(node); + const accentColor = + node.nodeType === "WorkflowRun" + ? "#7c3aed" + : node.nodeType === "WorkflowStep" + ? "#1677ff" + : "#16a34a"; + + return { + id: node.nodeId, + data: { + label: ( +
+
{label.label}
+
+ {label.subtitle} +
+
+ ), + }, + position: { + x: level * 260, + y: row * 132, + }, + style: { + background: "var(--ant-color-bg-container)", + border: `1px solid ${accentColor}`, + borderRadius: 16, + boxShadow: "0 12px 28px rgba(15, 23, 42, 0.08)", + padding: 12, + }, + } satisfies Node; + }); + + const edges = graph.subgraph.edges.map((edge) => ({ + id: edge.edgeId, + label: edge.edgeType, + source: edge.fromNodeId, + target: edge.toNodeId, + animated: edge.edgeType !== "CONTAINS_STEP", + style: { + stroke: + edge.edgeType === "CHILD_OF" + ? "#16a34a" + : edge.edgeType === "OWNS" + ? "#7c3aed" + : "#1677ff", + strokeDasharray: edge.edgeType === "CHILD_OF" ? "4 4" : undefined, + strokeWidth: edge.edgeType === "CONTAINS_STEP" ? 1.5 : 2, + }, + })) satisfies Edge[]; + + return { edges, nodes }; +} + +function resolveMemberEditorHref(options: { + readonly memberLabel: string; + readonly scopeId: string; + readonly scopeLabel: string; + readonly scripts: readonly ScopeScriptSummary[]; + readonly service: ServiceCatalogSnapshot | null | undefined; + readonly workflows: readonly ScopeWorkflowSummary[]; + readonly preferredRevision?: RuntimeGAgentBindingRevision | null; +}): string { + const { + memberLabel, + preferredRevision, + scopeId, + scopeLabel, + scripts, + service, + workflows, + } = options; + const memberId = service?.serviceId || ""; + const workflowMatch = + workflows.find((item) => item.workflowId === memberId) || + workflows.find((item) => item.workflowName === memberId) || + workflows.find((item) => item.displayName === memberLabel) || + workflows.find( + (item) => + trimOptional(preferredRevision?.workflowName) && + item.workflowName === trimOptional(preferredRevision?.workflowName), + ) || + workflows.find( + (item) => + trimOptional(preferredRevision?.workflowName) && + item.displayName === trimOptional(preferredRevision?.workflowName), + ) || + null; + + if (workflowMatch) { + return buildStudioWorkflowEditorRoute({ + memberId, + memberLabel, + scopeId, + scopeLabel, + workflowId: workflowMatch.workflowId, + }); + } + + const scriptMatch = + scripts.find((item) => item.scriptId === memberId) || + scripts.find( + (item) => + trimOptional(preferredRevision?.scriptId) && + item.scriptId === trimOptional(preferredRevision?.scriptId), + ) || + null; + + if (scriptMatch) { + return buildStudioScriptsWorkspaceRoute({ + memberId, + memberLabel, + scopeId, + scopeLabel, + scriptId: scriptMatch.scriptId, + }); + } + + return buildStudioRoute({ + memberId, + memberLabel, + scopeId, + scopeLabel, + tab: "workflows", + }); +} + +const TeamMetricCard: React.FC<{ + readonly label: string; + readonly value: React.ReactNode; +}> = ({ label, value }) => ( +
+ {label} + + {value} + +
+); + +const TeamDetailPage: React.FC = () => { + const initialState = useMemo(() => readInitialTeamRouteState(), []); + const [activeTab, setActiveTab] = useState(initialState.tab); + const [selectedServiceId, setSelectedServiceId] = useState(initialState.serviceId); + const [selectedRunId, setSelectedRunId] = useState(initialState.runId); + + const scopeId = initialState.scopeId; + + const bindingQuery = useQuery({ + enabled: Boolean(scopeId), + queryKey: ["teams", "binding", scopeId], + queryFn: () => runtimeGAgentApi.getScopeBinding(scopeId), + }); + const servicesQuery = useQuery({ + enabled: Boolean(scopeId), + queryKey: ["teams", "services", scopeId], + queryFn: () => + servicesApi.listServices({ + tenantId: scopeId, + appId: scopeServiceAppId, + namespace: scopeServiceNamespace, + }), + }); + const actorsQuery = useQuery({ + enabled: Boolean(scopeId), + queryKey: ["teams", "actors", scopeId], + queryFn: () => runtimeGAgentApi.listActors(scopeId), + }); + const workflowsQuery = useQuery({ + enabled: Boolean(scopeId), + queryKey: ["teams", "workflows", scopeId], + queryFn: () => scopesApi.listWorkflows(scopeId), + }); + const scriptsQuery = useQuery({ + enabled: Boolean(scopeId), + queryKey: ["teams", "scripts", scopeId], + queryFn: () => scopesApi.listScripts(scopeId), + }); + + const currentBindingRevision = useMemo( + () => getRuntimeGAgentCurrentBindingRevision(bindingQuery.data), + [bindingQuery.data], + ); + const services = servicesQuery.data ?? []; + const selectedService = + services.find((service) => service.serviceId === selectedServiceId) ?? null; + const teamLabel = scopeId || "团队"; + + useEffect(() => { + if (!selectedServiceId && services.length > 0) { + const nextServiceId = + trimOptional(bindingQuery.data?.serviceId) || services[0]?.serviceId || ""; + setSelectedServiceId(nextServiceId); + } + }, [bindingQuery.data?.serviceId, selectedServiceId, services]); + + const runsQuery = useQuery({ + enabled: Boolean(scopeId && selectedService?.serviceId), + queryKey: ["teams", "runs", scopeId, selectedService?.serviceId], + queryFn: () => + scopeRuntimeApi.listServiceRuns(scopeId, selectedService?.serviceId || "", { + take: 12, + }), + }); + const recentRuns = runsQuery.data?.runs ?? []; + + useEffect(() => { + if (!recentRuns.length) { + setSelectedRunId(""); + return; + } + + if (selectedRunId && recentRuns.some((run) => run.runId === selectedRunId)) { + return; + } + + setSelectedRunId(recentRuns[0]?.runId || ""); + }, [recentRuns, selectedRunId]); + + const selectedRun = + recentRuns.find((run) => run.runId === selectedRunId) ?? recentRuns[0] ?? null; + + const selectedRunAuditQuery = useQuery({ + enabled: Boolean( + scopeId && + selectedService?.serviceId && + selectedRun?.runId && + selectedRun.actorId, + ), + queryKey: [ + "teams", + "run-audit", + scopeId, + selectedService?.serviceId, + selectedRun?.runId, + selectedRun?.actorId, + ], + queryFn: () => + scopeRuntimeApi.getServiceRunAudit( + scopeId, + selectedService?.serviceId || "", + selectedRun?.runId || "", + { + actorId: selectedRun?.actorId || undefined, + }, + ), + }); + + const topologyQuery = useQuery({ + enabled: Boolean(trimOptional(bindingQuery.data?.primaryActorId)), + queryKey: ["teams", "topology", trimOptional(bindingQuery.data?.primaryActorId)], + queryFn: () => + runtimeActorsApi.getActorGraphEnriched( + trimOptional(bindingQuery.data?.primaryActorId), + { + depth: 4, + take: 120, + direction: "Both", + }, + ), + }); + + const connectorBindingsQuery = useQuery({ + enabled: Boolean(scopeId && services.length > 0), + queryKey: [ + "teams", + "connectors", + scopeId, + services.map((service) => service.serviceId).join("|"), + ], + queryFn: async () => { + const results = await Promise.all( + services.map(async (service) => { + try { + const snapshot = await scopeRuntimeApi.getServiceBindings( + scopeId, + service.serviceId, + ); + return snapshot.bindings + .filter((binding) => binding.connectorRef) + .map( + (binding) => + ({ + bindingId: binding.bindingId, + connectorLabel: `${binding.connectorRef?.connectorType || ""}:${binding.connectorRef?.connectorId || ""}`, + displayName: binding.displayName || binding.bindingId, + retired: binding.retired, + serviceDisplayName: service.displayName || service.serviceId, + serviceId: service.serviceId, + targetLabel: describeScopeServiceBindingTarget(binding), + }) satisfies TeamConnectorRow, + ); + } catch { + return [] as TeamConnectorRow[]; + } + }), + ); + + return results.flat(); + }, + }); + + useEffect(() => { + if (!scopeId) { + return; + } + + history.replace( + buildTeamDetailHref({ + scopeId, + tab: activeTab, + serviceId: selectedService?.serviceId || undefined, + runId: selectedRun?.runId || undefined, + }), + ); + }, [activeTab, scopeId, selectedRun?.runId, selectedService?.serviceId]); + + const connectorRows = connectorBindingsQuery.data ?? []; + const actorCount = useMemo( + () => + (actorsQuery.data ?? []).reduce( + (count, group) => count + group.actorIds.length, + 0, + ), + [actorsQuery.data], + ); + const topologyGraph = useMemo( + () => buildTopologyGraph(topologyQuery.data), + [topologyQuery.data], + ); + const defaultEditorHref = resolveMemberEditorHref({ + memberLabel: selectedService?.displayName || selectedService?.serviceId || teamLabel, + preferredRevision: currentBindingRevision, + scopeId, + scopeLabel: teamLabel, + scripts: scriptsQuery.data ?? [], + service: selectedService, + workflows: workflowsQuery.data ?? [], + }); + + const eventTimeline = selectedRunAuditQuery.data?.audit.timeline ?? []; + const eventSummary = selectedRunAuditQuery.data?.audit.summary; + + const memberColumns = [ + { + dataIndex: "displayName", + key: "member", + render: (_: unknown, service: ServiceCatalogSnapshot) => ( +
+ + {service.displayName || service.serviceId} + +
+ {service.serviceKey} +
+
+ ), + title: "成员", + }, + { + dataIndex: "serviceId", + key: "serviceId", + render: (value: string) => ( + {value} + ), + title: "Service ID", + }, + { + dataIndex: "primaryActorId", + key: "actorId", + render: (value: string) => ( + {shortActorId(value)} + ), + title: "Actor", + }, + { + dataIndex: "deploymentStatus", + key: "status", + render: (value: string) => ( + + ), + title: "状态", + }, + { + dataIndex: "endpoints", + key: "endpoints", + render: (value: ServiceCatalogSnapshot["endpoints"]) => value.length, + title: "端点", + }, + { + dataIndex: "updatedAt", + key: "updatedAt", + render: (value: string) => formatDateTime(value), + title: "更新时间", + }, + { + key: "actions", + render: (_: unknown, service: ServiceCatalogSnapshot) => ( + + + + + + ), + title: "操作", + }, + ]; + + const connectorColumns = [ + { + dataIndex: "displayName", + key: "displayName", + title: "连接器绑定", + }, + { + dataIndex: "connectorLabel", + key: "connectorLabel", + render: (value: string) => {value}, + title: "连接器", + }, + { + dataIndex: "serviceDisplayName", + key: "serviceDisplayName", + render: (value: string, row: TeamConnectorRow) => ( +
+ {value} +
+ {row.serviceId} +
+
+ ), + title: "来源成员", + }, + { + dataIndex: "targetLabel", + key: "targetLabel", + title: "目标", + }, + { + dataIndex: "retired", + key: "retired", + render: (value: boolean) => ( + + ), + title: "状态", + }, + ]; + + const eventColumns = [ + { + dataIndex: "timestamp", + key: "timestamp", + render: (value: string | null) => formatDateTime(value), + title: "时间", + width: 180, + }, + { + dataIndex: "eventType", + key: "eventType", + title: "类型", + width: 220, + }, + { + dataIndex: "agentId", + key: "agentId", + render: (value: string) => {shortActorId(value)}, + title: "Actor", + width: 180, + }, + { + dataIndex: "stepId", + key: "stepId", + render: (value: string) => value || "—", + title: "步骤", + width: 140, + }, + { + dataIndex: "message", + key: "message", + title: "详情", + }, + ]; + + const tabItems = [ + { + key: "overview", + label: "概览", + children: ( +
+ +
+ + + + + + + + +
+
+ + + {!bindingQuery.data?.available || !currentBindingRevision ? ( + + ) : ( +
+ + + {bindingQuery.data.displayName || bindingQuery.data.serviceId} + + + + + {describeRuntimeGAgentBindingRevisionTarget(currentBindingRevision)} + + + Actor {currentBindingRevision.primaryActorId || "n/a"} · Revision{" "} + {currentBindingRevision.revisionId} + +
+ )} +
+
+ ), + }, + { + key: "topology", + label: "事件拓扑", + children: ( + + {topologyQuery.error ? ( + + ) : topologyQuery.isLoading ? ( + + ) : topologyGraph.nodes.length > 0 ? ( + + ) : ( + + )} + + ), + }, + { + key: "events", + label: "事件流", + children: ( +
+ + + ({ + label: `${run.runId} · ${run.completionStatus}`, + value: run.runId, + }))} + onChange={setSelectedRunId} + placeholder="选择运行" + style={{ minWidth: 260 }} + value={selectedRun?.runId || undefined} + /> + {selectedService ? ( + + ) : null} + + + + {eventSummary ? ( + +
+ + + + +
+
+ ) : null} + + + {runsQuery.error ? ( + + ) : runsQuery.isLoading ? ( + + ) : selectedRunAuditQuery.error ? ( + + ) : selectedRunAuditQuery.isLoading ? ( + + ) : eventTimeline.length > 0 ? ( + + columns={eventColumns} + dataSource={eventTimeline} + pagination={{ pageSize: 8, showSizeChanger: false }} + rowKey={(row) => + `${row.timestamp || "ts"}:${row.eventType}:${row.agentId}:${row.stepId}` + } + size="small" + /> + ) : ( + + )} + +
+ ), + }, + { + key: "members", + label: "成员", + children: ( + + {servicesQuery.error ? ( + + ) : servicesQuery.isLoading ? ( + + ) : services.length > 0 ? ( + + columns={memberColumns} + dataSource={services} + pagination={{ pageSize: 8, showSizeChanger: false }} + rowKey="serviceKey" + /> + ) : ( + + )} + + ), + }, + { + key: "connectors", + label: "连接器", + children: ( + + {connectorBindingsQuery.isLoading ? ( + + ) : connectorRows.length > 0 ? ( + + columns={connectorColumns} + dataSource={connectorRows} + pagination={{ pageSize: 8, showSizeChanger: false }} + rowKey={(row) => `${row.serviceId}:${row.bindingId}`} + /> + ) : ( + + )} + + ), + }, + { + key: "advanced", + label: "高级编辑", + children: ( +
+ +
+ + + + + + +
+
+ + + + + 团队: {scopeId || "n/a"} + + + 默认成员:{" "} + + {selectedService?.serviceId || bindingQuery.data?.serviceId || "n/a"} + + + + 团队构建器会自动保留当前 scope,上下文按钮会优先尝试打开匹配的行为定义或脚本行为。 + + + +
+ ), + }, + ]; + + return ( + + + + + } + layoutMode="document" + onBack={() => history.push(buildTeamsHref())} + title={teamLabel || "团队详情"} + titleHelp="团队详情页把 Scope 的运行语义重新包装成 Team 视图,并把事件拓扑、事件流、成员和高级编辑收敛到统一入口。" + > + {!scopeId ? ( + + ) : ( +
+ + + {selectedService ? ( + + 默认成员 {selectedService.displayName || selectedService.serviceId} + + ) : null} + {selectedRun ? ( + + 最近运行 {selectedRun.runId} + + ) : null} + + setActiveTab(value as TeamDetailTab)} + /> +
+ )} +
+ ); +}; + +export default TeamDetailPage; diff --git a/apps/aevatar-console-web/src/pages/teams/new.tsx b/apps/aevatar-console-web/src/pages/teams/new.tsx new file mode 100644 index 00000000..eb387739 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/teams/new.tsx @@ -0,0 +1,62 @@ +import { BuildOutlined, RocketOutlined } from "@ant-design/icons"; +import { Button, Space, Typography } from "antd"; +import React from "react"; +import { history } from "@/shared/navigation/history"; +import { buildTeamsHref } from "@/shared/navigation/teamRoutes"; +import { buildStudioRoute } from "@/shared/studio/navigation"; +import { AevatarPageShell, AevatarPanel } from "@/shared/ui/aevatarPageShells"; + +const TeamCreatePage: React.FC = () => { + return ( + history.push(buildTeamsHref())}>返回我的团队 + } + layoutMode="document" + onBack={() => history.push(buildTeamsHref())} + title="组建团队" + titleHelp="当前版本先复用已有 Studio 能力作为团队构建器入口,后续再承接模板化的组建流程。" + > + +
+ + 这一步不会引入新的后端流程,先复用现有 Studio 工作台完成团队搭建,再从团队详情页观察事件拓扑和事件流。 + + + + + +
+
+
+ ); +}; + +export default TeamCreatePage; diff --git a/apps/aevatar-console-web/src/pages/teams/runtime/teamIntegrations.ts b/apps/aevatar-console-web/src/pages/teams/runtime/teamIntegrations.ts new file mode 100644 index 00000000..2685c640 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/teams/runtime/teamIntegrations.ts @@ -0,0 +1,179 @@ +import type { + StudioConnectorCatalog, + StudioConnectorDefinition, + StudioRoleCatalog, + StudioWorkspaceSettings, +} from "@/shared/studio/models"; + +export type TeamIntegrationItem = { + key: string; + name: string; + type: string; + enabled: boolean; + summary: string; + usedByRoles: string[]; +}; + +export type TeamIntegrationsSummary = { + available: boolean; + connectorCount: number; + directoryCount: number; + items: TeamIntegrationItem[]; + linkedConnectorCount: number; + roleReferenceCount: number; + runtimeBaseUrl: string; + runtimeHostLabel: string; + summary: string; + unresolvedReferences: string[]; + workspaceSummary: string; +}; + +function trimOptional(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +function summarizeRuntimeBase(runtimeBaseUrl: string): string { + const normalized = trimOptional(runtimeBaseUrl); + if (!normalized) { + return "Not configured"; + } + + try { + return new URL(normalized).host || normalized; + } catch { + return normalized; + } +} + +function describeConnector(connector: StudioConnectorDefinition): string { + if (connector.type === "http") { + return trimOptional(connector.http?.baseUrl) || "HTTP connector"; + } + + if (connector.type === "cli") { + return trimOptional(connector.cli?.command) || "CLI connector"; + } + + if (connector.type === "mcp") { + return ( + trimOptional(connector.mcp?.serverName) || + trimOptional(connector.mcp?.command) || + "MCP connector" + ); + } + + return "Connector definition"; +} + +export function deriveTeamIntegrationsSummary(input: { + connectorCatalog: StudioConnectorCatalog | null; + roleCatalog: StudioRoleCatalog | null; + workspaceSettings: StudioWorkspaceSettings | null; +}): TeamIntegrationsSummary { + const runtimeBaseUrl = trimOptional(input.workspaceSettings?.runtimeBaseUrl); + const runtimeHostLabel = summarizeRuntimeBase(runtimeBaseUrl); + const directoryCount = input.workspaceSettings?.directories.length ?? 0; + const connectors = input.connectorCatalog?.connectors ?? []; + const roles = input.roleCatalog?.roles ?? []; + + const connectorRoleMap = new Map>(); + roles.forEach((role) => { + const roleName = trimOptional(role.name) || trimOptional(role.id) || "role"; + role.connectors.forEach((connectorName) => { + const normalizedName = trimOptional(connectorName).toLowerCase(); + if (!normalizedName) { + return; + } + + if (!connectorRoleMap.has(normalizedName)) { + connectorRoleMap.set(normalizedName, new Set()); + } + + connectorRoleMap.get(normalizedName)?.add(roleName); + }); + }); + + const connectorNameSet = new Set( + connectors + .map((connector) => trimOptional(connector.name).toLowerCase()) + .filter(Boolean), + ); + + const unresolvedReferences = [...connectorRoleMap.keys()] + .filter((connectorName) => !connectorNameSet.has(connectorName)) + .sort(); + + const items = connectors + .map((connector) => { + const normalizedName = trimOptional(connector.name).toLowerCase(); + const usedByRoles = [ + ...(connectorRoleMap.get(normalizedName) ?? new Set()), + ].sort(); + + return { + key: `${connector.type}:${connector.name}`, + name: trimOptional(connector.name) || "Unnamed connector", + type: trimOptional(connector.type) || "unknown", + enabled: connector.enabled !== false, + summary: describeConnector(connector), + usedByRoles, + }; + }) + .sort((left, right) => { + if (left.usedByRoles.length !== right.usedByRoles.length) { + return right.usedByRoles.length - left.usedByRoles.length; + } + + if (left.enabled !== right.enabled) { + return Number(right.enabled) - Number(left.enabled); + } + + return left.name.localeCompare(right.name); + }); + + const linkedConnectorCount = items.filter( + (connector) => connector.usedByRoles.length > 0, + ).length; + const roleReferenceCount = [...connectorRoleMap.values()].reduce( + (count, names) => count + names.size, + 0, + ); + const available = + runtimeBaseUrl.length > 0 || + directoryCount > 0 || + connectors.length > 0 || + roles.length > 0; + + let summary = "No integration facts are currently visible for this team."; + if (connectors.length > 0 && linkedConnectorCount > 0) { + summary = `${linkedConnectorCount} of ${connectors.length} connector definitions are referenced by saved team roles.`; + } else if (connectors.length > 0) { + summary = `${connectors.length} connector definitions are available in this workspace, but no saved team role is explicitly using them yet.`; + } else if (unresolvedReferences.length > 0) { + summary = `${unresolvedReferences.length} saved connector references are visible, but the matching connector definitions are not currently loaded.`; + } else if (available) { + summary = + "Workspace integration context is visible, but no connector definitions are currently available."; + } + + const workspaceSummary = [ + runtimeBaseUrl ? `Runtime ${runtimeBaseUrl}` : "Runtime base URL unavailable", + directoryCount > 0 + ? `${directoryCount} workflow directories visible` + : "No workflow directories visible", + ].join(" · "); + + return { + available, + connectorCount: connectors.length, + directoryCount, + items, + linkedConnectorCount, + roleReferenceCount, + runtimeBaseUrl, + runtimeHostLabel, + summary, + unresolvedReferences, + workspaceSummary, + }; +} 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 new file mode 100644 index 00000000..7da4b856 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.test.ts @@ -0,0 +1,425 @@ +import { deriveTeamRuntimeLens, selectTeamCompareRuns } from "./teamRuntimeLens"; + +describe("teamRuntimeLens", () => { + it("selects the latest run and a prior successful baseline", () => { + const runs = [ + { + runId: "run-2", + lastUpdatedAt: "2026-04-09T09:05:00Z", + completionStatus: "failed", + lastSuccess: false, + }, + { + runId: "run-1", + lastUpdatedAt: "2026-04-09T09:00:00Z", + completionStatus: "completed", + lastSuccess: true, + }, + ] as any; + + expect(selectTeamCompareRuns(runs)).toEqual({ + currentRun: runs[0], + baselineRun: runs[1], + }); + }); + + it("derives a blocked health state and compare summary from runtime facts", () => { + const lens = deriveTeamRuntimeLens({ + scopeId: "scope-team", + binding: { + available: true, + scopeId: "scope-team", + serviceId: "default", + displayName: "Support Escalation Triage", + serviceKey: "scope-team:default", + defaultServingRevisionId: "rev-2", + activeServingRevisionId: "rev-2", + deploymentId: "dep-2", + deploymentStatus: "Active", + primaryActorId: "actor-intake", + updatedAt: "2026-04-09T09:00:00Z", + revisions: [ + { + revisionId: "rev-2", + implementationKind: "workflow", + status: "Published", + artifactHash: "hash-2", + failureReason: "", + isDefaultServing: true, + isActiveServing: true, + isServingTarget: true, + allocationWeight: 100, + servingState: "Active", + deploymentId: "dep-2", + primaryActorId: "actor-intake", + createdAt: "2026-04-09T08:00:00Z", + preparedAt: "2026-04-09T08:01:00Z", + publishedAt: "2026-04-09T08:02:00Z", + retiredAt: null, + workflowName: "support-triage", + workflowDefinitionActorId: "definition://support-triage", + inlineWorkflowCount: 1, + scriptId: "", + scriptRevision: "", + scriptDefinitionActorId: "", + scriptSourceHash: "", + staticActorTypeName: "", + }, + { + revisionId: "rev-1", + implementationKind: "workflow", + status: "Published", + artifactHash: "hash-1", + failureReason: "", + isDefaultServing: false, + isActiveServing: false, + isServingTarget: false, + allocationWeight: 0, + servingState: "", + deploymentId: "", + primaryActorId: "actor-intake-v1", + createdAt: "2026-04-08T08:00:00Z", + preparedAt: "2026-04-08T08:01:00Z", + publishedAt: "2026-04-08T08:02:00Z", + retiredAt: null, + workflowName: "support-triage-v1", + workflowDefinitionActorId: "definition://support-triage-v1", + inlineWorkflowCount: 1, + scriptId: "", + scriptRevision: "", + scriptDefinitionActorId: "", + scriptSourceHash: "", + staticActorTypeName: "", + }, + ], + }, + services: [ + { + serviceKey: "scope-team:default", + tenantId: "scope-team", + appId: "default", + namespace: "default", + serviceId: "default", + displayName: "Support Runtime", + defaultServingRevisionId: "rev-2", + activeServingRevisionId: "rev-2", + deploymentId: "dep-2", + primaryActorId: "actor-intake", + deploymentStatus: "Active", + endpoints: [], + policyIds: [], + updatedAt: "2026-04-09T09:00:00Z", + }, + ], + actors: [ + { + gAgentType: "IntakeAgent", + actorIds: ["actor-intake"], + }, + ], + runs: [ + { + scopeId: "scope-team", + serviceId: "default", + runId: "run-current", + actorId: "actor-intake", + definitionActorId: "definition://support-triage", + revisionId: "rev-2", + deploymentId: "dep-2", + workflowName: "support-triage", + completionStatus: "waiting_approval", + stateVersion: 2, + lastEventId: "evt-2", + lastUpdatedAt: "2026-04-09T09:05:00Z", + boundAt: "2026-04-09T09:00:00Z", + bindingUpdatedAt: "2026-04-09T09:00:00Z", + lastSuccess: false, + totalSteps: 4, + completedSteps: 2, + roleReplyCount: 1, + lastOutput: "", + lastError: "Waiting on approval", + }, + { + scopeId: "scope-team", + serviceId: "default", + runId: "run-good", + actorId: "actor-intake-v1", + definitionActorId: "definition://support-triage-v1", + revisionId: "rev-1", + deploymentId: "dep-1", + workflowName: "support-triage-v1", + completionStatus: "completed", + stateVersion: 1, + lastEventId: "evt-1", + lastUpdatedAt: "2026-04-09T08:55:00Z", + boundAt: "2026-04-09T08:50:00Z", + bindingUpdatedAt: "2026-04-09T08:50:00Z", + lastSuccess: true, + totalSteps: 3, + completedSteps: 3, + roleReplyCount: 1, + lastOutput: "Resolved", + lastError: "", + }, + ], + actorGraph: null, + currentRunAudit: { + summary: { + scopeId: "scope-team", + serviceId: "default", + runId: "run-current", + actorId: "actor-intake", + definitionActorId: "definition://support-triage", + revisionId: "rev-2", + deploymentId: "dep-2", + workflowName: "support-triage", + completionStatus: "waiting_approval", + stateVersion: 2, + lastEventId: "evt-2", + lastUpdatedAt: "2026-04-09T09:05:00Z", + boundAt: "2026-04-09T09:00:00Z", + bindingUpdatedAt: "2026-04-09T09:00:00Z", + lastSuccess: false, + totalSteps: 4, + completedSteps: 2, + roleReplyCount: 1, + lastOutput: "", + lastError: "Waiting on approval", + }, + audit: { + reportVersion: "1", + projectionScope: "service", + topologySource: "audit", + completionStatus: "waiting_approval", + workflowName: "support-triage", + rootActorId: "actor-intake", + commandId: "cmd-1", + stateVersion: 2, + lastEventId: "evt-2", + createdAt: "2026-04-09T09:00:00Z", + updatedAt: "2026-04-09T09:05:00Z", + startedAt: "2026-04-09T09:00:00Z", + endedAt: null, + durationMs: 1000, + success: false, + input: "hello", + finalOutput: "", + finalError: "Waiting on approval", + topology: [ + { + parent: "actor-intake", + child: "actor-risk", + }, + { + parent: "actor-risk", + child: "actor-ops", + }, + ], + steps: [ + { + stepId: "risk_review", + stepType: "human_approval", + targetRole: "operator", + requestedAt: "2026-04-09T09:01:00Z", + completedAt: null, + success: null, + workerId: "actor-intake", + outputPreview: "", + error: "", + requestParameters: {}, + completionAnnotations: {}, + nextStepId: "", + branchKey: "", + assignedVariable: "", + assignedValue: "", + suspensionType: "human_approval", + suspensionPrompt: "Approve escalation", + suspensionTimeoutSeconds: null, + requestedVariableName: "", + durationMs: null, + }, + ], + roleReplies: [ + { + timestamp: "2026-04-09T09:02:30Z", + roleId: "operator", + sessionId: "session-1", + content: "Escalation needs approval from on-call.", + contentLength: 39, + }, + ], + timeline: [ + { + timestamp: "2026-04-09T09:01:30Z", + stage: "human_gate", + message: "Approval requested from operator", + agentId: "actor-intake", + stepId: "risk_review", + stepType: "human_approval", + eventType: "suspension_requested", + data: {}, + }, + ], + summary: { + totalSteps: 4, + requestedSteps: 2, + completedSteps: 2, + roleReplyCount: 1, + stepTypeCounts: {}, + }, + }, + }, + baselineRunAudit: { + summary: { + scopeId: "scope-team", + serviceId: "default", + runId: "run-good", + actorId: "actor-intake-v1", + definitionActorId: "definition://support-triage-v1", + revisionId: "rev-1", + deploymentId: "dep-1", + workflowName: "support-triage-v1", + completionStatus: "completed", + stateVersion: 1, + lastEventId: "evt-1", + lastUpdatedAt: "2026-04-09T08:55:00Z", + boundAt: "2026-04-09T08:50:00Z", + bindingUpdatedAt: "2026-04-09T08:50:00Z", + lastSuccess: true, + totalSteps: 3, + completedSteps: 3, + roleReplyCount: 1, + lastOutput: "Resolved", + lastError: "", + }, + audit: { + reportVersion: "1", + projectionScope: "service", + topologySource: "audit", + completionStatus: "completed", + workflowName: "support-triage-v1", + rootActorId: "actor-intake-v1", + commandId: "cmd-0", + stateVersion: 1, + lastEventId: "evt-1", + createdAt: "2026-04-09T08:50:00Z", + updatedAt: "2026-04-09T08:55:00Z", + startedAt: "2026-04-09T08:50:00Z", + endedAt: "2026-04-09T08:55:00Z", + durationMs: 900, + success: true, + input: "hello", + finalOutput: "Resolved", + finalError: "", + topology: [ + { + parent: "actor-intake-v1", + child: "actor-risk", + }, + ], + steps: [ + { + stepId: "risk_review", + stepType: "llm_call", + targetRole: "triage", + requestedAt: "2026-04-09T08:51:00Z", + completedAt: "2026-04-09T08:52:00Z", + success: true, + workerId: "actor-intake-v1", + outputPreview: "Escalation cleared", + error: "", + requestParameters: {}, + completionAnnotations: {}, + nextStepId: "notify_customer", + branchKey: "", + assignedVariable: "", + assignedValue: "", + suspensionType: "", + suspensionPrompt: "", + suspensionTimeoutSeconds: null, + requestedVariableName: "", + durationMs: 100, + }, + ], + roleReplies: [], + timeline: [], + summary: { + totalSteps: 3, + requestedSteps: 3, + completedSteps: 3, + roleReplyCount: 1, + stepTypeCounts: {}, + }, + }, + }, + workflowCount: 2, + scriptCount: 1, + }); + + expect(lens.healthStatus).toBe("blocked"); + expect(lens.humanInterventionDetected).toBe(true); + expect(lens.compare.available).toBe(true); + expect(lens.compare.details.some((item) => item.includes("Revision changed"))).toBe(true); + expect(lens.compare.sections.some((section) => section.title === "Step deltas")).toBe(true); + expect(lens.compare.sections.some((section) => section.title === "Handoff deltas")).toBe(true); + expect(lens.playback.available).toBe(true); + expect(lens.playback.currentRunId).toBe("run-current"); + expect(lens.playback.rootActorId).toBe("actor-intake"); + expect(lens.playback.commandId).toBe("cmd-1"); + expect(lens.playback.workflowName).toBe("support-triage"); + expect(lens.playback.prompt).toContain("Approve escalation"); + expect(lens.playback.steps[0]?.actorId).toBe("actor-intake"); + expect(lens.playback.steps[0]?.runId).toBe("run-current"); + expect(lens.playback.events[0]?.actorId).toBe("actor-intake"); + expect(lens.playback.events[0]?.runId).toBe("run-current"); + expect(lens.playback.roleReplies[0]).toContain("operator"); + }); + + it("keeps health at attention when serving is active but no recent run exists", () => { + const lens = deriveTeamRuntimeLens({ + scopeId: "scope-team", + binding: { + available: true, + scopeId: "scope-team", + serviceId: "default", + displayName: "Support Escalation Triage", + serviceKey: "scope-team:default", + defaultServingRevisionId: "rev-2", + activeServingRevisionId: "rev-2", + deploymentId: "dep-2", + deploymentStatus: "Active", + primaryActorId: "actor-intake", + updatedAt: "2026-04-09T09:00:00Z", + revisions: [], + } as any, + services: [ + { + serviceKey: "scope-team:default", + tenantId: "scope-team", + appId: "default", + namespace: "default", + serviceId: "default", + displayName: "Support Runtime", + deploymentStatus: "Active", + endpoints: [], + policyIds: [], + updatedAt: "2026-04-09T09:00:00Z", + }, + ] as any, + actors: [], + runs: [], + actorGraph: null, + currentRunAudit: null, + baselineRunAudit: null, + workflowCount: 0, + scriptCount: 0, + }); + + expect(lens.healthStatus).toBe("attention"); + expect(lens.healthSummary).toBe( + "The current team has an active serving deployment, but no recent run is available to prove runtime health.", + ); + expect(lens.partialSignals).toContain("No recent runs"); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.ts b/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.ts new file mode 100644 index 00000000..387324f5 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.ts @@ -0,0 +1,1055 @@ +import type { WorkflowActorGraphEnrichedSnapshot } from "@/shared/models/runtime/actors"; +import type { RuntimeGAgentActorGroup } from "@/shared/models/runtime/gagents"; +import type { + ScopeServiceRunAuditSnapshot, + ScopeServiceRunSummary, +} from "@/shared/models/runtime/scopeServices"; +import type { ServiceCatalogSnapshot } from "@/shared/models/services"; +import { + describeStudioScopeBindingRevisionContext, + describeStudioScopeBindingRevisionTarget, + getStudioScopeBindingCurrentRevision, + type StudioScopeBindingRevision, + type StudioScopeBindingStatus, +} from "@/shared/studio/models"; + +export type TeamHealthStatus = + | "healthy" + | "attention" + | "degraded" + | "blocked" + | "human-overridden"; + +export type TeamHealthTone = "default" | "info" | "success" | "warning" | "error"; + +export type TeamMemberSummary = { + actorId: string; + actorType: string; + isFocused: boolean; +}; + +export type TeamCompareSection = { + key: string; + title: string; + items: string[]; +}; + +export type TeamCompareSummary = { + available: boolean; + title: string; + summary: string; + details: string[]; + sections: TeamCompareSection[]; +}; + +export type TeamGraphNodeSummary = { + actorId: string; + actorType: string; + caption: string; + isFocused: boolean; + relationCount: number; +}; + +export type TeamGraphRelationshipSummary = { + key: string; + fromActorId: string; + toActorId: string; + edgeType: string; + direction: "inbound" | "outbound" | "peer"; +}; + +export type TeamGraphSummary = { + available: boolean; + focusActorId: string; + focusReason: string; + nodeCount: number; + edgeCount: number; + stageSummary: string; + nodes: TeamGraphNodeSummary[]; + relationships: TeamGraphRelationshipSummary[]; +}; + +export type TeamGovernanceSummary = { + servingRevision: string; + traceability: string; + humanIntervention: string; + fallback: string; + rollout: string; +}; + +export type TeamPlaybackStatus = + | "waiting" + | "active" + | "completed" + | "failed"; + +export type TeamPlaybackStep = { + key: string; + stepId: string; + stepType: string; + actorId: string | null; + runId: string | null; + owner: string; + status: TeamPlaybackStatus; + summary: string; + detail: string; + timestamp: string | null; +}; + +export type TeamPlaybackEvent = { + key: string; + stage: string; + message: string; + detail: string; + actorId: string | null; + runId: string | null; + stepId: string | null; + timestamp: string | null; + tone: TeamHealthTone; +}; + +export type TeamPlaybackSummary = { + available: boolean; + commandId: string | null; + currentRunId: string | null; + launchPrompt: string; + rootActorId: string | null; + title: string; + summary: string; + interactionLabel: string; + prompt: string; + timeoutLabel: string; + workflowName: string | null; + steps: TeamPlaybackStep[]; + events: TeamPlaybackEvent[]; + roleReplies: string[]; +}; + +export type TeamRuntimeLens = { + scopeId: string; + title: string; + subtitle: string; + activeRevision: StudioScopeBindingRevision | null; + previousRevision: StudioScopeBindingRevision | null; + currentService: ServiceCatalogSnapshot | null; + currentRun: ScopeServiceRunSummary | null; + baselineRun: ScopeServiceRunSummary | null; + currentRunAudit: ScopeServiceRunAuditSnapshot | null; + baselineRunAudit: ScopeServiceRunAuditSnapshot | null; + healthStatus: TeamHealthStatus; + healthTone: TeamHealthTone; + healthSummary: string; + healthDetails: string[]; + members: TeamMemberSummary[]; + graph: TeamGraphSummary; + compare: TeamCompareSummary; + playback: TeamPlaybackSummary; + governance: TeamGovernanceSummary; + workflowCount: number; + scriptCount: number; + serviceCount: number; + recentRunCount: number; + partialSignals: string[]; + currentBindingTarget: string; + currentBindingContext: string; + humanInterventionDetected: boolean; +}; + +export type TeamRuntimeLensInput = { + scopeId: string; + binding: StudioScopeBindingStatus | null; + services: readonly ServiceCatalogSnapshot[]; + actors: readonly RuntimeGAgentActorGroup[]; + runs: readonly ScopeServiceRunSummary[]; + actorGraph: WorkflowActorGraphEnrichedSnapshot | null; + currentRunAudit: ScopeServiceRunAuditSnapshot | null; + baselineRunAudit: ScopeServiceRunAuditSnapshot | null; + workflowCount: number; + scriptCount: number; +}; + +function trimOptional(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +function normalizeStatus(value: string | null | undefined): string { + return trimOptional(value).toLowerCase(); +} + +function shortActorId(value: string | null | undefined): string { + const normalized = trimOptional(value); + if (!normalized) { + return "n/a"; + } + + const segment = normalized.split("/").pop() || normalized; + return segment.split(":").pop() || segment; +} + +function sortRuns(runs: readonly ScopeServiceRunSummary[]): ScopeServiceRunSummary[] { + return [...runs].sort((left, right) => { + const leftTime = Date.parse(left.lastUpdatedAt || ""); + const rightTime = Date.parse(right.lastUpdatedAt || ""); + return (Number.isFinite(rightTime) ? rightTime : 0) - (Number.isFinite(leftTime) ? leftTime : 0); + }); +} + +function isSuccessfulRun(run: ScopeServiceRunSummary | null | undefined): boolean { + if (!run) { + return false; + } + + if (run.lastSuccess === true) { + return true; + } + + return ["completed", "finished", "succeeded", "success"].includes( + normalizeStatus(run.completionStatus), + ); +} + +function isBlockedRun(run: ScopeServiceRunSummary | null | undefined): boolean { + if (!run) { + return false; + } + + return [ + "waiting", + "waiting_signal", + "waiting_approval", + "human_input", + "human_approval", + "suspended", + "blocked", + ].includes(normalizeStatus(run.completionStatus)); +} + +function isFailedRun(run: ScopeServiceRunSummary | null | undefined): boolean { + if (!run) { + return false; + } + + if (run.lastSuccess === false) { + return true; + } + + return ["failed", "error", "stopped", "cancelled", "degraded"].includes( + normalizeStatus(run.completionStatus), + ); +} + +function hasHumanIntervention( + audit: ScopeServiceRunAuditSnapshot | null | undefined, +): boolean { + if (!audit) { + return false; + } + + return audit.audit.steps.some((step) => { + const normalizedType = normalizeStatus(step.stepType); + const normalizedSuspension = normalizeStatus(step.suspensionType); + return ( + normalizedType.includes("human") || + normalizedSuspension.includes("human") || + normalizedSuspension.includes("approval") || + normalizedSuspension.includes("signal") + ); + }); +} + +function describeHighlightedStep( + audit: ScopeServiceRunAuditSnapshot | null | undefined, +): string { + if (!audit) { + return ""; + } + + const suspendedStep = audit.audit.steps.find( + (step) => + trimOptional(step.suspensionType) || + normalizeStatus(step.stepType).includes("human"), + ); + if (suspendedStep) { + return `${suspendedStep.stepId} · ${suspendedStep.stepType || "step"}`; + } + + const failedStep = audit.audit.steps.find((step) => step.success === false); + if (failedStep) { + return `${failedStep.stepId} · ${failedStep.stepType || "step"}`; + } + + return ""; +} + +function describeStepState( + success: boolean | null | undefined, + completedAt: string | null | undefined, + suspensionType: string | null | undefined, + error: string | null | undefined, +): TeamPlaybackStatus { + if (trimOptional(suspensionType)) { + return "waiting"; + } + + if (success === false || trimOptional(error)) { + return "failed"; + } + + if (success === true || trimOptional(completedAt)) { + return "completed"; + } + + return "active"; +} + +function formatStepDiffValue(value: string | null | undefined): string { + return trimOptional(value) || "n/a"; +} + +export function selectTeamCompareRuns( + runs: readonly ScopeServiceRunSummary[], +): { + baselineRun: ScopeServiceRunSummary | null; + currentRun: ScopeServiceRunSummary | null; +} { + const sortedRuns = sortRuns(runs); + const currentRun = sortedRuns[0] ?? null; + const baselineRun = + sortedRuns.find((run) => run.runId !== currentRun?.runId && isSuccessfulRun(run)) || + sortedRuns[1] || + null; + + return { + baselineRun, + currentRun, + }; +} + +function deriveFocusActorId(input: TeamRuntimeLensInput, currentRun: ScopeServiceRunSummary | null): { + actorId: string; + reason: string; +} { + const currentRunActorId = trimOptional(currentRun?.actorId); + if (currentRunActorId) { + return { + actorId: currentRunActorId, + reason: "Focused on the actor behind the most recent team activity.", + }; + } + + const activeRevision = getStudioScopeBindingCurrentRevision(input.binding); + const activeRevisionActorId = trimOptional(activeRevision?.primaryActorId); + if (activeRevisionActorId) { + return { + actorId: activeRevisionActorId, + reason: "Focused on the currently serving revision actor because no active run was selected.", + }; + } + + const bindingActorId = trimOptional(input.binding?.primaryActorId); + if (bindingActorId) { + return { + actorId: bindingActorId, + reason: "Focused on the team primary actor from the current binding.", + }; + } + + const firstActorId = input.actors.flatMap((group) => group.actorIds)[0] ?? ""; + if (firstActorId) { + return { + actorId: firstActorId, + reason: "Focused on the first known team member because no stronger runtime signal was available.", + }; + } + + return { + actorId: "", + reason: "No actor focus is available yet.", + }; +} + +function deriveStepDiffs(input: { + baselineRunAudit: ScopeServiceRunAuditSnapshot | null; + currentRunAudit: ScopeServiceRunAuditSnapshot | null; +}): string[] { + const currentSteps = input.currentRunAudit?.audit.steps ?? []; + const baselineSteps = input.baselineRunAudit?.audit.steps ?? []; + if (currentSteps.length === 0 && baselineSteps.length === 0) { + return []; + } + + const currentById = new Map(currentSteps.map((step) => [step.stepId, step])); + const baselineById = new Map(baselineSteps.map((step) => [step.stepId, step])); + const stepIds = [...new Set([...currentById.keys(), ...baselineById.keys()])].slice(0, 8); + + return stepIds.flatMap((stepId) => { + const current = currentById.get(stepId); + const baseline = baselineById.get(stepId); + if (current && !baseline) { + return [ + `Step ${stepId} is new in the current run as ${formatStepDiffValue(current.stepType)}.`, + ]; + } + if (!current && baseline) { + return [ + `Step ${stepId} was visible in the baseline but is absent from the current run.`, + ]; + } + if (!current || !baseline) { + return []; + } + + const deltas: string[] = []; + if (normalizeStatus(current.stepType) !== normalizeStatus(baseline.stepType)) { + deltas.push( + `type ${formatStepDiffValue(baseline.stepType)} -> ${formatStepDiffValue(current.stepType)}`, + ); + } + if ( + normalizeStatus(current.suspensionType) !== + normalizeStatus(baseline.suspensionType) + ) { + deltas.push( + `gate ${formatStepDiffValue(baseline.suspensionType)} -> ${formatStepDiffValue(current.suspensionType)}`, + ); + } + if (normalizeStatus(current.targetRole) !== normalizeStatus(baseline.targetRole)) { + deltas.push( + `owner ${formatStepDiffValue(baseline.targetRole)} -> ${formatStepDiffValue(current.targetRole)}`, + ); + } + if (trimOptional(current.nextStepId) !== trimOptional(baseline.nextStepId)) { + deltas.push( + `next ${formatStepDiffValue(baseline.nextStepId)} -> ${formatStepDiffValue(current.nextStepId)}`, + ); + } + if (current.success !== baseline.success) { + deltas.push( + `status ${baseline.success === true ? "completed" : baseline.success === false ? "failed" : "pending"} -> ${ + current.success === true ? "completed" : current.success === false ? "failed" : "pending" + }`, + ); + } + + return deltas.length > 0 ? [`Step ${stepId}: ${deltas.join(", ")}.`] : []; + }); +} + +function deriveHandoffDiffs(input: { + baselineRunAudit: ScopeServiceRunAuditSnapshot | null; + currentRunAudit: ScopeServiceRunAuditSnapshot | null; +}): string[] { + const currentTopology = input.currentRunAudit?.audit.topology ?? []; + const baselineTopology = input.baselineRunAudit?.audit.topology ?? []; + if (currentTopology.length === 0 && baselineTopology.length === 0) { + return []; + } + + const toKey = (entry: { parent: string; child: string }) => + `${shortActorId(entry.parent)}->${shortActorId(entry.child)}`; + + const currentKeys = new Set(currentTopology.map(toKey)); + const baselineKeys = new Set(baselineTopology.map(toKey)); + + const added = [...currentKeys].filter((key) => !baselineKeys.has(key)).slice(0, 4); + const removed = [...baselineKeys].filter((key) => !currentKeys.has(key)).slice(0, 4); + const kept = [...currentKeys].filter((key) => baselineKeys.has(key)).slice(0, 2); + + const items: string[] = []; + if (added.length > 0) { + items.push(`New handoffs in the current run: ${added.join(", ")}.`); + } + if (removed.length > 0) { + items.push(`Handoffs no longer visible: ${removed.join(", ")}.`); + } + if (items.length === 0 && kept.length > 0) { + items.push(`Handoff path stayed stable on ${kept.join(", ")}.`); + } + return items; +} + +function deriveCompareSummary(input: { + baselineRun: ScopeServiceRunSummary | null; + baselineRunAudit: ScopeServiceRunAuditSnapshot | null; + currentRun: ScopeServiceRunSummary | null; + currentRunAudit: ScopeServiceRunAuditSnapshot | null; +}): TeamCompareSummary { + const { baselineRun, baselineRunAudit, currentRun, currentRunAudit } = input; + if (!currentRun) { + return { + available: false, + title: "Run Compare / Change Diff", + summary: "No team activity has been captured yet.", + details: [], + sections: [], + }; + } + + if (!baselineRun) { + return { + available: false, + title: "Run Compare / Change Diff", + summary: "No successful baseline is available yet.", + details: ["The team has not produced a comparable prior good run."], + sections: [], + }; + } + + const runtimeItems: string[] = []; + if ( + trimOptional(currentRun.revisionId) && + trimOptional(baselineRun.revisionId) && + trimOptional(currentRun.revisionId) !== trimOptional(baselineRun.revisionId) + ) { + runtimeItems.push(`Revision changed from ${baselineRun.revisionId} to ${currentRun.revisionId}.`); + } else if (trimOptional(currentRun.revisionId)) { + runtimeItems.push(`Revision stayed on ${currentRun.revisionId}.`); + } + + if (normalizeStatus(currentRun.completionStatus) !== normalizeStatus(baselineRun.completionStatus)) { + runtimeItems.push( + `Run outcome moved from ${baselineRun.completionStatus || "unknown"} to ${currentRun.completionStatus || "unknown"}.`, + ); + } + + if ( + trimOptional(currentRun.actorId) && + trimOptional(baselineRun.actorId) && + trimOptional(currentRun.actorId) !== trimOptional(baselineRun.actorId) + ) { + runtimeItems.push(`Focus actor moved from ${baselineRun.actorId} to ${currentRun.actorId}.`); + } + + const currentHighlightedStep = describeHighlightedStep(currentRunAudit); + const baselineHighlightedStep = describeHighlightedStep(baselineRunAudit); + if (currentHighlightedStep && currentHighlightedStep !== baselineHighlightedStep) { + runtimeItems.push(`Current highlighted step: ${currentHighlightedStep}.`); + } + + if (trimOptional(currentRun.lastError)) { + runtimeItems.push(`Current run error: ${trimOptional(currentRun.lastError)}.`); + } else if (trimOptional(currentRun.lastOutput)) { + runtimeItems.push(`Current run output preview: ${trimOptional(currentRun.lastOutput)}.`); + } + + const stepItems = deriveStepDiffs({ + baselineRunAudit, + currentRunAudit, + }); + const handoffItems = deriveHandoffDiffs({ + baselineRunAudit, + currentRunAudit, + }); + + const sections: TeamCompareSection[] = [ + { + key: "runtime", + title: "Runtime deltas", + items: + runtimeItems.length > 0 + ? runtimeItems + : ["No top-level runtime delta was detected."], + }, + ...(stepItems.length > 0 + ? [ + { + key: "steps", + title: "Step deltas", + items: stepItems, + }, + ] + : []), + ...(handoffItems.length > 0 + ? [ + { + key: "handoffs", + title: "Handoff deltas", + items: handoffItems, + }, + ] + : []), + ]; + + return { + available: true, + title: "Run Compare / Change Diff", + summary: `Comparing run ${currentRun.runId} against baseline ${baselineRun.runId}.`, + details: sections.flatMap((section) => section.items), + sections, + }; +} + +function deriveHealth(input: { + binding: StudioScopeBindingStatus | null; + currentRun: ScopeServiceRunSummary | null; + currentRunAudit: ScopeServiceRunAuditSnapshot | null; + currentService: ServiceCatalogSnapshot | null; +}): { + details: string[]; + status: TeamHealthStatus; + summary: string; + tone: TeamHealthTone; +} { + 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.currentService?.deploymentStatus)) { + details.push(`Service deployment is ${input.currentService?.deploymentStatus}.`); + } + + 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.", + details: [ + ...details, + "No recent team activity is available to verify the active deployment.", + ], + }; + } + + return { + status: "attention", + tone: "info", + summary: "The current team is partially visible, but no recent run is available yet.", + details, + }; + } + + if (isBlockedRun(input.currentRun)) { + return { + status: "blocked", + tone: "warning", + summary: "The current team is waiting on a blocked or human-gated run.", + details: [ + ...details, + `Current run ${input.currentRun?.runId || "n/a"} is ${input.currentRun?.completionStatus || "blocked"}.`, + ], + }; + } + + if (humanInterventionDetected) { + return { + status: "human-overridden", + tone: "warning", + summary: "The current team is healthy enough to inspect, but human intervention is active.", + details, + }; + } + + if (isFailedRun(input.currentRun)) { + return { + status: "degraded", + tone: "error", + summary: "The current team is degraded by a failed or unhealthy recent run.", + details: [ + ...details, + input.currentRun?.lastError + ? `Latest error: ${input.currentRun.lastError}.` + : `Current run status is ${input.currentRun?.completionStatus || "failed"}.`, + ], + }; + } + + if (isSuccessfulRun(input.currentRun)) { + return { + status: "healthy", + tone: "success", + summary: "The current team has an active serving path with no visible critical blocker.", + details, + }; + } + + return { + status: "attention", + tone: "info", + summary: "The current team is partially visible, but the latest runtime signal is not strong enough to call healthy.", + details, + }; +} + +function deriveMembers( + actorGroups: readonly RuntimeGAgentActorGroup[], + focusActorId: string, + fallbackActorId: string, +): TeamMemberSummary[] { + const members = actorGroups.flatMap((group) => + group.actorIds.map((actorId) => ({ + actorId, + actorType: trimOptional(group.gAgentType) || "Team member", + isFocused: actorId === focusActorId, + })), + ); + + if (members.length > 0) { + return members; + } + + if (fallbackActorId) { + return [ + { + actorId: fallbackActorId, + actorType: "Primary actor", + isFocused: fallbackActorId === focusActorId, + }, + ]; + } + + return []; +} + +function deriveGraphSummary(input: { + actorGraph: WorkflowActorGraphEnrichedSnapshot | null; + focusActorId: string; + focusReason: string; +}): TeamGraphSummary { + if (!input.actorGraph) { + return { + available: false, + focusActorId: input.focusActorId, + focusReason: input.focusReason, + nodeCount: 0, + edgeCount: 0, + stageSummary: "No collaboration graph is currently available.", + nodes: [], + relationships: [], + }; + } + + const rootNodeId = + trimOptional(input.focusActorId) || input.actorGraph.subgraph.rootNodeId; + const relationCountByNode = new Map(); + input.actorGraph.subgraph.edges.forEach((edge) => { + relationCountByNode.set( + edge.fromNodeId, + (relationCountByNode.get(edge.fromNodeId) ?? 0) + 1, + ); + relationCountByNode.set( + edge.toNodeId, + (relationCountByNode.get(edge.toNodeId) ?? 0) + 1, + ); + }); + + const nodes = [...input.actorGraph.subgraph.nodes] + .sort((left, right) => { + const leftFocused = left.nodeId === rootNodeId ? 1 : 0; + const rightFocused = right.nodeId === rootNodeId ? 1 : 0; + if (leftFocused !== rightFocused) { + return rightFocused - leftFocused; + } + return (relationCountByNode.get(right.nodeId) ?? 0) - (relationCountByNode.get(left.nodeId) ?? 0); + }) + .slice(0, 6) + .map((node) => ({ + actorId: node.nodeId, + actorType: trimOptional(node.nodeType) || "actor", + caption: + trimOptional(node.properties.label) || + trimOptional(node.properties.role) || + `Last updated ${node.updatedAt || "unknown"}`, + isFocused: node.nodeId === rootNodeId, + relationCount: relationCountByNode.get(node.nodeId) ?? 0, + })); + + const relationships: TeamGraphRelationshipSummary[] = input.actorGraph.subgraph.edges + .slice(0, 8) + .map((edge) => ({ + key: edge.edgeId || `${edge.fromNodeId}:${edge.toNodeId}`, + fromActorId: edge.fromNodeId, + toActorId: edge.toNodeId, + edgeType: trimOptional(edge.edgeType) || "relation", + direction: + edge.fromNodeId === rootNodeId + ? ("outbound" as const) + : edge.toNodeId === rootNodeId + ? ("inbound" as const) + : ("peer" as const), + })); + + return { + available: true, + focusActorId: input.focusActorId, + focusReason: input.focusReason, + nodeCount: input.actorGraph.subgraph.nodes.length, + edgeCount: input.actorGraph.subgraph.edges.length, + stageSummary: + relationships.length > 0 + ? "This canvas shows the currently focused actor and the nearest visible collaboration paths around it." + : "The current actor graph has no visible collaboration edges yet.", + nodes, + relationships, + }; +} + +function derivePlaybackSummary( + currentRunAudit: ScopeServiceRunAuditSnapshot | null, +): TeamPlaybackSummary { + if (!currentRunAudit) { + return { + available: false, + commandId: null, + currentRunId: null, + launchPrompt: "", + rootActorId: null, + title: "Human Escalation Playback", + summary: "No run audit is available for the current team activity.", + interactionLabel: "", + prompt: "", + timeoutLabel: "", + workflowName: null, + steps: [], + events: [], + roleReplies: [], + }; + } + + const runId = trimOptional(currentRunAudit.summary.runId) || null; + const rootActorId = + trimOptional(currentRunAudit.audit.rootActorId) || + trimOptional(currentRunAudit.summary.actorId) || + null; + const commandId = trimOptional(currentRunAudit.audit.commandId) || null; + const workflowName = + trimOptional(currentRunAudit.audit.workflowName) || + trimOptional(currentRunAudit.summary.workflowName) || + null; + const launchPrompt = trimOptional(currentRunAudit.audit.input); + const stepActorMap = new Map(); + currentRunAudit.audit.steps.forEach((step) => { + const actorId = + trimOptional(step.workerId) || trimOptional(currentRunAudit.audit.rootActorId); + if (!actorId) { + return; + } + + stepActorMap.set(step.stepId, actorId); + }); + + const steps = currentRunAudit.audit.steps.slice(0, 5).map((step) => { + const status = describeStepState( + step.success, + step.completedAt, + step.suspensionType, + step.error, + ); + const actorId = + trimOptional(step.workerId) || + trimOptional(currentRunAudit.audit.rootActorId) || + trimOptional(currentRunAudit.summary.actorId) || + null; + const owner = trimOptional(step.targetRole) || trimOptional(step.workerId) || "team"; + const stepType = trimOptional(step.stepType) || "step"; + const detailParts = [ + trimOptional(step.suspensionPrompt) + ? `Prompt: ${trimOptional(step.suspensionPrompt)}` + : "", + trimOptional(step.error) ? `Error: ${trimOptional(step.error)}` : "", + trimOptional(step.outputPreview) + ? `Output: ${trimOptional(step.outputPreview)}` + : "", + trimOptional(step.nextStepId) + ? `Next: ${trimOptional(step.nextStepId)}` + : "", + ].filter(Boolean); + + return { + key: step.stepId, + stepId: step.stepId, + stepType, + actorId, + runId, + owner, + status, + summary: `${stepType} · ${owner}`, + detail: + detailParts[0] || "No additional playback detail is available for this step yet.", + timestamp: step.completedAt || step.requestedAt, + }; + }); + + const gatingStep = + currentRunAudit.audit.steps.find((step) => trimOptional(step.suspensionType)) || + currentRunAudit.audit.steps.find((step) => normalizeStatus(step.stepType).includes("human")) || + currentRunAudit.audit.steps.find((step) => step.success === false) || + null; + + const events: TeamPlaybackEvent[] = + currentRunAudit.audit.timeline.length > 0 + ? currentRunAudit.audit.timeline.slice(-5).reverse().map((event, index) => ({ + key: `${event.stage}:${event.timestamp || index}`, + stage: trimOptional(event.stage) || "runtime", + message: trimOptional(event.message) || "No timeline message", + detail: + trimOptional(event.stepId) || trimOptional(event.agentId) + ? [trimOptional(event.stepId), trimOptional(event.agentId)] + .filter(Boolean) + .join(" · ") + : trimOptional(event.eventType) || "runtime event", + actorId: + trimOptional(event.agentId) || + stepActorMap.get(trimOptional(event.stepId)) || + trimOptional(currentRunAudit.audit.rootActorId) || + trimOptional(currentRunAudit.summary.actorId) || + null, + runId, + stepId: trimOptional(event.stepId) || null, + timestamp: event.timestamp, + tone: + normalizeStatus(event.stage).includes("error") || + normalizeStatus(event.eventType).includes("error") + ? ("error" as const) + : normalizeStatus(event.stage).includes("wait") || + normalizeStatus(event.stage).includes("human") + ? ("warning" as const) + : ("info" as const), + })) + : steps.slice(0, 3).map((step, index) => ({ + key: `derived-step-${step.stepId}`, + stage: step.status === "waiting" ? "waiting" : "step", + message: `${step.stepId} is ${step.status}.`, + detail: step.summary, + actorId: step.actorId, + runId: step.runId, + stepId: step.stepId, + timestamp: step.timestamp, + tone: + step.status === "failed" + ? ("error" as const) + : step.status === "waiting" + ? ("warning" as const) + : ("info" as const), + })); + + return { + available: true, + commandId, + currentRunId: runId, + launchPrompt, + rootActorId, + title: "Human Escalation Playback", + summary: gatingStep + ? `Current playback is centered on ${gatingStep.stepId}, which is the clearest visible gate in the latest run.` + : `Playback is showing the latest ${steps.length} visible steps from the current run.`, + interactionLabel: trimOptional(gatingStep?.suspensionType) || trimOptional(gatingStep?.stepType), + prompt: trimOptional(gatingStep?.suspensionPrompt), + timeoutLabel: + gatingStep?.suspensionTimeoutSeconds != null + ? `${gatingStep.suspensionTimeoutSeconds}s timeout` + : "", + workflowName, + steps, + events, + roleReplies: currentRunAudit.audit.roleReplies.slice(-3).map((reply) => { + const prefix = trimOptional(reply.roleId) || trimOptional(reply.sessionId) || "reply"; + const content = trimOptional(reply.content) || "No reply content"; + return `${prefix}: ${content}`; + }), + }; +} + +export function deriveTeamRuntimeLens( + input: TeamRuntimeLensInput, +): TeamRuntimeLens { + const activeRevision = getStudioScopeBindingCurrentRevision(input.binding); + const previousRevision = + input.binding?.revisions.find( + (revision) => revision.revisionId !== activeRevision?.revisionId, + ) || null; + const currentService = + input.services.find((service) => service.serviceId === input.binding?.serviceId) || + input.services[0] || + null; + const { baselineRun, currentRun } = selectTeamCompareRuns(input.runs); + const focus = deriveFocusActorId(input, currentRun); + const members = deriveMembers(input.actors, focus.actorId, trimOptional(input.binding?.primaryActorId)); + const compare = deriveCompareSummary({ + baselineRun, + baselineRunAudit: input.baselineRunAudit, + currentRun, + currentRunAudit: input.currentRunAudit, + }); + const playback = derivePlaybackSummary(input.currentRunAudit); + const health = deriveHealth({ + binding: input.binding, + currentRun, + currentRunAudit: input.currentRunAudit, + currentService, + }); + const partialSignals: string[] = []; + if (!input.actorGraph) { + partialSignals.push("Actor graph unavailable"); + } + if (!baselineRun) { + partialSignals.push("No successful baseline run"); + } + if (!currentRun) { + partialSignals.push("No recent runs"); + } + + return { + scopeId: input.scopeId, + title: + trimOptional(input.binding?.displayName) || `Team ${input.scopeId}`, + subtitle: + trimOptional(input.binding?.serviceKey) || + "Team-first runtime workspace", + activeRevision, + previousRevision, + currentService, + currentRun, + baselineRun, + currentRunAudit: input.currentRunAudit, + baselineRunAudit: input.baselineRunAudit, + healthStatus: health.status, + healthTone: health.tone, + healthSummary: health.summary, + healthDetails: health.details, + members, + graph: deriveGraphSummary({ + actorGraph: input.actorGraph, + focusActorId: focus.actorId, + focusReason: focus.reason, + }), + compare, + playback, + governance: { + servingRevision: activeRevision?.revisionId || "Unknown", + traceability: currentRun + ? `Recent run ${currentRun.runId} is traceable through team activity.` + : "No recent run is available yet.", + humanIntervention: hasHumanIntervention(input.currentRunAudit) + ? "Human intervention is visible in the current run." + : "No active human intervention is visible.", + fallback: baselineRun + ? `Prior good run ${baselineRun.runId} on revision ${baselineRun.revisionId || "unknown"}.` + : "Fallback state unavailable.", + rollout: currentService?.deploymentStatus + ? `Current deployment is ${currentService.deploymentStatus}.` + : "Deployment status unavailable.", + }, + workflowCount: input.workflowCount, + scriptCount: input.scriptCount, + 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 new file mode 100644 index 00000000..b767a5e9 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/teams/runtime/useTeamRuntimeLens.ts @@ -0,0 +1,173 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; +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 { deriveTeamRuntimeLens, selectTeamCompareRuns } from "./teamRuntimeLens"; + +const scopeServiceAppId = "default"; +const scopeServiceNamespace = "default"; + +export function useTeamRuntimeLens(scopeId: string) { + const normalizedScopeId = scopeId.trim(); + + const bindingQuery = useQuery({ + enabled: normalizedScopeId.length > 0, + queryKey: ["teams", "binding", normalizedScopeId], + queryFn: () => studioApi.getScopeBinding(normalizedScopeId), + }); + const workflowsQuery = useQuery({ + enabled: normalizedScopeId.length > 0, + queryKey: ["teams", "workflows", normalizedScopeId], + queryFn: () => scopesApi.listWorkflows(normalizedScopeId), + }); + const scriptsQuery = useQuery({ + enabled: normalizedScopeId.length > 0, + queryKey: ["teams", "scripts", normalizedScopeId], + queryFn: () => scopesApi.listScripts(normalizedScopeId), + }); + const servicesQuery = useQuery({ + enabled: normalizedScopeId.length > 0, + queryKey: ["teams", "services", normalizedScopeId], + queryFn: () => + servicesApi.listServices({ + tenantId: normalizedScopeId, + appId: scopeServiceAppId, + namespace: scopeServiceNamespace, + }), + }); + const actorsQuery = useQuery({ + enabled: normalizedScopeId.length > 0, + queryKey: ["teams", "actors", normalizedScopeId], + queryFn: () => runtimeGAgentApi.listActors(normalizedScopeId), + }); + + const serviceId = + bindingQuery.data?.serviceId || + servicesQuery.data?.[0]?.serviceId || + ""; + const runsQuery = useQuery({ + enabled: normalizedScopeId.length > 0 && serviceId.length > 0, + queryKey: ["teams", "runs", normalizedScopeId, serviceId], + queryFn: () => + scopeRuntimeApi.listServiceRuns(normalizedScopeId, serviceId, { + take: 12, + }), + }); + + const compareRuns = useMemo( + () => selectTeamCompareRuns(runsQuery.data?.runs ?? []), + [runsQuery.data?.runs], + ); + const currentRunId = compareRuns.currentRun?.runId?.trim() || ""; + const baselineRunId = compareRuns.baselineRun?.runId?.trim() || ""; + + const focusActorId = + compareRuns.currentRun?.actorId?.trim() || + bindingQuery.data?.primaryActorId?.trim() || + actorsQuery.data?.flatMap((group) => group.actorIds)[0] || + ""; + + const currentRunAuditQuery = useQuery({ + enabled: + normalizedScopeId.length > 0 && + serviceId.length > 0 && + currentRunId.length > 0, + queryKey: [ + "teams", + "run-audit", + normalizedScopeId, + serviceId, + currentRunId, + compareRuns.currentRun?.actorId, + ], + queryFn: () => + scopeRuntimeApi.getServiceRunAudit( + normalizedScopeId, + serviceId, + currentRunId, + { + actorId: compareRuns.currentRun?.actorId || undefined, + }, + ), + }); + const baselineRunAuditQuery = useQuery({ + enabled: + normalizedScopeId.length > 0 && + serviceId.length > 0 && + baselineRunId.length > 0, + queryKey: [ + "teams", + "baseline-run-audit", + normalizedScopeId, + serviceId, + baselineRunId, + compareRuns.baselineRun?.actorId, + ], + queryFn: () => + scopeRuntimeApi.getServiceRunAudit( + normalizedScopeId, + serviceId, + baselineRunId, + { + actorId: compareRuns.baselineRun?.actorId || undefined, + }, + ), + }); + const actorGraphQuery = useQuery({ + enabled: focusActorId.length > 0, + queryKey: ["teams", "actor-graph", focusActorId], + queryFn: () => + runtimeActorsApi.getActorGraphEnriched(focusActorId, { + depth: 2, + direction: "Both", + take: 24, + }), + }); + + const lens = useMemo( + () => + deriveTeamRuntimeLens({ + scopeId: normalizedScopeId, + binding: bindingQuery.data ?? null, + services: servicesQuery.data ?? [], + actors: actorsQuery.data ?? [], + runs: runsQuery.data?.runs ?? [], + actorGraph: actorGraphQuery.data ?? null, + currentRunAudit: currentRunAuditQuery.data ?? null, + baselineRunAudit: baselineRunAuditQuery.data ?? null, + workflowCount: workflowsQuery.data?.length ?? 0, + scriptCount: scriptsQuery.data?.length ?? 0, + }), + [ + actorGraphQuery.data, + actorsQuery.data, + baselineRunAuditQuery.data, + bindingQuery.data, + baselineRunId, + currentRunAuditQuery.data, + currentRunId, + normalizedScopeId, + runsQuery.data?.runs, + scriptsQuery.data?.length, + servicesQuery.data, + workflowsQuery.data?.length, + ], + ); + + return { + actorGraphQuery, + actorsQuery, + baselineRunAuditQuery, + bindingQuery, + currentRunAuditQuery, + lens, + runsQuery, + scriptsQuery, + servicesQuery, + workflowsQuery, + }; +} diff --git a/apps/aevatar-console-web/src/routesConfig.test.ts b/apps/aevatar-console-web/src/routesConfig.test.ts new file mode 100644 index 00000000..07ad663a --- /dev/null +++ b/apps/aevatar-console-web/src/routesConfig.test.ts @@ -0,0 +1,56 @@ +describe("console routes", () => { + function loadRoutes(): typeof import("../config/routes").default { + let loadedRoutes!: typeof import("../config/routes").default; + jest.isolateModules(() => { + loadedRoutes = require("../config/routes").default as typeof import("../config/routes").default; + }); + return loadedRoutes; + } + + function findRoute( + routes: ReturnType, + path: string, + ): Record { + const matchedRoute = routes.find((route) => route.path === path); + if (!matchedRoute) { + throw new Error(`Expected route ${path} to exist.`); + } + + return matchedRoute as Record; + } + + function hasRoute( + routes: ReturnType, + path: string, + ): boolean { + return routes.some((route) => route.path === path); + } + + beforeEach(() => { + jest.resetModules(); + }); + + it("keeps Team-first navigation as the default route model", () => { + const routes = loadRoutes(); + + expect(findRoute(routes, "/teams").hideInMenu).toBe(false); + expect(findRoute(routes, "/studio").hideInMenu).toBe(true); + expect(findRoute(routes, "/chat").hideInMenu).toBe(true); + expect(findRoute(routes, "/runtime/runs").hideInMenu).toBe(true); + expect(findRoute(routes, "/scopes/overview").hideInMenu).toBe(true); + expect(findRoute(routes, "/runtime/gagents").name).toBe("Member Runtime"); + expect(findRoute(routes, "/scopes/overview").name).toBe("Team Overview"); + expect(findRoute(routes, "/scopes").redirect).toBe("/teams"); + expect(hasRoute(routes, "/workflows")).toBe(false); + expect(hasRoute(routes, "/primitives")).toBe(false); + expect(hasRoute(routes, "/runs")).toBe(false); + expect(hasRoute(routes, "/actors")).toBe(false); + expect(hasRoute(routes, "/gagents")).toBe(false); + expect(hasRoute(routes, "/mission-control")).toBe(false); + expect(findRoute(routes, "/runtime/explorer").menuGroupKey).toBe("platform"); + expect(findRoute(routes, "/services").menuGroupKey).toBe("platform"); + expect(findRoute(routes, "/deployments").menuGroupKey).toBe("platform"); + expect(findRoute(routes, "/governance").menuGroupKey).toBe("platform"); + expect(findRoute(routes, "/settings").name).toBe("Settings"); + }); +}); diff --git a/apps/aevatar-console-web/src/shared/auth/session.test.ts b/apps/aevatar-console-web/src/shared/auth/session.test.ts index 705cf79a..da4bdef9 100644 --- a/apps/aevatar-console-web/src/shared/auth/session.test.ts +++ b/apps/aevatar-console-web/src/shared/auth/session.test.ts @@ -104,7 +104,8 @@ describe('auth session storage', () => { }); it('accepts only safe in-app redirect targets', () => { - expect(sanitizeReturnTo('/runs?tab=active')).toBe('/runs?tab=active'); + expect(sanitizeReturnTo('/runs?tab=active')).toBe('/runtime/runs?tab=active'); + expect(sanitizeReturnTo('/gagents?scopeId=scope-a')).toBe('/runtime/gagents?scopeId=scope-a'); expect(sanitizeReturnTo('https://example.com')).toBe(CONSOLE_HOME_ROUTE); expect(sanitizeReturnTo('/login?redirect=/overview')).toBe(CONSOLE_HOME_ROUTE); expect(sanitizeReturnTo('//evil.example.com')).toBe(CONSOLE_HOME_ROUTE); diff --git a/apps/aevatar-console-web/src/shared/auth/session.ts b/apps/aevatar-console-web/src/shared/auth/session.ts index f02145f1..df859328 100644 --- a/apps/aevatar-console-web/src/shared/auth/session.ts +++ b/apps/aevatar-console-web/src/shared/auth/session.ts @@ -37,6 +37,14 @@ export interface AuthInitialState { const AUTH_SESSION_STORAGE_KEY = 'aevatar-console:nyxid:session'; const ACCESS_TOKEN_CLOCK_SKEW_MS = 30_000; const AUTH_BLOCKED_PATHS = new Set(['/login', '/auth/callback']); +const LEGACY_RETURN_TO_ALIASES = new Map([ + ['/workflows', '/runtime/workflows'], + ['/primitives', '/runtime/primitives'], + ['/runs', '/runtime/runs'], + ['/actors', '/runtime/explorer'], + ['/gagents', '/runtime/gagents'], + ['/mission-control', '/runtime/mission-control'], +]); function getStorage(): Storage | undefined { if (typeof window === 'undefined') { @@ -155,5 +163,10 @@ export function sanitizeReturnTo(value?: string | null): string { return CONSOLE_HOME_ROUTE; } - return normalized; + const canonicalTarget = LEGACY_RETURN_TO_ALIASES.get(target); + if (!canonicalTarget) { + return normalized; + } + + return `${canonicalTarget}${normalized.slice(target.length)}`; } diff --git a/apps/aevatar-console-web/src/shared/config/consoleFeatures.test.ts b/apps/aevatar-console-web/src/shared/config/consoleFeatures.test.ts new file mode 100644 index 00000000..265f26d8 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/config/consoleFeatures.test.ts @@ -0,0 +1,20 @@ +describe("consoleFeatures", () => { + function loadModule(): typeof import("./consoleFeatures") { + let loadedModule!: typeof import("./consoleFeatures"); + jest.isolateModules(() => { + loadedModule = require("./consoleFeatures") as typeof import("./consoleFeatures"); + }); + return loadedModule; + } + + beforeEach(() => { + jest.resetModules(); + }); + + it("treats Team-first as always enabled", () => { + const module = loadModule(); + + expect(module.isTeamFirstEnabled()).toBe(true); + expect(module.CONSOLE_FEATURES.teamFirstEnabled).toBe(true); + }); +}); diff --git a/apps/aevatar-console-web/src/shared/config/consoleFeatures.ts b/apps/aevatar-console-web/src/shared/config/consoleFeatures.ts new file mode 100644 index 00000000..d14e1115 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/config/consoleFeatures.ts @@ -0,0 +1,7 @@ +export function isTeamFirstEnabled(): boolean { + return true; +} + +export const CONSOLE_FEATURES = { + teamFirstEnabled: true, +} as const; diff --git a/apps/aevatar-console-web/src/shared/navigation/consoleHome.test.ts b/apps/aevatar-console-web/src/shared/navigation/consoleHome.test.ts new file mode 100644 index 00000000..509cfb8b --- /dev/null +++ b/apps/aevatar-console-web/src/shared/navigation/consoleHome.test.ts @@ -0,0 +1,20 @@ +describe("consoleHome", () => { + function loadModule(): typeof import("./consoleHome") { + let loadedModule!: typeof import("./consoleHome"); + jest.isolateModules(() => { + loadedModule = require("./consoleHome") as typeof import("./consoleHome"); + }); + return loadedModule; + } + + beforeEach(() => { + jest.resetModules(); + }); + + it("uses the teams home route by default", () => { + const module = loadModule(); + + expect(module.getConsoleHomeRoute()).toBe("/teams"); + expect(module.CONSOLE_HOME_ROUTE).toBe("/teams"); + }); +}); diff --git a/apps/aevatar-console-web/src/shared/navigation/consoleHome.ts b/apps/aevatar-console-web/src/shared/navigation/consoleHome.ts index 2aafe664..525bad2e 100644 --- a/apps/aevatar-console-web/src/shared/navigation/consoleHome.ts +++ b/apps/aevatar-console-web/src/shared/navigation/consoleHome.ts @@ -1 +1,7 @@ -export const CONSOLE_HOME_ROUTE = "/scopes/overview"; +export const TEAMS_HOME_ROUTE = "/teams"; + +export function getConsoleHomeRoute(): string { + return TEAMS_HOME_ROUTE; +} + +export const CONSOLE_HOME_ROUTE = TEAMS_HOME_ROUTE; diff --git a/apps/aevatar-console-web/src/shared/navigation/navigationGroups.tsx b/apps/aevatar-console-web/src/shared/navigation/navigationGroups.tsx new file mode 100644 index 00000000..4c552522 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/navigation/navigationGroups.tsx @@ -0,0 +1,37 @@ +import { + DashboardOutlined, + SettingOutlined, + TeamOutlined, +} from "@ant-design/icons"; +import React from "react"; + +export type NavigationGroup = { + flattenSingleItem?: boolean; + icon: React.ReactNode; + key: string; + label: string; +}; + +const TEAM_FIRST_NAVIGATION_GROUP_ORDER: readonly NavigationGroup[] = [ + { + flattenSingleItem: true, + icon: , + key: "home", + label: "Teams", + }, + { + icon: , + key: "platform", + label: "Platform", + }, + { + flattenSingleItem: true, + icon: , + key: "settings", + label: "Settings", + }, +] as const; + +export function getNavigationGroupOrder(): readonly NavigationGroup[] { + return TEAM_FIRST_NAVIGATION_GROUP_ORDER; +} diff --git a/apps/aevatar-console-web/src/shared/navigation/runtimeRoutes.ts b/apps/aevatar-console-web/src/shared/navigation/runtimeRoutes.ts index 924e4b21..151e18ae 100644 --- a/apps/aevatar-console-web/src/shared/navigation/runtimeRoutes.ts +++ b/apps/aevatar-console-web/src/shared/navigation/runtimeRoutes.ts @@ -57,6 +57,7 @@ export function buildRuntimeRunsHref(options?: { payloadBase64?: string; actorId?: string; draftKey?: string; + returnTo?: string; }): string { return buildHref(runtimePaths.runs, { route: options?.route ?? options?.workflow, @@ -69,6 +70,7 @@ export function buildRuntimeRunsHref(options?: { payloadBase64: options?.payloadBase64, actorId: options?.actorId, draftKey: options?.draftKey, + returnTo: options?.returnTo, }); } diff --git a/apps/aevatar-console-web/src/shared/navigation/scopeRoutes.test.ts b/apps/aevatar-console-web/src/shared/navigation/scopeRoutes.test.ts new file mode 100644 index 00000000..497ded59 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/navigation/scopeRoutes.test.ts @@ -0,0 +1,36 @@ +import { + buildScopeOverviewHref, + buildTeamWorkspaceRoute, + readScopeQueryDraft, + resolveScopeOverviewPath, +} from "./scopeRoutes"; + +describe("scopeRoutes", () => { + it("reads the scope from a team detail pathname when the query is empty", () => { + expect(readScopeQueryDraft("", "/teams/scope-alpha")).toEqual({ + scopeId: "scope-alpha", + }); + }); + + it("keeps the team pathname when building the overview href from a team detail route", () => { + expect( + buildScopeOverviewHref( + { scopeId: "scope-alpha" }, + { workflowId: "wf-1" }, + "/teams/scope-alpha", + ), + ).toBe("/teams/scope-alpha?scopeId=scope-alpha&workflowId=wf-1"); + }); + + it("builds the canonical team workspace route with scope context", () => { + expect(buildTeamWorkspaceRoute("scope-alpha")).toBe( + "/teams/scope-alpha?scopeId=scope-alpha", + ); + }); + + it("falls back to the legacy scope overview path outside team detail routes", () => { + expect(resolveScopeOverviewPath({ scopeId: "scope-alpha" }, "/scopes/overview")).toBe( + "/scopes/overview", + ); + }); +}); diff --git a/apps/aevatar-console-web/src/shared/navigation/scopeRoutes.ts b/apps/aevatar-console-web/src/shared/navigation/scopeRoutes.ts new file mode 100644 index 00000000..0ab9874f --- /dev/null +++ b/apps/aevatar-console-web/src/shared/navigation/scopeRoutes.ts @@ -0,0 +1,110 @@ +export type ScopeQueryDraft = { + scopeId: string; +}; + +function readString(value: string | null): string { + return value?.trim() ?? ""; +} + +function trimOptional(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +function readTeamScopeId(pathname: string): string { + const match = pathname.match(/^\/teams\/([^/?#]+)/); + if (!match) { + return ""; + } + + try { + return decodeURIComponent(match[1] ?? "").trim(); + } catch { + return (match[1] ?? "").trim(); + } +} + +export function normalizeScopeDraft(draft: ScopeQueryDraft): ScopeQueryDraft { + return { + scopeId: trimOptional(draft.scopeId), + }; +} + +export function readScopeQueryDraft( + search = typeof window === "undefined" ? "" : window.location.search, + pathname = typeof window === "undefined" ? "" : window.location.pathname, +): ScopeQueryDraft { + const params = new URLSearchParams(search); + const queryScopeId = readString(params.get("scopeId")); + if (queryScopeId) { + return { + scopeId: queryScopeId, + }; + } + + return { + scopeId: readTeamScopeId(pathname), + }; +} + +function buildScopeParams( + draft: ScopeQueryDraft, + extras?: Record, +): URLSearchParams { + const params = new URLSearchParams(); + + if (draft.scopeId.trim()) { + params.set("scopeId", draft.scopeId.trim()); + } + + for (const [key, value] of Object.entries(extras ?? {})) { + const normalized = value?.trim(); + if (normalized) { + params.set(key, normalized); + } + } + + return params; +} + +export function buildScopeHref( + path: string, + draft: ScopeQueryDraft, + extras?: Record, +): string { + const suffix = buildScopeParams(draft, extras).toString(); + return suffix ? `${path}?${suffix}` : path; +} + +export function buildTeamWorkspaceRoute( + scopeId: string, + extras?: Record, +): string { + const normalizedScopeId = trimOptional(scopeId); + const path = normalizedScopeId + ? `/teams/${encodeURIComponent(normalizedScopeId)}` + : "/teams"; + + return buildScopeHref(path, { scopeId: normalizedScopeId }, extras); +} + +export function resolveScopeOverviewPath( + draft: ScopeQueryDraft, + pathname = typeof window === "undefined" ? "" : window.location.pathname, +): string { + if (pathname.startsWith("/teams/")) { + const normalizedScopeId = trimOptional(draft.scopeId) || readTeamScopeId(pathname); + if (normalizedScopeId) { + return `/teams/${encodeURIComponent(normalizedScopeId)}`; + } + } + + return "/scopes/overview"; +} + +export function buildScopeOverviewHref( + draft: ScopeQueryDraft, + extras?: Record, + pathname = typeof window === "undefined" ? "" : window.location.pathname, +): string { + return buildScopeHref(resolveScopeOverviewPath(draft, pathname), draft, extras); +} diff --git a/apps/aevatar-console-web/src/shared/navigation/teamRoutes.ts b/apps/aevatar-console-web/src/shared/navigation/teamRoutes.ts new file mode 100644 index 00000000..2cfe2cda --- /dev/null +++ b/apps/aevatar-console-web/src/shared/navigation/teamRoutes.ts @@ -0,0 +1,57 @@ +type TeamDetailTab = + | 'overview' + | 'topology' + | 'events' + | 'members' + | 'connectors' + | 'advanced'; + +type QueryValue = string | undefined; + +function buildHref( + pathname: string, + query?: Record, +): string { + if (!query) { + return pathname; + } + + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + const normalized = value?.trim(); + if (normalized) { + params.set(key, normalized); + } + }); + + const search = params.toString(); + return search ? `${pathname}?${search}` : pathname; +} + +export function buildTeamsHref(): string { + return '/teams'; +} + +export function buildTeamCreateHref(): string { + return '/teams/new'; +} + +export function buildTeamDetailHref(options: { + scopeId: string; + tab?: TeamDetailTab; + serviceId?: string; + runId?: string; +}): string { + const scopeId = options.scopeId.trim(); + if (!scopeId) { + return buildTeamsHref(); + } + + return buildHref(`/teams/${encodeURIComponent(scopeId)}`, { + tab: options.tab, + serviceId: options.serviceId, + runId: options.runId, + }); +} + +export type { TeamDetailTab }; diff --git a/apps/aevatar-console-web/src/shared/scope/context.ts b/apps/aevatar-console-web/src/shared/scope/context.ts new file mode 100644 index 00000000..0a8e5b15 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/scope/context.ts @@ -0,0 +1,24 @@ +import type { StudioAuthSession } from "@/shared/studio/models"; + +export type ResolvedScopeContext = { + scopeId: string; + scopeSource: string; +}; + +function trimOptional(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +export function resolveStudioScopeContext( + authSession?: StudioAuthSession | null, +): ResolvedScopeContext | null { + const authScopeId = trimOptional(authSession?.scopeId); + if (authScopeId) { + return { + scopeId: authScopeId, + scopeSource: trimOptional(authSession?.scopeSource), + }; + } + + return 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 d926a141..a730631c 100644 --- a/apps/aevatar-console-web/src/shared/studio/navigation.test.ts +++ b/apps/aevatar-console-web/src/shared/studio/navigation.test.ts @@ -13,13 +13,14 @@ describe('buildStudioRoute', () => { it('includes workflow, template, tab, and prompt query params when provided', () => { expect( buildStudioRoute({ + scopeId: 'scope-1', workflowId: 'workspace-demo', template: 'published_demo', tab: 'executions', prompt: 'Run the current draft', }), ).toBe( - '/studio?workflow=workspace-demo&template=published_demo&tab=executions&prompt=Run+the+current+draft', + '/studio?scopeId=scope-1&workflow=workspace-demo&template=published_demo&tab=executions&prompt=Run+the+current+draft', ); }); @@ -72,17 +73,31 @@ describe('buildStudioRoute', () => { }); it('builds dedicated workflow and script workspace routes', () => { - expect(buildStudioWorkflowWorkspaceRoute()).toBe('/studio?tab=workflows'); + expect(buildStudioWorkflowWorkspaceRoute({ scopeId: 'scope-1' })).toBe( + '/studio?scopeId=scope-1&tab=workflows', + ); + expect( + buildStudioWorkflowWorkspaceRoute({ + scopeId: 'scope-a', + scopeLabel: '团队 A', + memberId: 'service-alpha', + memberLabel: '默认成员', + }), + ).toBe( + '/studio?scopeId=scope-a&scopeLabel=%E5%9B%A2%E9%98%9F+A&memberId=service-alpha&memberLabel=%E9%BB%98%E8%AE%A4%E6%88%90%E5%91%98&tab=workflows', + ); expect( buildStudioWorkflowEditorRoute({ + scopeId: 'scope-1', workflowId: 'workflow-1', }), - ).toBe('/studio?workflow=workflow-1&tab=studio'); + ).toBe('/studio?scopeId=scope-1&workflow=workflow-1&tab=studio'); expect( buildStudioScriptsWorkspaceRoute({ + scopeId: 'scope-1', scriptId: 'script-1', }), - ).toBe('/studio?script=script-1&tab=scripts'); + ).toBe('/studio?scopeId=scope-1&script=script-1&tab=scripts'); }); it('infers the workflow editor when only a workflow id is provided', () => { @@ -100,4 +115,18 @@ describe('buildStudioRoute', () => { }), ).toBe('/studio?tab=executions&execution=execution-1'); }); + + it('preserves team context query params when building Studio routes', () => { + expect( + buildStudioRoute({ + scopeId: 'scope-a', + scopeLabel: '团队 A', + memberId: 'service-alpha', + memberLabel: '成员 Alpha', + workflowId: 'workflow-1', + }), + ).toBe( + '/studio?scopeId=scope-a&scopeLabel=%E5%9B%A2%E9%98%9F+A&memberId=service-alpha&memberLabel=%E6%88%90%E5%91%98+Alpha&workflow=workflow-1&tab=studio', + ); + }); }); diff --git a/apps/aevatar-console-web/src/shared/studio/navigation.ts b/apps/aevatar-console-web/src/shared/studio/navigation.ts index 448cede9..b5fb515f 100644 --- a/apps/aevatar-console-web/src/shared/studio/navigation.ts +++ b/apps/aevatar-console-web/src/shared/studio/navigation.ts @@ -8,6 +8,10 @@ export type StudioTab = | 'settings'; type StudioRouteOptions = { + scopeId?: string; + scopeLabel?: string; + memberId?: string; + memberLabel?: string; workflowId?: string; scriptId?: string; template?: string; @@ -47,6 +51,18 @@ function resolveStudioTab(options?: StudioRouteOptions): StudioTab | undefined { export function buildStudioRoute(options?: StudioRouteOptions): string { const params = new URLSearchParams(); + if (options?.scopeId?.trim()) { + params.set('scopeId', options.scopeId.trim()); + } + if (options?.scopeLabel?.trim()) { + params.set('scopeLabel', options.scopeLabel.trim()); + } + if (options?.memberId?.trim()) { + params.set('memberId', options.memberId.trim()); + } + if (options?.memberLabel?.trim()) { + params.set('memberLabel', options.memberLabel.trim()); + } if (options?.workflowId?.trim()) { params.set('workflow', options.workflowId.trim()); } @@ -80,13 +96,23 @@ export function buildStudioRoute(options?: StudioRouteOptions): string { return query ? `/studio?${query}` : '/studio'; } -export function buildStudioWorkflowWorkspaceRoute(): string { +export function buildStudioWorkflowWorkspaceRoute(options?: { + scopeId?: string; + scopeLabel?: string; + memberId?: string; + memberLabel?: string; +}): string { return buildStudioRoute({ + ...options, tab: 'workflows', }); } export function buildStudioWorkflowEditorRoute(options?: { + scopeId?: string; + scopeLabel?: string; + memberId?: string; + memberLabel?: string; workflowId?: string; template?: string; draftMode?: 'new'; @@ -100,6 +126,10 @@ export function buildStudioWorkflowEditorRoute(options?: { } export function buildStudioScriptsWorkspaceRoute(options?: { + scopeId?: string; + scopeLabel?: string; + memberId?: string; + memberLabel?: string; scriptId?: string; }): string { return buildStudioRoute({ diff --git a/apps/aevatar-console-web/src/shared/ui/aevatarPageShells.tsx b/apps/aevatar-console-web/src/shared/ui/aevatarPageShells.tsx index cdf748bc..8c18926d 100644 --- a/apps/aevatar-console-web/src/shared/ui/aevatarPageShells.tsx +++ b/apps/aevatar-console-web/src/shared/ui/aevatarPageShells.tsx @@ -247,7 +247,7 @@ export const AevatarPageShell: React.FC = ({ : pageContainerChildrenViewportStyle } content={content} - extra={extra ? [extra] : undefined} + extra={extra} onBack={onBack} pageHeaderRender={pageHeaderRender} style={ diff --git a/apps/aevatar-console-web/src/shared/ui/aevatarWorkbench.ts b/apps/aevatar-console-web/src/shared/ui/aevatarWorkbench.ts index 9bd17a81..edc50b4e 100644 --- a/apps/aevatar-console-web/src/shared/ui/aevatarWorkbench.ts +++ b/apps/aevatar-console-web/src/shared/ui/aevatarWorkbench.ts @@ -186,11 +186,16 @@ export function resolveAevatarSemanticTone( return "success"; } - if (normalized === "delayed" || normalized === "disconnected") { + if (normalized === "unavailable" || normalized === "disconnected") { return "error"; } - if (normalized === "snapshot_available") { + if ( + normalized === "delayed" || + normalized === "partial" || + normalized === "seeded" || + normalized === "snapshot_available" + ) { return "warning"; } diff --git a/docs/design/2026-04-08-aevatar-product-definition.md b/docs/design/2026-04-08-aevatar-product-definition.md new file mode 100644 index 00000000..8b6845af --- /dev/null +++ b/docs/design/2026-04-08-aevatar-product-definition.md @@ -0,0 +1,1331 @@ +--- +title: "Aevatar Console Web 产品需求文档(Team-first / Frontend-only)" +status: draft +owner: potter-sun +--- + +# Aevatar Console Web 产品需求文档(Team-first / Frontend-only) + +## 1. 文档目的 + +这份文档用于重新定义 `apps/aevatar-console-web` 的产品需求,目标不是描述理想中的新平台,而是基于以下三类真实输入,产出一份**可执行、可落地、前端-only** 的产品需求文档: + +- 当前 Aevatar 后端与 Console Web 的真实能力 +- issue [#146](https://github.com/aevatarAI/aevatar/issues/146) +- PR [#145](https://github.com/aevatarAI/aevatar/pull/145) +- 最近几轮关于产品定位、团队视角、Studio 入口、Connector 语义的讨论 + +本轮文档的核心目标是: + +1. 明确 Aevatar 现在到底是什么产品。 +2. 明确 Console Web 本轮重构到底要做什么,不做什么。 +3. 把“Team-first”讲法和“后端真实能力”对齐,避免产品文档空转。 + +## 2. 一句话结论 + +Aevatar 当前本质上是一个 **多 Agent 应用平台**; +Console Web 本轮重构的目标,是在**不改后端 contract** 的前提下,把它从“工程控制台”重构成“AI 团队控制台”。 + +换句话说: + +> 这不是一次后端能力开发,而是一次前端产品翻译工程。 + +## 3. 本轮边界 + +### 3.1 明确范围 + +本轮重构明确收敛为 **Frontend-only**: + +- 不新增后端 API +- 不改后端数据模型 +- 不新增 team-level 聚合能力 +- 不调整 `scope-first`、`service`、`revision`、`run`、`audit` 等正式 contract + +### 3.2 本轮真正要做的事 + +- 重写产品叙事 +- 重构信息架构 +- 重组路由和导航 +- 用更接近用户心智的语言重新解释现有能力 +- 把 Studio 放回正确的产品位置 + +### 3.3 本轮不做的事 + +- 不重新设计 Runtime +- 不实现新的 Team 嵌套数据模型 +- 不强行把后端没有的“团队总览聚合接口”做成前端假事实 +- 不把 Connector 重建模成 Agent + +## 4. 输入对齐:我们采纳什么,不采纳什么 + +### 4.1 来自 issue #146 / PR #145 的核心采纳点 + +以下方向被采纳: + +- `Scope = Team` 的用户层表达 +- `Teams + Platform` 双层信息架构 +- `Studio = 团队构建器`,不再作为一级导航 +- 团队详情页成为核心页面 +- 团队详情中应看到: + - 团队概览 + - 团队成员 + - 事件拓扑 + - 事件流 + - 高级编辑 + +### 4.2 基于真实后端能力做出的修正 + +PR #145 里有一部分描述在产品方向上是对的,但在实现语义上过于乐观。本轮文档做如下修正: + +1. `Teams 首页` + PR 假设存在稳定 `listScopes()`。 + 当前代码里并未确认存在这条正式前端/后端主链,因此 V1 不能把“多团队卡片首页”当成硬前提。 + +2. `团队事件拓扑` + 当前更接近 `actorId -> graph`,不是 `scopeId -> full team topology`。 + 因此 V1 必须按“选中成员后查看拓扑”来定义。 + +3. `团队事件流` + 当前更接近 `scopeId + serviceId + runId -> audit`,不是纯 scope 级总瀑布流。 + 因此 V1 必须按“选中 service/run 后查看事件流”来定义。 + +4. `Connector` + PR 中“连接器”页方向成立,但本轮文档明确: + Connector 默认不等于一个团队成员,它更接近外部能力或外部系统。 + +## 5. 产品定义 + +### 5.1 Aevatar 是什么 + +Aevatar 是一个基于虚拟 Actor 的 AI Agent 平台。 + +它当前提供三类核心构建原语: + +- `GAgent` +- `Workflow` +- `Script` + +它们都可以被发布成正式 `Service`,并进入: + +- 版本治理 +- 服务绑定 +- 正式运行 +- 审计与观测 + +因此,Aevatar 的本体不是“一个聊天机器人”,而是: + +- 可构建的 AI 协作系统 +- 可运行的 AI 服务系统 +- 可治理的 AI 平台系统 + +### 5.2 Console Web 是什么 + +Console Web 不是 Studio 的宿主壳,也不是 Runtime 的纯管理后台。 + +它应该被定义成: + +> 用户进入 Aevatar 后理解、配置、运行、观察 AI 团队的主要控制台。 + +从这个定义出发,Console Web 要优先回答的是: + +- 我有哪些团队 +- 这个团队由谁组成 +- 他们之间怎么协作 +- 最近发生了什么 +- 我从哪里进入配置这个团队 + +而不是先回答: + +- Workflow 在哪里 +- GAgent 在哪里 +- Capability 在哪里 +- Runtime Explorer 在哪里 + +## 6. 产品心智模型 + +### 6.1 后端真实心智模型 + +当前后端已经稳定存在的核心对象是: + +- `Scope` +- `Service` +- `Revision` +- `Run` +- `Audit` +- `Actor` +- `Actor Graph` +- `Studio capability` + +从 Mainnet Host 的正式 README 看,当前运行 contract 已经明确是 `scope-first`。 + +### 6.2 前端用户层心智模型 + +为了让用户能理解,前端采用如下映射: + +| 后端对象 | 用户层表达 | +|---|---| +| `Scope` | 团队 | +| `Actor / GAgent / RoleGAgent` | 团队成员 | +| `Workflow` | 团队协作流程 | +| `Script` | 团队成员行为 | +| `Service` | 团队能力入口 | +| `Run` | 团队任务 / 一次执行 | +| `Audit` | 团队活动记录 / 事件流 | +| `Studio` | 团队构建器 | + +### 6.3 Scope 的产品语义 + +本轮文档对 `Scope` 采取双层理解: + +- **用户层表达**:一个 Team +- **内部产品理解**:一个运行工作空间 / 组织单元 + +原因是 `Scope` 当前不仅代表团队,还承载: + +- 消息互通边界 +- Secret / Connector 绑定边界 +- Service / Revision 运行边界 +- 运行时隔离边界 + +所以: + +- 在前端 V1 上,把 `Scope` 讲成 Team 是对的 +- 但在产品长期演进上,不应把 `Scope` 限死为“最小不可再分团队” + +### 6.4 团队图模型 + +团队详情页的核心可视模型,不应是技术对象树,而应是: + +- **成员节点**:接待员、处理专员、跟进员等职责型成员 +- **外部系统节点**:Telegram、Feishu、Knowledge API、CRM、LLM Provider +- **关系连线**:成员之间、成员与外部系统之间的事件协作关系 + +这意味着前端默认主语应当是: + +- 角色 +- 协作 +- 事件 + +而不是: + +- workflow +- script +- actor type +- endpoint type + +### 6.5 Connector 的产品语义 + +本轮明确采用如下定义: + +- `Connector` 默认不是一个 Agent +- `Connector` 更接近团队成员可调用的外部能力 +- 或者团队连接到的外部系统 + +只有在系统中真的存在“专门负责某种集成编排的 Agent”时,才把那个 Agent 当作成员节点展示。 + +因此在前端里: + +- 常规 Connector 应优先表现为“集成 / 外部系统” +- 不应默认把每个 Connector 画成一个成员 + +### 6.6 未来模型:团队可递归组合 + +最近讨论里提出的一个重要方向是: + +- 团队本身也可以被理解成一个更大的复合 Agent +- 多个团队未来可以继续组合成更高层的组织网络 + +本轮不实现这套递归模型,但产品文档需要为未来保留这条方向。 + +因此,本轮的语言约束是: + +- Team 是当前最重要的产品主语 +- 但 Team 不应被描述成永远不可嵌套的最终单位 + +## 7. 用户与核心任务 + +### 7.1 主要用户 1:团队构建者 + +这类用户关心: + +- 我怎么搭建一个 AI 团队 +- 团队里有哪些成员 +- 每个成员负责什么 +- 我怎么配置流程、角色、集成和行为 +- 我怎么进入编辑器调整这个团队 + +他们最需要的入口是: + +- 团队首页 +- 团队详情 +- Studio + +### 7.2 主要用户 2:平台管理员 / 技术运营 + +这类用户关心: + +- 哪些服务在运行 +- 当前 serving 的 revision 是什么 +- 某次执行发生了什么 +- 哪些服务、绑定、部署存在风险 +- Actor / Service / Deployment 之间的关系 + +他们最需要的入口是: + +- Governance +- Services +- Topology +- Deployments + +### 7.3 商业角色修正 + +如果把本轮重构放到“未来对外卖给用户”的语境里,还需要补一层角色修正: + +- 日常 champion 更可能是:AI workflow 技术 PM 或自动化负责人 +- 日常使用者更可能是:团队构建者、技术运营、平台管理员 +- 早期真正买单者更可能是:工程负责人、平台负责人、CTO + +这意味着: + +- `Team-first` 可以是前台叙事 +- 但它不能掩盖产品真正要证明的价值: + - 统一运行 + - 协作可见 + - 日志可追 + - 版本可控 + +### 7.4 当前最大问题 + +当前 Console Web 的主要问题不是“没有能力”,而是“用户不知道怎么用”。 + +其根因是: + +- 菜单按工程对象组织,不按用户任务组织 +- Team 不是主语 +- Studio 是一个孤立入口 +- Runtime 能力分散在多个技术名词页面中 +- 用户看不到“一个团队”的完整心智 + +## 8. 产品目标 + +### 8.1 本轮目标 + +1. 用户打开 Console 后,能在 10 秒内理解: + - 这是一个 AI 团队管理与运行平台 +2. 用户能沿一条自然路径使用产品: + - 进入团队 + - 看团队 + - 看活动 + - 再进入 Studio 配团队 +3. 团队页能够讲清楚: + - 谁是成员 + - 成员间如何协作 + - 最近发生了什么 +4. Platform 仍然保留,但明确是后台治理层,不是首页主叙事 + +### 8.2 产品叙事约束 + +本轮需要同时守住 3 层真相: + +- `External narrative = Team-first` +- `Proof of value = control-plane outcomes` +- `Internal product truth = unified runtime / control plane` + +因此: + +- 首页可以先讲 Team +- 但团队页必须能让技术用户看到运行、协作、活动、版本和变化 +- 不能把产品讲成“只是一个更好看的 AI 团队故事” + +### 8.3 非目标 + +本轮不是: + +- 新增后端 Team API +- 新做 Team 嵌套运行时 +- 改造 Studio 内部能力模型 +- 改造 Connector 数据模型 +- 改造 Service / Revision / Run 的正式语义 + +## 9. 信息架构策略 + +### 9.1 一个 Console,两层体验 + +本轮采用两层架构: + +- `Teams`:面向团队构建者 +- `Platform`:面向管理员和技术运营 + +### 9.2 推荐导航结构 + +**Teams** + +- 我的团队 +- 组建团队 + +**Platform** + +- Governance +- Services +- Topology +- Deployments + +**System** + +- Settings + +### 9.3 旧导航处理原则 + +以下现有一级入口不再保留为主导航主语: + +- Studio +- Workflows +- Capabilities +- Chat +- Invoke Lab +- Runs +- GAgents + +它们的处理策略应为: + +- `hideInMenu` +- `redirect` +- 或被吸收入团队详情页内部 + +## 10. 默认用户路径 + +为了解决“现在根本不知道怎么用”的问题,产品必须提供清晰路径: + +1. 用户进入 `Teams` +2. 用户先看到“我当前所在团队”的工作台首页 +3. 用户进入某个团队详情 +4. 用户先理解: + - 这个团队由谁组成 + - 它们之间怎么协作 + - 最近发生了什么 + - 当前运行状态如何 +5. 当用户需要调整团队定义时,再进入 `高级编辑` +6. `高级编辑` 打开 Studio,并始终带着当前 Team 上下文 + +这条路径意味着: + +- Studio 是 Team 的二级入口 +- 团队页才是主入口 +- `Teams` 默认首页必须稳定,不因是否存在 `listScopes()` 而改变第一屏心智 + +### 10.1 默认首页信息层级 + +V1 明确规定: + +- `Teams` 默认首页 = `当前团队工作台` +- 不采用“团队列表”和“当前团队首页”并列双态 +- 如果后续确认存在稳定 `listScopes()`,它也只能升级为: + - header 内的 team switcher + - 或单独的“全部团队”页 +- 不能替换 V1 的默认第一屏 + +默认首页的视觉层级必须是: + +1. 当前团队是谁 +2. 当前谁在接手、哪里阻塞、最近发生了什么 +3. 我下一步最应该做什么 + +不允许默认退化为: + +- 团队卡片列表 +- 模块入口宫格 +- 多个摘要卡片并列堆叠的 dashboard 首屏 + +最小页面结构如下: + +```text +Teams Home / Current Team Workspace +├─ Team identity header +│ ├─ team name +│ ├─ current mission / scope +│ └─ primary CTA +├─ Live collaboration snapshot +│ ├─ current owner +│ ├─ latest handoff +│ └─ blocked / at-risk state +├─ Recent activity rail +│ ├─ latest run +│ ├─ latest change +│ └─ latest anomaly +└─ Secondary actions + ├─ open team detail + └─ continue in Studio +``` + +对应的导航流如下: + +```text +Teams Home + -> Team Detail + -> Members / Activity / Topology / Integrations + -> Advanced Edit + -> Studio (with current scopeId) +``` + +### 10.2 用户旅程与情绪路径 + +V1 的默认体验不只是“能走通”,还必须让用户在每一步都减少不确定感。 + +```text +STEP | USER DOES | USER FEELS | PRODUCT MUST DO +-----|--------------------------|--------------------|-------------------------------------------------- +1 | 打开 Teams | 我先看看这是什么 | 直接给出当前团队工作台,而不是模块列表 +2 | 看首页主舞台 | 我大概看懂了 | 告诉我当前谁在接手、哪里阻塞、下一步做什么 +3 | 点进 Team Detail | 我想确认细节 | 自动聚焦当前活跃路径,而不是让我先自己筛选 +4 | 看协作画布 + 活动轨 | 我知道发生了什么 | 用 handoff / anomaly / change 解释当前运行态 +5 | 决定下一步 | 我可以行动了 | 把最值得做的动作放成主 CTA +6 | 进入 Team Builder/Studio | 我要继续调整它 | 保持当前团队上下文,不制造“跳去另一个工具”的割裂感 +``` + +约束如下: + +- 首次进入团队详情时,默认情绪目标应是“我看懂了”,而不是“我要先学会怎么操作这个页面” +- 当页面存在阻塞或异常时,情绪目标应从“理解团队”切换为“立即知道该处理哪里” +- 只有在用户明确进入高级编辑后,产品才把主语从“运行中的团队”切换到“被配置的团队” + +## 11. 页面需求(V1) + +### 11.1 Teams 首页 + +#### 产品目标 + +让用户先进入“团队”语境,而不是技术模块语境。 + +#### V1 定义 + +由于当前未确认存在稳定 `listScopes()` 正式主链,V1 对 `Teams 首页` 采用**单态策略**: + +- 默认固定为“当前团队首页 / 当前 scope 工作台” +- 第一屏的目标不是盘点有多少团队,而是让用户立刻进入一个可理解的团队上下文 +- 如果未来确认存在稳定的 scope 列表接口: + - 增加 team switcher + - 或增加“全部团队”页 + - 但不替换 V1 默认首页 + +#### 页面应展示 + +按优先级从上到下展示: + +1. 当前团队身份 + - 团队名称 + - 当前职责 / mission + - 当前运行摘要 +2. 当前协作快照 + - 当前由谁接手 + - 最近一次 handoff + - 当前阻塞或风险 +3. 最近活动摘要 + - 最近 run + - 最近 change + - 最近异常 +4. 明确操作入口 + - 进入团队详情 + - 进入高级编辑 + - 必要时进入组建团队 + +#### 页面布局约束 + +- 默认首屏必须是一个完整构图,而不是一组均权卡片 +- `当前协作快照` 必须是首屏主锚点,不能被摘要卡片抢走注意力 +- `最近活动摘要` 应作为辅助信息轨道存在,而不是首页主舞台 +- 如果当前没有可进入的团队,空态主 CTA 必须是 `组建第一个团队` +- 空态下进入 Platform 只能作为次级出口,不能抢首页主语 + +#### 主 CTA 规则 + +`Teams Home` 的主 CTA 必须随当前团队状态动态变化: + +- 无当前团队:`组建第一个团队` +- 有明确阻塞 / 异常:`处理当前阻塞` +- 团队处于正常运行:`查看当前团队` + +约束如下: + +- 不允许把 `打开 Team Builder` 作为所有状态下的固定主 CTA +- `高级编辑` 默认只能是次级动作,除非页面处于纯空态 +- 主 CTA 必须帮助用户减少判断成本,而不是把“接下来做什么”重新丢回给用户 + +#### 页面不应默认承诺 + +- 多团队真实列表 +- 团队级全局 KPI 聚合 + +### 11.2 团队详情 / 统一工作台 + +#### 页面定义 + +V1 的 `Team Detail` 明确定义为一个**统一工作台布局**,而不是一组并列的说明页、tab 页或摘要卡片集合。 + +这意味着: + +- 用户进入团队详情后,首先进入的是一个稳定的 workspace shell +- 成员、拓扑、事件流、集成都在这个 shell 内联动切换和展开 +- 不允许把团队详情实现成“Overview / Members / Topology / Activity”几页松散拼接 + +#### 页面目标 + +先让用户形成“这个团队现在正在做什么”的态势感知,再进入技术实现细节。 + +#### 默认布局 + +```text +Team Detail Workspace +├─ Team header +│ ├─ team identity +│ ├─ mission / serving summary +│ └─ primary / secondary actions +├─ Primary workspace +│ └─ collaboration canvas +├─ Activity rail +│ ├─ latest run +│ ├─ latest handoff +│ ├─ latest anomaly +│ └─ latest change +└─ Context inspector + ├─ selected member + ├─ selected integration + ├─ selected run + └─ advanced edit entry +``` + +#### 主锚点规则 + +- `collaboration canvas` 是团队详情的**唯一主锚点** +- 用户进入团队详情后,视线首先应落在: + - 当前由谁接手 + - 最近一次 handoff + - 当前阻塞 / 风险点 +- `activity rail` 的职责是解释“刚刚发生了什么” +- `context inspector` 的职责是解释“当前选中的对象到底是什么” +- 不允许把 `activity rail` 与 `collaboration canvas` 做成双主舞台并列竞争 + +#### 布局约束 + +- `collaboration canvas` 必须是主工作区,而不是附属图表 +- `activity rail` 必须长期可见,用来承载时间感和变化感 +- `context inspector` 负责承载技术细节,不得反客为主 +- 团队详情不能退化成一列摘要卡片 + 下方详情块的传统 dashboard 结构 +- 如果屏幕空间不足,应先压缩辅助面板,而不是削弱 `collaboration canvas` 的主舞台地位 + +#### 响应式规则 + +`Team Detail Workspace` 在不同 viewport 下都必须保持 **画布优先** 的主层级: + +- Desktop:`collaboration canvas + activity rail + context inspector` 三段式同时可见 +- Tablet:保留 `collaboration canvas` 为主列,`activity rail` 与 `context inspector` 合并为次级侧栏或可切换侧栏 +- Narrow screen / mobile:`Team header` 在上,`collaboration canvas` 紧随其后,底部固定使用 segmented panel 承载 `活动 / 详情` + +约束如下: + +- 不允许在窄屏时把页面直接退化成“从上到下全部堆叠”的阅读页 +- 不允许在窄屏时移除 `collaboration canvas` +- 屏幕变窄时,应优先折叠次级信息,而不是先牺牲主工作区 +- 不允许在窄屏时把辅助信息随机做成 drawer、popover 或临时浮层混用 + +#### 默认聚焦规则 + +用户首次进入 `Team Detail Workspace` 时,界面必须默认自动聚焦**当前活跃路径**,而不是让用户从空白状态自己选择: + +- 自动选中当前 owner 对应成员 +- 自动高亮最近一次 handoff 对应关系 +- 自动带出当前最相关的 run / activity + +设计目标是让用户第一眼就能回答: + +- 现在球在谁手里 +- 刚刚发生了什么 +- 当前哪里卡住了 + +不允许默认进入以下状态: + +- 只有一张未聚焦的大画布 +- 只有一组待选择的成员列表 +- 活动轨为空、需要用户先手动选 run 才有内容 + +#### 应包含 + +- 团队名称 +- 团队简介 / 当前职责 +- 团队成员摘要 +- 当前绑定能力摘要 +- 当前 revision / current serving 摘要 +- 最近运行或最近活动摘要 +- 进入高级编辑的入口 + +#### 应弱化 + +- workflow/script/gagent 的底层实现差异 + +#### 页面关系 + +- `概览` 不再是独立心智上的“第一页” +- 它只是统一工作台加载后的默认状态 +- `成员 / 事件拓扑 / 事件流 / 集成` 都是这个工作台中的不同观察面 +- `高级编辑` 是从工作台进入 Team Builder 的出口,不是平级产品页 + +### 11.3 团队详情 / 团队成员 + +#### 页面目标 + +把成员心智建立起来,但成员视图是统一工作台中的一个观察面,不是独立产品页。 + +#### 应包含 + +- 成员名称 +- 成员职责 +- 成员当前状态 +- 成员对应的实现类型 +- 成员映射到的 Governance Service ID + +#### 产品要求 + +实现类型可以展示,但不能作为主标题主语。 +- 成员选择后,应驱动同一工作台中的拓扑、事件流和 inspector 联动更新。 + +### 11.4 团队详情 / 事件拓扑 + +#### 页面目标 + +让用户看到“团队怎么协作”,而不是“系统里有哪些 Actor”。 + +#### 实际后端约束 + +当前真实能力更接近: + +- `actorId -> graph` + +而不是: + +- `scopeId -> full team topology` + +#### V1 实现要求 + +- 以“选中成员后查看拓扑”为准 +- 主图用成员节点和外部系统节点来讲故事 +- 技术字段放在侧栏或详情区 +- 拓扑视图默认承载在统一工作台的主工作区中,而不是独立的 full page 技术页 + +### 11.5 团队详情 / 事件流 + +#### 页面目标 + +让用户看到“最近发生了什么”。 + +#### 实际后端约束 + +当前真实能力更接近: + +- `scopeId + serviceId + runId -> audit` + +#### V1 实现要求 + +- 以“选择 service / run 后查看事件流”为准 +- 事件流默认应讲成团队活动,而不是原始 runtime log 面板 +- 事件流优先作为统一工作台的活动轨存在,而不是独立日志页 +- 如果需要进入更深的 run detail,应从活动轨进入,而不是让首页先退化成 run list +- 活动轨的默认文案和排序必须服务于 `collaboration canvas`,解释 handoff、阻塞、异常和变化 + +### 11.6 团队详情 / 集成 + +#### 页面目标 + +让用户理解这个团队接了哪些外部系统。 + +#### 页面语义 + +- 这里展示的是“集成 / 外部系统 / 连接能力” +- 不是默认展示为“团队成员” + +#### 数据策略 + +V1 只使用现有可读到的 scope binding / governance / studio 范围信息。 +如果某些 Connector 事实无法稳定读取,不强行伪造完整清单。 + +#### 布局要求 + +- 集成信息默认进入统一工作台的 inspector 或辅助面板 +- 不应抢占主工作区主语 + +### 11.7 团队详情 / 高级编辑 + +#### 页面目标 + +让 Studio 变成“编辑这个团队”的地方。 + +#### 产品要求 + +- 从团队详情进入 +- 带当前 `scopeId` +- 在 UI 上明确当前正在编辑哪个团队 +- 从语言上弱化“打开独立 Studio 工具”的感觉,强化“继续配置当前团队”的感觉 +- 作为统一工作台中的明确出口存在,不作为与 `成员 / 拓扑 / 活动` 并列的内容 tab + +### 11.8 交互状态与诚实性 + +#### 全局原则 + +V1 明确采用**诚实状态设计**: + +- 不把缺失数据包装成完整实时事实 +- 不把延迟状态包装成健康状态 +- 不把局部推断包装成全团队真实状态 +- 宁可显示 `partial / delayed / unavailable`,也不伪造“看起来完整”的团队页 + +#### Provenance 标签 + +用户层统一使用以下 provenance / freshness 标签: + +- `live`:该信息直接来自当前可用的实时或近实时能力 +- `delayed`:该信息存在刷新延迟,但仍可作为参考 +- `partial`:该信息只覆盖了团队事实的一部分 +- `unavailable`:该信息当前无法可靠读取 +- `seeded`:该信息是基于当前已有绑定、成员或配置推导出的初始视图,不代表完整实时事实 + +#### 使用规则 + +- provenance 标签必须出现在用户能看到的一级信息层,而不是只藏在 tooltip 里 +- `partial` 和 `seeded` 不能用绿色健康态样式伪装 +- `unavailable` 必须解释缺的是什么,不允许只写“加载失败” +- 页面即使处于 `partial`,也应保持主布局稳定,不因为局部缺失而整页塌陷 + +#### 状态矩阵 + +```text +FEATURE | LOADING | EMPTY | ERROR | SUCCESS | PARTIAL +-------------------------|---------------------------------|-----------------------------------------|--------------------------------------------|------------------------------------------|------------------------------------------------------- +Teams Home | 保留完整工作台骨架 skeleton | 无当前团队,主 CTA=组建第一个团队 | 无法解析当前团队,显示重试与 fallback 说明 | 当前团队工作台可见,主 CTA 随状态切换 | 只展示可确认的团队摘要,并标记 missing modules +Team Detail Workspace | 保留 header/canvas/rail 外壳 | 无可用团队上下文,说明 scope 缺失 | 团队详情加载失败,保留 shell 并显示错误区 | 统一工作台完整可见 | shell 保持稳定,局部面板以 provenance 标签降级 +Collaboration Canvas | 画布骨架 + 当前 owner 占位 | 无可展示协作关系,提示先选成员或无数据 | 拓扑读取失败,画布区显示明确错误原因 | 当前成员 / 外部系统关系可见 | 只展示 seeded/member-level 关系,并标记 partial +Activity Rail | 时间轴骨架 + 最近事件占位 | 暂无最近活动,给出运行入口或解释 | audit / run 读取失败,活动轨显示错误状态 | 最近 handoff/异常/变化按时间顺序可见 | 仅展示可读到的 run 或 change,并标记 delayed/partial +Integrations Inspector | 集成条目占位 | 当前未连接外部系统 | 集成信息读取失败 | 已连接系统与连接能力可见 | 仅显示可确认集成,未确认部分标记 unavailable +Advanced Edit Handoff | 按钮 resolving 中 | 当前团队不可编辑,解释权限或上下文缺失 | Studio 跳转失败,保留当前页并给出重试 | 成功带着 scopeId 进入 Team Builder | 仅带部分上下文进入 Studio,并明确提示缺失信息 +``` + +#### 页面级要求 + +- `Teams Home` 必须优先稳住“当前团队工作台”布局,即使只有部分数据可读 +- `Teams Home` 处于 empty 时,必须明确说明“当前没有可进入团队”,并把 Team Builder 作为第一行动 +- `Teams Home` 处于 success / partial 时,主 CTA 必须优先指向当前最值得处理的动作,而不是默认把用户送去编辑器 +- `Team Detail Workspace` 在 `partial` 状态下仍保持 `collaboration canvas + activity rail + inspector` 三段式结构 +- `Team Detail Workspace` 进入 success / partial 时,必须先自动聚焦当前活跃路径,而不是让用户先做筛选 +- `Collaboration Canvas` 若只能展示成员级或 seeded 关系,必须明确告诉用户不是 full team topology +- `Activity Rail` 若只能展示 service/run 级事实,必须明确告诉用户这是局部活动视图 +- `Advanced Edit` 若上下文不完整,也不能悄悄跳成无上下文 Studio + +#### 文案原则 + +- 优先说明“现在能确定什么” +- 然后说明“还缺什么” +- 最后给出“下一步可以做什么” +- 避免使用会掩盖不确定性的文案,例如: + - healthy + - all synced + - fully connected + - no issues + +#### 关键运行证明模块 + +V1 的团队工作台不能只停留在“看得懂团队”,还必须明确补上 **runtime truth / control-plane proof** 模块。 + +至少包含以下 4 个: + +1. `Run Compare / Change Diff` + - 目标:让技术 owner 能快速看出“这次为什么和上次成功运行不一样” + - 位置:`Team Activity / Run Detail` + - 规则:默认比较当前运行与最近一次可作为 baseline 的成功运行 + - 诚实性要求:如果没有可比较 baseline,必须明确显示 `no successful baseline yet / compare unavailable` + +2. `Human Escalation Playback` + - 目标:证明系统真的支持 human-in-the-loop,而不是只展示理想化自动流 + - 位置:`Team Activity / Run Detail` + - 规则:至少能看出哪里阻塞、在等谁、何时恢复、恢复后如何回到主流程 + - 不纳入:独立 human inbox / work queue + +3. `Health / Trust Rail` + - 目标:让第一次打开页面的人在 30 秒内知道这个团队现在是否健康、是否可相信 + - 位置:`Team Detail Header` 或持续可见的 side rail + - 最少回答: + - 现在是否 healthy / blocked / degraded + - 是否存在 human override + - 当前是否 risky to change + - 诚实性要求:关键事实 unknown / delayed 时只能降级为 `attention / degraded`,不能显示为 `healthy` + +4. `Governance Snapshot` + - 目标:让 champion 和 buyer 能快速回答“这个团队能不能放心上线 / 试点” + - 位置:`Team Detail` 中共享摘要模块 + - 最少回答: + - 谁在 serving + - 最近改了什么 + - 是否可追踪 / 可审计 + - 是否存在 known fallback 或 prior good state + - 不纳入:完整 governance console replacement + +这些模块共享同一组 runtime truth,不能各自拼一套事实口径。 + +### 11.9 Platform 页面 + +Platform 页面的目标不是重做,而是重新归位。 + +V1 原则: + +- Governance、Services、Topology、Deployments 保留 +- 保持技术密度 +- 明确它们属于平台治理层 +- 不再承担产品首页职责 + +### 11.10 首个验证工作流 + +如果本轮除了前端重构之外,还需要为后续产品验证准备一个最小演示主线,推荐默认采用: + +- `Support Escalation Triage` + +推荐原因: + +- 成员职责容易理解 +- handoff 明确 +- 失败点明确 +- 事件流和拓扑都容易讲清楚 +- 很适合验证“Team-first 是否真的让复杂系统更易懂” + +建议的团队成员: + +- Intake Member:识别问题类型、优先级、意图 +- Knowledge Member:生成基于知识库的候选答复 +- Risk Review Member:检查退款、SLA、合规或策略风险 +- Escalation Member:决定自动回复还是转人工 + +团队页至少应能讲清: + +- 当前由谁接手 +- 上一步 handoff 发生在哪里 +- 当前哪里失败或阻塞 +- 本次运行对应哪个 workflow / script / config version +- 与上一次成功运行相比,哪里发生了变化 + +这些信息中,V1 默认应优先通过 `collaboration canvas + activity rail` 的组合被看懂, +而不是要求用户先读一组摘要卡片或先切到 run 日志页。 + +## 12. 数据与能力映射(基于当前真实代码) + +| 产品需求 | 当前能力 | 现状判断 | +|---|---|---| +| 当前团队上下文 | `studioApi.getAuthSession()` + scope 解析 | 已有 | +| scope 概览 | `studioApi.getScopeBinding(scopeId)` | 已有 | +| scope workflows | `scopesApi.listWorkflows(scopeId)` | 已有 | +| scope scripts | `scopesApi.listScripts(scopeId)` | 已有 | +| scope services | `servicesApi.listServices({ tenantId: scopeId })` | 已有 | +| 团队成员 | `runtimeGAgentApi.listActors(scopeId)` | 已有 | +| 事件拓扑 | `runtimeActorsApi.getActorGraphEnriched(actorId)` | 已有,但成员级 | +| service runs | `scopeRuntimeApi.listServiceRuns(scopeId, serviceId)` | 已有 | +| 事件流 | `scopeRuntimeApi.getServiceRunAudit(scopeId, serviceId, runId)` | 已有,但 service/run 级 | +| Studio 能力 | `/api/editor` `/api/connectors` `/api/roles` `/api/workspace` 等 | 已有 | +| 多团队列表 | `scopesApi.listScopes()` | 当前未确认 | + +补充策略: + +- V1 建议采用 `partially live` 模式 +- 即:尽量直接复用已有 runtime / topology / audit / Studio 能力 +- 允许前端在 Team 语义层做轻量组合与包装 +- 如果某处 Team 抽象被后端粒度卡住,优先收窄到“一个 workflow / 一个 team page”而不是扩 backend scope + +### 12.1 执行级收口 + +为避免主 PRD 停留在产品叙事层,V1 还需锁定以下执行约束: + +- 团队页所有 `service / actor / run / version / health / fallback / human-override` 派生事实,统一经由共享 `team runtime lens` 组合。 +- `team runtime lens` 第一版放在 Team 页面本地编排层中实现;只有稳定的纯派生逻辑和 route/type helper 才进入 shared 层。 +- Team-first 页面禁止建立第二套 runtime model;也禁止靠 query-time replay、页面私有缓存或影子事实源去“补真相”。 +- `/teams` 在 V1 不是“全部团队列表”,而是“解析当前认证团队上下文并跳转到 `/teams/:scopeId`”的入口;只有确认存在稳定 `listScopes()` 后,才考虑真实 team switcher。 +- `Studio` 团队深链必须显式带 `scopeId`;只有缺失时才允许退回 app context / auth session fallback。 +- Team-first 默认首页和导航迁移必须走前端 feature flag,先 internal / demo,再切默认;旧工程页在迁移期继续保留为 hidden route 或 redirect,不再承担首页主语。 + +## 13. 术语规范 + +### 13.1 用户层默认术语 + +- Team / 团队 +- Team Member / 团队成员 +- Team Activity / 团队活动 +- Event Stream / 事件流 +- Team Builder / 团队构建器 +- Integrations / 集成 + +### 13.2 仅在高级信息区出现的术语 + +- workflow +- script +- static gagent +- revision +- endpoint +- actor id +- type url + +### 13.3 术语处理原则 + +- 用户层先用业务心智 +- 技术术语不消失,但降到二级信息 +- 不在首页和一级导航中强推实现名词 +- Team 是默认主语,但真正证明价值的仍然是可观察的 control-plane 结果 + +### 13.4 视觉方向与反 slop 约束 + +V1 明确采用 **温暖的 operator control-plane** 方向,而不是通用 SaaS 后台风格。 + +该方向基于当前 `console-web` 已存在的视觉基础继续演进: + +- 字体基线:`AlibabaSans` +- 底色方向:暖白 / 纸面感背景 +- 主文字色:深墨色 +- 强调色:克制蓝色用于主操作和当前焦点 +- 辅助强调:铜棕色用于次级标签、来源、说明与状态分层 + +#### 页面气质要求 + +- 看起来像“有人在认真值守的工作台”,不是冷冰冰监控墙 +- 看起来像“一个有判断力的控制台”,不是营销页,也不是模板化后台 +- 页面记忆点来自: + - 协作画布 + - 活动轨 + - typography 层级 + - 状态诚实性 +- 页面记忆点不能来自: + - 大量阴影 + - 装饰性渐变 + - 圆角卡片堆叠 + - 图标色块阵列 + +#### 组件与表面规则 + +- 卡片只有在“卡片本身就是交互单元”时才允许存在 +- 页面骨架、首屏和主工作区不得默认用均权卡片拼接 +- 阴影只能作为轻微分层辅助手段,不能承担主要层级表达 +- 主层级必须依赖: + - 布局 + - 留白 + - 字号 / 字重 + - 边框与底色对比 + +#### 明确禁止 + +- 紫白渐变、蓝紫渐变、霓虹 AI 风格背景 +- 三栏功能卡片首屏 +- 居中大标题 + 居中说明 + 居中按钮的营销式 hero 套板 +- 所有元素使用同一套大圆角 +- 用阴影和磨砂感假装“高级” +- 用成排彩色 icon circle 充当信息层级 + +#### 实现约束 + +- 必须抽取稳定的 color / typography / spacing tokens +- 首屏主视觉必须围绕 `collaboration canvas` 组织,而不是围绕卡片摘要组织 +- 去掉装饰性阴影后,页面仍必须保持 premium 感和清晰层级 +- 新增界面默认先问:如果删掉 30% 的装饰,这页还成立吗 + +#### 动效预算 + +V1 明确采用**最小 motion budget**,只允许少量对理解当前状态有帮助的动效: + +- 当前 handoff 边高亮 / 脉冲 +- 活动轨中新事件的 reveal +- 从团队工作台进入 Team Builder 时的面板过渡 + +约束如下: + +- 除上述场景外,默认静态 +- 不允许加入装饰性漂浮、呼吸光效、循环背景动画 +- 动效的职责只能是: + - 强调当前焦点 + - 解释状态变化 + - 让上下文切换更连贯 +- 动效不能承担品牌表达主任务,也不能掩盖信息层级不足 + +### 13.5 最小设计系统 + +由于当前仓库不存在独立 `DESIGN.md`,V1 必须在本 PRD 内先定义一套**最小设计系统**,避免实现阶段回落到默认样式。 + +#### 颜色角色 + +```text +ROLE | PURPOSE +----------------------|------------------------------------------------- +Paper / Warm Base | 页面背景、工作台基底、弱分隔底色 +Ink / Primary Text | 主标题、正文、关键数据 +Muted / Secondary Text| 辅助说明、时间、来源、次级状态说明 +Action Blue | 当前焦点、主按钮、当前选中、关键入口 +Copper Accent | 来源标签、上下文说明、次级强调、辅助分层 +Success Green | 明确成功且已确认的 live 状态 +Warn Amber | delayed / partial / risk / attention +Danger Red | blocked / failed / unavailable +``` + +约束: + +- `Warn Amber` 优先用于 `partial / delayed / risk` +- `Success Green` 不能用于 seeded、partial、unknown 状态 +- 状态色必须服务于事实诚实性,不能只为了“页面更有颜色” + +#### 字体层级 + +```text +LEVEL | USAGE +----------------------|------------------------------------------------- +Display / Page Title | Teams Home 与 Team Detail 顶部主标题 +Section Heading | collaboration canvas / activity rail / inspector 标题 +Body / Primary Text | 正文、状态说明、成员职责 +Meta / Label | provenance、来源、更新时间、技术副信息 +Mono / Code Accent | revision、serviceId、actorId、runId 等技术字段 +``` + +约束: + +- 主标题必须依赖字重和留白建立层级,不能靠放大阴影或彩色背景取胜 +- meta 信息必须明显比主正文弱,但仍可读 +- 技术字段只在 inspector 或高级信息区进入 monospace + +#### 间距与布局刻度 + +```text +TOKEN | USAGE +------------|---------------------------------------------- +space-4 | 紧密标签、icon 与文本微间距 +space-8 | 小型控件、rail 内部条目间距 +space-12 | inspector 行间距、表单局部节奏 +space-16 | 常规面板内边距、区块节奏 +space-24 | 主工作区内部模块间距 +space-32 | 页面级主要区块切分 +space-40+ | 首屏呼吸感、标题区与主舞台分隔 +``` + +约束: + +- 主工作台依赖大块留白和布局分区,不依赖卡片堆叠 +- `activity rail` 的节奏应比主画布更紧 +- `inspector` 的节奏应比活动轨更稳、更规整 + +#### 边框、表面与圆角 + +- 首选轻边框和底色差做表面层级 +- 阴影只能做弱辅助手段 +- 圆角使用应克制,避免所有元素同一套大圆角 +- 主画布、活动轨、inspector 可以是不同表面层,但必须看起来属于同一个系统 + +#### 组件级规则 + +- `collaboration canvas`:主舞台组件,优先保证可读布局,不追求装饰质感 +- `activity rail`:次级但长期可见,强调时间序和变化,不堆彩色 tag +- `context inspector`:信息密度最高,但视觉存在感最低 +- `header select / scope switcher`:沿用当前暖白 + 铜棕 + 蓝色焦点的语言,不改成冷灰 dropdown 模板 +- `status tag`:必须与 provenance 语义绑定,不能只表达情绪色 + +### 13.6 响应式与可访问性基线 + +#### 响应式断点原则 + +```text +VIEWPORT | PRIORITY +---------------------|-------------------------------------------------- +Desktop | 保持三段式完整工作台 +Tablet | 保住画布主舞台,压缩或合并次级信息 +Narrow screen/mobile | 先保 header + canvas,再用 segmented panel 承载活动与详情 +``` + +约束: + +- `Teams Home` 与 `Team Detail Workspace` 都必须先保住主舞台,再处理次级内容 +- 不接受“移动端就是桌面端全部往下堆”的默认响应式 +- 首页和团队详情都必须在 375px 宽度下仍能一眼看出当前团队、当前状态和主 CTA + +#### 移动端辅助面板规则 + +- 默认采用底部 `segmented panel` +- 仅保留两个入口: + - `活动` + - `详情` +- `活动` 承载 activity rail +- `详情` 承载 context inspector 与集成、技术细节 +- 不新增第三个“设置/更多”泛化入口,避免把移动端做成另一套 IA + +#### 键盘与焦点 + +- 所有一级操作必须可通过键盘到达 +- 焦点顺序必须遵循: + - `header` + - `primary CTA` + - `collaboration canvas` + - `activity rail` + - `context inspector` +- 焦点态必须清晰可见,不能只依赖颜色细微变化 +- 画布中的可交互节点、关系和过滤控件必须可通过键盘聚焦和切换 + +#### 屏幕阅读器与语义 + +- 页面必须有明确 landmark: + - header + - main + - complementary + - navigation +- `collaboration canvas` 需要提供文字摘要,说明: + - 当前 owner + - 最近 handoff + - 当前阻塞 / 风险 +- provenance、状态、错误和空态不能只用颜色表达,必须有文本语义 + +#### 触控与点击目标 + +- 触控设备上的主要点击目标最小尺寸为 `44px` +- rail 条目、member 节点入口、drawer trigger、segmented control 不得做成难点中的小字链接 +- 任何关键操作不得只依赖 hover 才能发现 + +#### 对比度与可读性 + +- 主正文、状态文案、meta 信息都必须满足基本对比度要求 +- 铜棕和辅助色只能在可读性达标时使用,不能为了“温暖感”牺牲可读性 +- provenance 标签、warn / danger 状态在暖白底上必须保持足够对比 + +### 13.7 当前可复用设计基础 + +本轮实现应明确复用以下现有设计基础,而不是重新发明一套样式语言: + +- [global.less](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/global.less) + - 已有 `AlibabaSans` + - 已有暖白基底与深墨文字方向 +- [AevatarHeaderSelect.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/shared/ui/AevatarHeaderSelect.tsx) + - 已有暖白 + 铜棕 + 蓝色焦点的控件语言 +- [StudioShell.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/studio/components/StudioShell.tsx) + - 已有工作台式导航与壳子思路 +- [overview.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/scopes/overview.tsx) + - 已有 scope 上下文与数据组合基础,可作为 Team Detail 的能力底座 + +复用原则: + +- 复用语言,不复用旧的信息架构 +- 复用组件气质,不复用“工程对象页”心智 +- 复用现有暖白/铜棕/蓝色系统,不回退到冷灰 Ant 默认风格 + +### 13.8 本轮明确不纳入设计范围 + +以下内容在本轮设计中明确不做: + +- 营销式 hero 首页 +- 新品牌系统或整站 rebrand +- 装饰性 3D、霓虹、玻璃拟态视觉实验 +- 因移动端而重做第二套信息架构 +- 为缺失后端能力发明“看起来完整”的团队总图 +- 用额外卡片网格包装旧技术页面,假装这就是 Team-first + +## 14. 前端交付策略 + +对应的文件级实施拆解见: + +- [2026-04-09-aevatar-console-web-frontend-implementation-checklist.md](./2026-04-09-aevatar-console-web-frontend-implementation-checklist.md) + +### 14.1 P0:先建立正确主语 + +- 前端 feature flag +- 重组路由 +- 重组菜单 +- 调整首页入口 +- 隐藏工程术语页面入口 + +### 14.2 P1:做团队详情外壳 + +- 统一工作台壳子 +- 团队成员观察面 +- `Health / Trust Rail` +- 高级编辑入口 + +### 14.3 P2:接入事件拓扑和事件流 + +- 基于现有 actor/service/run 粒度实现 +- 不假装有全团队聚合接口 +- 将拓扑和活动接入统一工作台,而不是新增分裂页面 +- `Run Compare / Change Diff` +- `Human Escalation Playback` + +### 14.4 P3:补 Platform 分组与旧路由收口 + +- `Governance Snapshot` +- Governance / Services / Topology / Deployments 分层 +- redirects +- 文案统一 + +### 14.5 条件项 + +如果后续确认存在稳定 `listScopes()`,再新增: + +- 团队切换器 +- 或“全部团队”页 + +但不改变 V1 的默认首页主语。 + +### 14.6 Phase 1 验证计划 + +如果本轮要承担“为后续产品对外验证准备 demo”的任务,建议采用两周验证节奏: + +1. 第 1 周 + - 做出一个 Team-first 原型 + - 范围收敛在单一 workflow + - 至少包含: + - Team overview + - Team activity + - Team collaboration map + - version / change context + - Health / Trust Rail + - 首次打开默认锚定到 `Support Escalation Triage` + - 允许采用 `partially live + seeded example history` 的 demo 方式,但必须清楚标注 provenance +2. 第 2 周 + - 用该原型跑 5 次左右的访谈 / 演示 + - 访谈对象优先覆盖: + - 技术 PM / 自动化负责人 + - 平台负责人 / 工程负责人 / CTO + +判定标准: + +- 如果对方只觉得“好看”“更易懂”,但说不出它替代什么旧方案,说明 Team-first 过度停留在叙事层 +- 如果技术用户能理解价值,但买方不感到 urgency,说明对外话术还需要更靠近 control-plane +- 如果没有人能清楚说出它替代了哪段当前的脚本 / dashboard / 人肉调度链路,说明 wedge 还不够锋利 + +### 14.7 Phase 1 Gate + +只有在以下条件同时成立时,才建议把本轮方向继续放大: + +1. 用户能复述: + - 谁在处理当前步骤 + - 最近一次 handoff 在哪里发生 + - 当前失败或阻塞点是什么 +2. 至少有 2 个目标用户明确说出: + - 这能替代他们当前的一部分脚本 / dashboard / 人工调度混搭方案 +3. 至少有 1 位平台或工程负责人对 pilot、治理、上线方式产生继续讨论意愿 + +## 15. 成功标准 + +本轮成功的标志是: + +1. 新用户能快速理解“这是 AI 团队控制台”。 +2. 用户能自然走完“看团队 -> 看活动 -> 进 Studio 配团队”的路径。 +3. 用户层主界面不再被工程术语主导。 +4. Platform 层仍可满足管理员工作,但不再污染首页主叙事。 +5. 全部改动在前端内完成,不依赖后端新增能力。 +6. 团队页不只“更易懂”,还能够支撑对运行、协作、事件和版本变化的真实理解。 + +## 16. 风险与待定问题 + +### 16.1 已知风险 + +1. 多团队列表 / team switcher 依赖未确认的 `listScopes()`。 +2. 团队事件拓扑在技术上仍是成员级,不是 scope 总图。 +3. 团队事件流在技术上仍是 service/run 级,不是 team 总流。 +4. Connector 的展示完整度受现有可读信息限制。 + +### 16.2 待定但不阻塞本轮的问题 + +1. 未来 Team 是否要升级为“组织单元”而不只是“团队” +2. Team 是否要支持嵌套与递归展示 +3. Connector 是否在某些场景中需要“成员化”呈现 + +这些都属于后续产品演进问题,不阻塞本轮前端重构。 diff --git a/docs/design/2026-04-09-aevatar-console-web-frontend-implementation-checklist.md b/docs/design/2026-04-09-aevatar-console-web-frontend-implementation-checklist.md new file mode 100644 index 00000000..4ef8333d --- /dev/null +++ b/docs/design/2026-04-09-aevatar-console-web-frontend-implementation-checklist.md @@ -0,0 +1,205 @@ +# 2026-04-09 Aevatar Console Web Frontend Implementation Checklist + +## 1. 目标 + +将 [2026-04-08-aevatar-product-definition.md](./2026-04-08-aevatar-product-definition.md) 落成一份可直接执行的前端实施清单,确保 `console-web` 从当前的 `Projects / Platform / Studio` 组合入口,收敛到: + +- `Team-first` 的默认入口 +- `control-plane` 价值不丢失的团队详情工作台 +- `Studio` 从团队上下文进入,而不是孤立入口 +- 全程复用现有后端能力,不发明第二套 runtime truth + +## 2. 本轮边界 + +- 只做 `frontend-only` 交付,不改后端 contract。 +- 不引入第二套 runtime 模型,所有团队事实统一来自同一个 `team runtime lens`。 +- 不做真实多团队列表;在没有 `listScopes()` 或等价接口前,`/teams` 只负责解析当前团队上下文并跳到 `/teams/:scopeId`。 +- 不做 dedicated human inbox / work queue。 +- 不做 full historical replay editor。 +- 不做 organization-level health center。 + +## 3. 设计与工程原则 + +- 外部叙事使用 `Team-first`,价值证明仍然是 `运行 / 协作 / 日志 / 版本`。 +- `collaboration canvas` 是团队详情的主工作区,不能退化成普通 dashboard。 +- `unknown / delayed / partial` 必须诚实暴露,不能伪装成 healthy。 +- `Studio` 深链必须带显式 `scopeId`;只有在缺失时才允许退回 app context / auth session fallback。 +- 旧入口需要保留过渡路径,但新默认首页必须可通过 feature flag 切到 `Teams`。 + +## 4. 交付顺序 + +### Wave 0: 开关与默认入口 + +- [ ] 在 [config/config.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/config/config.ts) 暴露 `process.env.AEVATAR_CONSOLE_TEAM_FIRST_ENABLED`。 +- [ ] 新增 [src/shared/config/consoleFeatures.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/shared/config/consoleFeatures.ts),集中解析 Team-first 开关,避免在页面里散落读 env。 +- [ ] 修改 [src/shared/navigation/consoleHome.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/shared/navigation/consoleHome.ts),让默认首页根据 feature flag 在 `/teams` 与 `/scopes/overview` 之间切换。 +- [ ] 修改 [src/app.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/app.tsx),让 `DEFAULT_PROTECTED_ROUTE`、菜单高亮与首页回跳逻辑统一使用新的 home route。 + +### Wave 1: 路由与团队入口 + +- [ ] 修改 [config/routes.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/config/routes.ts): + - 新增 `/teams` + - 新增 `/teams/:scopeId` + - 保留 `/overview`、`/scopes`、`/scopes/overview` 的过渡跳转 + - 根路由 `/` 在 flag 打开时默认跳到 `/teams` +- [ ] 新增 [src/pages/teams/index.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/teams/index.tsx),仅负责“解析当前团队上下文并重定向”。 +- [ ] 新增 [src/pages/teams/detail.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/teams/detail.tsx),承载团队详情统一工作台。 +- [ ] 约束 `/teams` 行为: + - 有 `scopeId` 时直接跳 `/teams/:scopeId` + - 无 `scopeId` 且能从 auth/app context 解析时自动跳转 + - 两者都没有时进入诚实 empty / blocked state,不伪造 team list + +### Wave 2: Scope 上下文与共享 helper 收口 + +- [ ] 把 [src/pages/scopes/components/resolvedScope.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/scopes/components/resolvedScope.ts) 提升到共享层,建议新位置: + - [src/shared/scope/context.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/shared/scope/context.ts) +- [ ] 把 [src/pages/scopes/components/scopeQuery.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/scopes/components/scopeQuery.ts) 提升到共享导航层,建议新位置: + - [src/shared/navigation/scopeRoutes.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/shared/navigation/scopeRoutes.ts) +- [ ] 让 `Teams`、`Scopes Overview`、`Studio` 使用同一套 `scopeId` 解析与 URL 构建 helper,避免重复 query 语义。 +- [ ] 清理页内重复的 binding / revision 展示逻辑;若现有 [src/shared/studio/models.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/shared/studio/models.ts) 不够用,则新增共享 summary helper,不要把 Team 页面逻辑塞回 `scopes` 私有目录。 + +### Wave 3: Team Runtime Lens + +- [ ] 在 [src/pages/teams/](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/teams/) 下新增 `team runtime lens` 组合层,第一版保持 page-local,不要一开始就抽成全局平台层。 +- [ ] `team runtime lens` 必须统一产出这些前端派生事实: + - 当前 `scopeId` + - 当前绑定 revision / target / actor + - 服务与 deployment 基本状态 + - 最近 run / 当前活跃 run / attention state + - compare 所需的当前版本与最近成功版本 + - governance snapshot 所需的最小可信事实 +- [ ] `team runtime lens` 只做组合和派生,不承担持久状态,不做 query-time replay,不建立进程内事实缓存。 +- [ ] `team runtime lens` 先加载核心团队上下文,再按 tab / selection 懒加载 graph、compare、playback 所需数据。 + +建议首批实现文件: + +- [src/pages/teams/runtime/useTeamRuntimeLens.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/teams/runtime/useTeamRuntimeLens.ts) +- [src/pages/teams/runtime/teamRuntimeLens.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.ts) +- [src/pages/teams/runtime/teamRuntimeLens.test.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/teams/runtime/teamRuntimeLens.test.ts) + +### Wave 4: 团队详情统一工作台 + +- [ ] 以 [src/pages/scopes/overview.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/scopes/overview.tsx) 的 `page shell + inspector + context drawer` 结构为复用基座,而不是从零另起一套页面骨架。 +- [ ] 以 [src/pages/studio/components/StudioShell.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/studio/components/StudioShell.tsx) 的工作台密度为参考,确保团队详情不是传统卡片堆叠页。 +- [ ] 在团队详情中落地这几个固定模块: + - `Team header` + - `Collaboration canvas` + - `Activity rail` + - `Context inspector` + - `Health / Trust Rail` + - `Governance Snapshot` +- [ ] 默认首屏自动聚焦当前活跃路径;没有活跃 run 时回到当前 serving binding。 +- [ ] 窄屏保持 `canvas first`,活动与详情进入 segmented panel / drawer,不允许页面退化成纯纵向长列表。 + +### Wave 5: Run Compare / Change Diff + +- [ ] 在团队 activity 中落地 `Run Compare / Change Diff`,让团队页具备调试价值,而不只是讲故事。 +- [ ] compare 默认比较: + - 当前 serving / active run + - 最近一次成功且可比较的 run +- [ ] 若缺 comparator,必须诚实展示 `not enough history`,不能伪造 diff。 +- [ ] compare 模块的数据只能来自共享 `team runtime lens` 的衍生事实或其受控懒加载,不允许页面各自重复拼查询。 + +### Wave 6: Health / Trust Rail 与 Governance Snapshot + +- [ ] 团队详情顶部或右侧放置紧凑的 `Health / Trust Rail`,至少能回答: + - 当前是否健康 + - 当前是否 degraded / blocked + - 当前是否被 human override + - 当前是否 risky to change +- [ ] 加入轻量 `Governance Snapshot`,回答四个买家级问题: + - Who is serving now + - What changed recently + - Can a human intervene + - Is there enough audit trail to trust this team +- [ ] `Health / Trust Rail` 与 `Governance Snapshot` 共享同一 runtime truth,不允许各自产生一套健康口径。 + +### Wave 7: Studio 团队上下文深链 + +- [ ] 修改 [src/shared/studio/navigation.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/shared/studio/navigation.ts),为 `buildStudioRoute()` 及其上层 helper 增加显式 `scopeId`。 +- [ ] 修改 [src/pages/studio/index.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/studio/index.tsx): + - 优先读取 route 上的 `scopeId` + - route 无 `scopeId` 时,再退回 app context / auth session + - 保持现有 query 语义:`workflow / script / execution / tab / draft / legacy` +- [ ] 团队页、activity、run detail、binding detail 进入 `Studio` 时,全部使用显式 `scopeId` 深链。 +- [ ] 不把 `scopeId` 可见就当成授权成立;授权仍由既有 auth / backend 返回值决定。 + +### Wave 8: 旧页面与过渡清理 + +- [ ] 删除或下线路由孤儿页 [src/pages/overview/index.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/overview/index.tsx),避免继续存在第二套首页叙事。 +- [ ] 评估 [src/pages/overview/useOverviewData.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/overview/useOverviewData.ts) 是否还有复用价值: + - 有复用价值则迁移到共享层 + - 无复用价值则一并删除 +- [ ] 在过渡期保留 `/scopes/overview`,但它降级为 legacy project workspace,不再承担默认首页角色。 + +## 5. 数据来源对齐 + +`Team runtime lens` 首批允许复用的真实数据来源: + +- `studioApi.getAuthSession()` +- `studioApi.getScopeBinding(scopeId)` +- `scopesApi.listWorkflows(scopeId)` +- `scopesApi.listScripts(scopeId)` +- `servicesApi.listServices({ tenantId: scopeId, ... })` +- 现有 runtime actors / runs API +- 现有 governance / binding / deployment 页面已经读取到的事实 + +本轮禁止: + +- 在 Team 页面里临时回放 event store 拼状态 +- 为 Team 页面单独维护 service/run/binding 的影子缓存真相 +- 因为数据不全就伪造 `healthy` 或 `fully live` + +## 6. 测试清单 + +- [ ] 为新开关补测试: + - [src/shared/config/consoleFeatures.test.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/shared/config/consoleFeatures.test.ts) + - [src/shared/navigation/consoleHome.test.ts](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/shared/navigation/consoleHome.test.ts) +- [ ] 为新路由与跳转补测试: + - [src/pages/teams/index.test.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/teams/index.test.tsx) + - [src/pages/teams/detail.test.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/teams/detail.test.tsx) +- [ ] 更新 [src/pages/studio/index.test.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/studio/index.test.tsx),覆盖 route `scopeId` 优先级。 +- [ ] 更新 [src/pages/scopes/overview.test.tsx](/Users/potter/Desktop/sbt_project/aevatar/apps/aevatar-console-web/src/pages/scopes/overview.test.tsx),保证 legacy 路径在过渡期仍然可用。 +- [ ] 为 `team runtime lens` 的派生逻辑补单测,重点覆盖: + - missing data + - partial data + - degraded / blocked + - compare baseline 缺失 + - human override + +## 7. 验收标准 + +达到以下结果后,Wave 1 可视为完成: + +- 打开 flag 后,登录后的默认入口进入 `Teams`,而不是 `Projects`。 +- `/teams` 能稳定解析当前团队并进入 `/teams/:scopeId`。 +- 团队详情能在不改后端 contract 的前提下显示: + - collaboration canvas + - activity rail + - health / trust rail + - governance snapshot + - Studio deep link +- 团队 activity 支持最小可用的 run compare。 +- `Studio` 团队深链带 `scopeId` 后,仍然保持现有 `workflow / script / execution` 打开能力。 +- 关闭 flag 后,现有 `/scopes/overview` 仍保持旧行为。 + +## 8. Rollout 策略 + +- 阶段 1:flag 默认关闭,仅 internal / demo 环境开启。 +- 阶段 2:团队页可 dogfood 后,切换默认首页到 `/teams`。 +- 阶段 3:确认旧首页不再承担主叙事后,再清理 legacy 文案与孤儿入口。 + +## 9. 推荐实现顺序 + +建议严格按以下顺序开工: + +1. feature flag + home route +2. `/teams` 与 `/teams/:scopeId` +3. scope helper 抽离 +4. `team runtime lens` +5. team detail shell +6. run compare +7. health / trust rail +8. governance snapshot +9. Studio `scopeId` deep link +10. legacy cleanup + tests diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Actors/OrleansActorRuntime.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Actors/OrleansActorRuntime.cs index e8e462aa..390cb352 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Actors/OrleansActorRuntime.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Actors/OrleansActorRuntime.cs @@ -56,6 +56,7 @@ public async Task DestroyAsync(string id, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); await _callbackScheduler.PurgeActorAsync(id, ct); + using var reentrancyScope = RequestContext.AllowCallChainReentrancy(); var grain = _grainFactory.GetGrain(id); var parentId = await grain.GetParentAsync(); @@ -113,6 +114,7 @@ await _streams.GetStream(parentId).UpsertRelayAsync( public async Task UnlinkAsync(string childId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + using var reentrancyScope = RequestContext.AllowCallChainReentrancy(); var child = _grainFactory.GetGrain(childId); var parentId = await child.GetParentAsync(); if (!string.IsNullOrWhiteSpace(parentId)) diff --git a/src/Aevatar.Mainnet.Host.Api/README.md b/src/Aevatar.Mainnet.Host.Api/README.md index 62ea41dc..73f64247 100644 --- a/src/Aevatar.Mainnet.Host.Api/README.md +++ b/src/Aevatar.Mainnet.Host.Api/README.md @@ -53,6 +53,31 @@ export AEVATAR_Orleans__SiloPort=11111 export AEVATAR_Orleans__GatewayPort=30000 ``` +## 本地持久化开发模式(Orleans + Garnet) + +如果只是想避免本地 scope workflow / actor state 因后端重启而完全丢失,而当前机器又没有 Kafka / Elasticsearch / Neo4j,可以先用仓库内置的 `PersistentLocal` 环境: + +```bash +ASPNETCORE_ENVIRONMENT=PersistentLocal dotnet run --project src/Aevatar.Mainnet.Host.Api +``` + +该模式默认启用: + +- `ActorRuntime:Provider=Orleans` +- `ActorRuntime:OrleansStreamBackend=InMemory` +- `ActorRuntime:OrleansPersistenceBackend=Garnet` +- `Projection:Document:Providers:InMemory:Enabled=true` +- `Projection:Graph:Providers:InMemory:Enabled=true` + +前提: + +- 本机 `localhost:6379` 可用(Redis / Garnet 兼容连接) + +说明: + +- 该模式的目标是保住本地 actor 持久态与 workflow 存储回补能力,适合单机开发验证。 +- 它不是完整的 distributed / production profile;若需要 durable document / graph projection,仍应使用 `Distributed` 环境并启动 Kafka、Elasticsearch、Neo4j。 + ## 多机集群测试(Docker) 仓库提供的集群启动脚本会拉起 3 节点 Mainnet + Kafka + Garnet + Elasticsearch + Neo4j。 diff --git a/src/Aevatar.Mainnet.Host.Api/appsettings.PersistentLocal.json b/src/Aevatar.Mainnet.Host.Api/appsettings.PersistentLocal.json new file mode 100644 index 00000000..0af9da9b --- /dev/null +++ b/src/Aevatar.Mainnet.Host.Api/appsettings.PersistentLocal.json @@ -0,0 +1,50 @@ +{ + "ActorRuntime": { + "Provider": "Orleans", + "OrleansStreamBackend": "InMemory", + "OrleansStreamProviderName": "AevatarOrleansStreamProvider", + "OrleansActorEventNamespace": "aevatar.actor.events", + "OrleansPersistenceBackend": "Garnet", + "OrleansGarnetConnectionString": "localhost:6379" + }, + "Orleans": { + "ClusteringMode": "Localhost", + "ClusterId": "aevatar-mainnet-cluster", + "ServiceId": "aevatar-mainnet-host-api", + "SiloHost": "127.0.0.1", + "PrimarySiloEndpoint": "", + "SiloPort": 11111, + "GatewayPort": 30000, + "QueueCount": 8, + "QueueCacheSize": 4096 + }, + "Projection": { + "Document": { + "Providers": { + "Elasticsearch": { + "Enabled": false, + "Endpoints": [] + }, + "InMemory": { + "Enabled": true + } + } + }, + "Graph": { + "Providers": { + "Neo4j": { + "Enabled": false, + "Uri": "" + }, + "InMemory": { + "Enabled": true + } + } + }, + "Policies": { + "Environment": "Development", + "DenyInMemoryDocumentReadStore": false, + "DenyInMemoryGraphFactStore": false + } + } +} diff --git a/src/Aevatar.Studio.Application/AppScopedWorkflowService.cs b/src/Aevatar.Studio.Application/AppScopedWorkflowService.cs index 1d895a73..9600a744 100644 --- a/src/Aevatar.Studio.Application/AppScopedWorkflowService.cs +++ b/src/Aevatar.Studio.Application/AppScopedWorkflowService.cs @@ -64,10 +64,12 @@ public async Task> ListAsync( body: null, ct) ?? []; - return workflows + var summaries = workflows .OrderByDescending(static item => item.UpdatedAt) .Select(workflow => ToWorkflowSummary(normalizedScopeId, workflow)) .ToList(); + + return await MergeStoredWorkflowSummariesAsync(normalizedScopeId, summaries, ct); } public async Task GetAsync( @@ -81,59 +83,70 @@ public async Task> ListAsync( if (_workflowQueryPort != null && _workflowActorBindingReader != null) { var workflow = await _workflowQueryPort.GetByWorkflowIdAsync(normalizedScopeId, normalizedWorkflowId, ct); - if (workflow == null) - return null; - - var binding = string.IsNullOrWhiteSpace(workflow.ActorId) - ? null - : await _workflowActorBindingReader.GetAsync(workflow.ActorId, ct); - - var yaml = binding?.WorkflowYaml ?? string.Empty; - - // Fallback: if binding projection hasn't materialized the YAML yet, - // try the artifact store which is written synchronously during save. - if (string.IsNullOrWhiteSpace(yaml) && - _artifactStore != null && - !string.IsNullOrWhiteSpace(workflow.ServiceKey)) + if (workflow != null) { - // Try with known revision ID. - if (!string.IsNullOrWhiteSpace(workflow.ActiveRevisionId)) - { - var artifact = await _artifactStore.GetAsync(workflow.ServiceKey, workflow.ActiveRevisionId, ct); - yaml = artifact?.DeploymentPlan?.WorkflowPlan?.WorkflowYaml ?? string.Empty; - } + var binding = string.IsNullOrWhiteSpace(workflow.ActorId) + ? null + : await _workflowActorBindingReader.GetAsync(workflow.ActorId, ct); + + var yaml = binding?.WorkflowYaml ?? string.Empty; - // If revision ID is empty (deployment snapshot not ready yet), - // scan for the latest revision via the service lifecycle query. + // Fallback: if binding projection hasn't materialized the YAML yet, + // try the artifact store which is written synchronously during save. if (string.IsNullOrWhiteSpace(yaml) && - _workflowQueryPort != null && - _serviceLifecycleQueryPort != null) + _artifactStore != null && + !string.IsNullOrWhiteSpace(workflow.ServiceKey)) { - var identity = new ServiceIdentity - { - TenantId = normalizedScopeId, - AppId = "default", - Namespace = "default", - ServiceId = normalizedWorkflowId, - }; - var svc = await _serviceLifecycleQueryPort.GetServiceAsync(identity, ct); - var revId = svc?.ActiveServingRevisionId; - if (string.IsNullOrWhiteSpace(revId)) - revId = svc?.DefaultServingRevisionId; - if (!string.IsNullOrWhiteSpace(revId)) + // Try with known revision ID. + if (!string.IsNullOrWhiteSpace(workflow.ActiveRevisionId)) { - var artifact = await _artifactStore.GetAsync(workflow.ServiceKey, revId, ct); + var artifact = await _artifactStore.GetAsync(workflow.ServiceKey, workflow.ActiveRevisionId, ct); yaml = artifact?.DeploymentPlan?.WorkflowPlan?.WorkflowYaml ?? string.Empty; } + + // If revision ID is empty (deployment snapshot not ready yet), + // scan for the latest revision via the service lifecycle query. + if (string.IsNullOrWhiteSpace(yaml) && + _workflowQueryPort != null && + _serviceLifecycleQueryPort != null) + { + var identity = new ServiceIdentity + { + TenantId = normalizedScopeId, + AppId = "default", + Namespace = "default", + ServiceId = normalizedWorkflowId, + }; + var svc = await _serviceLifecycleQueryPort.GetServiceAsync(identity, ct); + var revId = svc?.ActiveServingRevisionId; + if (string.IsNullOrWhiteSpace(revId)) + revId = svc?.DefaultServingRevisionId; + if (!string.IsNullOrWhiteSpace(revId)) + { + var artifact = await _artifactStore.GetAsync(workflow.ServiceKey, revId, ct); + yaml = artifact?.DeploymentPlan?.WorkflowPlan?.WorkflowYaml ?? string.Empty; + } + } } + + return ToWorkflowFileResponse( + normalizedScopeId, + workflow, + yaml, + layout: TryReadPersistedLayout(normalizedScopeId, normalizedWorkflowId), + findingsFallbackMessage: "Workflow YAML is not available yet."); + } + + var storedWorkflow = await TryGetStoredWorkflowAsync(normalizedWorkflowId, ct); + if (storedWorkflow != null) + { + return ToStoredWorkflowFileResponse( + normalizedScopeId, + storedWorkflow, + TryReadPersistedLayout(normalizedScopeId, normalizedWorkflowId)); } - return ToWorkflowFileResponse( - normalizedScopeId, - workflow, - yaml, - layout: TryReadPersistedLayout(normalizedScopeId, normalizedWorkflowId), - findingsFallbackMessage: "Workflow YAML is not available yet."); + return null; } var detail = await SendAsync( @@ -144,7 +157,15 @@ public async Task> ListAsync( allowNotFound: true); if (detail == null || detail.Workflow == null) - return null; + { + var storedWorkflow = await TryGetStoredWorkflowAsync(normalizedWorkflowId, ct); + return storedWorkflow == null + ? null + : ToStoredWorkflowFileResponse( + normalizedScopeId, + storedWorkflow, + TryReadPersistedLayout(normalizedScopeId, normalizedWorkflowId)); + } return ToWorkflowFileResponse( normalizedScopeId, @@ -162,17 +183,27 @@ public async Task SaveAsync( ArgumentNullException.ThrowIfNull(request); var normalizedScopeId = NormalizeRequired(scopeId, nameof(scopeId)); + var requestedWorkflowName = string.IsNullOrWhiteSpace(request.WorkflowName) + ? string.Empty + : request.WorkflowName.Trim(); var normalizedYaml = NormalizeRequired(request.Yaml, nameof(request.Yaml)); + if (!string.IsNullOrWhiteSpace(requestedWorkflowName)) + { + normalizedYaml = AlignWorkflowYamlName(normalizedYaml, requestedWorkflowName); + } + var parsed = _yamlDocumentService.Parse(normalizedYaml); - var workflowName = !string.IsNullOrWhiteSpace(parsed.Document?.Name) + var workflowName = !string.IsNullOrWhiteSpace(requestedWorkflowName) + ? requestedWorkflowName + : !string.IsNullOrWhiteSpace(parsed.Document?.Name) ? parsed.Document.Name.Trim() : NormalizeRequired(request.WorkflowName, nameof(request.WorkflowName)); var workflowId = string.IsNullOrWhiteSpace(request.WorkflowId) ? StudioDocumentIdNormalizer.Normalize(workflowName, "workflow") : NormalizeRequired(request.WorkflowId, nameof(request.WorkflowId)); - var displayName = string.IsNullOrWhiteSpace(request.WorkflowName) + var displayName = string.IsNullOrWhiteSpace(requestedWorkflowName) ? workflowId - : request.WorkflowName.Trim(); + : requestedWorkflowName; ScopeWorkflowUpsertResult upsert; if (_workflowCommandPort != null) @@ -222,6 +253,24 @@ public async Task SaveAsync( parsed); } + private string AlignWorkflowYamlName(string yaml, string workflowName) + { + if (string.IsNullOrWhiteSpace(yaml) || string.IsNullOrWhiteSpace(workflowName)) + return yaml; + + var parsed = _yamlDocumentService.Parse(yaml); + if (parsed.Document == null) + return yaml; + + if (string.Equals(parsed.Document.Name?.Trim(), workflowName, StringComparison.Ordinal)) + return yaml; + + return _yamlDocumentService.Serialize(parsed.Document with + { + Name = workflowName, + }); + } + public static WorkflowDirectorySummary CreateScopeDirectory(string scopeId) => new( BuildScopeDirectoryId(scopeId), @@ -296,6 +345,111 @@ private static string ResolveWorkflowDisplayName(ScopeWorkflowSummary workflow) return workflow.WorkflowId; } + private async Task> MergeStoredWorkflowSummariesAsync( + string scopeId, + IReadOnlyList runtimeSummaries, + CancellationToken ct) + { + if (_workflowStoragePort == null) + return runtimeSummaries; + + IReadOnlyList storedWorkflows; + try + { + storedWorkflows = await _workflowStoragePort.ListWorkflowYamlsAsync(ct); + } + catch + { + return runtimeSummaries; + } + + if (storedWorkflows.Count == 0) + return runtimeSummaries; + + var merged = runtimeSummaries.ToDictionary(summary => summary.WorkflowId, StringComparer.Ordinal); + foreach (var storedWorkflow in storedWorkflows) + { + if (merged.ContainsKey(storedWorkflow.WorkflowId)) + continue; + + merged[storedWorkflow.WorkflowId] = ToStoredWorkflowSummary(scopeId, storedWorkflow); + } + + return merged.Values + .OrderByDescending(static item => item.UpdatedAtUtc) + .ToList(); + } + + private async Task TryGetStoredWorkflowAsync(string workflowId, CancellationToken ct) + { + if (_workflowStoragePort == null) + return null; + + try + { + return await _workflowStoragePort.GetWorkflowYamlAsync(workflowId, ct); + } + catch + { + return null; + } + } + + private WorkflowSummary ToStoredWorkflowSummary( + string scopeId, + StoredWorkflowYaml storedWorkflow) + { + var parse = _yamlDocumentService.Parse(storedWorkflow.Yaml); + var scopeDirectory = CreateScopeDirectory(scopeId); + return new WorkflowSummary( + storedWorkflow.WorkflowId, + ResolveStoredWorkflowName(storedWorkflow, parse), + parse.Document?.Description ?? string.Empty, + $"{storedWorkflow.WorkflowId}.yaml", + $"{scopeDirectory.Path}/{storedWorkflow.WorkflowId}.yaml", + scopeDirectory.DirectoryId, + scopeDirectory.Label, + parse.Document?.Steps.Count ?? 0, + TryReadPersistedLayout(scopeId, storedWorkflow.WorkflowId) != null, + storedWorkflow.UpdatedAtUtc ?? DateTimeOffset.UtcNow); + } + + private WorkflowFileResponse ToStoredWorkflowFileResponse( + string scopeId, + StoredWorkflowYaml storedWorkflow, + WorkflowLayoutDocument? layout) + { + var parse = _yamlDocumentService.Parse(storedWorkflow.Yaml); + var scopeDirectory = CreateScopeDirectory(scopeId); + return new WorkflowFileResponse( + storedWorkflow.WorkflowId, + ResolveStoredWorkflowName(storedWorkflow, parse), + $"{storedWorkflow.WorkflowId}.yaml", + $"{scopeDirectory.Path}/{storedWorkflow.WorkflowId}.yaml", + scopeDirectory.DirectoryId, + scopeDirectory.Label, + storedWorkflow.Yaml, + parse.Document, + layout, + parse.Findings, + storedWorkflow.UpdatedAtUtc); + } + + private static string ResolveStoredWorkflowName( + StoredWorkflowYaml storedWorkflow, + WorkflowParseResult parseResult) + { + var parsedName = parseResult.Document?.Name?.Trim(); + if (!string.IsNullOrWhiteSpace(parsedName)) + return parsedName; + + var storedName = storedWorkflow.WorkflowName?.Trim(); + if (!string.IsNullOrWhiteSpace(storedName)) + return storedName; + + return storedWorkflow.WorkflowId; + } + private async Task SendAsync( HttpMethod method, string relativePath, diff --git a/src/Aevatar.Studio.Application/Studio/Abstractions/IWorkflowStoragePort.cs b/src/Aevatar.Studio.Application/Studio/Abstractions/IWorkflowStoragePort.cs index 42c30be7..4d32348b 100644 --- a/src/Aevatar.Studio.Application/Studio/Abstractions/IWorkflowStoragePort.cs +++ b/src/Aevatar.Studio.Application/Studio/Abstractions/IWorkflowStoragePort.cs @@ -6,4 +6,14 @@ namespace Aevatar.Studio.Application.Studio.Abstractions; public interface IWorkflowStoragePort { Task UploadWorkflowYamlAsync(string workflowId, string workflowName, string yaml, CancellationToken ct); + + Task> ListWorkflowYamlsAsync(CancellationToken ct); + + Task GetWorkflowYamlAsync(string workflowId, CancellationToken ct); } + +public sealed record StoredWorkflowYaml( + string WorkflowId, + string WorkflowName, + string Yaml, + DateTimeOffset? UpdatedAtUtc); diff --git a/src/Aevatar.Studio.Application/Studio/Services/WorkspaceService.cs b/src/Aevatar.Studio.Application/Studio/Services/WorkspaceService.cs index 96acf4fa..6806b3dc 100644 --- a/src/Aevatar.Studio.Application/Studio/Services/WorkspaceService.cs +++ b/src/Aevatar.Studio.Application/Studio/Services/WorkspaceService.cs @@ -123,6 +123,7 @@ public async Task SaveWorkflowAsync( var normalizedName = string.IsNullOrWhiteSpace(request.WorkflowName) ? "workflow" : request.WorkflowName.Trim(); + var normalizedYaml = AlignWorkflowYamlName(request.Yaml, normalizedName); var normalizedFileName = EnsureYamlExtension(string.IsNullOrWhiteSpace(request.FileName) ? normalizedName @@ -140,7 +141,7 @@ public async Task SaveWorkflowAsync( FilePath: filePath, DirectoryId: directory.DirectoryId, DirectoryLabel: directory.Label, - Yaml: request.Yaml, + Yaml: normalizedYaml, Layout: request.Layout, UpdatedAtUtc: DateTimeOffset.UtcNow), cancellationToken); @@ -148,6 +149,24 @@ public async Task SaveWorkflowAsync( return ToWorkflowFileResponse(stored); } + private string AlignWorkflowYamlName(string yaml, string workflowName) + { + if (string.IsNullOrWhiteSpace(yaml) || string.IsNullOrWhiteSpace(workflowName)) + return yaml; + + var parsed = _yamlDocumentService.Parse(yaml); + if (parsed.Document == null) + return yaml; + + if (string.Equals(parsed.Document.Name?.Trim(), workflowName, StringComparison.Ordinal)) + return yaml; + + return _yamlDocumentService.Serialize(parsed.Document with + { + Name = workflowName, + }); + } + private WorkflowFileResponse ToWorkflowFileResponse(StoredWorkflowFile file) { var parse = _yamlDocumentService.Parse(file.Yaml); diff --git a/src/Aevatar.Studio.Domain/Studio/Compatibility/WorkflowCompatibilityProfile.cs b/src/Aevatar.Studio.Domain/Studio/Compatibility/WorkflowCompatibilityProfile.cs index fda81198..70a06ea1 100644 --- a/src/Aevatar.Studio.Domain/Studio/Compatibility/WorkflowCompatibilityProfile.cs +++ b/src/Aevatar.Studio.Domain/Studio/Compatibility/WorkflowCompatibilityProfile.cs @@ -194,6 +194,9 @@ private static WorkflowCompatibilityProfile CreateAevatarV1() comparer, new Dictionary(comparer) { + ["llm"] = "llm_call", + ["chat"] = "llm_call", + ["task"] = "llm_call", ["loop"] = "while", ["sub_workflow"] = "workflow_call", ["for_each"] = "foreach", diff --git a/src/Aevatar.Studio.Hosting/Endpoints/WorkflowGenerateOrchestrator.cs b/src/Aevatar.Studio.Hosting/Endpoints/WorkflowGenerateOrchestrator.cs index 525920da..44c603df 100644 --- a/src/Aevatar.Studio.Hosting/Endpoints/WorkflowGenerateOrchestrator.cs +++ b/src/Aevatar.Studio.Hosting/Endpoints/WorkflowGenerateOrchestrator.cs @@ -39,6 +39,15 @@ internal sealed class WorkflowGenerateOrchestrator private static readonly Regex YamlFenceRegex = new( @"```(?:ya?ml)?\s*\n([\s\S]*?)```", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly string[] AuthoringSchemaRules = + [ + "Use canonical step types only. For a simple assistant/chat workflow, the step type must be llm_call (not llm, chat, or task).", + "Use only these step-level fields: id, type, target_role (or role), parameters, next, branches, children, retry, on_error, timeout_ms.", + "Do not put model, provider, temperature, max_tokens, max_history_messages, connectors, or system_prompt on steps. Those belong under roles[*].", + "Do not use steps[*].messages.", + "Do not use steps[*].params. Put step options under steps[*].parameters.", + "If you need to shape LLM input, use steps[*].parameters.prompt_prefix.", + ]; private readonly WorkflowEditorService _editorService; @@ -106,7 +115,7 @@ await onProgress( ct); } var parse = _editorService.ParseYaml(new ParseYamlRequest(candidateYaml, request.AvailableWorkflowNames)); - if (parse.Document == null || HasErrors(parse.Findings)) + if (parse.Document == null) { lastFindings = parse.Findings.Count > 0 ? parse.Findings @@ -133,6 +142,25 @@ await onProgress( var normalized = _editorService.Normalize(new NormalizeWorkflowRequest( parse.Document, request.AvailableWorkflowNames)); + var blockingParseFindings = GetBlockingParseFindings(parse.Findings); + if (blockingParseFindings.Count > 0) + { + lastCandidate = string.IsNullOrWhiteSpace(normalized.Yaml) + ? candidateYaml + : normalized.Yaml; + lastFindings = blockingParseFindings; + if (onProgress != null && attempt < MaxAttempts) + { + await onProgress( + new WorkflowGenerateProgress( + WorkflowGenerateProgressStage.RepairingDraft, + attempt, + BuildRepairStatusMessage(lastFindings, attempt)), + ct); + } + continue; + } + if (HasErrors(normalized.Findings)) { lastCandidate = normalized.Yaml; @@ -210,6 +238,7 @@ private static string BuildInitialPrompt(string request, string currentYaml) } parts.Add($"User request:\n{request}"); + parts.Add(BuildAuthoringSchemaHintBlock()); return string.Join("\n\n", parts); } @@ -257,11 +286,32 @@ private static string BuildRepairPrompt( builder.AppendLine(); } + builder.AppendLine(); + builder.AppendLine("Workflow authoring constraints:"); + foreach (var rule in AuthoringSchemaRules) + { + builder.Append("- "); + builder.AppendLine(rule); + } + builder.AppendLine(); builder.Append("Return workflow YAML only."); return builder.ToString().Trim(); } + private static string BuildAuthoringSchemaHintBlock() + { + var builder = new StringBuilder(); + builder.AppendLine("Workflow authoring constraints:"); + foreach (var rule in AuthoringSchemaRules) + { + builder.Append("- "); + builder.AppendLine(rule); + } + + return builder.ToString().Trim(); + } + private static string BuildFailureMessage(IReadOnlyList findings) { if (findings.Count == 0) @@ -280,4 +330,25 @@ private static string BuildRepairStatusMessage(IReadOnlyList var firstFinding = findings[0]; return $"{headline} {firstFinding.Path}: {firstFinding.Message}"; } + + private static IReadOnlyList GetBlockingParseFindings( + IReadOnlyList findings) => + findings + .Where(static finding => finding.Level == ValidationLevel.Error) + .Where(static finding => !IsSanitizableParseFinding(finding)) + .ToList(); + + private static bool IsSanitizableParseFinding(ValidationFinding finding) + { + if (string.Equals(finding.Code, "unknown_field", StringComparison.OrdinalIgnoreCase)) + return true; + + if (!string.Equals(finding.Code, "runtime_validation", StringComparison.OrdinalIgnoreCase)) + return false; + + var message = finding.Message ?? string.Empty; + return message.Contains("Unknown field '", StringComparison.OrdinalIgnoreCase) || + (message.Contains("Property '", StringComparison.OrdinalIgnoreCase) && + message.Contains("not found on type", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs index b62a32b2..b14ef1ad 100644 --- a/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs +++ b/src/Aevatar.Studio.Infrastructure/Storage/ChronoStorageWorkflowStoragePort.cs @@ -5,6 +5,8 @@ namespace Aevatar.Studio.Infrastructure.Storage; internal sealed class ChronoStorageWorkflowStoragePort : IWorkflowStoragePort { + private const string WorkflowDirectory = "workflows"; + private readonly ChronoStorageCatalogBlobClient _blobClient; public ChronoStorageWorkflowStoragePort(ChronoStorageCatalogBlobClient blobClient) @@ -15,10 +17,86 @@ public ChronoStorageWorkflowStoragePort(ChronoStorageCatalogBlobClient blobClien public async Task UploadWorkflowYamlAsync(string workflowId, string workflowName, string yaml, CancellationToken ct) { var yamlBytes = Encoding.UTF8.GetBytes(yaml); - var key = $"workflows/{workflowId}.yaml"; + var key = $"{WorkflowDirectory}/{workflowId}.yaml"; var context = _blobClient.TryResolveContext(string.Empty, key); if (context == null) return; await _blobClient.UploadAsync(context, yamlBytes, "text/yaml", ct); } + + public async Task> ListWorkflowYamlsAsync(CancellationToken ct) + { + var directoryContext = ResolveWorkflowDirectoryContext(); + if (directoryContext == null) + return []; + + var objects = await _blobClient.ListObjectsAsync(directoryContext, WorkflowDirectory, ct); + if (objects.Objects.Count == 0) + return []; + + var workflows = new List(objects.Objects.Count); + foreach (var storageObject in objects.Objects) + { + var workflowId = TryResolveWorkflowId(storageObject.Key); + if (string.IsNullOrWhiteSpace(workflowId)) + continue; + + var stored = await GetWorkflowYamlAsync(workflowId, ct); + if (stored != null) + { + var updatedAtUtc = TryParseUpdatedAt(storageObject.LastModified) ?? stored.UpdatedAtUtc; + workflows.Add(stored with { UpdatedAtUtc = updatedAtUtc }); + } + } + + return workflows; + } + + public async Task GetWorkflowYamlAsync(string workflowId, CancellationToken ct) + { + var normalizedWorkflowId = workflowId?.Trim() ?? string.Empty; + if (normalizedWorkflowId.Length == 0) + return null; + + var context = _blobClient.TryResolveContext(string.Empty, $"{WorkflowDirectory}/{normalizedWorkflowId}.yaml"); + if (context == null) + return null; + + var payload = await _blobClient.TryDownloadAsync(context, ct); + if (payload == null || payload.Length == 0) + return null; + + var yaml = Encoding.UTF8.GetString(payload); + return new StoredWorkflowYaml( + normalizedWorkflowId, + normalizedWorkflowId, + yaml, + UpdatedAtUtc: null); + } + + private ChronoStorageCatalogBlobClient.RemoteScopeContext? ResolveWorkflowDirectoryContext() => + _blobClient.TryResolveContext(string.Empty, $"{WorkflowDirectory}/.index"); + + private static string? TryResolveWorkflowId(string relativeKey) + { + if (string.IsNullOrWhiteSpace(relativeKey)) + return null; + + var normalizedKey = relativeKey.Trim(); + if (!normalizedKey.StartsWith($"{WorkflowDirectory}/", StringComparison.Ordinal) || + !normalizedKey.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return Path.GetFileNameWithoutExtension(normalizedKey); + } + + private static DateTimeOffset? TryParseUpdatedAt(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + return DateTimeOffset.TryParse(raw, out var parsed) ? parsed : null; + } } diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansActorRuntimeForwardingTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansActorRuntimeForwardingTests.cs index dc032e34..d1232258 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansActorRuntimeForwardingTests.cs +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansActorRuntimeForwardingTests.cs @@ -57,6 +57,22 @@ public async Task LinkAsync_ShouldCreateCallChainReentrancyScope_ForGrainCalls() RequestContext.ReentrancyId.Should().Be(Guid.Empty); } + [Fact] + public async Task UnlinkAsync_ShouldCreateCallChainReentrancyScope_ForGrainCalls() + { + RequestContext.Clear(); + var runtime = CreateRuntime(out _, out var grains, out _); + await runtime.LinkAsync("parent", "child"); + grains["parent"].ObservedReentrancyIds.Clear(); + grains["child"].ObservedReentrancyIds.Clear(); + + await runtime.UnlinkAsync("child"); + + grains["parent"].ObservedReentrancyIds.Should().Contain(id => id != Guid.Empty); + grains["child"].ObservedReentrancyIds.Should().Contain(id => id != Guid.Empty); + RequestContext.ReentrancyId.Should().Be(Guid.Empty); + } + [Fact] public async Task DestroyAsync_ShouldCleanupIncomingAndOutgoingForwardingBindings() { @@ -130,6 +146,25 @@ public async Task DestroyAsync_ShouldPurgeDurableCallbackSchedulerState() callbackSchedulerGrains["actor-1"].PurgeCalls.Should().Be(1); } + [Fact] + public async Task DestroyAsync_ShouldCreateCallChainReentrancyScope_ForGrainCalls() + { + RequestContext.Clear(); + var runtime = CreateRuntime(out _, out var grains, out _); + await runtime.LinkAsync("parent", "middle"); + await runtime.LinkAsync("middle", "child"); + grains["parent"].ObservedReentrancyIds.Clear(); + grains["middle"].ObservedReentrancyIds.Clear(); + grains["child"].ObservedReentrancyIds.Clear(); + + await runtime.DestroyAsync("middle"); + + grains["parent"].ObservedReentrancyIds.Should().Contain(id => id != Guid.Empty); + grains["middle"].ObservedReentrancyIds.Should().Contain(id => id != Guid.Empty); + grains["child"].ObservedReentrancyIds.Should().Contain(id => id != Guid.Empty); + RequestContext.ReentrancyId.Should().Be(Guid.Empty); + } + private static OrleansActorRuntime CreateRuntime( out InMemoryStreamForwardingRegistry registry, out Dictionary grains, diff --git a/test/Aevatar.Studio.Tests/WorkflowCompatibilityProfileTests.cs b/test/Aevatar.Studio.Tests/WorkflowCompatibilityProfileTests.cs index 58550c39..7e410e10 100644 --- a/test/Aevatar.Studio.Tests/WorkflowCompatibilityProfileTests.cs +++ b/test/Aevatar.Studio.Tests/WorkflowCompatibilityProfileTests.cs @@ -14,6 +14,9 @@ public void AevatarV1_ShouldHaveExpectedVersion() } [Theory] + [InlineData("llm", "llm_call")] + [InlineData("chat", "llm_call")] + [InlineData("task", "llm_call")] [InlineData("loop", "while")] [InlineData("sub_workflow", "workflow_call")] [InlineData("foreach_llm", "foreach")] diff --git a/test/Aevatar.Tools.Cli.Tests/AppScopedWorkflowServiceTests.cs b/test/Aevatar.Tools.Cli.Tests/AppScopedWorkflowServiceTests.cs index b7b2bda5..46a2a122 100644 --- a/test/Aevatar.Tools.Cli.Tests/AppScopedWorkflowServiceTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/AppScopedWorkflowServiceTests.cs @@ -2,12 +2,14 @@ using System.Text; using Aevatar.Studio.Application; using Aevatar.Studio.Application.Studio.Abstractions; +using Aevatar.Studio.Application.Studio.Contracts; using Aevatar.GAgentService.Abstractions; using Aevatar.GAgentService.Abstractions.Ports; using Aevatar.Studio.Domain.Studio.Models; using Aevatar.Workflow.Application.Abstractions.Runs; using FluentAssertions; using Microsoft.AspNetCore.Http; +using System.Text.RegularExpressions; namespace Aevatar.Tools.Cli.Tests; @@ -92,6 +94,86 @@ public async Task GetAsync_WhenLifecycleQueryPortIsUnavailable_ShouldSkipRevisio response.Findings[0].Message.Should().Be("Workflow YAML is not available yet."); } + [Fact] + public async Task SaveAsync_ShouldRewriteYamlNameFromRequestedWorkflowName() + { + var commandPort = new StubScopeWorkflowCommandPort(); + var service = new AppScopedWorkflowService( + new StubHttpClientFactory(new HttpClient(new StubHttpMessageHandler(_ => throw new InvalidOperationException("HTTP backend should not be called."))) + { + BaseAddress = new Uri("https://backend.example"), + }), + new StubWorkflowYamlDocumentService(), + workflowCommandPort: commandPort); + + var response = await service.SaveAsync( + "scope-1", + new SaveWorkflowFileRequest( + WorkflowId: null, + DirectoryId: "scope:scope-1", + WorkflowName: "renamed-workflow", + FileName: null, + Yaml: "name: draft\nsteps: []\n")); + + commandPort.LastRequest.Should().NotBeNull(); + commandPort.LastRequest!.WorkflowName.Should().Be("renamed-workflow"); + commandPort.LastRequest.WorkflowYaml.Should().StartWith("name: renamed-workflow"); + response.Name.Should().Be("renamed-workflow"); + response.Yaml.Should().StartWith("name: renamed-workflow"); + } + + [Fact] + public async Task ListAsync_WhenRuntimeListIsEmpty_ShouldFallbackToStoredWorkflowYaml() + { + var service = new AppScopedWorkflowService( + new StubHttpClientFactory(new HttpClient(new StubHttpMessageHandler(_ => throw new InvalidOperationException("HTTP backend should not be called."))) + { + BaseAddress = new Uri("https://backend.example"), + }), + new StubWorkflowYamlDocumentService(), + workflowQueryPort: new StubScopeWorkflowQueryPort(), + workflowStoragePort: new StubWorkflowStoragePort( + new StoredWorkflowYaml( + "hello-chat", + "hello-chat", + "name: hello-chat\ndescription: stored workflow\nsteps: []\n", + new DateTimeOffset(2026, 4, 10, 9, 0, 0, TimeSpan.Zero)))); + + var workflows = await service.ListAsync("scope-1"); + + workflows.Should().ContainSingle(); + workflows[0].WorkflowId.Should().Be("hello-chat"); + workflows[0].Name.Should().Be("hello-chat"); + workflows[0].Description.Should().Be("stored workflow"); + } + + [Fact] + public async Task GetAsync_WhenRuntimeWorkflowMissing_ShouldFallbackToStoredWorkflowYaml() + { + var service = new AppScopedWorkflowService( + new StubHttpClientFactory(new HttpClient(new StubHttpMessageHandler(_ => throw new InvalidOperationException("HTTP backend should not be called."))) + { + BaseAddress = new Uri("https://backend.example"), + }), + new StubWorkflowYamlDocumentService(), + workflowQueryPort: new StubScopeWorkflowQueryPort(), + workflowActorBindingReader: new StubWorkflowActorBindingReader(null), + workflowStoragePort: new StubWorkflowStoragePort( + new StoredWorkflowYaml( + "hello-chat", + "hello-chat", + "name: hello-chat\ndescription: restored from storage\nsteps: []\n", + new DateTimeOffset(2026, 4, 10, 9, 0, 0, TimeSpan.Zero)))); + + var workflow = await service.GetAsync("scope-1", "hello-chat"); + + workflow.Should().NotBeNull(); + workflow!.WorkflowId.Should().Be("hello-chat"); + workflow.Name.Should().Be("hello-chat"); + workflow.Yaml.Should().Contain("restored from storage"); + workflow.Findings.Should().BeEmpty(); + } + private static AppScopedWorkflowService CreateService( Func responseFactory) { @@ -139,14 +221,61 @@ protected override Task SendAsync( private sealed class StubWorkflowYamlDocumentService : IWorkflowYamlDocumentService { - public WorkflowParseResult Parse(string yaml) => new(null, []); + private static readonly Regex NameRegex = new(@"(?m)^name:\s*(.+?)\s*$", RegexOptions.Compiled); + private static readonly Regex DescriptionRegex = new(@"(?m)^description:\s*(.+?)\s*$", RegexOptions.Compiled); + + public WorkflowParseResult Parse(string yaml) + { + if (string.IsNullOrWhiteSpace(yaml)) + return new(null, []); + + var input = yaml ?? string.Empty; + var nameMatch = NameRegex.Match(input); + var descriptionMatch = DescriptionRegex.Match(input); + return new(new WorkflowDocument + { + Name = nameMatch.Success ? nameMatch.Groups[1].Value.Trim() : string.Empty, + Description = descriptionMatch.Success ? descriptionMatch.Groups[1].Value.Trim() : string.Empty, + }, []); + } + + public string Serialize(WorkflowDocument document) => $"name: {document.Name}\nsteps: []\n"; + } + + private sealed class StubScopeWorkflowCommandPort : IScopeWorkflowCommandPort + { + public ScopeWorkflowUpsertRequest? LastRequest { get; private set; } - public string Serialize(WorkflowDocument document) => string.Empty; + public Task UpsertAsync( + ScopeWorkflowUpsertRequest request, + CancellationToken ct = default) + { + LastRequest = request; + return Task.FromResult(new ScopeWorkflowUpsertResult( + new ScopeWorkflowSummary( + ScopeId: request.ScopeId, + WorkflowId: request.WorkflowId, + DisplayName: request.DisplayName ?? request.WorkflowName ?? request.WorkflowId, + ServiceKey: $"{request.ScopeId}:default:default:{request.WorkflowId}", + WorkflowName: request.WorkflowName ?? request.WorkflowId, + ActorId: "actor-1", + ActiveRevisionId: "rev-1", + DeploymentId: "deploy-1", + DeploymentStatus: "draft", + UpdatedAt: DateTimeOffset.UtcNow), + RevisionId: "rev-1", + DefinitionActorIdPrefix: "actor", + ExpectedActorId: "actor-1")); + } } private sealed class StubScopeWorkflowQueryPort : IScopeWorkflowQueryPort { - private readonly ScopeWorkflowSummary _workflow; + private readonly ScopeWorkflowSummary? _workflow; + + public StubScopeWorkflowQueryPort() + { + } public StubScopeWorkflowQueryPort(ScopeWorkflowSummary workflow) { @@ -154,26 +283,35 @@ public StubScopeWorkflowQueryPort(ScopeWorkflowSummary workflow) } public Task> ListAsync(string scopeId, CancellationToken ct = default) => - Task.FromResult>([_workflow]); + Task.FromResult>(_workflow == null ? [] : [_workflow]); public Task GetByWorkflowIdAsync(string scopeId, string workflowId, CancellationToken ct = default) => - Task.FromResult(string.Equals(workflowId, _workflow.WorkflowId, StringComparison.Ordinal) ? _workflow : null); + Task.FromResult( + _workflow != null && string.Equals(workflowId, _workflow.WorkflowId, StringComparison.Ordinal) + ? _workflow + : null); public Task GetByActorIdAsync(string scopeId, string actorId, CancellationToken ct = default) => - Task.FromResult(string.Equals(actorId, _workflow.ActorId, StringComparison.Ordinal) ? _workflow : null); + Task.FromResult( + _workflow != null && string.Equals(actorId, _workflow.ActorId, StringComparison.Ordinal) + ? _workflow + : null); } private sealed class StubWorkflowActorBindingReader : IWorkflowActorBindingReader { - private readonly WorkflowActorBinding _binding; + private readonly WorkflowActorBinding? _binding; - public StubWorkflowActorBindingReader(WorkflowActorBinding binding) + public StubWorkflowActorBindingReader(WorkflowActorBinding? binding) { _binding = binding; } public Task GetAsync(string actorId, CancellationToken ct = default) => - Task.FromResult(string.Equals(actorId, _binding.ActorId, StringComparison.Ordinal) ? _binding : null); + Task.FromResult( + _binding != null && string.Equals(actorId, _binding.ActorId, StringComparison.Ordinal) + ? _binding + : null); } private sealed class StubArtifactStore : IServiceRevisionArtifactStore @@ -184,4 +322,26 @@ public Task SaveAsync(string serviceKey, string revisionId, PreparedServiceRevis public Task GetAsync(string serviceKey, string revisionId, CancellationToken ct = default) => Task.FromResult(null); } + + private sealed class StubWorkflowStoragePort : IWorkflowStoragePort + { + private readonly Dictionary _storedWorkflows; + + public StubWorkflowStoragePort(params StoredWorkflowYaml[] storedWorkflows) + { + _storedWorkflows = storedWorkflows.ToDictionary(item => item.WorkflowId, StringComparer.Ordinal); + } + + public Task UploadWorkflowYamlAsync(string workflowId, string workflowName, string yaml, CancellationToken ct) => + Task.CompletedTask; + + public Task> ListWorkflowYamlsAsync(CancellationToken ct) => + Task.FromResult>(_storedWorkflows.Values.ToList()); + + public Task GetWorkflowYamlAsync(string workflowId, CancellationToken ct) => + Task.FromResult( + _storedWorkflows.TryGetValue(workflowId, out var storedWorkflow) + ? storedWorkflow + : null); + } } diff --git a/test/Aevatar.Tools.Cli.Tests/WorkflowGenerateOrchestratorTests.cs b/test/Aevatar.Tools.Cli.Tests/WorkflowGenerateOrchestratorTests.cs index 165bf1d1..8cc150cc 100644 --- a/test/Aevatar.Tools.Cli.Tests/WorkflowGenerateOrchestratorTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/WorkflowGenerateOrchestratorTests.cs @@ -82,6 +82,85 @@ await act.Should() .WithMessage("*valid workflow YAML*"); } + [Fact] + public async Task GenerateAsync_ShouldNormalizeAiHallucinatedStepFieldsBeforeFailing() + { + var orchestrator = CreateOrchestrator(); + + var result = await orchestrator.GenerateAsync( + new WorkflowGenerateRequest( + "Create the simplest possible chat workflow", + null, + [], + null), + (prompt, metadata, ct) => + { + _ = prompt; + _ = metadata; + _ = ct; + return Task.FromResult( + """ + name: ai-draft + steps: + - id: chat + type: llm_call + model: gpt-5.4 + messages: + - role: user + content: hello + params: + prompt_prefix: "Reply clearly." + """); + }, + null, + CancellationToken.None); + + result.Attempts.Should().Be(1); + result.Yaml.Should().Contain("name: ai-draft"); + result.Yaml.Should().Contain("id: chat"); + result.Yaml.Should().Contain("type: llm_call"); + result.Yaml.Should().NotContain("model:"); + result.Yaml.Should().NotContain("messages:"); + result.Yaml.Should().NotContain("params:"); + } + + [Theory] + [InlineData("task")] + [InlineData("llm")] + [InlineData("chat")] + public async Task GenerateAsync_ShouldNormalizeHallucinatedStepTypeAliases(string hallucinatedType) + { + var orchestrator = CreateOrchestrator(); + + var result = await orchestrator.GenerateAsync( + new WorkflowGenerateRequest( + "Create the simplest possible chat workflow", + null, + [], + null), + (prompt, metadata, ct) => + { + _ = prompt; + _ = metadata; + _ = ct; + return Task.FromResult( + $""" + name: ai-draft + steps: + - id: chat + type: {hallucinatedType} + parameters: + prompt_prefix: "Reply with a short joke." + """); + }, + null, + CancellationToken.None); + + result.Attempts.Should().Be(1); + result.Yaml.Should().Contain("type: llm_call"); + result.Yaml.Should().NotContain($"{Environment.NewLine} type: {hallucinatedType}{Environment.NewLine}"); + } + [Theory] [InlineData("```yaml\nname: sample\nsteps:\n - id: a\n type: llm_call\n```", "name: sample")] [InlineData("name: sample\nsteps:\n - id: a\n type: llm_call", "name: sample")] diff --git a/test/Aevatar.Tools.Cli.Tests/WorkspaceServiceTests.cs b/test/Aevatar.Tools.Cli.Tests/WorkspaceServiceTests.cs index 8071d7b8..811ff299 100644 --- a/test/Aevatar.Tools.Cli.Tests/WorkspaceServiceTests.cs +++ b/test/Aevatar.Tools.Cli.Tests/WorkspaceServiceTests.cs @@ -3,6 +3,7 @@ using Aevatar.Studio.Application.Studio.Services; using Aevatar.Studio.Domain.Studio.Models; using FluentAssertions; +using System.Text.RegularExpressions; namespace Aevatar.Tools.Cli.Tests; @@ -38,11 +39,50 @@ public async Task AddDirectoryAsync_ShouldExpandTildePath() } } + [Fact] + public async Task SaveWorkflowAsync_ShouldRewriteYamlNameFromRequestedWorkflowName() + { + var store = new InMemoryStudioWorkspaceStore(); + var directory = new StudioWorkspaceDirectory( + DirectoryId: "dir-1", + Label: "Test Root", + Path: Path.Combine(Path.GetTempPath(), $"studio-workflows-{Guid.NewGuid():N}"), + IsBuiltIn: false); + await store.SaveSettingsAsync(new StudioWorkspaceSettings( + RuntimeBaseUrl: "http://127.0.0.1:5100", + Directories: [directory], + AppearanceTheme: "blue", + ColorMode: "light")); + + var service = new WorkspaceService(store, new StubWorkflowYamlDocumentService()); + + var response = await service.SaveWorkflowAsync(new SaveWorkflowFileRequest( + WorkflowId: null, + DirectoryId: directory.DirectoryId, + WorkflowName: "renamed-workflow", + FileName: null, + Yaml: "name: draft\nsteps: []\n")); + + store.LastSavedWorkflowFile.Should().NotBeNull(); + store.LastSavedWorkflowFile!.Yaml.Should().StartWith("name: renamed-workflow"); + response.Name.Should().Be("renamed-workflow"); + response.Yaml.Should().StartWith("name: renamed-workflow"); + } + private sealed class StubWorkflowYamlDocumentService : IWorkflowYamlDocumentService { - public WorkflowParseResult Parse(string yaml) => new(new WorkflowDocument(), []); + private static readonly Regex NameRegex = new(@"(?m)^name:\s*(.+?)\s*$", RegexOptions.Compiled); + + public WorkflowParseResult Parse(string yaml) + { + var match = NameRegex.Match(yaml ?? string.Empty); + return new(new WorkflowDocument + { + Name = match.Success ? match.Groups[1].Value.Trim() : string.Empty, + }, []); + } - public string Serialize(WorkflowDocument document) => string.Empty; + public string Serialize(WorkflowDocument document) => $"name: {document.Name}\nsteps: []\n"; } private sealed class InMemoryStudioWorkspaceStore : IStudioWorkspaceStore @@ -52,6 +92,7 @@ private sealed class InMemoryStudioWorkspaceStore : IStudioWorkspaceStore Directories: [], AppearanceTheme: "blue", ColorMode: "light"); + public StoredWorkflowFile? LastSavedWorkflowFile { get; private set; } public Task GetSettingsAsync(CancellationToken cancellationToken = default) => Task.FromResult(_settings); @@ -68,8 +109,11 @@ public Task> ListWorkflowFilesAsync(Cancellati public Task GetWorkflowFileAsync(string workflowId, CancellationToken cancellationToken = default) => Task.FromResult(null); - public Task SaveWorkflowFileAsync(StoredWorkflowFile workflowFile, CancellationToken cancellationToken = default) => - throw new NotSupportedException(); + public Task SaveWorkflowFileAsync(StoredWorkflowFile workflowFile, CancellationToken cancellationToken = default) + { + LastSavedWorkflowFile = workflowFile; + return Task.FromResult(workflowFile); + } public Task> ListExecutionsAsync(CancellationToken cancellationToken = default) => Task.FromResult>([]);