diff --git a/apps/aevatar-console-web/AGENTS.md b/apps/aevatar-console-web/AGENTS.md index 7a345d6867..73d022cd4b 100644 --- a/apps/aevatar-console-web/AGENTS.md +++ b/apps/aevatar-console-web/AGENTS.md @@ -119,25 +119,23 @@ pnpm --dir apps/aevatar-console-web build `routeDraftWorkflowId` or `draftWorkflowId` for draft hints, and `publishedServiceId` for service identities. An unresolved value must be named as a candidate until its source establishes the concrete identity. -- Canonical Team routes express `scope -> team -> member` ownership: - `/scopes/:scopeId/teams`, `/scopes/:scopeId/teams/:teamId`, and - `/scopes/:scopeId/teams/:teamId/members/:memberId/...`. -- `/scopes` is only the authenticated technical entry for resolving a scope; it - is not the Team collection URL. -- Canonical member workflow editors are - `/scopes/:scopeId/teams/:teamId/members/:memberId/workflow` and - `/scopes/:scopeId/teams/:teamId/members/new/workflow`. The `workflow` path - segment names the member implementation editor surface, not a workflow - resource identity. A `workflowId` query value is only a draft hint and cannot - replace the path's member identity. -- Do not add or preserve hidden `/teams/:scopeId...` compatibility routes. - Parse paths by resource name, not by fragile segment indexes. +- Canonical console resources are `/scopes/:scopeId/workflows`, + `/scopes/:scopeId/activity`, `/scopes/:scopeId/channels`, and + `/scopes/:scopeId/settings`, with their resource-owned child routes. +- `/workflows` resolves the authenticated account's scope. `/`, `/overview`, + and `/scopes` use this home; `/scopes` is not a Team collection URL. +- Legacy Team/member and other console pages are retired under + `docs/superpowers/specs/2026-09-16-console-route-consolidation.md`. Do not + restore hidden legacy routes or the `workflow-activity-vnext` URL segment. + Backend Team/member identity boundaries above still apply. ## Workflow Activity vNext Baseline - Before changing any route, page, component, hook, query, adapter, model, style, locale, or test for - `/scopes/:scopeId/workflow-activity-vnext`, read all three of these sources + `/scopes/:scopeId/{workflows,activity,channels,settings}`, first read + `docs/superpowers/specs/2026-09-16-console-route-consolidation.md`, then read + all three of these sources completely: `docs/design-baselines/workflow-activity-vnext/README.md` and `docs/superpowers/specs/2026-08-04-workflow-activity-vnext-design.md` and @@ -181,9 +179,10 @@ pnpm --dir apps/aevatar-console-web build from `apps/aevatar-console-web/` before and after changing the baseline. The verifier must confirm the declared hash, deterministic generator output, and exact 17-frame inventory. -- Keep this feature frontend-only and isolated to its new route namespace. - Do not change backend code or alter existing Workflow, Run, Settings, Studio, - Team, member, redirect, or menu behavior to implement it. +- Keep this feature frontend-only. The 2026-09-16 route consolidation decision + replaces the original namespace isolation and legacy-page preservation + rules; the current experience owns the production console routes and shell. + Do not infer backend resource removal from frontend page retirement. ## UI and Interaction diff --git a/apps/aevatar-console-web/README.md b/apps/aevatar-console-web/README.md index f4c4d54141..38796c1412 100644 --- a/apps/aevatar-console-web/README.md +++ b/apps/aevatar-console-web/README.md @@ -1,6 +1,7 @@ # Aevatar Console Web -`aevatar-console-web` is the Ant Design Pro based admin shell for Aevatar. +`aevatar-console-web` is the Aevatar console for Workflows, Activity, Channels, +and Settings, using the existing Workflow Activity design system. ## Stack @@ -104,15 +105,16 @@ Current proxy split during local development: - `/api/chat`, `/api/workflows/*`, `/api/actors/*`, `/api/runs/*`, `/api/primitives`, `/api/capabilities`, most `/api/scopes/*` runtime routes -> `Mainnet Host API` - `/api/app/*`, `/api/auth/*`, `/api/workspace/*`, `/api/editor/*`, `/api/executions/*`, `/api/roles/*`, `/api/connectors/*`, `/api/scopes/{scopeId}/teams*` -> `Studio Hosting API target` -## Current scope +## Current Routes -- `Overview` -- `Studio` -- `Primitives` -- `Runs` -- `Actors` -- `Workflows` -- `Observability` -- `Settings` +`/workflows` resolves the signed-in account's scope. The console uses +`/scopes/:scopeId/workflows`, `/scopes/:scopeId/activity`, +`/scopes/:scopeId/channels`, and `/scopes/:scopeId/settings`, including their +resource detail and editing routes. `/`, `/overview`, and `/scopes` open this +home. Login and callback remain at `/login` and `/auth/callback`. -If Studio shows `Failed to load Studio workflow` with an RFC 9110 `404 Not Found` payload, check that `AEVATAR_API_TARGET` points to `Aevatar.Mainnet.Host.Api` rather than `Aevatar.Workflow.Host.Api`; scope workflow detail requests are served by mainnet. +The old Teams, Members, Studio, and other legacy console pages are removed. +Retired business URLs, including the former `workflow-activity-vnext` prefix, +render the not-found page. Backend resources and identity contracts are +unchanged. See the [route consolidation specification](docs/superpowers/specs/2026-09-16-console-route-consolidation.md) +for the complete route inventory and the shared-component migration. diff --git a/apps/aevatar-console-web/config/routes.ts b/apps/aevatar-console-web/config/routes.ts index ff5fd185d4..be8e07092a 100644 --- a/apps/aevatar-console-web/config/routes.ts +++ b/apps/aevatar-console-web/config/routes.ts @@ -1,15 +1,3 @@ -/** - * @name umi 的路由配置 - * @description Aevatar Console 当前同时使用 path/component/routes/redirect/name/icon,以及用于菜单组织的 hideInMenu、parentKeys 和未来 badge 注入预留字段。 - * @param path path 只支持两种占位符配置,第一种是动态参数 :id 的形式,第二种是 * 通配符,通配符只能出现路由字符串的最后。 - * @param component 配置 location 和 path 匹配后用于渲染的 React 组件路径。可以是绝对路径,也可以是相对路径,如果是相对路径,会从 src/pages 开始找起。 - * @param routes 配置子路由,通常在需要为多个路径增加 layout 组件时使用。 - * @param redirect 配置路由跳转 - * @param wrappers 配置路由组件的包装组件,通过包装组件可以为当前的路由组件组合进更多的功能。 比如,可以用于路由级别的权限校验 - * @param name 配置路由的标题,默认读取国际化文件 menu.ts 中 menu.xxxx 的值,如配置 name 为 login,则读取 menu.ts 中 menu.login 的取值作为标题 - * @param icon 配置路由的图标,取值参考 https://ant.design/components/icon-cn, 注意去除风格后缀和大小写,如想要配置图标为 则取值应为 stepBackward 或 StepBackward,如想要配置图标为 则取值应为 user 或者 User - * @doc https://umijs.org/docs/guides/routes - */ import { CONSOLE_HOME_ROUTE } from '../src/shared/navigation/consoleHome'; const workflowCanvasBenchmarkRoutes = @@ -26,338 +14,39 @@ const workflowCanvasBenchmarkRoutes = export default [ ...workflowCanvasBenchmarkRoutes, - { - path: '/login', - component: './login', - layout: false, - }, - { - path: '/auth/callback', - component: './auth/callback', - layout: false, - }, - { - path: '/overview', + { path: '/login', component: './login', layout: false }, + { path: '/auth/callback', component: './auth/callback', layout: false }, + ...['/', '/overview', '/scopes'].map((path) => ({ + path, redirect: CONSOLE_HOME_ROUTE, hideInMenu: true, - }, - { - path: '/chat', - name: 'Chat', - component: './chat', - menuGroupKey: 'chat', - hideInMenu: false, - }, - { - path: '/scopes', - component: './scopes', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext', - redirect: '/scopes/:scopeId/workflow-activity-vnext/workflows', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/workflows', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/workflows/new', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/workflows/new/templates', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/workflows/:workflowId', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/activity', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/activity/:runId', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/channels', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/channels/bind/:botId', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/channels/:registrationId/edit', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/channels/:registrationId', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/workflow-activity-vnext/settings', - component: './workflow-activity-vnext', - hideInMenu: true, - }, - { - path: '/scopes/:scopeId/teams/new', - name: 'Create Team', - component: './teams/new', - hideInMenu: true, - parentKeys: ['/scopes'], - }, - { - path: '/scopes/:scopeId/teams', - name: 'My Teams', - component: './teams', - hideInMenu: true, - parentKeys: ['/scopes'], - }, - { - path: '/scopes/:scopeId/teams/:teamId/members/new/workflow', - name: 'Team Member Workflow Studio', - component: './team-member-workflow-studio', - hideInMenu: true, - parentKeys: ['/scopes'], - }, - { - path: '/scopes/:scopeId/teams/:teamId/members/:memberId/workflow', - name: 'Team Member Workflow Studio', - component: './team-member-workflow-studio', - hideInMenu: true, - parentKeys: ['/scopes'], - }, - { - path: '/scopes/:scopeId/teams/:teamId/members/:memberId/invoke', - name: 'Team Member Invoke', - component: './team-member-invoke', - hideInMenu: true, - parentKeys: ['/scopes'], - }, - { - path: '/scopes/:scopeId/teams/:teamId/members/:memberId/runs', - name: 'Team Member Published Runs', - component: './runtime-published-runs', - hideInMenu: true, - parentKeys: ['/scopes'], - }, - { - path: '/scopes/:scopeId/teams/:teamId/members/:memberId/automations', - component: './teams/detail', - hideInMenu: true, - parentKeys: ['/scopes'], - }, - { - path: '/scopes/:scopeId/teams/:teamId/work-orders/:workOrderId', - name: 'WorkOrder Details', - component: './team-work-order-detail', - hideInMenu: true, - parentKeys: ['/scopes'], - }, - { - path: '/scopes/:scopeId/teams/:teamId', - name: 'Team Details', - component: './teams/detail', - hideInMenu: true, - parentKeys: ['/scopes'], - }, - { - path: '/scopes/assets', - component: './scopes/assets', - hideInMenu: true, - }, - { - path: '/scopes/files', - name: 'Files', - component: './scopes/files', - menuGroupKey: 'build', - }, - { - path: '/studio', - component: './studio', - hideInMenu: true, - }, - { - path: '/runtime/workflows', - component: './workflows', - hideInMenu: true, - }, - { - path: '/runtime/primitives', - name: 'Connectors', - component: './primitives', - hideInMenu: true, - }, - { - path: '/scopes/invoke', - component: './scopes/invoke', - hideInMenu: true, - }, - { - path: '/runtime/runs', - name: 'Event Stream', - component: './runs', - menuGroupKey: 'platform', - }, - { - path: '/runtime/mission-control', - name: 'Mission Control', - component: './MissionControl', - hideInMenu: true, - }, - { - path: '/runtime/mission-wall', - component: './MissionWall', - hideInMenu: true, - }, - { - path: '/services', - name: 'Services', - component: './services', - menuGroupKey: 'platform', - }, - { - path: '/services/:serviceId', - component: './services', - hideInMenu: true, - parentKeys: ['/services'], - }, - { - path: '/governance', - name: 'Governance', - component: './governance', - menuGroupKey: 'platform', - }, - { - path: '/governance/policies', - component: './governance/policies', - hideInMenu: true, - parentKeys: ['/governance'], - }, - { - path: '/governance/bindings', - component: './governance/bindings', - hideInMenu: true, - parentKeys: ['/governance'], - }, - { - path: '/governance/endpoints', - component: './governance/endpoints', - hideInMenu: true, - parentKeys: ['/governance'], - }, - { - path: '/governance/activation', - component: './governance/activation', - hideInMenu: true, - parentKeys: ['/governance'], - }, - { - path: '/deployments', - name: 'Deployments', - component: './Deployments', - menuGroupKey: 'platform', - }, - { - path: '/runtime/explorer', - name: 'Topology', - component: './actors', - menuGroupKey: 'platform', - }, - { - path: '/runtime/explorer/detail', - component: './actors/detail', - hideInMenu: true, - parentKeys: ['/runtime/explorer'], - }, - { - path: '/runtime/gagents', - name: 'Members', - component: './gagents', - hideInMenu: true, - }, - { - path: '/scopes/overview', - component: './scopes/overview', - hideInMenu: true, - }, - { - path: '/settings', - name: 'Settings', - component: './settings', - menuGroupKey: 'settings', - }, - { - path: '/scopes/workflows', - redirect: '/runtime/workflows', - hideInMenu: true, - }, - { - path: '/scopes/scripts', - redirect: '/studio?tab=scripts', - hideInMenu: true, - }, - { - path: '/governance/audit', - redirect: '/governance?view=changes', - hideInMenu: true, - }, + })), { path: '/workflows', component: './workflow-activity-vnext/WorkflowHomePage', hideInMenu: true, }, { - path: '/primitives', - redirect: '/runtime/primitives', - hideInMenu: true, - }, - { - path: '/runs', - redirect: '/runtime/runs', + path: '/scopes/:scopeId', + redirect: '/scopes/:scopeId/workflows', hideInMenu: true, }, - { - path: '/actors', - redirect: '/runtime/explorer', - hideInMenu: true, - }, - { - path: '/gagents', - redirect: '/runtime/gagents', - hideInMenu: true, - }, - { - path: '/mission-control', - redirect: '/runtime/mission-control', - hideInMenu: true, - }, - { - path: '/mission-wall', - redirect: '/runtime/mission-wall', + ...[ + 'workflows', + 'workflows/new', + 'workflows/new/templates', + 'workflows/:workflowId', + 'activity', + 'activity/:runId', + 'channels', + 'channels/bind/:botId', + 'channels/:registrationId/edit', + 'channels/:registrationId', + 'settings', + ].map((resourcePath) => ({ + path: `/scopes/:scopeId/${resourcePath}`, + component: './workflow-activity-vnext', hideInMenu: true, - }, - { - path: '/', - redirect: CONSOLE_HOME_ROUTE, - }, - { - component: '404', - layout: false, - path: '/*', - }, + })), + { path: '/*', component: '404', layout: false }, ]; diff --git a/apps/aevatar-console-web/docs/design-baselines/workflow-activity-vnext/README.md b/apps/aevatar-console-web/docs/design-baselines/workflow-activity-vnext/README.md index 779f8ac6ca..ddcbcfbfba 100644 --- a/apps/aevatar-console-web/docs/design-baselines/workflow-activity-vnext/README.md +++ b/apps/aevatar-console-web/docs/design-baselines/workflow-activity-vnext/README.md @@ -4,8 +4,13 @@ Status: **Normative for the Workflow Activity vNext frontend**. +The [2026-09-16 route consolidation decision](../../superpowers/specs/2026-09-16-console-route-consolidation.md) +supersedes the original namespace isolation and legacy-page preservation rules. +It is authoritative for current URLs and retired frontend surfaces; the visual +assets and remaining product contracts below continue to apply. + Any implementation or review of routes below -`/scopes/:scopeId/workflow-activity-vnext` must read this directory together +`/scopes/:scopeId/{workflows,activity,channels,settings}` must read this directory together with the [`design specification`](../../superpowers/specs/2026-08-04-workflow-activity-vnext-design.md) and @@ -49,7 +54,7 @@ do not fabricate data to close the gap. The 2026-09-14 home decision supersedes the original preview-only entry rule. `/workflows` resolves the freshly fetched `/api/auth/me` scope and opens -`/scopes/:scopeId/workflow-activity-vnext/workflows`. `/`, `/overview`, `/scopes`, +`/scopes/:scopeId/workflows`. `/`, `/overview`, `/scopes`, and default login/callback recovery use this home. Explicit safe deep links keep their original destination. There is no fixed workspace or intermediate Teams home. See the repository-root diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-08-04-workflow-activity-vnext-design.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-08-04-workflow-activity-vnext-design.md index 71a68350c1..b524e523a4 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-08-04-workflow-activity-vnext-design.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-08-04-workflow-activity-vnext-design.md @@ -1,5 +1,10 @@ # Workflow + Activity + Settings vNext Design +> Current routing follows the [2026-09-16 consolidation decision](2026-09-16-console-route-consolidation.md). +> It removes the `workflow-activity-vnext` URL segment and retires legacy +> console pages, superseding the isolation and legacy-preservation requirements +> below. The remaining visual, API, identity, and state contracts still apply. + ## Status Proposed for review on 2026-08-04. This document is the deliverable for the diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-08-04-workflow-activity-vnext-user-paths.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-08-04-workflow-activity-vnext-user-paths.md index 24cd2de614..48637a0284 100644 --- a/apps/aevatar-console-web/docs/superpowers/specs/2026-08-04-workflow-activity-vnext-user-paths.md +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-08-04-workflow-activity-vnext-user-paths.md @@ -1,5 +1,10 @@ # Workflow + Activity + Settings vNext User Paths +> Current routing follows the [2026-09-16 consolidation decision](2026-09-16-console-route-consolidation.md). +> Interpret scoped URLs below without the `workflow-activity-vnext` segment. +> Legacy console pages are retired; all retained journeys, recovery behavior, +> and completion evidence continue to apply. + ## Status Proposed as the normative user-path companion to diff --git a/apps/aevatar-console-web/docs/superpowers/specs/2026-09-16-console-route-consolidation.md b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-16-console-route-consolidation.md new file mode 100644 index 0000000000..f4c20e9c59 --- /dev/null +++ b/apps/aevatar-console-web/docs/superpowers/specs/2026-09-16-console-route-consolidation.md @@ -0,0 +1,86 @@ +# Console Route Consolidation + +## Status And Precedence + +Approved by the user's 2026-09-16 request on top of +`feat/2026-08-04_workflow-activity-vnext`. + +The current Workflow, Activity, Channels, and Settings experience becomes the +only production console surface. This decision supersedes the original vNext +namespace isolation requirement, the requirement to retain legacy console +pages, and earlier Team/member frontend route requirements. The existing +Excalidraw visual baseline, API contracts, identity boundaries, authentication, +localization, and user journeys continue to apply. + +## Canonical Routes + +All scoped routes remove the `workflow-activity-vnext` segment: + +| Surface | Canonical URL | +| --- | --- | +| Workflows | `/scopes/:scopeId/workflows` | +| Create Workflow | `/scopes/:scopeId/workflows/new` | +| Workflow templates | `/scopes/:scopeId/workflows/new/templates` | +| Workflow editor and Schedule | `/scopes/:scopeId/workflows/:workflowId` | +| Activity | `/scopes/:scopeId/activity` | +| Run detail | `/scopes/:scopeId/activity/:runId` | +| Channels | `/scopes/:scopeId/channels` | +| Bind a NyxID bot | `/scopes/:scopeId/channels/bind/:botId` | +| Channel connection result | `/scopes/:scopeId/channels/:registrationId` | +| Edit Channel | `/scopes/:scopeId/channels/:registrationId/edit` | +| Settings | `/scopes/:scopeId/settings` | + +The 2026-09-17 bot-adoption update replaces direct Telegram creation with +binding an existing NyxID bot. Keep that flow, its Ornn skill selector, and +Channel editing on these canonical URLs. The old `channels/connect/telegram` +entry is retired along with its replaced page. + +`/workflows` retains the account-owned home behavior: fetch the current auth +profile, resolve its scope, and open that scope's Workflow list. `/`, +`/overview`, and `/scopes` redirect to `/workflows`. `/scopes/:scopeId` opens +the scoped Workflow list. No fixed workspace is introduced. + +`/login`, `/auth/callback`, and the not-found surface remain. Authentication +preserves safe destinations, including their query and hash. Account Settings +uses the current scope and the owning page's navigation guard, so opening it +cannot bypass unsaved Workflow changes. + +Retired business URLs, including the former vNext namespace, render the +not-found surface. There are no hidden Teams, Members, Runtime, or legacy +Settings route aliases. The existing `/workflow-canvas-benchmark` diagnostic +route remains available only through its explicit development opt-in; it is +absent from the default production route table. + +## Retired Frontend Surfaces + +Remove the old Teams, Members, Studio, Scopes, Chat, Workflows, Runtime Runs, +Actors, GAgents, Primitives, Mission Control, Mission Wall, Services, +Governance, Deployments, and Settings pages, together with their obsolete +navigation helpers and global console shell. + +This is frontend retirement only. It does not delete backend resources, +endpoints, persisted data, or the distinct Team/member/workflow/service +identities used by API contracts. + +The current editor still reuses the existing Workflow canvas, editor surface, +node library, and empty state. Move these dependencies to +`src/shared/workflowEditor/`. Move the LLM selection and save-observation +helpers to `src/shared/settings/` so the retained console no longer imports +retired page modules. Preserve their behavior and direct tests. + +The internal `pages/workflow-activity-vnext` directory, locale keys, and query +keys may retain their names. They do not define public URLs and do not require +a separate namespace migration. + +## Verification + +Protect the complete canonical route inventory, home resolution, auth +recovery, Workflow navigation and editing, Activity details, Channel creation +and editing, Settings, and account-menu navigation with focused existing +tests. Retain regression coverage for shared components moved out of retired +pages. Audit internal imports after deletion. + +Run only dependency-related tests and changed-file static checks locally. +Full frontend tests, type checking, and production build belong to GitHub CI. +Local preview must use the configured remote backend, with no mock data, +authentication bypass, or local backend substitution. diff --git a/apps/aevatar-console-web/jest.config.ts b/apps/aevatar-console-web/jest.config.ts index c446a03b97..2f89b752f5 100644 --- a/apps/aevatar-console-web/jest.config.ts +++ b/apps/aevatar-console-web/jest.config.ts @@ -80,22 +80,12 @@ const browserProjectConfig = createProjectConfig('browser'); const nodeProjectConfig = createProjectConfig('node'); const nodeTestFiles = [ - '/src/pages/MissionControl/runtimeAdapter.test.ts', - '/src/pages/actors/actorPresentation.test.ts', - '/src/pages/chat/chatTaskPlan.test.ts', - '/src/pages/governance/components/governanceQuery.test.ts', - '/src/pages/runs/runEventPresentation.test.ts', - '/src/pages/scopes/components/resolvedScope.test.ts', - '/src/pages/scopes/components/scopeQuery.test.ts', - '/src/pages/services/components/serviceQuery.test.ts', - '/src/pages/workflows/workflowPresentation.test.ts', '/src/shared/agui/customEventData.test.ts', '/src/shared/agui/sseFrameNormalizer.test.ts', '/src/shared/config/proxyConfig.test.ts', '/src/shared/datetime/dateTime.test.ts', '/src/shared/playground/stepSummary.test.ts', '/src/shared/studio/document.test.ts', - '/src/shared/studio/navigation.test.ts', '/src/shared/workflows/catalogVisibility.test.ts', ] as const; diff --git a/apps/aevatar-console-web/src/app.layout.test.ts b/apps/aevatar-console-web/src/app.layout.test.ts index fe354809b3..29a03287cb 100644 --- a/apps/aevatar-console-web/src/app.layout.test.ts +++ b/apps/aevatar-console-web/src/app.layout.test.ts @@ -1,212 +1,99 @@ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; -import { getLocale, setLocale } from '@umijs/max'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { setLocale, useIntl } from '@umijs/max'; import React from 'react'; import defaultSettings from '../config/defaultSettings'; +import { createNyxIDServiceSession } from '../tests/fixtures/nyxidServiceSession'; import { layout } from './app'; +import { persistAuthSession } from './shared/auth/session'; +import { history } from './shared/navigation/history'; -describe('layout menu collapse behavior', () => { - beforeEach(() => { - setLocale('en-US', false); - window.history.replaceState({}, '', '/scopes'); +function runtimeLayout() { + return layout({ + initialState: { auth: {} as never, settings: defaultSettings }, }); +} + +function renderRuntime(content: React.ReactNode) { + const renderChildren = runtimeLayout().childrenRender as ( + node: React.ReactNode, + ) => React.ReactNode; + return render( + React.createElement(React.Fragment, null, renderChildren(content)), + ); +} - it('keeps grouped navigation titles hidden in collapsed mode', () => { - const runtimeLayout = layout({ - initialState: { - auth: {} as never, - settings: defaultSettings, - }, - }); - - expect(runtimeLayout.menu).toMatchObject({ - collapsedWidth: 40, - collapsedShowGroupTitle: false, - collapsedShowTitle: false, - type: 'group', - }); - }); - - it('collapses the global menu for Studio create-member intent', () => { - window.history.replaceState( - {}, - '', - '/studio?tab=studio&intent=create-member', - ); - - const runtimeLayout = layout({ - initialState: { - auth: {} as never, - settings: defaultSettings, - }, - }); - - expect(runtimeLayout.defaultCollapsed).toBe(true); - expect(runtimeLayout.collapsed).toBe(true); - }); - - it('leaves the global menu uncontrolled for ordinary Studio entry', () => { - window.history.replaceState({}, '', '/studio?tab=studio'); - - const runtimeLayout = layout({ - initialState: { - auth: {} as never, - settings: defaultSettings, - }, - }); - - expect(runtimeLayout.defaultCollapsed).toBe(false); - expect(runtimeLayout.collapsed).toBeUndefined(); - }); - - it('hides console chrome for the fullscreen Mission Wall route', () => { - window.history.replaceState({}, '', '/runtime/mission-wall'); - - const runtimeLayout = layout({ - initialState: { - auth: {} as never, - settings: defaultSettings, - }, - }); - const menuRender = runtimeLayout.menuRender as - | ((props: unknown, defaultDom: unknown) => React.ReactNode) - | undefined; - const actionsRender = runtimeLayout.actionsRender as - | ((props: unknown, dom: unknown) => React.ReactNode[]) - | undefined; - - expect(runtimeLayout.headerRender).toBe(false); - expect(menuRender?.({}, React.createElement('nav'))).toBe(false); - expect(actionsRender?.({}, {})).toEqual([]); - expect(runtimeLayout.contentStyle).toMatchObject({ - background: '#09110f', - height: '100vh', - overflow: 'hidden', - padding: 0, - }); - }); +beforeEach(() => { + setLocale('en-US', false); + window.history.replaceState({}, '', '/scopes/scope-alpha/workflows'); +}); - it.each([ +it('uses the resource shell without a second global menu or header', () => { + for (const pathname of [ '/workflows', - '/scopes/scope-a/workflow-activity-vnext/workflows/wf-a', - ])('renders workflow route %s without the global console chrome', (pathname) => { + '/scopes/scope-alpha/workflows/wf-alpha', + '/scopes/scope-alpha/activity/run-alpha', + '/scopes/scope-alpha/channels', + '/scopes/scope-alpha/settings', + ]) { window.history.replaceState({}, '', pathname); - - const runtimeLayout = layout({ - initialState: { - auth: {} as never, - settings: defaultSettings, - }, - }); - const menuRender = runtimeLayout.menuRender as - | ((props: unknown, defaultDom: unknown) => React.ReactNode) - | undefined; - const actionsRender = runtimeLayout.actionsRender as - | ((props: unknown, dom: unknown) => React.ReactNode[]) - | undefined; - - expect(runtimeLayout.headerRender).toBe(false); - expect(menuRender?.({}, React.createElement('nav'))).toBe(false); - expect(actionsRender?.({}, {})).toEqual([]); - expect(runtimeLayout.contentStyle).toMatchObject({ - background: '#ffffff', - height: 'auto', + const config = runtimeLayout(); + expect(config.headerRender).toBe(false); + expect((config.menuRender as () => boolean)()).toBe(false); + expect((config.actionsRender as () => unknown[])()).toEqual([]); + expect(config.contentStyle).toMatchObject({ + position: 'fixed', inset: 0, - overflow: 'hidden', padding: 0, - position: 'fixed', - }); - }); - - it('updates the controlled global menu collapse state after SPA route changes', () => { - window.history.replaceState({}, '', '/scopes/scope-a/teams'); - const teamsLayout = layout({ - initialState: { - auth: {} as never, - settings: defaultSettings, - }, - }); - - window.history.pushState({}, '', '/studio?tab=studio&intent=create-member'); - const studioLayout = layout({ - initialState: { - auth: {} as never, - settings: defaultSettings, - }, - }); - - expect(teamsLayout.collapsed).toBeUndefined(); - expect(studioLayout.collapsed).toBe(true); - }); - - it('renders a global language switch in the layout actions', async () => { - const runtimeLayout = layout({ - initialState: { - auth: {} as never, - settings: defaultSettings, - }, - }); - const actionsRender = runtimeLayout.actionsRender as - | ((props: unknown, dom: unknown) => React.ReactNode[]) - | undefined; - - render(React.createElement(React.Fragment, null, actionsRender?.({}, {}))); - - fireEvent.click(screen.getByRole('button', { name: 'Switch language' })); - fireEvent.click(await screen.findByText('中文')); - - await waitFor(() => { - expect(getLocale()).toBe('zh-CN'); + overflow: 'hidden', }); - }); + } +}); - it('keeps page content in sync when the locale changes without a reload', async () => { - window.history.replaceState({}, '', '/studio'); - const runtimeLayout = layout({ - initialState: { - auth: {} as never, - settings: defaultSettings, - }, - }); - const childrenRender = runtimeLayout.childrenRender as - | ((children: React.ReactNode) => React.ReactNode) - | undefined; +it('protects canonical deep links and preserves their query and fragment through login', async () => { + window.history.replaceState( + {}, + '', + '/scopes/scope-alpha/activity/run-alpha?view=steps#output', + ); + const replace = jest.spyOn(history, 'replace').mockImplementation(() => {}); + renderRuntime(React.createElement('p', null, 'Protected content')); + expect(screen.queryByText('Protected content')).not.toBeInTheDocument(); + await waitFor(() => + expect(replace).toHaveBeenCalledWith( + '/login?redirect=%2Fscopes%2Fscope-alpha%2Factivity%2Frun-alpha%3Fview%3Dsteps%23output', + ), + ); +}); - render( - React.createElement( - React.Fragment, - null, - childrenRender?.(React.createElement(LocalizedRuntimeProbe)), - ), +it('leaves login and callback public', () => { + for (const pathname of ['/login', '/auth/callback']) { + window.history.replaceState({}, '', pathname); + const view = renderRuntime( + React.createElement('p', null, 'Public content'), ); + expect(screen.getByText('Public content')).toBeInTheDocument(); + view.unmount(); + } +}); - expect(screen.getByText('My AI teams')).toBeTruthy(); - - act(() => { - setLocale('zh-CN', false); - }); - - await waitFor(() => { - expect(screen.getByText('我的 AI 团队')).toBeTruthy(); - }); - expect(screen.queryByText('My AI teams')).toBeNull(); +it('keeps authenticated page locale changes reactive without restoring legacy layout', async () => { + persistAuthSession(createNyxIDServiceSession()); + renderRuntime(React.createElement(LocalizedRuntimeProbe)); + expect(screen.getByText('Channels')).toBeInTheDocument(); + act(() => { + setLocale('zh-CN', false); }); + await waitFor(() => + expect(screen.queryByText('Channels')).not.toBeInTheDocument(), + ); + expect(screen.getByText('渠道')).toBeInTheDocument(); }); -const LocalizedRuntimeProbe: React.FC = () => { - const { useIntl } = require('@umijs/max') as typeof import('@umijs/max'); +function LocalizedRuntimeProbe() { const intl = useIntl(); - return React.createElement( - 'div', + 'p', null, - intl.formatMessage({ - id: 'teams.home.title', - }), + intl.formatMessage({ id: 'workflowActivityVNext.nav.channels' }), ); -}; +} diff --git a/apps/aevatar-console-web/src/app.navigation.test.ts b/apps/aevatar-console-web/src/app.navigation.test.ts deleted file mode 100644 index d2e12982d5..0000000000 --- a/apps/aevatar-console-web/src/app.navigation.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -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; - } - - function loadMenuGrouper(): typeof import("./shared/navigation/navigationMenuGrouping").groupNavigationMenuItems { - let groupNavigationMenuItems!: typeof import("./shared/navigation/navigationMenuGrouping").groupNavigationMenuItems; - jest.isolateModules(() => { - groupNavigationMenuItems = require("./shared/navigation/navigationMenuGrouping").groupNavigationMenuItems as typeof import("./shared/navigation/navigationMenuGrouping").groupNavigationMenuItems; - }); - return groupNavigationMenuItems; - } - - beforeEach(() => { - jest.resetModules(); - }); - - it("places Chat as a top-level navigation group below Teams", () => { - const groups = loadNavigationGroups(); - - expect(groups.map((group) => group.label)).toEqual([ - "Teams", - "Chat", - "Settings", - ]); - expect(groups.map((group) => group.labelMessageId)).toEqual([ - "nav.groups.teams", - "nav.groups.chat", - "nav.groups.settings", - ]); - expect(groups.find((group) => group.key === "chat")?.flattenSingleItem).toBe(true); - expect(groups.find((group) => group.key === "chat")?.flattenSingleItemAsGroupLabel).toBe(true); - expect(groups.find((group) => group.key === "teams")?.flattenSingleItem).toBeUndefined(); - }); - - it("renders Teams before Chat even when the Chat route is declared first", () => { - const groupNavigationMenuItems = loadMenuGrouper(); - const groups = loadNavigationGroups(); - const menuItems = groupNavigationMenuItems( - [ - { - menuGroupKey: "chat", - name: "Chat", - path: "/chat", - }, - { - menuGroupKey: "teams", - name: "My Teams", - path: "/scopes", - }, - { - menuGroupKey: "platform", - name: "Event Stream", - path: "/runtime/runs", - }, - { - menuGroupKey: "settings", - name: "Settings", - path: "/settings", - }, - { - menuGroupKey: "unknown", - name: "Unknown", - path: "/unknown", - }, - ], - groups, - (group) => group.label, - ); - - expect(menuItems.map((item) => item.path ?? item.key)).toEqual([ - "menu-group:teams", - "/chat", - "/settings", - ]); - expect(menuItems[0].children?.map((child) => child.path)).toEqual([ - "/scopes", - ]); - }); -}); diff --git a/apps/aevatar-console-web/src/app.test.tsx b/apps/aevatar-console-web/src/app.test.tsx index 4eb9638033..426460f033 100644 --- a/apps/aevatar-console-web/src/app.test.tsx +++ b/apps/aevatar-console-web/src/app.test.tsx @@ -1,7 +1,6 @@ import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { ProtectedRouteRedirectGate } from './shared/auth/ProtectedRouteRedirectGate'; -import { requiresGlobalAuthGate } from './shared/auth/routeAccess'; const mockedHistoryReplace = jest.fn(); @@ -32,55 +31,23 @@ describe('ProtectedRouteRedirectGate', () => { }); }); - it('keeps Mission Wall behind the login flow', async () => { + it('keeps Channel editing behind the login flow', async () => { window.history.replaceState( {}, '', - '/runtime/mission-wall?focusRunId=run-1', + '/scopes/scope-alpha/channels/registration-alpha/edit', ); render( React.createElement(ProtectedRouteRedirectGate, { - pathname: '/runtime/mission-wall', + pathname: '/scopes/scope-alpha/channels/registration-alpha/edit', }), ); await waitFor(() => { expect(mockedHistoryReplace).toHaveBeenCalledWith( - '/login?redirect=%2Fruntime%2Fmission-wall%3FfocusRunId%3Drun-1', + '/login?redirect=%2Fscopes%2Fscope-alpha%2Fchannels%2Fregistration-alpha%2Fedit', ); }); }); - - it('preserves the delivered Team member workflow deep link through login', async () => { - window.history.replaceState( - {}, - '', - '/scopes/s-customer/teams/t-hr/members/m-reminder/workflow?workflowId=wf-reminder#run', - ); - - render( - React.createElement(ProtectedRouteRedirectGate, { - pathname: '/scopes/s-customer/teams/t-hr/members/m-reminder/workflow', - }), - ); - - await waitFor(() => { - expect(mockedHistoryReplace).toHaveBeenCalledWith( - '/login?redirect=%2Fscopes%2Fs-customer%2Fteams%2Ft-hr%2Fmembers%2Fm-reminder%2Fworkflow%3FworkflowId%3Dwf-reminder%23run', - ); - }); - }); -}); - -describe('global auth route classification', () => { - it('protects canonical Team member workflow routes while legacy Studio keeps its own recovery', () => { - expect( - requiresGlobalAuthGate( - '/scopes/s-customer/teams/t-hr/members/m-reminder/workflow', - ), - ).toBe(true); - expect(requiresGlobalAuthGate('/studio')).toBe(false); - expect(requiresGlobalAuthGate('/login')).toBe(false); - }); }); diff --git a/apps/aevatar-console-web/src/app.tsx b/apps/aevatar-console-web/src/app.tsx index e4d46c5c13..eba3ffcec2 100644 --- a/apps/aevatar-console-web/src/app.tsx +++ b/apps/aevatar-console-web/src/app.tsx @@ -1,24 +1,16 @@ import { ProConfigProvider } from '@ant-design/pro-components'; import { QueryClientProvider } from '@tanstack/react-query'; import { getLocale, useIntl } from '@umijs/max'; -import { Badge, ConfigProvider } from 'antd'; +import { ConfigProvider } from 'antd'; import React from 'react'; -import BrandLogo from '@/components/BrandLogo'; -import MainLayout from '@/layouts/MainLayout'; -import { buildMissionSnapshotFromRuntime } from '@/pages/MissionControl/runtimeAdapter'; -import { readMissionControlRouteContext } from '@/pages/MissionControl/services/api'; -import { runtimeActorsApi } from '@/shared/api/runtimeActorsApi'; -import { runtimeRunsApi } from '@/shared/api/runtimeRunsApi'; import { normalizeConsoleLocale, resolveAntdLocale, resolveProIntl, } from '@/shared/i18n/localeProvider'; import { CONSOLE_HOME_ROUTE } from '@/shared/navigation/consoleHome'; -import { loadRecentRuns } from '@/shared/runs/recentRuns'; import { AevatarPageLoading } from '@/shared/ui/AevatarLoading'; import { aevatarThemeConfig } from '@/shared/ui/aevatarWorkbench'; -import { ConsoleHeaderActions } from '@/shared/ui/ConsoleHeaderActions'; import { ConsoleToastProvider } from '@/shared/ui/ConsoleToast'; import defaultSettings from '../config/defaultSettings'; import { errorConfig } from './requestErrorConfig'; @@ -28,691 +20,85 @@ import { } from './shared/auth/client'; import { getNyxIDRuntimeConfig } from './shared/auth/config'; import { ProtectedRouteRedirectGate } from './shared/auth/ProtectedRouteRedirectGate'; -import { - PUBLIC_ROUTES, - requiresGlobalAuthGate, -} from './shared/auth/routeAccess'; import { buildAuthInitialState, loadStoredAuthSession, sanitizeReturnTo, } from './shared/auth/session'; import { history } from './shared/navigation/history'; -import { - getNavigationGroupOrder, - type NavigationGroup, -} from './shared/navigation/navigationGroups'; -import { - groupNavigationMenuItems, - type NavigationMenuItem, -} from './shared/navigation/navigationMenuGrouping'; -import { getNavigationSelectedKeys } from './shared/navigation/navigationMenuSelection'; import { queryClient } from './shared/query/queryClient'; -const DEFAULT_PROTECTED_ROUTE = CONSOLE_HOME_ROUTE; -const FULLSCREEN_DISPLAY_ROUTES = new Set(['/runtime/mission-wall']); -const WORKFLOW_ACTIVITY_VNEXT_ROUTE = - /^\/scopes\/[^/]+\/workflow-activity-vnext(?:\/|$)/; -const STUDIO_HOST_ROUTES = new Set([ - '/studio', - '/scopes/:scopeId/teams/:teamId/members/new/workflow', - '/scopes/:scopeId/teams/:teamId/members/:memberId/workflow', +const PUBLIC_ROUTES = new Set([ + '/login', + '/auth/callback', + ...(process.env.AEVATAR_WORKFLOW_CANVAS_BENCHMARK === '1' + ? ['/workflow-canvas-benchmark'] + : []), ]); -function isFullscreenDisplayRoute(pathname: string): boolean { - return ( - FULLSCREEN_DISPLAY_ROUTES.has(pathname) || - isWorkflowActivityVNextRoute(pathname) - ); -} - -function isWorkflowActivityVNextRoute(pathname: string): boolean { - return ( - pathname === CONSOLE_HOME_ROUTE || - WORKFLOW_ACTIVITY_VNEXT_ROUTE.test(pathname) - ); -} - -function isStudioHostRoute(pathname: string): boolean { - if (STUDIO_HOST_ROUTES.has(pathname)) { - return true; - } - - return /^\/scopes\/[^/]+\/teams\/[^/]+\/members\/(?:new|[^/]+)\/workflow$/.test( - pathname, - ); -} - -function shouldDefaultCollapseLayout( - pathname: string, - search: string, -): boolean { - if (!isStudioHostRoute(pathname)) { - return false; - } - - return new URLSearchParams(search).get('intent') === 'create-member'; -} - -function shouldCollapseLayout(pathname: string, search: string): boolean { - return shouldDefaultCollapseLayout(pathname, search); -} - -function buildLoginRoute(returnTo: string): string { - const params = new URLSearchParams({ - redirect: sanitizeReturnTo(returnTo), - }); - return `/login?${params.toString()}`; -} - -function getCurrentReturnTo(pathname: string): string { - return pathname === '/' - ? DEFAULT_PROTECTED_ROUTE - : `${pathname}${window.location.search}${window.location.hash}`; -} - -/** - * @see https://umijs.org/docs/api/runtime-config#getinitialstate - * */ export async function getInitialState(): Promise<{ settings: typeof defaultSettings; auth: ReturnType; }> { - const authConfig = getNyxIDRuntimeConfig(); - return { settings: defaultSettings, - auth: buildAuthInitialState(authConfig), + auth: buildAuthInitialState(getNyxIDRuntimeConfig()), }; } type RuntimeInitialState = Awaited>; -type LayoutRuntimeProps = { - initialState?: RuntimeInitialState; -}; - -type LiveOpsAttentionSnapshot = { - hasPendingAttention: boolean; - pendingCount: number; -}; -type LiveOpsAttentionCandidate = { - actorId?: string; - runId?: string; - scopeId?: string; - serviceId?: string; -}; - -type AuthSessionBootstrapProps = { +const AuthSessionBootstrap: React.FC<{ pathname: string; children: React.ReactNode; -}; - -type ConsoleRuntimeProvidersProps = { - children: React.ReactNode; - isFullscreenDisplayRoute: boolean; - isPublicRoute: boolean; - isStudioRoute: boolean; - pathname: string; - search: string; -}; - -const LIVE_OPS_ATTENTION_BADGE_KEY = 'live.attention'; -const LIVE_OPS_ATTENTION_MAX_CANDIDATES = 6; -const LIVE_OPS_ATTENTION_MAX_AGE_MS = 12 * 60 * 60 * 1000; -const LIVE_OPS_ATTENTION_REFRESH_MS = 30_000; -const NAVIGATION_GROUP_ORDER: readonly NavigationGroup[] = - getNavigationGroupOrder(); -const NAVIGATION_MENU_MESSAGE_IDS: Readonly> = { - '/chat': 'nav.items.chat', - '/scopes': 'nav.items.myTeams', - '/runtime/runs': 'nav.items.eventStream', - '/services': 'nav.items.services', - '/governance': 'nav.items.governance', - '/deployments': 'nav.items.deployments', - '/runtime/explorer': 'nav.items.topology', - '/settings': 'nav.items.settings', -}; -const LIVE_OPS_DEFAULT_ATTENTION_SNAPSHOT: LiveOpsAttentionSnapshot = { - hasPendingAttention: false, - pendingCount: 0, -}; -const liveOpsAttentionListeners = new Set<() => void>(); -let liveOpsAttentionSnapshot = LIVE_OPS_DEFAULT_ATTENTION_SNAPSHOT; - -const navigationGroupLabelStyle: React.CSSProperties = { - color: '#667085', - display: 'inline-flex', - fontSize: 14, - fontWeight: 700, - lineHeight: '22px', -}; - -const LocalizedNavigationText: React.FC<{ - defaultLabel?: React.ReactNode; - messageId: string; -}> = ({ defaultLabel, messageId }) => { - const intl = useIntl(); - const defaultMessage = - typeof defaultLabel === 'string' ? defaultLabel : undefined; - - return ( - <> - {intl.formatMessage({ - defaultMessage, - id: messageId, - })} - - ); -}; - -const NavigationGroupLabel: React.FC<{ - group: NavigationGroup; -}> = ({ group }) => ( - - - -); - -function trimOptional(value?: string | null): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - -function subscribeLiveOpsAttention(listener: () => void): () => void { - liveOpsAttentionListeners.add(listener); - return () => { - liveOpsAttentionListeners.delete(listener); - }; -} - -function getLiveOpsAttentionSnapshot(): LiveOpsAttentionSnapshot { - return liveOpsAttentionSnapshot; -} - -function setLiveOpsAttentionSnapshot(next: LiveOpsAttentionSnapshot): void { - if ( - liveOpsAttentionSnapshot.pendingCount === next.pendingCount && - liveOpsAttentionSnapshot.hasPendingAttention === next.hasPendingAttention - ) { - return; - } - - liveOpsAttentionSnapshot = next; - liveOpsAttentionListeners.forEach((listener) => { - listener(); - }); -} - -function buildLiveOpsAttentionCandidateKey( - candidate: LiveOpsAttentionCandidate, -): string { - const actorId = trimOptional(candidate.actorId); - if (actorId) { - return `actor:${actorId}`; - } - - return [ - 'run', - trimOptional(candidate.scopeId) || '', - trimOptional(candidate.serviceId) || '', - trimOptional(candidate.runId) || '', - ].join(':'); -} - -function collectLiveOpsAttentionCandidates( - pathname: string, - search: string, -): LiveOpsAttentionCandidate[] { - const nowMs = Date.now(); - const deduped = new Map(); - - for (const entry of loadRecentRuns()) { - const recordedAtMs = Date.parse(entry.recordedAt); - if ( - Number.isFinite(recordedAtMs) && - nowMs - recordedAtMs > LIVE_OPS_ATTENTION_MAX_AGE_MS - ) { - continue; - } - - if (entry.status === 'finished' || entry.status === 'error') { - continue; - } - - const candidate: LiveOpsAttentionCandidate = { - actorId: trimOptional(entry.actorId), - runId: trimOptional(entry.runId), - scopeId: trimOptional(entry.scopeId), - serviceId: trimOptional(entry.serviceOverrideId), - }; - const key = buildLiveOpsAttentionCandidateKey(candidate); - if (!deduped.has(key)) { - deduped.set(key, candidate); - } - - if (deduped.size >= LIVE_OPS_ATTENTION_MAX_CANDIDATES) { - break; - } - } - - if (pathname === '/runtime/mission-control') { - const context = readMissionControlRouteContext(search); - const candidate: LiveOpsAttentionCandidate = { - actorId: trimOptional(context.actorId), - runId: trimOptional(context.runId), - scopeId: trimOptional(context.scopeId), - serviceId: trimOptional(context.serviceId), - }; - const key = buildLiveOpsAttentionCandidateKey(candidate); - if ( - (candidate.actorId || (candidate.scopeId && candidate.runId)) && - !deduped.has(key) - ) { - deduped.set(key, candidate); - } - } - - return Array.from(deduped.values()).slice( - 0, - LIVE_OPS_ATTENTION_MAX_CANDIDATES, - ); -} - -async function resolveLiveOpsAttentionActorId( - candidate: LiveOpsAttentionCandidate, -): Promise { - const actorId = trimOptional(candidate.actorId); - if (actorId) { - return actorId; - } - - const scopeId = trimOptional(candidate.scopeId); - const runId = trimOptional(candidate.runId); - if (!scopeId || !runId) { - return undefined; - } - - try { - const summary = await runtimeRunsApi.getRunSummary(scopeId, runId, { - serviceId: trimOptional(candidate.serviceId), - }); - return trimOptional(summary.actorId); - } catch { - return undefined; - } -} - -async function runNeedsLiveOpsAttention( - candidate: LiveOpsAttentionCandidate, -): Promise { - const actorId = await resolveLiveOpsAttentionActorId(candidate); - if (!actorId) { - return false; - } - - try { - const fetchedAtMs = Date.now(); - const [graph, timeline] = await Promise.all([ - runtimeActorsApi.getActorGraphEnriched(actorId, { - depth: 4, - direction: 'Both', - take: 120, - }), - runtimeActorsApi.getActorTimeline(actorId, { - take: 120, - }), - ]); - - const snapshot = buildMissionSnapshotFromRuntime({ - connectionStatus: 'degraded', - nowMs: fetchedAtMs, - recentEvents: [], - routeContext: { - actorId, - runId: trimOptional(candidate.runId), - scopeId: trimOptional(candidate.scopeId), - serviceId: trimOptional(candidate.serviceId), - }, - resources: { - artifacts: { - fetchedAtMs, - graph, - timeline, - }, - session: { - runId: trimOptional(candidate.runId), - status: 'running', - }, - }, - }); - - return ( - snapshot.intervention?.required === true && - (snapshot.intervention.kind === 'human_approval' || - snapshot.intervention.kind === 'human_input') - ); - } catch { - return false; - } -} - -async function loadLiveOpsAttentionSnapshot( - pathname: string, - search: string, -): Promise { - const candidates = collectLiveOpsAttentionCandidates(pathname, search); - if (candidates.length === 0) { - return LIVE_OPS_DEFAULT_ATTENTION_SNAPSHOT; - } - - const results = await Promise.allSettled( - candidates.map((candidate) => runNeedsLiveOpsAttention(candidate)), - ); - const pendingCount = results.reduce((count, result) => { - if (result.status === 'fulfilled' && result.value) { - return count + 1; - } - - return count; - }, 0); - - return { - hasPendingAttention: pendingCount > 0, - pendingCount, - }; -} - -const NavigationMenuLabel: React.FC<{ - badgeKey?: string; - label: React.ReactNode; - showLiveOpsDot?: boolean; -}> = React.memo(({ badgeKey, label, showLiveOpsDot = false }) => { - const snapshot = React.useSyncExternalStore( - subscribeLiveOpsAttention, - getLiveOpsAttentionSnapshot, - getLiveOpsAttentionSnapshot, - ); - const showCountBadge = - badgeKey === LIVE_OPS_ATTENTION_BADGE_KEY && snapshot.pendingCount > 0; - - return ( - - - - {label} - - {showLiveOpsDot && snapshot.hasPendingAttention ? ( - - {showCountBadge ? ( - - ) : null} - - ); -}); - -NavigationMenuLabel.displayName = 'NavigationMenuLabel'; - -const LiveOpsGroupIcon: React.FC<{ - icon: React.ReactNode; -}> = React.memo(({ icon }) => { - const snapshot = React.useSyncExternalStore( - subscribeLiveOpsAttention, - getLiveOpsAttentionSnapshot, - getLiveOpsAttentionSnapshot, - ); - - if (!snapshot.hasPendingAttention || !React.isValidElement(icon)) { - return <>{icon}; - } - - return ( - - {icon} - - ); -}); - -LiveOpsGroupIcon.displayName = 'LiveOpsGroupIcon'; - -function decorateNavigationMenuItems( - items: NavigationMenuItem[], - groupItems = true, -): NavigationMenuItem[] { - const sourceItems = groupItems - ? groupNavigationMenuItems(items, NAVIGATION_GROUP_ORDER, (group) => - React.createElement(NavigationGroupLabel, { group }), - ) - : items; - - return sourceItems.map((item) => { - const path = typeof item.path === 'string' ? item.path : undefined; - const badgeKey = - typeof item.menuBadgeKey === 'string' ? item.menuBadgeKey : undefined; - const groupKey = - typeof item.menuGroupKey === 'string' ? item.menuGroupKey : undefined; - const nameMessageId = - path && typeof item.name === 'string' - ? NAVIGATION_MENU_MESSAGE_IDS[path] - : undefined; - const children = Array.isArray(item.children) - ? decorateNavigationMenuItems(item.children, false) - : undefined; - const isLiveOpsGroup = - groupKey === 'live' && Array.isArray(children) && children.length > 0; - const hasRenderableIcon = React.isValidElement(item.icon); - const localizedName = nameMessageId - ? React.createElement(LocalizedNavigationText, { - defaultLabel: item.name, - messageId: nameMessageId, - }) - : item.name; - const name = - badgeKey || isLiveOpsGroup - ? React.createElement(NavigationMenuLabel, { - badgeKey, - label: localizedName, - showLiveOpsDot: isLiveOpsGroup && !hasRenderableIcon, - }) - : localizedName; - const icon = - isLiveOpsGroup && hasRenderableIcon - ? React.createElement(LiveOpsGroupIcon, { - icon: item.icon, - }) - : item.icon; - - return { - ...item, - children, - icon, - name, - }; - }); -} - -const LiveOpsAttentionBridge: React.FC<{ - enabled: boolean; - pathname: string; - search: string; -}> = ({ enabled, pathname, search }) => { - React.useEffect(() => { - if (!enabled) { - setLiveOpsAttentionSnapshot(LIVE_OPS_DEFAULT_ATTENTION_SNAPSHOT); - return undefined; - } - - let cancelled = false; - let refreshing = false; - - const refresh = async () => { - if (refreshing || cancelled) { - return; - } - - refreshing = true; - try { - const snapshot = await loadLiveOpsAttentionSnapshot(pathname, search); - if (!cancelled) { - setLiveOpsAttentionSnapshot(snapshot); - } - } finally { - refreshing = false; - } - }; - - const refreshWhenVisible = () => { - if (document.visibilityState === 'visible') { - void refresh(); - } - }; - - const refreshOnFocus = () => { - void refresh(); - }; - - void refresh(); - const intervalId = window.setInterval(() => { - void refresh(); - }, LIVE_OPS_ATTENTION_REFRESH_MS); - document.addEventListener('visibilitychange', refreshWhenVisible); - window.addEventListener('focus', refreshOnFocus); - window.addEventListener('storage', refreshOnFocus); - - return () => { - cancelled = true; - window.clearInterval(intervalId); - document.removeEventListener('visibilitychange', refreshWhenVisible); - window.removeEventListener('focus', refreshOnFocus); - window.removeEventListener('storage', refreshOnFocus); - }; - }, [enabled, pathname, search]); - - return null; -}; - -const AuthSessionBootstrap: React.FC = ({ - pathname, - children, -}) => { +}> = ({ pathname, children }) => { const [ready, setReady] = React.useState(() => Boolean(loadStoredAuthSession()), ); - React.useEffect(() => { let cancelled = false; - if (loadStoredAuthSession()) { setReady(true); return undefined; } - setReady(false); void ensureActiveAuthSession().then((session) => { - if (cancelled) { - return; - } - + if (cancelled) return; if (!session) { - history.replace(buildLoginRoute(getCurrentReturnTo(pathname))); + const returnTo = + pathname === '/' + ? CONSOLE_HOME_ROUTE + : `${pathname}${window.location.search}${window.location.hash}`; + const params = new URLSearchParams({ + redirect: sanitizeReturnTo(returnTo), + }); + history.replace(`/login?${params.toString()}`); return; } - setReady(true); }); - return () => { cancelled = true; }; }, [pathname]); - - if (!ready) { - return ; - } - - return <>{children}; + return ready ? children : ; }; -const ConsoleRuntimeProviders: React.FC = ({ +const ConsoleRuntimeProviders: React.FC<{ children: React.ReactNode }> = ({ children, - isFullscreenDisplayRoute, - isPublicRoute, - isStudioRoute, - pathname, - search, }) => { const intl = useIntl(); - const currentLocale = normalizeConsoleLocale(intl.locale || getLocale()); - const localizedContent = - isPublicRoute || isFullscreenDisplayRoute ? ( - children - ) : ( - {children} - ); - + const locale = normalizeConsoleLocale(intl.locale || getLocale()); return ( - + - - - {localizedContent} - + {children} @@ -720,144 +106,49 @@ const ConsoleRuntimeProviders: React.FC = ({ ); }; -// ProLayout runtime API: https://procomponents.ant.design/components/layout export const layout = ({ initialState, -}: LayoutRuntimeProps): Record => { - const pathname = window.location.pathname; - const search = window.location.search; - const collapseForRoute = shouldCollapseLayout(pathname, search); - const fullscreenDisplayRoute = isFullscreenDisplayRoute(pathname); - const workflowActivityVNextRoute = isWorkflowActivityVNextRoute(pathname); - - return { - onPageChange: () => { - const pathname = window.location.pathname; - if (PUBLIC_ROUTES.has(pathname)) { - return; - } - - if (isStudioHostRoute(pathname)) { - return; - } - - if (pathname === '/') { - history.replace(DEFAULT_PROTECTED_ROUTE); - } - }, - postMenuData: (menuData: NavigationMenuItem[]) => - decorateNavigationMenuItems(menuData), - menuRender: (_: unknown, defaultDom: React.ReactNode) => { - if (isFullscreenDisplayRoute(window.location.pathname)) { - return false; - } - - if (!React.isValidElement(defaultDom)) { - return defaultDom; - } - - return React.cloneElement( - defaultDom as React.ReactElement<{ selectedKeys?: string[] }>, - { - selectedKeys: getNavigationSelectedKeys(window.location.pathname), - }, - ); - }, - actionsRender: () => { - if (isFullscreenDisplayRoute(window.location.pathname)) { - return []; - } - - return []; - }, - childrenRender: (children: React.ReactNode) => - initialState ? ( - (() => { - const pathname = window.location.pathname; - const search = window.location.search; - const isPublicRoute = PUBLIC_ROUTES.has(pathname); - const isStudioRoute = isStudioHostRoute(pathname); - const isDisplayRoute = isFullscreenDisplayRoute(pathname); - const requiresGlobalAuth = requiresGlobalAuthGate(pathname); - const liveSession = loadStoredAuthSession(); - const needsProtectedRouteRedirect = - requiresGlobalAuth && !liveSession && !hasRestorableAuthSession(); - - const content = needsProtectedRouteRedirect ? ( - - ) : requiresGlobalAuth && !liveSession ? ( - - {children} - - ) : ( - children - ); - return ( - - {content} - - ); - })() +}: { + initialState?: RuntimeInitialState; +}): Record => ({ + ...initialState?.settings, + title: '', + headerRender: false, + menuRender: () => false, + actionsRender: () => [], + contentStyle: { + background: '#ffffff', + display: 'block', + height: 'auto', + inset: 0, + minHeight: 0, + overflow: 'hidden', + padding: 0, + position: 'fixed', + width: '100%', + }, + onPageChange: () => { + if (window.location.pathname === '/') history.replace(CONSOLE_HOME_ROUTE); + }, + childrenRender: (children: React.ReactNode) => { + if (!initialState) return ; + const pathname = window.location.pathname; + const isPublicRoute = PUBLIC_ROUTES.has(pathname); + const liveSession = loadStoredAuthSession(); + const content = + !isPublicRoute && !liveSession ? ( + hasRestorableAuthSession() ? ( + + {children} + + ) : ( + + ) ) : ( - - ), - ...initialState?.settings, - title: '', - menu: { - ...(initialState?.settings.menu as Record | undefined), - collapsedWidth: 40, - collapsedShowGroupTitle: false, - collapsedShowTitle: false, - type: 'group', - }, - contentStyle: workflowActivityVNextRoute - ? { - background: '#ffffff', - display: 'block', - height: 'auto', - inset: 0, - minHeight: 0, - overflow: 'hidden', - padding: 0, - position: 'fixed', - width: '100%', - } - : fullscreenDisplayRoute - ? { - background: '#09110f', - display: 'block', - height: '100vh', - minHeight: 0, - overflow: 'hidden', - padding: 0, - } - : { - background: 'transparent', - display: 'flex', - flexDirection: 'column', - height: 'calc(100vh - 56px)', - minHeight: 0, - overflow: 'hidden', - padding: 0, - }, - defaultCollapsed: shouldDefaultCollapseLayout(pathname, search), - headerRender: fullscreenDisplayRoute ? false : undefined, - ...(collapseForRoute ? { collapsed: true } : {}), - logo: , - }; -}; + children + ); + return {content}; + }, +}); -/** - * @name request config - * Centralizes network request error handling through the Umi request plugin. - * @doc https://umijs.org/docs/max/request#config - */ -export const request: Record = { - ...errorConfig, -}; +export const request: Record = { ...errorConfig }; diff --git a/apps/aevatar-console-web/src/layouts/MainLayout.test.tsx b/apps/aevatar-console-web/src/layouts/MainLayout.test.tsx deleted file mode 100644 index 323bb2f0b7..0000000000 --- a/apps/aevatar-console-web/src/layouts/MainLayout.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { act, render, waitFor } from "@testing-library/react"; -import { ConfigProvider } from "antd"; -import React from "react"; -import MainLayout from "./MainLayout"; -import { history } from "@/shared/navigation/history"; -import { STUDIO_HOST_BODY_CLASS } from "@/shared/studio/studioLayout"; -import { aevatarThemeConfig } from "@/shared/ui/aevatarWorkbench"; - -function renderMainLayout(): void { - render( - - -
layout content
-
-
, - ); -} - -describe("MainLayout", () => { - beforeEach(() => { - document.body.classList.remove(STUDIO_HOST_BODY_CLASS); - }); - - it("clears stale Studio host styling on non-Studio routes", async () => { - document.body.classList.add(STUDIO_HOST_BODY_CLASS); - window.history.replaceState({}, "", "/scopes"); - - renderMainLayout(); - - await waitFor(() => { - expect(document.body.classList.contains(STUDIO_HOST_BODY_CLASS)).toBe(false); - }); - }); - - it("tracks Studio route transitions while the shell stays mounted", async () => { - window.history.replaceState({}, "", "/scopes"); - - renderMainLayout(); - - await waitFor(() => { - expect(document.body.classList.contains(STUDIO_HOST_BODY_CLASS)).toBe(false); - }); - - act(() => { - history.push("/studio"); - }); - - await waitFor(() => { - expect(document.body.classList.contains(STUDIO_HOST_BODY_CLASS)).toBe(true); - }); - - act(() => { - history.push("/scopes"); - }); - - await waitFor(() => { - expect(document.body.classList.contains(STUDIO_HOST_BODY_CLASS)).toBe(false); - }); - }); -}); diff --git a/apps/aevatar-console-web/src/layouts/MainLayout.tsx b/apps/aevatar-console-web/src/layouts/MainLayout.tsx deleted file mode 100644 index 0cb34b99cd..0000000000 --- a/apps/aevatar-console-web/src/layouts/MainLayout.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { theme } from "antd"; -import React from "react"; -import { - getLocationSnapshot, - subscribeToLocationChanges, -} from "@/shared/navigation/history"; -import { syncStudioHostBodyClass } from "@/shared/studio/studioLayout"; -import { - buildAevatarViewportStyle, - type AevatarThemeSurfaceToken, -} from "@/shared/ui/aevatarWorkbench"; - -type MainLayoutProps = { - children: React.ReactNode; -}; - -const MainLayout: React.FC = ({ children }) => { - const { token } = theme.useToken(); - const locationSnapshot = React.useSyncExternalStore( - subscribeToLocationChanges, - getLocationSnapshot, - () => "", - ); - - React.useEffect(() => { - const pathname = locationSnapshot.split("?")[0]?.split("#")[0] ?? ""; - return syncStudioHostBodyClass(pathname === "/studio"); - }, [locationSnapshot]); - - return ( -
-
- {children} -
-
- ); -}; - -export default MainLayout; diff --git a/apps/aevatar-console-web/src/pages/Deployments/deploymentActionAvailability.test.ts b/apps/aevatar-console-web/src/pages/Deployments/deploymentActionAvailability.test.ts deleted file mode 100644 index 9f2fdb34b6..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/deploymentActionAvailability.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { setLocale } from '@umijs/max'; -import { buildDeploymentDeactivateAvailability } from './deploymentActionAvailability'; - -describe('buildDeploymentDeactivateAvailability', () => { - beforeEach(() => { - setLocale('zh-CN', false); - }); - - const deployment = { - activatedAt: '2026-03-30T10:00:00Z', - deploymentId: 'dep-1', - primaryActorId: 'actor-1', - revisionId: 'rev-11', - status: 'active', - updatedAt: '2026-03-30T10:05:00Z', - }; - - it('disables deactivate when no deployment is selected', () => { - const availability = buildDeploymentDeactivateAvailability(null); - - expect(availability.enabled).toBe(false); - expect(availability.reason).toContain('未选中部署'); - }); - - it('allows deactivate for active deployments', () => { - const availability = buildDeploymentDeactivateAvailability(deployment); - - expect(availability.enabled).toBe(true); - expect(availability.reason).toContain('仍需等待'); - }); - - it('disables deactivate for inactive deployments', () => { - const availability = buildDeploymentDeactivateAvailability({ - ...deployment, - status: 'inactive', - }); - - expect(availability.enabled).toBe(false); - expect(availability.reason).toContain('只适用于活动部署'); - }); - - it('disables deactivate for retired deployments', () => { - const availability = buildDeploymentDeactivateAvailability({ - ...deployment, - status: 'retired', - }); - - expect(availability.enabled).toBe(false); - expect(availability.summary).toContain('不可停用'); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/Deployments/deploymentActionAvailability.ts b/apps/aevatar-console-web/src/pages/Deployments/deploymentActionAvailability.ts deleted file mode 100644 index dd04307f11..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/deploymentActionAvailability.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ServiceDeploymentSnapshot } from '@/shared/models/services'; -import { t } from "@/shared/i18n/messages"; - -export type DeploymentActionAvailability = { - enabled: boolean; - reason: string; - summary: string; -}; - -function normalizeStatus(status: string | null | undefined): string { - return (status ?? '').replace(/[^a-z0-9]/gi, '').toLowerCase(); -} - -function isActiveDeploymentStatus(status: string | null | undefined): boolean { - const normalized = normalizeStatus(status); - if (normalized === 'inactive' || normalized === 'deactivated') { - return false; - } - - return ( - normalized === 'active' || - normalized === 'activated' || - normalized === 'canary' || - normalized === 'ready' || - normalized === 'running' || - normalized.startsWith('active') - ); -} - -export function buildDeploymentDeactivateAvailability( - deployment: ServiceDeploymentSnapshot | null | undefined, -): DeploymentActionAvailability { - if (!deployment?.deploymentId?.trim()) { - return { - enabled: false, - reason: t("pages.deployments.deploymentactionavailability.the.deployment.is.not", "The deployment is not selected and the deactivation command cannot be submitted."), - summary: t("pages.deployments.deploymentactionavailability.deployment.is.not.selected", "deployment is not selected."), - }; - } - - if (!isActiveDeploymentStatus(deployment.status)) { - return { - enabled: false, - reason: t("pages.deployments.deploymentactionavailability.the.current.deployment.status", "The current deployment status is {value1} and the deactivation command only applies to active deployments.", { value1: deployment.status || 'unknown' }), - summary: t("pages.deployments.deploymentactionavailability.the.current.deployment.cannot", "The current deployment cannot be deactivated."), - }; - } - - return { - enabled: true, - reason: t("pages.deployments.deploymentactionavailability.deactivation.will.submit.the", "Deactivation will submit the command and still need to wait for the catalog/serving/traffic evidence to be refreshed."), - summary: t("pages.deployments.deploymentactionavailability.the.current.deployment.can", "The current deployment can submit deactivation commands."), - }; -} diff --git a/apps/aevatar-console-web/src/pages/Deployments/index.test.tsx b/apps/aevatar-console-web/src/pages/Deployments/index.test.tsx deleted file mode 100644 index b800c5ab68..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/index.test.tsx +++ /dev/null @@ -1,668 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { setLocale } from '@umijs/max'; -import React from 'react'; -import { - cleanupTestQueryClients, - renderWithQueryClient, -} from '../../../tests/reactQueryTestUtils'; -import DeploymentsPage from './index'; - -jest.mock('@/shared/api/servicesApi', () => ({ - servicesApi: { - advanceRollout: jest.fn(), - deactivateDeployment: jest.fn(), - deployRevision: jest.fn(), - getDeployments: jest.fn(), - getRevisions: jest.fn(), - getRollout: jest.fn(), - getService: jest.fn(), - getServingSet: jest.fn(), - getTraffic: jest.fn(), - listServices: jest.fn(), - pauseRollout: jest.fn(), - replaceServingTargets: jest.fn(), - resumeRollout: jest.fn(), - rollbackRollout: jest.fn(), - }, -})); - -jest.mock('@/shared/studio/api', () => ({ - studioApi: { - getAuthSession: jest.fn(async () => ({ - scope: { - id: 'scope-1', - }, - })), - }, -})); - -const { servicesApi: mockServicesApi } = jest.requireMock( - '@/shared/api/servicesApi', -) as { - servicesApi: { - advanceRollout: jest.Mock; - deactivateDeployment: jest.Mock; - deployRevision: jest.Mock; - getDeployments: jest.Mock; - getRevisions: jest.Mock; - getRollout: jest.Mock; - getService: jest.Mock; - getServingSet: jest.Mock; - getTraffic: jest.Mock; - listServices: jest.Mock; - pauseRollout: jest.Mock; - replaceServingTargets: jest.Mock; - resumeRollout: jest.Mock; - rollbackRollout: jest.Mock; - }; -}; - -function renderDeploymentsPage(path = '/deployments?tenantId=scope-1') { - window.history.replaceState({}, '', path); - return renderWithQueryClient(React.createElement(DeploymentsPage)); -} - -beforeEach(() => { - jest.clearAllMocks(); - setLocale('zh-CN', false); - - mockServicesApi.listServices.mockResolvedValue([ - { - serviceKey: 'scope-1:trade-agent', - tenantId: 'scope-1', - appId: 'trade-app', - namespace: 'cn.market', - serviceId: 'trade-agent', - displayName: 'Trade Agent', - defaultServingRevisionId: 'rev-11', - activeServingRevisionId: 'rev-11', - deploymentId: 'dep-1', - primaryActorId: 'actor-1', - deploymentStatus: 'active', - endpoints: [], - policyIds: ['policy-1'], - updatedAt: '2026-03-30T10:00:00Z', - }, - ]); - - mockServicesApi.getService.mockResolvedValue({ - serviceKey: 'scope-1:trade-agent', - tenantId: 'scope-1', - appId: 'trade-app', - namespace: 'cn.market', - serviceId: 'trade-agent', - displayName: 'Trade Agent', - defaultServingRevisionId: 'rev-11', - activeServingRevisionId: 'rev-11', - deploymentId: 'dep-1', - primaryActorId: 'actor-1', - deploymentStatus: 'active', - endpoints: [], - policyIds: ['policy-1'], - updatedAt: '2026-03-30T10:00:00Z', - }); - - mockServicesApi.getRevisions.mockResolvedValue({ - serviceKey: 'scope-1:trade-agent', - revisions: [ - { - revisionId: 'rev-12', - implementationKind: 'workflow', - status: 'validated', - artifactHash: 'hash-12', - failureReason: '', - endpoints: [], - createdAt: '2026-03-30T10:00:00Z', - preparedAt: '2026-03-30T10:02:00Z', - publishedAt: '2026-03-30T10:05:00Z', - retiredAt: null, - }, - { - revisionId: 'rev-11', - implementationKind: 'workflow', - status: 'active', - artifactHash: 'hash-11', - failureReason: '', - endpoints: [], - createdAt: '2026-03-29T10:00:00Z', - preparedAt: '2026-03-29T10:02:00Z', - publishedAt: '2026-03-29T10:05:00Z', - retiredAt: null, - }, - ], - updatedAt: '2026-03-30T10:00:00Z', - }); - - mockServicesApi.getDeployments.mockResolvedValue({ - serviceKey: 'scope-1:trade-agent', - deployments: [ - { - deploymentId: 'dep-1', - revisionId: 'rev-11', - primaryActorId: 'actor-1', - status: 'active', - activatedAt: '2026-03-29T10:05:00Z', - updatedAt: '2026-03-30T10:00:00Z', - }, - ], - updatedAt: '2026-03-30T10:00:00Z', - }); - - mockServicesApi.getServingSet.mockResolvedValue({ - serviceKey: 'scope-1:trade-agent', - generation: 3, - activeRolloutId: 'rollout-1', - targets: [ - { - deploymentId: 'dep-1', - revisionId: 'rev-11', - primaryActorId: 'actor-1', - allocationWeight: 90, - servingState: 'active', - enabledEndpointIds: ['chat'], - }, - { - deploymentId: 'dep-2', - revisionId: 'rev-12', - primaryActorId: 'actor-2', - allocationWeight: 10, - servingState: 'canary', - enabledEndpointIds: ['chat'], - }, - ], - updatedAt: '2026-03-30T10:00:00Z', - }); - - mockServicesApi.getRollout.mockResolvedValue({ - serviceKey: 'scope-1:trade-agent', - rolloutId: 'rollout-1', - displayName: 'March Canary', - status: 'canary', - currentStageIndex: 1, - stages: [ - { - stageId: 'stage-0', - stageIndex: 0, - targets: [], - }, - { - stageId: 'stage-1', - stageIndex: 1, - targets: [ - { - deploymentId: 'dep-1', - revisionId: 'rev-11', - primaryActorId: 'actor-1', - allocationWeight: 90, - servingState: 'active', - enabledEndpointIds: ['chat'], - }, - { - deploymentId: 'dep-2', - revisionId: 'rev-12', - primaryActorId: 'actor-2', - allocationWeight: 10, - servingState: 'canary', - enabledEndpointIds: ['chat'], - }, - ], - }, - ], - baselineTargets: [ - { - deploymentId: 'dep-1', - revisionId: 'rev-11', - primaryActorId: 'actor-1', - allocationWeight: 100, - servingState: 'active', - enabledEndpointIds: ['chat'], - }, - ], - failureReason: '', - startedAt: '2026-03-30T10:01:00Z', - updatedAt: '2026-03-30T10:05:00Z', - }); - - mockServicesApi.getTraffic.mockResolvedValue({ - serviceKey: 'scope-1:trade-agent', - generation: 3, - activeRolloutId: 'rollout-1', - endpoints: [ - { - endpointId: 'chat', - targets: [ - { - deploymentId: 'dep-1', - revisionId: 'rev-11', - primaryActorId: 'actor-1', - allocationWeight: 90, - servingState: 'active', - }, - { - deploymentId: 'dep-2', - revisionId: 'rev-12', - primaryActorId: 'actor-2', - allocationWeight: 10, - servingState: 'canary', - }, - ], - }, - ], - updatedAt: '2026-03-30T10:05:00Z', - }); - - mockServicesApi.deployRevision.mockResolvedValue({ - targetActorId: 'actor-1', - commandId: 'cmd-1', - correlationId: 'corr-1', - }); - - mockServicesApi.replaceServingTargets.mockResolvedValue({ - targetActorId: 'actor-1', - commandId: 'cmd-2', - correlationId: 'corr-2', - }); - - mockServicesApi.advanceRollout.mockResolvedValue({ - targetActorId: 'actor-1', - commandId: 'cmd-3', - correlationId: 'corr-3', - }); - mockServicesApi.pauseRollout.mockResolvedValue({ - targetActorId: 'actor-1', - commandId: 'cmd-4', - correlationId: 'corr-4', - }); - mockServicesApi.resumeRollout.mockResolvedValue({ - targetActorId: 'actor-1', - commandId: 'cmd-5', - correlationId: 'corr-5', - }); - mockServicesApi.rollbackRollout.mockResolvedValue({ - targetActorId: 'actor-1', - commandId: 'cmd-6', - correlationId: 'corr-6', - }); - mockServicesApi.deactivateDeployment.mockResolvedValue({ - targetActorId: 'actor-1', - commandId: 'cmd-7', - correlationId: 'corr-7', - }); -}); - -afterEach(() => { - cleanupTestQueryClients(); -}); - -describe('DeploymentsPage', () => { - it('shows the service deployment list before an operator opens a service', async () => { - renderDeploymentsPage(); - - expect(await screen.findByText('Platform')).toBeInTheDocument(); - expect( - await screen.findByRole('heading', { name: 'Deployments' }), - ).toBeInTheDocument(); - expect( - await screen.findByText( - '部署是 Platform 的发布工作台,聚焦当前服务态、发布推进进度和流量分配。', - ), - ).toBeInTheDocument(); - expect(await screen.findByText('发布服务列表')).toBeInTheDocument(); - expect(await screen.findByText('Trade Agent')).toBeInTheDocument(); - expect(screen.queryByText('发布摘要')).toBeNull(); - expect(screen.queryByText('正在加载发布服务')).toBeNull(); - }); - - it('keeps the deployment inventory in a loading state until the first response resolves', async () => { - let resolveServices: (value: unknown[]) => void = () => {}; - mockServicesApi.listServices.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveServices = resolve; - }), - ); - - renderDeploymentsPage(); - - expect(await screen.findByRole('status')).toHaveAttribute( - 'data-variant', - 'table', - ); - expect(screen.getByText('正在加载发布服务')).toHaveClass( - 'aevatar-loading-visually-hidden', - ); - expect( - screen.queryByText('发布对象清单仍在加载,返回前不会把当前范围误判为空。'), - ).toBeNull(); - expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(4); - expect(screen.queryByText('当前范围没有服务')).toBeNull(); - - resolveServices([]); - - expect(await screen.findByText('当前范围没有服务')).toBeInTheDocument(); - }); - - it('separates deployment inventory failures from a true empty scope', async () => { - mockServicesApi.listServices.mockRejectedValueOnce( - new Error('deployment inventory unavailable'), - ); - - renderDeploymentsPage(); - - expect(await screen.findByText('发布服务列表暂不可用')).toBeInTheDocument(); - expect( - screen.getByText('deployment inventory unavailable'), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: '重试发布列表' }), - ).toBeInTheDocument(); - expect(screen.queryByText('当前范围没有服务')).toBeNull(); - }); - - it('shows an actionable deployment empty state only after an empty response', async () => { - mockServicesApi.listServices.mockResolvedValueOnce([]); - - renderDeploymentsPage(); - - expect(await screen.findByText('当前范围没有服务')).toBeInTheDocument(); - expect( - screen.getByText( - '当前团队、App 和 Namespace 下没有可发布服务。可以调整范围后重新加载。', - ), - ).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: '调整发布范围' })); - - await waitFor(() => { - expect(window.location.pathname).toBe('/deployments'); - }); - }); - - it('warns when scope edits have not been loaded yet', async () => { - renderDeploymentsPage(); - - expect(await screen.findByText('Trade Agent')).toBeInTheDocument(); - fireEvent.change(screen.getByPlaceholderText('命名空间'), { - target: { - value: 'cn.changed', - }, - }); - - expect(await screen.findByText('范围已编辑但尚未加载')).toBeInTheDocument(); - expect(screen.getByText('显示上次加载范围')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: '加载范围变更' }), - ).toBeInTheDocument(); - expect(screen.getAllByText('Trade Agent').length).toBeGreaterThan(0); - - fireEvent.click(screen.getByRole('button', { name: '重置' })); - - expect(await screen.findByText('已加载范围已锁定')).toBeInTheDocument(); - expect(screen.getByText('显示已加载范围')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: '加载发布列表' }), - ).toBeInTheDocument(); - }); - - it('renders the selected service workbench from URL context', async () => { - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - expect(await screen.findByText('部署数')).toBeInTheDocument(); - expect( - await screen.findByRole('tab', { name: '部署目录', selected: true }), - ).toBeInTheDocument(); - expect( - await screen.findByRole('tab', { name: 'Serving' }), - ).toBeInTheDocument(); - expect( - await screen.findByRole('tab', { name: 'Rollout' }), - ).toBeInTheDocument(); - }); - - it('opens the selected deployment from a governance handoff URL', async () => { - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent&deploymentId=dep-1', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - expect(await screen.findByText('部署数')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: '部署候选版本' }), - ).toBeInTheDocument(); - expect(screen.getAllByText('Trade Agent').length).toBeGreaterThan(0); - expect(screen.queryByText('dep-1')).toBeNull(); - }); - - it('opens the service deployment drawer from the service list row', async () => { - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market', - ); - - fireEvent.click(await screen.findByText('Trade Agent')); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - expect( - await screen.findByRole('button', { name: '部署候选版本' }), - ).toBeInTheDocument(); - }); - - it('opens the rollout control drawer from the workbench header', async () => { - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - fireEvent.click(await screen.findByRole('button', { name: '发布控制' })); - - expect(await screen.findByText('推进发布推进')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: '回滚发布推进' }), - ).toBeInTheDocument(); - }); - - it('does not present rollout control as an action when no rollout is active', async () => { - mockServicesApi.getRollout.mockResolvedValueOnce(null); - - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '发布控制' })).toBeNull(); - expect( - screen.getByRole('button', { name: '无活动控制' }), - ).toBeDisabled(); - }); - - it('does not present traffic adjustment as an action when no serving targets exist', async () => { - mockServicesApi.getServingSet.mockResolvedValueOnce({ - activeRolloutId: '', - generation: 0, - serviceKey: 'scope-1:trade-agent', - targets: [], - updatedAt: '2026-03-30T10:00:00Z', - }); - - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '调整流量' })).toBeNull(); - expect( - screen.getAllByRole('button', { name: '查看流量状态' })[0], - ).toBeDisabled(); - }); - - it('dispatches the candidate revision from the candidate drawer', async () => { - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - fireEvent.click( - await screen.findByRole('button', { name: '部署候选版本' }), - ); - fireEvent.click( - await screen.findByRole('button', { name: '发布候选版本' }), - ); - - await waitFor(() => { - expect(mockServicesApi.deployRevision).toHaveBeenCalledWith( - 'trade-agent', - expect.objectContaining({ - revisionId: 'rev-12', - }), - ); - }); - }); - - it('keeps a release handoff after candidate submission without marking serving observed', async () => { - mockServicesApi.getServingSet.mockResolvedValueOnce({ - activeRolloutId: 'rollout-1', - generation: 3, - serviceKey: 'scope-1:trade-agent', - targets: [ - { - allocationWeight: 100, - deploymentId: 'dep-1', - enabledEndpointIds: ['chat'], - primaryActorId: 'actor-1', - revisionId: 'rev-11', - servingState: 'active', - }, - ], - updatedAt: '2026-03-30T10:00:00Z', - }); - mockServicesApi.getTraffic.mockResolvedValueOnce({ - activeRolloutId: 'rollout-1', - endpoints: [ - { - endpointId: 'chat', - targets: [ - { - allocationWeight: 100, - deploymentId: 'dep-1', - primaryActorId: 'actor-1', - revisionId: 'rev-11', - servingState: 'active', - }, - ], - }, - ], - generation: 3, - serviceKey: 'scope-1:trade-agent', - updatedAt: '2026-03-30T10:00:00Z', - }); - - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - fireEvent.click( - await screen.findByRole('button', { name: '部署候选版本' }), - ); - fireEvent.click( - await screen.findByRole('button', { name: '发布候选版本' }), - ); - - expect(await screen.findByText('候选版本部署已提交')).toBeInTheDocument(); - expect(screen.getByText('已提交,不代表已完成')).toBeInTheDocument(); - expect( - screen.getByText( - '这只表示候选版本部署命令已接收,尚未说明候选修订已经被服务态观察到。', - ), - ).toBeInTheDocument(); - expect( - screen.getByText( - '3 项证据需要人工核对,避免把旧 ReadModel 当作本次完成。', - ), - ).toBeInTheDocument(); - expect(screen.queryByText('已观察')).toBeNull(); - expect(screen.getAllByText('需核对').length).toBeGreaterThanOrEqual(3); - expect(screen.getByText('Serving evidence')).toBeInTheDocument(); - expect(screen.getByText('Traffic evidence')).toBeInTheDocument(); - expect(screen.getByText('候选修订')).toBeInTheDocument(); - expect(screen.getAllByText('rev-12').length).toBeGreaterThan(0); - expect(screen.queryByText('候选版本已在服务态生效')).toBeNull(); - - fireEvent.click(screen.getByRole('button', { name: '查看发布推进证据' })); - - expect( - await screen.findByRole('tab', { name: 'Rollout', selected: true }), - ).toBeInTheDocument(); - }); - - it('shows rollback as a pending baseline evidence handoff', async () => { - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - fireEvent.click(await screen.findByRole('button', { name: '发布控制' })); - fireEvent.click( - await screen.findByRole('button', { name: '回滚发布推进' }), - ); - - expect(await screen.findByText('发布推进回滚已提交')).toBeInTheDocument(); - expect(screen.getByText('已提交,不代表已完成')).toBeInTheDocument(); - expect( - screen.getByText( - '这只表示回滚命令已接收,不代表服务态已经回到基线。', - ), - ).toBeInTheDocument(); - expect( - screen.getByText('发布推进回滚请求已提交,等待基线证据刷新。'), - ).toBeInTheDocument(); - expect(screen.getAllByText('待观察').length).toBeGreaterThanOrEqual(3); - expect(screen.getByText('Traffic split')).toBeInTheDocument(); - }); - - it('opens the deployment detail drawer from the catalog table', async () => { - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - fireEvent.click(await screen.findByRole('tab', { name: '部署目录' })); - fireEvent.click(await screen.findByRole('button', { name: '查看详情' })); - - expect(await screen.findByText('部署详情')).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: '停用部署' }), - ).toBeInTheDocument(); - }); - - it('does not present deactivate as an action for inactive deployments', async () => { - mockServicesApi.getDeployments.mockResolvedValueOnce({ - deployments: [ - { - activatedAt: '2026-03-29T10:05:00Z', - deploymentId: 'dep-1', - primaryActorId: 'actor-1', - revisionId: 'rev-11', - status: 'inactive', - updatedAt: '2026-03-30T10:00:00Z', - }, - ], - serviceKey: 'scope-1:trade-agent', - updatedAt: '2026-03-30T10:00:00Z', - }); - - renderDeploymentsPage( - '/deployments?tenantId=scope-1&appId=trade-app&namespace=cn.market&serviceId=trade-agent&deploymentId=dep-1', - ); - - expect(await screen.findByText('发布摘要')).toBeInTheDocument(); - fireEvent.click(await screen.findByRole('tab', { name: '部署目录' })); - fireEvent.click(await screen.findByRole('button', { name: '查看详情' })); - - expect(await screen.findByText('部署详情')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '停用部署' })).toBeNull(); - expect(screen.getByRole('button', { name: '不可停用' })).toBeDisabled(); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/Deployments/index.tsx b/apps/aevatar-console-web/src/pages/Deployments/index.tsx deleted file mode 100644 index 40cb7d14a7..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/index.tsx +++ /dev/null @@ -1,3414 +0,0 @@ -import { - PauseCircleOutlined, - PercentageOutlined, - ReloadOutlined, - RollbackOutlined, - SendOutlined, - StopOutlined, -} from '@ant-design/icons'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { - Alert, - Button, - Drawer, - Empty, - Input, - InputNumber, - Select, - Space, - Table, - Tabs, - Tag, - Typography, - theme, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import AevatarTooltip from '@/shared/ui/AevatarTooltip'; -import { - readServiceQueryDraft, - trimServiceQuery, - type ServiceQueryDraft, -} from '@/pages/services/components/serviceQuery'; -import { servicesApi } from '@/shared/api/servicesApi'; -import { formatDateTime } from '@/shared/datetime/dateTime'; -import { history } from '@/shared/navigation/history'; -import { buildPlatformDeploymentsHref } from '@/shared/navigation/platformRoutes'; -import { - invalidateServiceResourceQueries, - serviceResourceQueryKeys, -} from '@/shared/query/serviceResourceQueryKeys'; -import { resolveStudioScopeContext } from '@/shared/scope/context'; -import { studioApi } from '@/shared/studio/api'; -import type { - ServiceCatalogSnapshot, - ServiceDeploymentSnapshot, - ServiceIdentityQuery, - ServiceRevisionSnapshot, - ServiceRolloutStageSnapshot, - ServiceServingTargetInput, - ServiceServingTargetSnapshot, - ServiceTrafficEndpointSnapshot, -} from '@/shared/models/services'; -import { - AevatarContextDrawer, - type AevatarBreadcrumbItem, - AevatarInspectorEmpty, -} from '@/shared/ui/aevatarPageShells'; -import { - AevatarCompactTag, - AevatarCompactText, - aevatarMonoFontFamily, - truncateMiddle, -} from '@/shared/ui/compactText'; -import { getUserFacingIdentifierLabel } from '@/shared/ui/userFacingIdentifiers'; -import InventoryReadinessState from '@/shared/ui/InventoryReadinessState'; -import { - aevatarDrawerBodyStyle, - aevatarDrawerScrollStyle, - buildAevatarMetricCardStyle, - buildAevatarPanelStyle, - buildAevatarTagStyle, - formatAevatarStatusLabel, - resolveAevatarMetricVisual, - type AevatarStatusDomain, - type AevatarThemeSurfaceToken, -} from '@/shared/ui/aevatarWorkbench'; -import ConsoleMenuPageShell from '@/shared/ui/ConsoleMenuPageShell'; -import ConsoleOperationNotice from '@/shared/ui/ConsoleOperationNotice'; -import { - cardStackStyle, - summaryFieldLabelStyle, - summaryMetricValueStyle, -} from '@/shared/ui/proComponents'; -import { - buildDeploymentReleaseHandoff, - type DeploymentReleaseHandoff, - type DeploymentReleaseHandoffAction, -} from './releaseHandoff'; -import { - buildDeploymentReleaseEvidenceSnapshot, - type DeploymentReleaseEvidenceSnapshot, - type DeploymentReleaseEvidenceStatus, -} from './releaseEvidence'; -import { buildDeploymentDeactivateAvailability } from './deploymentActionAvailability'; -import { - buildRolloutActionAvailability, - type RolloutControlAction, -} from './releaseActionAvailability'; -import { buildServingTargetPlanStatus } from './servingTargetPlan'; -import { - formatConsoleMessage, - t, - type ConsoleMessageDescriptor, -} from '@/shared/i18n/messages'; - -type DeploymentWorkbenchView = 'catalog' | 'serving' | 'rollout' | 'traffic'; - -type DeploymentDrawerTab = 'candidate' | 'weights' | 'control'; - -type RolloutControlDefinition = { - action: RolloutControlAction; - danger?: boolean; - icon: React.ReactNode; - label: ConsoleMessageDescriptor; - primary?: boolean; -}; - -const servingStateOptions = [ - { - label: 'Active', - value: 'active', - }, - { - label: 'Paused', - value: 'paused', - }, - { - label: 'Draining', - value: 'draining', - }, - { - label: 'Disabled', - value: 'disabled', - }, -]; - -type DeploymentDrawerState = { - open: boolean; - tab: DeploymentDrawerTab; -}; - -type DeploymentInspectorState = - | { - open: false; - } - | { - kind: 'serving'; - key: string; - open: true; - } - | { - kind: 'traffic'; - key: string; - open: true; - } - | { - kind: 'deployment'; - key: string; - open: true; - }; - -type DeploymentNotice = { - message: string; - tone: 'error' | 'info' | 'success' | 'warning'; -}; - -type DeploymentTrafficRow = { - endpointId: string; - key: string; - splitSummary: string; - targetCount: number; - targets: ReadonlyArray; -}; - -const defaultScopeServiceAppId = 'default'; -const defaultScopeServiceNamespace = 'default'; - -function formatVersionVisibilityLabel(value: string | null | undefined): string { - return value?.trim() - ? t("pages.deployments.index.version.ready", "Version ready") - : t("pages.deployments.index.no.version.information.yet", "No version information yet"); -} - -function formatDeploymentVisibilityLabel(value: string | null | undefined): string { - return value?.trim() - ? t("pages.deployments.index.deployment.attached", "Deployment attached") - : t("pages.deployments.index.not.bound", "Not bound"); -} - -function formatActorVisibilityLabel(value: string | null | undefined): string { - return value?.trim() - ? t("pages.deployments.index.actor.available", "Actor available") - : t("pages.deployments.index.none.yet", "None yet"); -} - -function formatTrafficTargetLabel(index: number): string { - return t("pages.deployments.index.traffic.target.number", "Target {value1}", { - value1: index + 1, - }); -} - -function formatTrafficTargetSummary( - target: - | ServiceServingTargetSnapshot - | ServiceTrafficEndpointSnapshot['targets'][number], -): string { - return t("pages.deployments.index.copy.2", "{value1}% · {value2} · {value3}", { - value1: target.allocationWeight, - value2: formatAevatarStatusLabel(target.servingState || 'unknown'), - value3: formatActorVisibilityLabel(target.primaryActorId), - }); -} -const tableHeaderCellStyle: React.CSSProperties = { - background: 'var(--ant-color-fill-alter)', - borderBottom: '1px solid var(--ant-color-border-secondary)', - color: 'var(--ant-color-text-secondary)', - fontSize: 11, - fontWeight: 700, - letterSpacing: 0.24, - padding: '12px 14px', - textAlign: 'left', - textTransform: 'uppercase', - whiteSpace: 'nowrap', -}; -const tableCellStyle: React.CSSProperties = { - borderBottom: '1px solid var(--ant-color-border-secondary)', - padding: '12px 14px', - verticalAlign: 'top', -}; -const compactHintTagStyle: React.CSSProperties = { - borderRadius: 999, - fontWeight: 600, - marginInlineEnd: 0, -}; -const platformBreadcrumbItems: AevatarBreadcrumbItem[] = [ - { - title: 'Platform', - }, - { - current: true, - title: 'Deployments', - }, -]; -const compactMonoValueStyle: React.CSSProperties = { - color: 'var(--ant-color-text-secondary)', - fontFamily: aevatarMonoFontFamily, - fontSize: 10.5, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}; -const rolloutControlDefinitions: RolloutControlDefinition[] = [ - { - action: 'advance', - icon: , - label: { - defaultMessage: 'Advance rollout', - id: 'pages.deployments.index.rollout.controls.advance', - }, - primary: true, - }, - { - action: 'pause', - icon: , - label: { - defaultMessage: 'Pause', - id: 'pages.deployments.index.rollout.controls.pause', - }, - }, - { - action: 'resume', - icon: , - label: { - defaultMessage: 'Resume', - id: 'pages.deployments.index.rollout.controls.resume', - }, - }, - { - action: 'rollback', - danger: true, - icon: , - label: { - defaultMessage: 'Rollback rollout', - id: 'pages.deployments.index.rollout.controls.rollback', - }, - }, -]; - -function buildScopePreview( - tenantId: string, - appId: string, - namespace: string, -): string { - return `${truncateMiddle(tenantId)}/${appId}/${namespace}`; -} - -function formatDeploymentScopeLabel(query: ServiceIdentityQuery): string { - const segments = [ - query.tenantId?.trim() || t("pages.deployments.index.no.team.set.up.2", "No team set up"), - query.appId?.trim() || t("pages.deployments.index.app.not.set.up.2", "App not set up"), - query.namespace?.trim() || t("pages.deployments.index.namespace.not.set.2", "namespace not set"), - ]; - const resultWindow = query.take && query.take > 0 ? query.take : 200; - - return t("pages.deployments.index.items.2", "{value1} · {value2} items", { value1: segments.join(' / '), value2: resultWindow }); -} - -function isSameDeploymentScope( - left: ServiceIdentityQuery, - right: ServiceIdentityQuery, -): boolean { - return ( - (left.tenantId?.trim() ?? '') === (right.tenantId?.trim() ?? '') && - (left.appId?.trim() ?? '') === (right.appId?.trim() ?? '') && - (left.namespace?.trim() ?? '') === (right.namespace?.trim() ?? '') && - (left.take ?? 200) === (right.take ?? 200) - ); -} - -const CompactIdentifierText: React.FC<{ - color?: string; - maxWidth?: React.CSSProperties['maxWidth']; - singleLine?: boolean; - strong?: boolean; - value: string; -}> = ({ - color, - maxWidth = '100%', - singleLine = false, - strong = false, - value, -}) => { - return ( - - ); -}; - -const CompactIdentifierTag: React.FC<{ - color?: string; - style?: React.CSSProperties; - value: string; -}> = ({ color, style, value }) => { - return ; -}; - -const CompactLabelText: React.FC<{ - color?: string; - maxChars?: number; - maxWidth?: React.CSSProperties['maxWidth']; - strong?: boolean; - value: string; -}> = ({ color, maxChars = 20, maxWidth = 112, strong = false, value }) => { - return ( - - ); -}; - -function readSelectedServiceId(): string { - if (typeof window === 'undefined') { - return ''; - } - - return ( - new URLSearchParams(window.location.search).get('serviceId')?.trim() ?? '' - ); -} - -function readSelectedDeploymentId(): string { - if (typeof window === 'undefined') { - return ''; - } - - return ( - new URLSearchParams(window.location.search).get('deploymentId')?.trim() ?? - '' - ); -} - -function buildRevisionSummary( - revision: ServiceRevisionSnapshot | null | undefined, -): Array<{ label: string; value: string }> { - if (!revision) { - return [ - { - label: t("pages.deployments.index.version.3", "Version"), - value: t("pages.deployments.index.none.yet.9", "None yet"), - }, - ]; - } - - return [ - { - label: t("pages.deployments.index.version.4", "Version"), - value: formatVersionVisibilityLabel(revision.revisionId), - }, - { - label: t("pages.deployments.index.state.5", "state"), - value: formatAevatarStatusLabel(revision.status || 'unknown'), - }, - { - label: t("pages.deployments.index.number.of.entrances.2", "Number of entrances"), - value: String(revision.endpoints.length), - }, - { - label: t("pages.deployments.index.products.2", "Products"), - value: revision.artifactHash - ? t("pages.deployments.index.artifact.ready", "Artifact ready") - : 'n/a', - }, - { - label: t("pages.deployments.index.ready.to.complete.2", "Ready to complete"), - value: formatDateTime(revision.preparedAt), - }, - { - label: t("pages.deployments.index.published.2", "Published"), - value: formatDateTime(revision.publishedAt), - }, - ]; -} - -function pickPreferredCandidateRevision( - revisions: readonly ServiceRevisionSnapshot[], - activeRevisionId: string, -): string { - if (!revisions.length) { - return ''; - } - - return ( - revisions.find((revision) => revision.revisionId !== activeRevisionId) - ?.revisionId ?? - revisions[0]?.revisionId ?? - '' - ); -} - -function buildTrafficRows( - endpoints: readonly ServiceTrafficEndpointSnapshot[], -): DeploymentTrafficRow[] { - return endpoints.map((endpoint) => ({ - endpointId: endpoint.endpointId, - key: endpoint.endpointId, - splitSummary: - endpoint.targets - .map((target) => - t("pages.deployments.index.traffic.target.summary", "{value1}% {value2}", { - value1: target.allocationWeight, - value2: formatAevatarStatusLabel(target.servingState || 'unknown'), - }), - ) - .join(' · ') || t("pages.deployments.index.no.traffic.target.yet.2", "No traffic target yet"), - targetCount: endpoint.targets.length, - targets: endpoint.targets, - })); -} - -function buildServingTargetKey(target: ServiceServingTargetSnapshot): string { - return `${target.deploymentId}-${target.revisionId}-${target.servingState}`; -} - -function describeTargets( - targets: - | ReadonlyArray - | ReadonlyArray, -): string { - if (!targets.length) { - return t("pages.deployments.index.none.yet.10", "None yet"); - } - - return targets - .map( - (target) => - t("pages.deployments.index.traffic.target.summary", "{value1}% {value2}", { - value1: target.allocationWeight, - value2: formatAevatarStatusLabel(target.servingState || 'unknown'), - }), - ) - .join(' / '); -} - -const DeploymentStatusTag: React.FC<{ - domain?: AevatarStatusDomain; - status: string; -}> = ({ domain = 'governance', status }) => { - const { token } = theme.useToken(); - - return ( - - {formatAevatarStatusLabel(status)} - - ); -}; - -const MetricCard: React.FC<{ - label: string; - tone?: 'default' | 'info' | 'success' | 'warning'; - value: string; -}> = ({ label, tone = 'default', value }) => { - const { token } = theme.useToken(); - const visual = resolveAevatarMetricVisual( - token as AevatarThemeSurfaceToken, - tone, - ); - - return ( -
- - {label} - - - {value} - -
- ); -}; - -const WorkbenchSection: React.FC<{ - children: React.ReactNode; - extra?: React.ReactNode; - title: string; -}> = ({ children, extra, title }) => { - const { token } = theme.useToken(); - const surfaceToken = token as AevatarThemeSurfaceToken; - - return ( -
-
- - {title} - - {extra ?
{extra}
: null} -
- {children} -
- ); -}; - -const DetailFieldCard: React.FC<{ - label: string; - value: React.ReactNode; -}> = ({ label, value }) => { - const { token } = theme.useToken(); - const surfaceToken = token as AevatarThemeSurfaceToken; - const primitiveValue = - typeof value === 'string' || typeof value === 'number' - ? String(value) - : null; - - return ( -
- {label} -
- {primitiveValue ? ( - - {primitiveValue} - - ) : ( - value - )} -
-
- ); -}; - -const DeploymentsScopeCard: React.FC<{ - draft: ServiceQueryDraft; - draftScopeLabel: string; - isDirty: boolean; - isLoading?: boolean; - loadedScopeLabel: string; - onChange: (draft: ServiceQueryDraft) => void; - onLoad: () => void; - onReset: () => void; - scopeLabel: string; -}> = ({ - draft, - draftScopeLabel, - isDirty, - isLoading = false, - loadedScopeLabel, - onChange, - onLoad, - onReset, - scopeLabel, -}) => ( -
-
- - - {t("pages.deployments.index.deployment.scope.2", "deployment scope")} - - {t("pages.deployments.index.team.application.namespace.2", "team/Application/Namespace")} - - -
- {scopeLabel} -
-
-
- -
-
- - {t("pages.deployments.index.team.2", "team")} - - onChange({ - ...draft, - tenantId: event.target.value, - }) - } - /> -
- -
- - {t("pages.deployments.index.application.2", "application")} - - onChange({ - ...draft, - appId: event.target.value, - }) - } - /> -
- -
- - {t("pages.deployments.index.namespace.3", "namespace")} - - onChange({ - ...draft, - namespace: event.target.value, - }) - } - /> -
-
- - {isDirty ? ( - - ) : ( - - )} - -
-
- - {t("pages.deployments.index.results.window.2", "results window")} - - onChange({ - ...draft, - take: Number(value) || 200, - }) - } - /> -
- - - - - -
-
-); - -const RevisionSummaryCard: React.FC<{ - label: string; - revision: ServiceRevisionSnapshot | null | undefined; -}> = ({ label, revision }) => { - const { token } = theme.useToken(); - const surfaceToken = token as AevatarThemeSurfaceToken; - - return ( -
- - {label} - - {revision ? ( - <> - - - {formatVersionVisibilityLabel(revision.revisionId)} - -
- {buildRevisionSummary(revision).map((item) => ( - - ))} -
- - ) : ( - - {t("pages.deployments.index.no.version.information.yet.2", "No version information yet")} - )} -
- ); -}; - -const TargetGroupCard: React.FC<{ - label: string; - targets: readonly ServiceServingTargetSnapshot[]; -}> = ({ label, targets }) => { - const { token } = theme.useToken(); - const surfaceToken = token as AevatarThemeSurfaceToken; - - return ( -
- - {label} - - {targets.length > 0 ? ( - targets.map((target) => ( -
- - {formatVersionVisibilityLabel(target.revisionId)} - {formatDeploymentVisibilityLabel(target.deploymentId)} - - {target.allocationWeight}% - -
- {formatActorVisibilityLabel(target.primaryActorId)}{' '} - · {target.enabledEndpointIds.join(', ') || t("pages.deployments.index.all.entrances.5", "All entrances")} -
-
- )) - ) : ( - - {t("pages.deployments.index.no.target.yet.2", "No target yet")} - )} -
- ); -}; - -const DrawerSection: React.FC<{ - children: React.ReactNode; - title: string; -}> = ({ children, title }) => { - const { token } = theme.useToken(); - const surfaceToken = token as AevatarThemeSurfaceToken; - - return ( -
- - {title} - - {children} -
- ); -}; - -const ReleaseHandoffPanel: React.FC<{ - evidence: DeploymentReleaseEvidenceSnapshot; - handoff: DeploymentReleaseHandoff; - onClose: () => void; - onOpenEvidence: () => void; -}> = ({ evidence, handoff, onClose, onOpenEvidence }) => { - const { token } = theme.useToken(); - const surfaceToken = token as AevatarThemeSurfaceToken; - const statusCopy: Record< - DeploymentReleaseEvidenceStatus, - { - color: string; - label: string; - } - > = { - observed: { - color: 'green', - label: t("pages.deployments.index.observed", "Observed"), - }, - pending: { - color: 'gold', - label: t("pages.deployments.index.to.be.seen", "To be seen"), - }, - review: { - color: 'blue', - label: t("pages.deployments.index.need.to.check", "Need to check"), - }, - }; - - return ( -
-
- - - - {handoff.pendingLabel} - - - {handoff.evidenceViewLabel} - - - - {handoff.title} - - - {handoff.evidenceDescription} - - - {evidence.summary} - - - - - - -
- -
- {handoff.summaryItems.map((item) => ( -
- - {item.label} - -
- -
-
- ))} -
- -
- {evidence.checks.map((check) => ( -
- - {statusCopy[check.status].label} - - - - {check.label} - - - {check.detail} - - -
- ))} -
-
- ); -}; - -const DeploymentsPage: React.FC = () => { - const { token } = theme.useToken(); - const surfaceToken = token as AevatarThemeSurfaceToken; - const queryClient = useQueryClient(); - - const [draft, setDraft] = useState(() => - readServiceQueryDraft(), - ); - const [query, setQuery] = useState(() => - trimServiceQuery(readServiceQueryDraft()), - ); - const [selectedServiceId, setSelectedServiceId] = useState(() => - readSelectedServiceId(), - ); - const [selectedDeploymentId, setSelectedDeploymentId] = useState(() => - readSelectedDeploymentId(), - ); - const [view, setView] = useState('catalog'); - const [drawerState, setDrawerState] = useState({ - open: false, - tab: 'candidate', - }); - const [inspectorState, setInspectorState] = - useState({ - open: false, - }); - const [drawerReason, setDrawerReason] = useState(''); - const [editableTargets, setEditableTargets] = useState< - ServiceServingTargetInput[] - >([]); - const [candidateRevisionId, setCandidateRevisionId] = useState(''); - const [notice, setNotice] = useState(null); - const [releaseHandoff, setReleaseHandoff] = - useState(null); - - const authSessionQuery = useQuery({ - queryKey: ['deployments', 'auth-session'], - queryFn: () => studioApi.getAuthSession(), - retry: false, - }); - const resolvedScope = useMemo( - () => resolveStudioScopeContext(authSessionQuery.data), - [authSessionQuery.data], - ); - - useEffect(() => { - if ( - draft.tenantId.trim() || - draft.appId.trim() || - draft.namespace.trim() || - !resolvedScope?.scopeId?.trim() - ) { - return; - } - - const nextDraft = { - ...draft, - appId: defaultScopeServiceAppId, - namespace: defaultScopeServiceNamespace, - tenantId: resolvedScope.scopeId.trim(), - }; - setDraft(nextDraft); - setQuery(trimServiceQuery(nextDraft)); - }, [draft, resolvedScope?.scopeId]); - - const servicesQuery = useQuery({ - queryFn: () => servicesApi.listServices(query), - queryKey: serviceResourceQueryKeys.list(query), - }); - - const serviceDetailQuery = useQuery({ - enabled: selectedServiceId.trim().length > 0, - queryFn: () => servicesApi.getService(selectedServiceId, query), - queryKey: serviceResourceQueryKeys.detail(query, selectedServiceId), - }); - const revisionsQuery = useQuery({ - enabled: selectedServiceId.trim().length > 0, - queryFn: () => servicesApi.getRevisions(selectedServiceId, query), - queryKey: serviceResourceQueryKeys.revisions(query, selectedServiceId), - }); - const deploymentsQuery = useQuery({ - enabled: selectedServiceId.trim().length > 0, - queryFn: () => servicesApi.getDeployments(selectedServiceId, query), - queryKey: serviceResourceQueryKeys.deployments(query, selectedServiceId), - }); - const servingQuery = useQuery({ - enabled: selectedServiceId.trim().length > 0, - queryFn: () => servicesApi.getServingSet(selectedServiceId, query), - queryKey: serviceResourceQueryKeys.serving(query, selectedServiceId), - }); - const rolloutQuery = useQuery({ - enabled: selectedServiceId.trim().length > 0, - queryFn: () => servicesApi.getRollout(selectedServiceId, query), - queryKey: serviceResourceQueryKeys.rollout(query, selectedServiceId), - }); - const trafficQuery = useQuery({ - enabled: selectedServiceId.trim().length > 0, - queryFn: () => servicesApi.getTraffic(selectedServiceId, query), - queryKey: serviceResourceQueryKeys.traffic(query, selectedServiceId), - }); - - const selectedService = useMemo( - () => - serviceDetailQuery.data ?? - servicesQuery.data?.find( - (service) => service.serviceId === selectedServiceId, - ) ?? - null, - [selectedServiceId, serviceDetailQuery.data, servicesQuery.data], - ); - - useEffect(() => { - if (servicesQuery.data === undefined) { - return; - } - - const services = servicesQuery.data ?? []; - if (!services.length) { - if (selectedServiceId) { - setSelectedServiceId(''); - } - if (selectedDeploymentId) { - setSelectedDeploymentId(''); - } - return; - } - - if (!selectedServiceId.trim()) { - return; - } - - if (services.some((service) => service.serviceId === selectedServiceId)) { - return; - } - - setSelectedServiceId(''); - if (selectedDeploymentId) { - setSelectedDeploymentId(''); - } - }, [selectedDeploymentId, selectedServiceId, servicesQuery.data]); - - useEffect(() => { - history.replace( - buildPlatformDeploymentsHref({ - appId: query.appId, - deploymentId: selectedDeploymentId || undefined, - namespace: query.namespace, - serviceId: selectedServiceId || undefined, - take: query.take, - tenantId: query.tenantId, - }), - ); - }, [query, selectedDeploymentId, selectedServiceId]); - - useEffect(() => { - if (selectedServiceId.trim() && deploymentsQuery.data === undefined) { - return; - } - - const deployments = deploymentsQuery.data?.deployments ?? []; - if (!selectedServiceId.trim()) { - if (selectedDeploymentId) { - setSelectedDeploymentId(''); - } - return; - } - - if (!selectedDeploymentId) { - return; - } - - if ( - deployments.some( - (deployment) => deployment.deploymentId === selectedDeploymentId, - ) - ) { - return; - } - - setSelectedDeploymentId(''); - }, [ - deploymentsQuery.data?.deployments, - selectedDeploymentId, - selectedServiceId, - ]); - - useEffect(() => { - setEditableTargets( - (servingQuery.data?.targets ?? []).map((target) => ({ - allocationWeight: target.allocationWeight, - enabledEndpointIds: target.enabledEndpointIds, - revisionId: target.revisionId, - servingState: target.servingState, - })), - ); - }, [servingQuery.data?.updatedAt]); - - const activeRevisionId = - serviceDetailQuery.data?.activeServingRevisionId || - serviceDetailQuery.data?.defaultServingRevisionId || - ''; - - useEffect(() => { - const revisions = revisionsQuery.data?.revisions ?? []; - if (!revisions.length) { - return; - } - - if ( - candidateRevisionId.trim() && - revisions.some((revision) => revision.revisionId === candidateRevisionId) - ) { - return; - } - - setCandidateRevisionId( - pickPreferredCandidateRevision(revisions, activeRevisionId), - ); - }, [activeRevisionId, candidateRevisionId, revisionsQuery.data?.revisions]); - - const selectedDeployment = useMemo( - () => - deploymentsQuery.data?.deployments.find( - (deployment) => deployment.deploymentId === selectedDeploymentId, - ) ?? null, - [deploymentsQuery.data?.deployments, selectedDeploymentId], - ); - - const activeDeployment = useMemo(() => { - const deployments = deploymentsQuery.data?.deployments ?? []; - const currentDeploymentId = - serviceDetailQuery.data?.deploymentId?.trim() ?? ''; - - return ( - deployments.find( - (deployment) => deployment.deploymentId === currentDeploymentId, - ) ?? - deployments.find((deployment) => - deployment.status.toLowerCase().includes('active'), - ) ?? - null - ); - }, [ - deploymentsQuery.data?.deployments, - serviceDetailQuery.data?.deploymentId, - ]); - - const focusDeployment = selectedDeployment ?? activeDeployment; - - const currentStage = useMemo(() => { - const rollout = rolloutQuery.data; - if (!rollout?.stages.length) { - return null; - } - - return ( - rollout.stages.find( - (stage) => stage.stageIndex === rollout.currentStageIndex, - ) ?? rollout.stages[rollout.stages.length - 1] - ); - }, [rolloutQuery.data]); - - const activeRevision = useMemo( - () => - revisionsQuery.data?.revisions.find( - (revision) => revision.revisionId === activeRevisionId, - ) ?? null, - [activeRevisionId, revisionsQuery.data?.revisions], - ); - - const candidateRevision = useMemo( - () => - revisionsQuery.data?.revisions.find( - (revision) => revision.revisionId === candidateRevisionId, - ) ?? null, - [candidateRevisionId, revisionsQuery.data?.revisions], - ); - - const trafficRows = useMemo( - () => buildTrafficRows(trafficQuery.data?.endpoints ?? []), - [trafficQuery.data?.endpoints], - ); - - const selectedServingTarget = useMemo(() => { - if (!inspectorState.open || inspectorState.kind !== 'serving') { - return null; - } - - return ( - servingQuery.data?.targets.find( - (target) => buildServingTargetKey(target) === inspectorState.key, - ) ?? null - ); - }, [inspectorState, servingQuery.data?.targets]); - - const selectedTrafficRow = useMemo(() => { - if (!inspectorState.open || inspectorState.kind !== 'traffic') { - return null; - } - - return trafficRows.find((row) => row.key === inspectorState.key) ?? null; - }, [inspectorState, trafficRows]); - - const inspectedDeployment = useMemo(() => { - if (!inspectorState.open || inspectorState.kind !== 'deployment') { - return null; - } - - return ( - deploymentsQuery.data?.deployments.find( - (deployment) => deployment.deploymentId === inspectorState.key, - ) ?? null - ); - }, [deploymentsQuery.data?.deployments, inspectorState]); - - const draftScopeLabel = useMemo( - () => formatDeploymentScopeLabel(trimServiceQuery(draft)), - [draft], - ); - - const loadedScopeLabel = useMemo( - () => formatDeploymentScopeLabel(query), - [query], - ); - - const isScopeDirty = useMemo( - () => !isSameDeploymentScope(trimServiceQuery(draft), query), - [draft, query], - ); - - const currentScopeLabel = useMemo(() => { - const segments = [ - query.tenantId?.trim() ?? draft.tenantId.trim(), - query.appId?.trim() ?? draft.appId.trim(), - query.namespace?.trim() ?? draft.namespace.trim(), - ].filter(Boolean); - - return segments.length > 0 - ? t("pages.deployments.index.current.scope.2", "Current scope {value1}", { value1: segments.join(' / ') }) - : t("pages.deployments.index.the.service.scope.has.2", "The service scope has not been locked yet"); - }, [draft.appId, draft.namespace, draft.tenantId, query]); - - const deploymentDigest = useMemo( - () => ({ - deployments: deploymentsQuery.data?.deployments.length ?? 0, - endpoints: - trafficQuery.data?.endpoints.length ?? - serviceDetailQuery.data?.endpoints.length ?? - 0, - stage: - currentStage && rolloutQuery.data - ? `${currentStage.stageIndex + 1}/${rolloutQuery.data.stages.length}` - : t("pages.deployments.index.no.activity.rollout.2", "No activity rollout"), - targets: servingQuery.data?.targets.length ?? 0, - }), - [ - currentStage, - deploymentsQuery.data?.deployments.length, - rolloutQuery.data, - serviceDetailQuery.data?.endpoints.length, - servingQuery.data?.targets.length, - trafficQuery.data?.endpoints.length, - ], - ); - - const visibleServiceDigest = useMemo( - () => ({ - endpointServices: (servicesQuery.data ?? []).filter( - (service) => service.endpoints.length > 0, - ).length, - services: servicesQuery.data?.length ?? 0, - servingServices: (servicesQuery.data ?? []).filter((service) => - service.deploymentId.trim(), - ).length, - waitingServices: (servicesQuery.data ?? []).filter( - (service) => !service.deploymentId.trim(), - ).length, - }), - [servicesQuery.data], - ); - const deploymentInventoryReady = - servicesQuery.data !== undefined && !servicesQuery.error; - - const releaseEvidence = useMemo( - () => - releaseHandoff - ? buildDeploymentReleaseEvidenceSnapshot({ - deployments: deploymentsQuery.data?.deployments ?? [], - handoff: releaseHandoff, - rollout: rolloutQuery.data, - serving: servingQuery.data, - traffic: trafficQuery.data, - }) - : null, - [ - deploymentsQuery.data?.deployments, - releaseHandoff, - rolloutQuery.data, - servingQuery.data, - trafficQuery.data, - ], - ); - const servingTargetPlanStatus = useMemo( - () => buildServingTargetPlanStatus(editableTargets), - [editableTargets], - ); - const rolloutActionAvailability = useMemo( - () => buildRolloutActionAvailability(rolloutQuery.data), - [rolloutQuery.data], - ); - const servingEntryAvailability = useMemo(() => { - const targetCount = servingQuery.data?.targets.length ?? 0; - - return { - enabled: targetCount > 0, - reason: - targetCount > 0 - ? t("pages.deployments.index.after.traffic.weighting.is", "After traffic weighting is turned on, the weight total and serving status will be verified before submission.") - : t("pages.deployments.index.there.are.currently.no.3", "There are currently no serving targets and traffic adjustment cannot be submitted."), - }; - }, [servingQuery.data?.targets.length]); - const rolloutControlEntryAvailability = useMemo(() => { - const enabled = Object.values(rolloutActionAvailability).some( - (availability) => availability.enabled, - ); - - return { - enabled, - reason: enabled - ? t("pages.deployments.index.after.release.control.is", "After release control is turned on, only actions allowed by the current rollout life cycle will be retained.") - : rolloutActionAvailability.advance.reason, - }; - }, [rolloutActionAvailability]); - const deploymentDeactivateAvailability = useMemo( - () => buildDeploymentDeactivateAvailability(inspectedDeployment), - [inspectedDeployment], - ); - - const invalidateDetailQueries = useCallback(async () => { - await invalidateServiceResourceQueries(queryClient); - }, [queryClient]); - - const openDrawer = useCallback((tab: DeploymentDrawerTab) => { - setDrawerState({ - open: true, - tab, - }); - }, []); - - const openInspector = useCallback( - (state: Exclude) => { - if (state.kind === 'deployment') { - setSelectedDeploymentId(state.key); - } - setInspectorState(state); - }, - [], - ); - - const recordReleaseHandoff = useCallback( - ( - action: DeploymentReleaseHandoffAction, - receipt: Parameters[0]['receipt'], - options: { - deploymentId?: string; - } = {}, - ) => { - const handoff = buildDeploymentReleaseHandoff({ - action, - activeRevisionId, - candidateRevisionId: - action === 'deploy-candidate' ? candidateRevisionId : undefined, - createdAt: new Date().toISOString(), - deploymentId: - options.deploymentId || - focusDeployment?.deploymentId || - selectedDeploymentId || - undefined, - endpointCount: trafficRows.length, - receipt, - rolloutId: rolloutQuery.data?.rolloutId, - rolloutStageLabel: - currentStage && rolloutQuery.data - ? `${currentStage.stageIndex + 1}/${rolloutQuery.data.stages.length}` - : undefined, - serviceId: selectedServiceId, - targetCount: - servingQuery.data?.targets.length ?? editableTargets.length, - }); - - setReleaseHandoff(handoff); - setNotice({ - message: handoff.noticeMessage, - tone: handoff.noticeTone, - }); - }, - [ - activeRevisionId, - candidateRevisionId, - currentStage, - editableTargets.length, - focusDeployment?.deploymentId, - rolloutQuery.data, - selectedDeploymentId, - selectedServiceId, - servingQuery.data?.targets.length, - trafficRows.length, - ], - ); - - const deployMutation = useMutation({ - mutationFn: () => { - if (!candidateRevisionId.trim()) { - throw new Error(t("pages.deployments.index.please.select.release.candidate.2", "Please select a release candidate first.")); - } - - return servicesApi.deployRevision(selectedServiceId, { - ...query, - revisionId: candidateRevisionId, - }); - }, - onError: (error: Error) => { - setReleaseHandoff(null); - setNotice({ - message: error.message || t("pages.deployments.index.release.candidate.failed.2", "Release candidate failed."), - tone: 'error', - }); - }, - onSuccess: async (receipt) => { - recordReleaseHandoff('deploy-candidate', receipt); - await invalidateDetailQueries(); - }, - }); - - const weightsMutation = useMutation({ - mutationFn: () => { - if (!servingTargetPlanStatus.enabled) { - throw new Error(servingTargetPlanStatus.reason); - } - - return servicesApi.replaceServingTargets(selectedServiceId, { - ...query, - reason: drawerReason, - rolloutId: rolloutQuery.data?.rolloutId, - targets: editableTargets, - }); - }, - onError: (error: Error) => { - setReleaseHandoff(null); - setNotice({ - message: error.message || t("pages.deployments.index.failed.to.apply.serving.2", "Failed to apply serving targets."), - tone: 'error', - }); - }, - onSuccess: async (receipt) => { - recordReleaseHandoff('replace-serving-targets', receipt); - await invalidateDetailQueries(); - }, - }); - - const rolloutMutation = useMutation({ - mutationFn: async (kind: 'advance' | 'pause' | 'resume' | 'rollback') => { - const availability = rolloutActionAvailability[kind]; - if (!availability.enabled) { - throw new Error(availability.reason); - } - - const rolloutId = rolloutQuery.data?.rolloutId; - if (!rolloutId) { - throw new Error(t("pages.deployments.index.there.is.no.active.2", "There is no active rollout for the current service.")); - } - - if (kind === 'advance') { - return servicesApi.advanceRollout(selectedServiceId, rolloutId, query); - } - - if (kind === 'pause') { - return servicesApi.pauseRollout(selectedServiceId, rolloutId, { - ...query, - reason: drawerReason, - }); - } - - if (kind === 'resume') { - return servicesApi.resumeRollout(selectedServiceId, rolloutId, query); - } - - return servicesApi.rollbackRollout(selectedServiceId, rolloutId, { - ...query, - reason: drawerReason, - }); - }, - onError: (error: Error) => { - setReleaseHandoff(null); - setNotice({ - message: error.message || t("pages.deployments.index.release.control.action.submission.2", "Release control action submission failed."), - tone: 'error', - }); - }, - onSuccess: async (receipt, kind) => { - const actionByKind: Record< - RolloutControlAction, - DeploymentReleaseHandoffAction - > = { - advance: 'advance-rollout', - pause: 'pause-rollout', - resume: 'resume-rollout', - rollback: 'rollback-rollout', - }; - recordReleaseHandoff(actionByKind[kind], receipt); - await invalidateDetailQueries(); - }, - }); - - const deactivateMutation = useMutation({ - mutationFn: (deploymentId: string) => { - if (!deploymentId.trim()) { - throw new Error(t("pages.deployments.index.please.select.deployment.2", "Please select a deployment.")); - } - const deployment = deploymentsQuery.data?.deployments.find( - (item) => item.deploymentId === deploymentId, - ); - const availability = buildDeploymentDeactivateAvailability(deployment); - if (!availability.enabled) { - throw new Error(availability.reason); - } - - return servicesApi.deactivateDeployment( - selectedServiceId, - deploymentId, - query, - ); - }, - onError: (error: Error) => { - setReleaseHandoff(null); - setNotice({ - message: error.message || t("pages.deployments.index.deactivating.the.deployment.failed.2", "Deactivating the deployment failed."), - tone: 'error', - }); - }, - onSuccess: async (receipt, deploymentId) => { - recordReleaseHandoff('deactivate-deployment', receipt, { - deploymentId, - }); - await invalidateDetailQueries(); - }, - }); - - const servingColumns = useMemo>( - () => [ - { - dataIndex: 'revisionId', - key: 'revisionId', - title: 'Revision', - render: (value: string, record) => ( - - - {formatVersionVisibilityLabel(value)} - - - {formatDeploymentVisibilityLabel(record.deploymentId)} - - - ), - }, - { - dataIndex: 'primaryActorId', - key: 'primaryActorId', - title: t("pages.deployments.index.main.actor.6", "Main actor"), - render: (value: string) => - formatActorVisibilityLabel(value), - }, - { - dataIndex: 'allocationWeight', - key: 'allocationWeight', - title: t("pages.deployments.index.weight.3", "weight"), - render: (value: number) => `${value}%`, - }, - { - dataIndex: 'servingState', - key: 'servingState', - title: t("pages.deployments.index.serving.status.4", "serving status"), - render: (value: string) => ( - - ), - }, - { - dataIndex: 'enabledEndpointIds', - key: 'enabledEndpointIds', - title: t("pages.deployments.index.entrance.5", "Entrance"), - render: (value: readonly string[]) => - value.length > 0 ? value.join(', ') : t("pages.deployments.index.all.entrances.6", "All entrances"), - }, - { - key: 'actions', - title: t("pages.deployments.index.operate.5", "operate"), - render: (_, record) => ( - - ), - }, - ], - [openInspector], - ); - - const rolloutColumns: ColumnsType = [ - { - dataIndex: 'stageIndex', - key: 'stageIndex', - title: 'Stage', - render: (value: number) => `Stage ${value + 1}`, - }, - { - dataIndex: 'stageId', - key: 'stageId', - title: t("pages.deployments.index.logo.2", "logo"), - }, - { - dataIndex: 'targets', - key: 'targets', - title: t("pages.deployments.index.target.allocation.2", "target allocation"), - render: (targets: readonly ServiceServingTargetSnapshot[]) => - describeTargets(targets), - }, - ]; - - const trafficColumns = useMemo>( - () => [ - { - dataIndex: 'endpointId', - key: 'endpointId', - title: 'Endpoint', - render: (value: string) => ( - - ), - }, - { - dataIndex: 'targetCount', - key: 'targetCount', - title: t("pages.deployments.index.number.of.targets.3", "number of targets"), - }, - { - dataIndex: 'splitSummary', - key: 'splitSummary', - title: t("pages.deployments.index.traffic.distribution.2", "traffic distribution"), - }, - { - dataIndex: 'targets', - key: 'states', - title: t("pages.deployments.index.serving.status.5", "serving status"), - render: (targets: DeploymentTrafficRow['targets']) => ( - - {targets.map((target) => ( - - {formatAevatarStatusLabel(target.servingState || 'unknown')} - - ))} - - ), - }, - { - key: 'actions', - title: t("pages.deployments.index.operate.6", "operate"), - render: (_, record) => ( - - ), - }, - ], - [openInspector], - ); - - const drawerDeploymentColumns = useMemo< - ColumnsType - >( - () => [ - { - dataIndex: 'deploymentId', - key: 'deploymentId', - title: 'Deployment', - width: 220, - render: (value: string, record) => ( - - - {formatDeploymentVisibilityLabel(value)} - - - {formatVersionVisibilityLabel(record.revisionId)} - - - ), - }, - { - dataIndex: 'primaryActorId', - key: 'primaryActorId', - title: t("pages.deployments.index.main.actor.7", "Main actor"), - width: 150, - render: (value: string) => - formatActorVisibilityLabel(value), - }, - { - dataIndex: 'status', - key: 'status', - title: t("pages.deployments.index.state.6", "state"), - width: 104, - render: (value: string) => ( - - ), - }, - { - dataIndex: 'activatedAt', - key: 'activatedAt', - title: t("pages.deployments.index.activation.time.3", "activation time"), - width: 148, - render: (value: string | null) => ( - - {formatDateTime(value)} - - ), - }, - { - dataIndex: 'updatedAt', - key: 'updatedAt', - title: t("pages.deployments.index.latest.updates.5", "Latest updates"), - width: 148, - render: (value: string) => ( - - {formatDateTime(value)} - - ), - }, - { - key: 'actions', - title: t("pages.deployments.index.operate.7", "operate"), - width: 104, - render: (_, record) => ( - - ), - }, - ], - [openInspector, surfaceToken.colorTextSecondary], - ); - - const handleDraftChange = useCallback((nextDraft: ServiceQueryDraft) => { - setDraft(nextDraft); - setSelectedServiceId(''); - setSelectedDeploymentId(''); - setReleaseHandoff(null); - }, []); - - const openServiceWorkbench = useCallback( - (service: Pick) => { - setSelectedServiceId(service.serviceId); - setSelectedDeploymentId(service.deploymentId || ''); - setInspectorState({ open: false }); - setReleaseHandoff(null); - setView('catalog'); - }, - [], - ); - - const closeServiceWorkbench = useCallback(() => { - setSelectedServiceId(''); - setSelectedDeploymentId(''); - setInspectorState({ open: false }); - setReleaseHandoff(null); - setDrawerState((current) => ({ - ...current, - open: false, - })); - }, []); - - const handleReset = useCallback(() => { - const nextDraft = isScopeDirty - ? { - appId: query.appId?.trim() ?? '', - namespace: query.namespace?.trim() ?? '', - take: query.take && query.take > 0 ? query.take : 200, - tenantId: query.tenantId?.trim() ?? '', - } - : resolvedScope?.scopeId?.trim() - ? { - ...readServiceQueryDraft(''), - appId: defaultScopeServiceAppId, - namespace: defaultScopeServiceNamespace, - tenantId: resolvedScope.scopeId.trim(), - } - : readServiceQueryDraft(''); - setDraft(nextDraft); - if (!isScopeDirty) { - setQuery(trimServiceQuery(nextDraft)); - } - setSelectedServiceId(''); - setSelectedDeploymentId(''); - setCandidateRevisionId(''); - setDrawerReason(''); - setReleaseHandoff(null); - setView('catalog'); - }, [isScopeDirty, query, resolvedScope?.scopeId]); - - const drawerSubtitle = selectedService - ? `${selectedService.tenantId}/${selectedService.appId}/${selectedService.namespace}` - : t("pages.deployments.index.publish.workspace.3", "Publish workspace"); - - return ( - -
- setNotice(null)} - /> - - setQuery(trimServiceQuery(draft))} - onReset={handleReset} - scopeLabel={currentScopeLabel} - /> - -
- - - - -
- -
-
- - - {t("pages.deployments.index.publish.service.list.2", "Publish service list")} - - {t("pages.deployments.index.first.lock.the.publishing.2", "First lock the publishing object from the service list")} - - {t("pages.deployments.index.scan.the.serving.deployment.2", "Scan the serving, deployment and entry scale, and then enter the release details of a service.")} - - - {isScopeDirty ? t("pages.deployments.index.show.last.loaded.range.2", "Show last loaded range") : t("pages.deployments.index.show.loaded.range.2", "Show loaded range")} - - - {loadedScopeLabel} - - - -
- - {servicesQuery.isLoading ? ( - - ) : servicesQuery.error ? ( - { - void servicesQuery.refetch(); - }, - }} - description={ - servicesQuery.error instanceof Error - ? servicesQuery.error.message - : t("pages.deployments.index.failed.to.load.service.2", "Failed to load service publishing list, please try again.") - } - kind="error" - title={t("pages.deployments.index.publishing.service.list.is", "Publishing service list is currently unavailable")} - /> - ) : servicesQuery.data?.length ? ( -
- - - - {[ - t("pages.deployments.index.state.7", "state"), - t("pages.deployments.index.serve.2", "Serve"), - t("pages.deployments.index.scope.2", "scope"), - t("pages.deployments.index.current.serving.2", "Current serving"), - t("pages.deployments.index.current.deployment.3", "Current deployment"), - t("pages.deployments.index.entrance.6", "Entrance"), - t("pages.deployments.index.latest.updates.6", "Latest updates"), - t("pages.deployments.index.operate.8", "operate"), - ].map((label) => ( - - ))} - - - - {(servicesQuery.data ?? []).map((service) => { - const selected = service.serviceId === selectedServiceId; - return ( - openServiceWorkbench(service)} - style={{ - background: selected - ? surfaceToken.colorPrimaryBg - : surfaceToken.colorBgContainer, - cursor: 'pointer', - }} - > - - - - - - - - - - ); - })} - -
- {label} -
- - -
- -
-
- - - {buildScopePreview( - service.tenantId, - service.appId, - service.namespace, - )} - - - - {service.activeServingRevisionId || - service.defaultServingRevisionId ? ( - - {formatVersionVisibilityLabel( - service.activeServingRevisionId || - service.defaultServingRevisionId, - )} - - ) : ( - - {t("pages.deployments.index.unpublished.2", "Unpublished")} - )} - - {service.deploymentId ? ( - - {formatDeploymentVisibilityLabel(service.deploymentId)} - - ) : ( - - {t("pages.deployments.index.not.hung.serving.3", "Not hung serving")} - )} - - 0 ? 'cyan' : 'default' - } - style={compactHintTagStyle} - > - {service.endpoints.length} - - - - {formatDateTime(service.updatedAt)} - - - -
-
- ) : ( - - )} -
-
- - - - - - - - - - - - - - - ) : null - } - onClose={closeServiceWorkbench} - open={Boolean(selectedServiceId)} - subtitle={drawerSubtitle} - title={ - selectedService?.displayName || - selectedServiceId || - 'Deployment Service' - } - width={1080} - > - {serviceDetailQuery.isLoading && !selectedService ? ( - - ) : !selectedService ? ( - - ) : ( -
- {releaseHandoff && releaseEvidence ? ( - setReleaseHandoff(null)} - onOpenEvidence={() => setView(releaseHandoff.evidenceView)} - /> - ) : null} - - -
- - - {focusDeployment?.deploymentId ? ( - {formatDeploymentVisibilityLabel(focusDeployment.deploymentId)} - ) : null} - {rolloutQuery.data?.rolloutId ? ( - {t("pages.deployments.index.rollout.active", "Rollout active")} - ) : null} - 0 ? 'cyan' : 'default' - } - style={compactHintTagStyle} - > - {selectedService.endpoints.length} {t("pages.deployments.index.entrance.7", "entrance")} - - -
- - - - -
- -
- - - - -
-
-
- - - - - columns={drawerDeploymentColumns} - dataSource={deploymentsQuery.data?.deployments ?? []} - locale={{ emptyText: t("pages.deployments.index.there.is.currently.no.4", "There is currently no deployment catalog") }} - onRow={(record) => ({ - onClick: () => - openInspector({ - kind: 'deployment', - key: record.deploymentId, - open: true, - }), - style: { cursor: 'pointer' }, - })} - pagination={false} - rowKey={(record) => record.deploymentId} - scroll={{ x: 860 }} - size="small" - tableLayout="fixed" - /> - - ), - }, - { - key: 'serving', - label: 'Serving', - children: ( - - - {t("pages.deployments.index.generation.3", "Generation")}{servingQuery.data?.generation ?? 0} - - {servingQuery.data?.activeRolloutId ? ( - - {t("pages.deployments.index.rollout.active", "Rollout active")} - - ) : null} - - - - - - - } - > - - columns={servingColumns} - dataSource={servingQuery.data?.targets ?? []} - locale={{ emptyText: t("pages.deployments.index.there.are.currently.no.5", "There are currently no serving targets") }} - onRow={(record) => ({ - onClick: () => - openInspector({ - kind: 'serving', - key: buildServingTargetKey(record), - open: true, - }), - style: { cursor: 'pointer' }, - })} - pagination={false} - rowKey={buildServingTargetKey} - size="middle" - /> - - ), - }, - { - key: 'traffic', - label: 'Traffic', - children: ( - - {trafficQuery.data?.activeRolloutId ? ( - - ) : null} - - {t("pages.deployments.index.generation.4", "Generation")}{trafficQuery.data?.generation ?? 0} - - - - - - - - } - > - - columns={trafficColumns} - dataSource={trafficRows} - locale={{ emptyText: t("pages.deployments.index.there.is.currently.no.5", "There is currently no traffic view") }} - onRow={(record) => ({ - onClick: () => - openInspector({ - kind: 'traffic', - key: record.key, - open: true, - }), - style: { cursor: 'pointer' }, - })} - pagination={false} - rowKey="key" - size="middle" - /> - - ), - }, - { - key: 'rollout', - label: 'Rollout', - children: rolloutQuery.data ? ( -
- - - - - } - > -
- - - - -
-
- -
- - - columns={rolloutColumns} - dataSource={rolloutQuery.data.stages} - pagination={false} - rowKey={(record) => record.stageId} - size="middle" - /> - - -
- - -
-
-
-
- ) : ( - - - - ), - }, - ]} - onChange={(key) => setView(key as DeploymentWorkbenchView)} - /> - -
- )} -
- - - setDrawerState((current) => ({ - ...current, - open: false, - })) - } - > -
-
- - - {rolloutQuery.data?.rolloutId ? ( - {t("pages.deployments.index.rollout.active", "Rollout active")} - ) : null} - {focusDeployment?.deploymentId ? ( - {formatDeploymentVisibilityLabel(focusDeployment.deploymentId)} - ) : null} - {focusDeployment?.revisionId ? ( - {formatVersionVisibilityLabel(focusDeployment.revisionId)} - ) : null} - -
- - -
- - - - setEditableTargets((current) => - current.map((item, itemIndex) => - itemIndex === index - ? { - ...item, - servingState: value, - } - : item, - ), - ) - } - /> -
- )) - ) : ( - - )} - setDrawerReason(event.target.value)} - /> - - - - - - -
- ), - key: 'weights', - label: t("pages.deployments.index.traffic.weight.2", "Traffic weight"), - }, - { - children: ( -
- - setDrawerReason(event.target.value)} - /> - - - {rolloutControlDefinitions.map((definition) => { - const availability = - rolloutActionAvailability[definition.action]; - - return ( - - - - - - ); - })} - -
- ), - key: 'control', - label: t("pages.deployments.index.release.control.8", "Release control"), - }, - ]} - onChange={(key) => - setDrawerState({ - open: true, - tab: key as DeploymentDrawerTab, - }) - } - /> - -
- - setInspectorState({ open: false })} - > -
- {inspectorState.open && inspectorState.kind === 'serving' ? ( - selectedServingTarget ? ( -
- -
- - - - - - -
-
- - - - - - -
- ) : ( - - ) - ) : null} - - {inspectorState.open && inspectorState.kind === 'traffic' ? ( - selectedTrafficRow ? ( -
- -
- - - - -
-
- -
- {selectedTrafficRow.targets.map((target, index) => ( - - ))} -
-
-
- ) : ( - - ) - ) : null} - - {inspectorState.open && inspectorState.kind === 'deployment' ? ( - inspectedDeployment ? ( -
- -
- - - - - - -
-
- - - - - - - - - - -
- ) : ( - - ) - ) : null} -
-
-
- ); -}; - -export default DeploymentsPage; diff --git a/apps/aevatar-console-web/src/pages/Deployments/releaseActionAvailability.test.ts b/apps/aevatar-console-web/src/pages/Deployments/releaseActionAvailability.test.ts deleted file mode 100644 index 3af6325050..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/releaseActionAvailability.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { setLocale } from '@umijs/max'; -import { buildRolloutActionAvailability } from './releaseActionAvailability'; - -describe('buildRolloutActionAvailability', () => { - beforeEach(() => { - setLocale('zh-CN', false); - }); - - const rollout = { - baselineTargets: [], - currentStageIndex: 0, - displayName: 'March Canary', - failureReason: '', - rolloutId: 'rollout-1', - serviceKey: 'scope-1:trade-agent', - stages: [], - startedAt: '2026-03-30T10:00:00Z', - status: 'canary', - updatedAt: '2026-03-30T10:05:00Z', - }; - - it('disables every control when there is no active rollout', () => { - const availability = buildRolloutActionAvailability(null); - - expect(availability.advance.enabled).toBe(false); - expect(availability.pause.enabled).toBe(false); - expect(availability.resume.enabled).toBe(false); - expect(availability.rollback.enabled).toBe(false); - expect(availability.advance.reason).toContain('没有活动发布推进'); - }); - - it('allows active rollout advance, pause, and rollback while keeping resume honest', () => { - const availability = buildRolloutActionAvailability(rollout); - - expect(availability.advance.enabled).toBe(true); - expect(availability.pause.enabled).toBe(true); - expect(availability.resume.enabled).toBe(false); - expect(availability.rollback.enabled).toBe(true); - expect(availability.resume.reason).toContain('暂停状态'); - }); - - it('allows only resume and rollback when the rollout is paused', () => { - const availability = buildRolloutActionAvailability({ - ...rollout, - status: 'paused', - }); - - expect(availability.advance.enabled).toBe(false); - expect(availability.pause.enabled).toBe(false); - expect(availability.resume.enabled).toBe(true); - expect(availability.rollback.enabled).toBe(true); - expect(availability.advance.reason).toContain('先恢复'); - }); - - it('disables controls for terminal rollout statuses', () => { - const availability = buildRolloutActionAvailability({ - ...rollout, - status: 'completed', - }); - - expect(availability.advance.enabled).toBe(false); - expect(availability.rollback.enabled).toBe(false); - expect(availability.rollback.reason).toContain('不可提交'); - }); - - it('treats RolledBack as a terminal rollout status', () => { - const availability = buildRolloutActionAvailability({ - ...rollout, - status: 'RolledBack', - }); - - expect(availability.advance.enabled).toBe(false); - expect(availability.pause.enabled).toBe(false); - expect(availability.resume.enabled).toBe(false); - expect(availability.rollback.enabled).toBe(false); - expect(availability.advance.reason).toContain('不可提交'); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/Deployments/releaseActionAvailability.ts b/apps/aevatar-console-web/src/pages/Deployments/releaseActionAvailability.ts deleted file mode 100644 index 024a38da98..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/releaseActionAvailability.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { ServiceRolloutSnapshot } from '@/shared/models/services'; -import { t } from "@/shared/i18n/messages"; - -export type RolloutControlAction = 'advance' | 'pause' | 'resume' | 'rollback'; - -export type ReleaseActionAvailability = { - enabled: boolean; - reason: string; -}; - -function normalizeStatus(status: string | null | undefined): string { - return (status ?? '').replace(/[^a-z0-9]/gi, '').toLowerCase(); -} - -function hasAnyStatus(status: string, patterns: readonly string[]): boolean { - return patterns.some((pattern) => status.includes(pattern)); -} - -export function buildRolloutActionAvailability( - rollout: ServiceRolloutSnapshot | null | undefined, -): Record { - if (!rollout?.rolloutId?.trim()) { - const reason = t("pages.deployments.releaseactionavailability.there.is.currently.no", "There is currently no active rollout and rollout control actions cannot be submitted."); - return { - advance: { enabled: false, reason }, - pause: { enabled: false, reason }, - resume: { enabled: false, reason }, - rollback: { enabled: false, reason }, - }; - } - - const status = normalizeStatus(rollout.status); - const terminal = hasAnyStatus(status, [ - 'cancel', - 'complete', - 'done', - 'fail', - 'inactive', - 'rolledback', - 'retire', - 'success', - ]); - const paused = hasAnyStatus(status, ['pause']); - const rollbackActive = hasAnyStatus(status, ['rollback', 'rollingback']); - - if (terminal) { - const reason = t("pages.deployments.releaseactionavailability.the.current.rollout.status", "The current rollout status is {value1}, and the control action cannot be submitted.", { value1: rollout.status || 'terminal' }); - return { - advance: { enabled: false, reason }, - pause: { enabled: false, reason }, - resume: { enabled: false, reason }, - rollback: { enabled: false, reason }, - }; - } - - if (rollbackActive) { - const reason = t("pages.deployments.releaseactionavailability.the.current.rollout.is", "The current rollout is already in the rollback process, wait for the rollback evidence to be refreshed before proceeding."); - return { - advance: { enabled: false, reason }, - pause: { enabled: false, reason }, - resume: { enabled: false, reason }, - rollback: { enabled: false, reason }, - }; - } - - return { - advance: { - enabled: !paused, - reason: paused - ? t("pages.deployments.releaseactionavailability.the.current.rollout.is.2", "The current rollout is paused; please resume it before advancing to the next stage.") - : t("pages.deployments.releaseactionavailability.the.push.will.submit", "The push will submit the command and still need to wait for the rollout/serving/traffic evidence to be refreshed."), - }, - pause: { - enabled: !paused, - reason: paused - ? t("pages.deployments.releaseactionavailability.the.current.rollout.is.3", "The current rollout is paused and does not need to be paused again.") - : t("pages.deployments.releaseactionavailability.pause.will.submit.the", "Pause will submit the command, and you still need to wait for the rollout status to show paused."), - }, - resume: { - enabled: paused, - reason: paused - ? t("pages.deployments.releaseactionavailability.recovery.will.submit.the", "Recovery will submit the command and still wait for the rollout state to become active again.") - : t("pages.deployments.releaseactionavailability.only.rollouts.in.the", "Only rollouts in the paused state need to be restored."), - }, - rollback: { - enabled: true, - reason: t("pages.deployments.releaseactionavailability.rollback.will.commit.the", "Rollback will commit the command and still need to wait for evidence that serving returns to baseline."), - }, - }; -} diff --git a/apps/aevatar-console-web/src/pages/Deployments/releaseEvidence.test.ts b/apps/aevatar-console-web/src/pages/Deployments/releaseEvidence.test.ts deleted file mode 100644 index 39404a413d..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/releaseEvidence.test.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { setLocale } from '@umijs/max'; -import { buildDeploymentReleaseEvidenceSnapshot } from './releaseEvidence'; -import { buildDeploymentReleaseHandoff } from './releaseHandoff'; - -describe('buildDeploymentReleaseEvidenceSnapshot', () => { - beforeEach(() => { - setLocale('zh-CN', false); - }); - - it('marks candidate deploy evidence observed only when rollout, serving, and traffic snapshots show it', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'deploy-candidate', - activeRevisionId: 'rev-11', - candidateRevisionId: 'rev-12', - receipt: { - commandId: 'cmd-1', - correlationId: 'corr-1', - }, - createdAt: '2026-03-30T10:00:00Z', - rolloutId: 'rollout-1', - serviceId: 'trade-agent', - }); - - const evidence = buildDeploymentReleaseEvidenceSnapshot({ - deployments: [], - handoff, - rollout: { - baselineTargets: [], - currentStageIndex: 0, - displayName: 'Canary', - failureReason: '', - rolloutId: 'rollout-1', - serviceKey: 'scope-1:trade-agent', - stages: [], - startedAt: '2026-03-30T10:00:00Z', - status: 'canary', - updatedAt: '2026-03-30T10:05:00Z', - }, - serving: { - activeRolloutId: 'rollout-1', - generation: 4, - serviceKey: 'scope-1:trade-agent', - targets: [ - { - allocationWeight: 10, - deploymentId: 'dep-2', - enabledEndpointIds: ['chat'], - primaryActorId: 'actor-2', - revisionId: 'rev-12', - servingState: 'canary', - }, - ], - updatedAt: '2026-03-30T10:06:00Z', - }, - traffic: { - activeRolloutId: 'rollout-1', - endpoints: [ - { - endpointId: 'chat', - targets: [ - { - allocationWeight: 10, - deploymentId: 'dep-2', - primaryActorId: 'actor-2', - revisionId: 'rev-12', - servingState: 'canary', - }, - ], - }, - ], - generation: 4, - serviceKey: 'scope-1:trade-agent', - updatedAt: '2026-03-30T10:06:00Z', - }, - }); - - expect(evidence.observedCount).toBe(3); - expect(evidence.summary).toBe('所有关键证据都已在本次提交后观察到。'); - expect(evidence.checks.map((check) => check.status)).toEqual([ - 'observed', - 'observed', - 'observed', - ]); - }); - - it('keeps candidate deploy evidence pending when serving and traffic do not show the candidate', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'deploy-candidate', - activeRevisionId: 'rev-11', - candidateRevisionId: 'rev-12', - receipt: { - commandId: 'cmd-1', - correlationId: 'corr-1', - }, - createdAt: '2026-03-30T10:10:00Z', - serviceId: 'trade-agent', - }); - - const evidence = buildDeploymentReleaseEvidenceSnapshot({ - deployments: [], - handoff, - serving: { - activeRolloutId: 'rollout-1', - generation: 3, - serviceKey: 'scope-1:trade-agent', - targets: [ - { - allocationWeight: 100, - deploymentId: 'dep-1', - enabledEndpointIds: ['chat'], - primaryActorId: 'actor-1', - revisionId: 'rev-11', - servingState: 'active', - }, - ], - updatedAt: '2026-03-30T10:05:00Z', - }, - traffic: { - activeRolloutId: 'rollout-1', - endpoints: [ - { - endpointId: 'chat', - targets: [ - { - allocationWeight: 100, - deploymentId: 'dep-1', - primaryActorId: 'actor-1', - revisionId: 'rev-11', - servingState: 'active', - }, - ], - }, - ], - generation: 3, - serviceKey: 'scope-1:trade-agent', - updatedAt: '2026-03-30T10:05:00Z', - }, - }); - - expect(evidence.observedCount).toBe(0); - expect(evidence.summary).toContain('3 项证据仍待观察'); - expect(evidence.checks.map((check) => check.status)).toEqual([ - 'pending', - 'pending', - 'pending', - ]); - }); - - it('keeps pre-submit candidate cache in review instead of observed', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'deploy-candidate', - activeRevisionId: 'rev-11', - candidateRevisionId: 'rev-12', - createdAt: '2026-03-30T10:10:00Z', - receipt: { - commandId: 'cmd-1', - correlationId: 'corr-1', - }, - rolloutId: 'rollout-1', - serviceId: 'trade-agent', - }); - - const evidence = buildDeploymentReleaseEvidenceSnapshot({ - deployments: [], - handoff, - rollout: { - baselineTargets: [], - currentStageIndex: 0, - displayName: 'Canary', - failureReason: '', - rolloutId: 'rollout-1', - serviceKey: 'scope-1:trade-agent', - stages: [], - startedAt: '2026-03-30T10:00:00Z', - status: 'InProgress', - updatedAt: '2026-03-30T10:05:00Z', - }, - serving: { - activeRolloutId: 'rollout-1', - generation: 4, - serviceKey: 'scope-1:trade-agent', - targets: [ - { - allocationWeight: 10, - deploymentId: 'dep-2', - enabledEndpointIds: ['chat'], - primaryActorId: 'actor-2', - revisionId: 'rev-12', - servingState: 'canary', - }, - ], - updatedAt: '2026-03-30T10:05:00Z', - }, - traffic: { - activeRolloutId: 'rollout-1', - endpoints: [ - { - endpointId: 'chat', - targets: [ - { - allocationWeight: 10, - deploymentId: 'dep-2', - primaryActorId: 'actor-2', - revisionId: 'rev-12', - servingState: 'canary', - }, - ], - }, - ], - generation: 4, - serviceKey: 'scope-1:trade-agent', - updatedAt: '2026-03-30T10:05:00Z', - }, - }); - - expect(evidence.observedCount).toBe(0); - expect(evidence.summary).toBe( - '3 项证据需要人工核对,避免把旧 ReadModel 当作本次完成。', - ); - expect(evidence.checks.map((check) => check.status)).toEqual([ - 'review', - 'review', - 'review', - ]); - expect(evidence.checks[1].detail).toContain('早于本次提交'); - }); - - it('marks deactivate evidence observed after catalog, serving, and traffic stop showing the deployment', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'deactivate-deployment', - deploymentId: 'dep-1', - receipt: { - commandId: 'cmd-7', - correlationId: 'corr-7', - }, - createdAt: '2026-03-30T10:00:00Z', - serviceId: 'trade-agent', - }); - - const evidence = buildDeploymentReleaseEvidenceSnapshot({ - deployments: [ - { - activatedAt: '2026-03-30T10:00:00Z', - deploymentId: 'dep-1', - primaryActorId: 'actor-1', - revisionId: 'rev-11', - status: 'inactive', - updatedAt: '2026-03-30T10:10:00Z', - }, - ], - handoff, - serving: { - activeRolloutId: '', - generation: 5, - serviceKey: 'scope-1:trade-agent', - targets: [], - updatedAt: '2026-03-30T10:10:00Z', - }, - traffic: { - activeRolloutId: '', - endpoints: [], - generation: 5, - serviceKey: 'scope-1:trade-agent', - updatedAt: '2026-03-30T10:10:00Z', - }, - }); - - expect(evidence.checks.map((check) => check.status)).toEqual([ - 'observed', - 'observed', - 'observed', - ]); - }); - - it('keeps deactivate catalog pending when the deployment is missing', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'deactivate-deployment', - createdAt: '2026-03-30T10:10:00Z', - deploymentId: 'dep-1', - receipt: { - commandId: 'cmd-7', - correlationId: 'corr-7', - }, - serviceId: 'trade-agent', - }); - - const evidence = buildDeploymentReleaseEvidenceSnapshot({ - deployments: [], - handoff, - serving: { - activeRolloutId: '', - generation: 5, - serviceKey: 'scope-1:trade-agent', - targets: [], - updatedAt: '2026-03-30T10:12:00Z', - }, - traffic: { - activeRolloutId: '', - endpoints: [], - generation: 5, - serviceKey: 'scope-1:trade-agent', - updatedAt: '2026-03-30T10:12:00Z', - }, - }); - - expect(evidence.checks[0]).toEqual( - expect.objectContaining({ - key: 'deployment-inactive', - status: 'pending', - }), - ); - expect(evidence.checks[0].detail).toContain('出现在目录'); - }); - - it('keeps deactivate catalog pending while the deployment is still active', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'deactivate-deployment', - createdAt: '2026-03-30T10:10:00Z', - deploymentId: 'dep-1', - receipt: { - commandId: 'cmd-7', - correlationId: 'corr-7', - }, - serviceId: 'trade-agent', - }); - - const evidence = buildDeploymentReleaseEvidenceSnapshot({ - deployments: [ - { - activatedAt: '2026-03-30T10:00:00Z', - deploymentId: 'dep-1', - primaryActorId: 'actor-1', - revisionId: 'rev-11', - status: 'active', - updatedAt: '2026-03-30T10:12:00Z', - }, - ], - handoff, - serving: { - activeRolloutId: '', - generation: 5, - serviceKey: 'scope-1:trade-agent', - targets: [], - updatedAt: '2026-03-30T10:12:00Z', - }, - traffic: { - activeRolloutId: '', - endpoints: [], - generation: 5, - serviceKey: 'scope-1:trade-agent', - updatedAt: '2026-03-30T10:12:00Z', - }, - }); - - expect(evidence.checks[0].status).toBe('pending'); - expect(evidence.checks[0].detail).toContain('状态离开活跃'); - }); - - it('observes rollback when rollout reaches RolledBack after the handoff', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'rollback-rollout', - createdAt: '2026-03-30T10:10:00Z', - receipt: { - commandId: 'cmd-6', - correlationId: 'corr-6', - }, - rolloutId: 'rollout-1', - serviceId: 'trade-agent', - }); - - const evidence = buildDeploymentReleaseEvidenceSnapshot({ - deployments: [], - handoff, - rollout: { - baselineTargets: [], - currentStageIndex: 0, - displayName: 'March Canary', - failureReason: '', - rolloutId: 'rollout-1', - serviceKey: 'scope-1:trade-agent', - stages: [], - startedAt: '2026-03-30T10:00:00Z', - status: 'RolledBack', - updatedAt: '2026-03-30T10:12:00Z', - }, - serving: { - activeRolloutId: '', - generation: 5, - serviceKey: 'scope-1:trade-agent', - targets: [], - updatedAt: '2026-03-30T10:12:00Z', - }, - traffic: { - activeRolloutId: '', - endpoints: [], - generation: 5, - serviceKey: 'scope-1:trade-agent', - updatedAt: '2026-03-30T10:12:00Z', - }, - }); - - expect(evidence.checks[0]).toEqual( - expect.objectContaining({ - key: 'rollout-status', - status: 'observed', - }), - ); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/Deployments/releaseEvidence.ts b/apps/aevatar-console-web/src/pages/Deployments/releaseEvidence.ts deleted file mode 100644 index 7376035b16..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/releaseEvidence.ts +++ /dev/null @@ -1,408 +0,0 @@ -import type { - ServiceDeploymentSnapshot, - ServiceRolloutSnapshot, - ServiceServingSetSnapshot, - ServiceTrafficViewSnapshot, -} from '@/shared/models/services'; -import type { DeploymentReleaseHandoff } from './releaseHandoff'; -import { t } from "@/shared/i18n/messages"; - -export type DeploymentReleaseEvidenceStatus = 'observed' | 'pending' | 'review'; - -export type DeploymentReleaseEvidenceCheck = { - detail: string; - key: string; - label: string; - status: DeploymentReleaseEvidenceStatus; -}; - -export type DeploymentReleaseEvidenceSnapshot = { - checks: DeploymentReleaseEvidenceCheck[]; - observedCount: number; - summary: string; -}; - -export type DeploymentReleaseEvidenceInput = { - deployments: readonly ServiceDeploymentSnapshot[]; - handoff: DeploymentReleaseHandoff; - rollout?: ServiceRolloutSnapshot | null; - serving?: ServiceServingSetSnapshot | null; - traffic?: ServiceTrafficViewSnapshot | null; -}; - -function normalizeStatus(value: string | null | undefined): string { - return (value ?? '').replace(/[^a-z0-9]/gi, '').toLowerCase(); -} - -function readSummaryValue( - handoff: DeploymentReleaseHandoff, - label: string, -): string { - return ( - handoff.summaryItems.find((item) => item.label === label)?.value.trim() ?? - '' - ); -} - -function buildCheck( - key: string, - label: string, - status: DeploymentReleaseEvidenceStatus, - detail: string, -): DeploymentReleaseEvidenceCheck { - return { - detail, - key, - label, - status, - }; -} - -function hasServingRevision( - serving: ServiceServingSetSnapshot | null | undefined, - revisionId: string, -): boolean { - return Boolean( - revisionId && - serving?.targets.some((target) => target.revisionId === revisionId), - ); -} - -function hasTrafficRevision( - traffic: ServiceTrafficViewSnapshot | null | undefined, - revisionId: string, -): boolean { - return Boolean( - revisionId && - traffic?.endpoints.some((endpoint) => - endpoint.targets.some((target) => target.revisionId === revisionId), - ), - ); -} - -function occurredAfterHandoff( - observedAt: string | null | undefined, - handoff: DeploymentReleaseHandoff, -): boolean { - if (!observedAt) { - return false; - } - - const observedTime = Date.parse(observedAt); - const submittedTime = Date.parse(handoff.createdAt); - if (Number.isNaN(observedTime) || Number.isNaN(submittedTime)) { - return false; - } - - return observedTime >= submittedTime; -} - -function buildFreshStatus( - observed: boolean, - observedAt: string | null | undefined, - handoff: DeploymentReleaseHandoff, -): DeploymentReleaseEvidenceStatus { - if (!observed) { - return 'pending'; - } - - return occurredAfterHandoff(observedAt, handoff) ? 'observed' : 'review'; -} - -function buildFreshDetail( - observed: boolean, - observedAt: string | null | undefined, - freshDetail: string, - staleDetail: string, - pendingDetail: string, - handoff: DeploymentReleaseHandoff, -): string { - if (!observed) { - return pendingDetail; - } - - if (occurredAfterHandoff(observedAt, handoff)) { - return freshDetail; - } - - return staleDetail; -} - -function deploymentIsInactive( - deployments: readonly ServiceDeploymentSnapshot[], - deploymentId: string, -): boolean { - const deployment = deployments.find( - (item) => item.deploymentId === deploymentId, - ); - if (!deployment) { - return false; - } - - const status = deployment.status.trim().toLowerCase(); - return status !== 'active'; -} - -function findDeployment( - deployments: readonly ServiceDeploymentSnapshot[], - deploymentId: string, -): ServiceDeploymentSnapshot | undefined { - return deployments.find((item) => item.deploymentId === deploymentId); -} - -function servingExcludesDeployment( - serving: ServiceServingSetSnapshot | null | undefined, - deploymentId: string, -): boolean { - return Boolean( - deploymentId && - serving && - !serving.targets.some((target) => target.deploymentId === deploymentId), - ); -} - -function trafficExcludesDeployment( - traffic: ServiceTrafficViewSnapshot | null | undefined, - deploymentId: string, -): boolean { - return Boolean( - deploymentId && - traffic && - !traffic.endpoints.some((endpoint) => - endpoint.targets.some((target) => target.deploymentId === deploymentId), - ), - ); -} - -export function buildDeploymentReleaseEvidenceSnapshot({ - deployments, - handoff, - rollout, - serving, - traffic, -}: DeploymentReleaseEvidenceInput): DeploymentReleaseEvidenceSnapshot { - const candidateRevisionId = readSummaryValue(handoff, t("pages.deployments.releaseevidence.candidate.revision", "Candidate revision")); - const deploymentId = readSummaryValue(handoff, 'Deployment'); - const checks: DeploymentReleaseEvidenceCheck[] = []; - - if (handoff.action === 'deploy-candidate') { - const rolloutMatchesHandoff = - Boolean(rollout?.rolloutId) && - (!readSummaryValue(handoff, 'Rollout') || - rollout?.rolloutId === readSummaryValue(handoff, 'Rollout')); - const rolloutStatus = buildFreshStatus( - rolloutMatchesHandoff, - rollout?.updatedAt, - handoff, - ); - const servingHasCandidate = hasServingRevision( - serving, - candidateRevisionId, - ); - const trafficHasCandidate = hasTrafficRevision( - traffic, - candidateRevisionId, - ); - - checks.push( - buildCheck( - 'rollout-active', - 'Rollout evidence', - rolloutStatus, - buildFreshDetail( - rolloutMatchesHandoff, - rollout?.updatedAt, - t("pages.deployments.releaseevidence.active.rollout.has.been", "Active rollout has been refreshed after this commit"), - rollout?.rolloutId - ? t("pages.deployments.releaseevidence.activity.rollout.is.visible", "Activity rollout is visible, but updatedAt is earlier than this submission, please wait for refresh") - : t("pages.deployments.releaseevidence.wait.for.this.rollout", "Wait for this rollout to appear or refresh"), - t("pages.deployments.releaseevidence.wait.for.this.rollout.2", "Wait for this rollout to appear or refresh"), - handoff, - ), - ), - buildCheck( - 'serving-candidate', - 'Serving evidence', - buildFreshStatus(servingHasCandidate, serving?.updatedAt, handoff), - buildFreshDetail( - servingHasCandidate, - serving?.updatedAt, - t("pages.deployments.releaseevidence.serving.targets.already.contain", "Serving targets already contain the candidate revision after this commit"), - t("pages.deployments.releaseevidence.serving.targets.already.contain.2", "Serving targets already contain the candidate revision, but updatedAt is earlier than this submission, please wait for readmodel to refresh"), - t("pages.deployments.releaseevidence.wait.for.serving.targets", "Wait for candidate revision to appear in serving targets"), - handoff, - ), - ), - buildCheck( - 'traffic-candidate', - 'Traffic evidence', - buildFreshStatus(trafficHasCandidate, traffic?.updatedAt, handoff), - buildFreshDetail( - trafficHasCandidate, - traffic?.updatedAt, - t("pages.deployments.releaseevidence.traffic.split.already.contains", "Traffic split already contains the candidate revision after this commit"), - t("pages.deployments.releaseevidence.traffic.split.already.contains.2", "Traffic split already contains the candidate revision, but updatedAt is earlier than this submission, please wait for readmodel to refresh"), - t("pages.deployments.releaseevidence.wait.for.traffic.split", "Wait for traffic split to point to candidate revision"), - handoff, - ), - ), - ); - } else if (handoff.action === 'replace-serving-targets') { - checks.push( - buildCheck( - 'serving-generation', - 'Serving generation', - occurredAfterHandoff(serving?.updatedAt, handoff) - ? 'review' - : 'pending', - occurredAfterHandoff(serving?.updatedAt, handoff) - ? t("pages.deployments.releaseevidence.serving.updatedat.is.later", "serving updatedAt {value1} is later than this submission, please confirm whether the weights match", { value1: serving?.updatedAt }) - : t("pages.deployments.releaseevidence.wait.for.serving.readmodel", "Wait for serving readmodel to refresh after this submission"), - ), - buildCheck( - 'traffic-generation', - 'Traffic split', - occurredAfterHandoff(traffic?.updatedAt, handoff) - ? 'review' - : 'pending', - occurredAfterHandoff(traffic?.updatedAt, handoff) - ? t("pages.deployments.releaseevidence.traffic.updatedat.is.later", "Traffic updatedAt {value1} is later than this submission, please check whether the weights match.", { value1: traffic?.updatedAt }) - : t("pages.deployments.releaseevidence.wait.for.the.traffic", "Wait for the traffic readmodel to be refreshed after this submission"), - ), - ); - } else if (handoff.action === 'deactivate-deployment') { - const deployment = findDeployment(deployments, deploymentId); - const inactive = deploymentIsInactive(deployments, deploymentId); - const catalogStatus = buildFreshStatus( - inactive, - deployment?.updatedAt, - handoff, - ); - const servingExcludesTarget = servingExcludesDeployment( - serving, - deploymentId, - ); - const trafficExcludesTarget = trafficExcludesDeployment( - traffic, - deploymentId, - ); - - checks.push( - buildCheck( - 'deployment-inactive', - 'Deployment catalog', - catalogStatus, - buildFreshDetail( - inactive, - deployment?.updatedAt, - t("pages.deployments.releaseevidence.has.left.active.after", "Target deployment has left active after this commit"), - t("pages.deployments.releaseevidence.is.no.longer.displayed", "Target deployment is no longer displayed as active, but updatedAt is earlier than this submission, please wait for the catalog to refresh"), - deployment - ? t("pages.deployments.releaseevidence.wait.for.state.to", "Wait for target deployment state to leave active") - : t("pages.deployments.releaseevidence.wait.for.to.appear", "Wait for target deployment to appear in catalog and show inactive status"), - handoff, - ), - ), - buildCheck( - 'serving-excludes-deployment', - 'Serving targets', - buildFreshStatus(servingExcludesTarget, serving?.updatedAt, handoff), - buildFreshDetail( - servingExcludesTarget, - serving?.updatedAt, - t("pages.deployments.releaseevidence.serving.targets.no.longer", "serving targets no longer contain the deployment after this submission"), - t("pages.deployments.releaseevidence.serving.targets.currently.do", "serving targets currently do not contain this deployment, but updatedAt is earlier than this submission, please wait for readmodel to refresh"), - t("pages.deployments.releaseevidence.wait.for.serving.targets.2", "Wait for serving targets to remove the deployment"), - handoff, - ), - ), - buildCheck( - 'traffic-excludes-deployment', - 'Traffic split', - buildFreshStatus(trafficExcludesTarget, traffic?.updatedAt, handoff), - buildFreshDetail( - trafficExcludesTarget, - traffic?.updatedAt, - t("pages.deployments.releaseevidence.traffic.split.no.longer", "Traffic split no longer contains the deployment after this submission"), - t("pages.deployments.releaseevidence.traffic.split.currently.does", "Traffic split currently does not contain this deployment, but updatedAt is earlier than this submission, please wait for readmodel to refresh"), - t("pages.deployments.releaseevidence.wait.for.traffic.split.2", "Wait for traffic split to remove the deployment"), - handoff, - ), - ), - ); - } else { - const rolloutStatus = rollout?.status ?? ''; - const actionStatusNeedle: Partial> = { - 'advance-rollout': 'inprogress', - 'pause-rollout': 'paused', - 'resume-rollout': 'inprogress', - 'rollback-rollout': 'rolledback', - }; - const needle = actionStatusNeedle[handoff.action] ?? ''; - const rolloutHasExpectedStatus = - needle && normalizeStatus(rolloutStatus).includes(needle); - - checks.push( - buildCheck( - 'rollout-status', - 'Rollout status', - buildFreshStatus( - Boolean(rolloutHasExpectedStatus), - rollout?.updatedAt, - handoff, - ), - rolloutStatus - ? buildFreshDetail( - Boolean(rolloutHasExpectedStatus), - rollout?.updatedAt, - t("pages.deployments.releaseevidence.the.current.rollout.status", "The current rollout status has been refreshed to {value1} after this commit", { value1: rolloutStatus }), - t("pages.deployments.releaseevidence.the.current.rollout.status.2", "The current rollout status is {value1}, but updatedAt is earlier than this submission, please wait for the refresh", { value1: rolloutStatus }), - t("pages.deployments.releaseevidence.the.current.rollout.status.3", "The current rollout status is {value1}, waiting for the status matching this command", { value1: rolloutStatus }), - handoff, - ) - : t("pages.deployments.releaseevidence.wait.for.rollout.status", "Wait for rollout status to refresh"), - ), - buildCheck( - 'serving-targets', - 'Serving targets', - occurredAfterHandoff(serving?.updatedAt, handoff) - ? 'review' - : 'pending', - occurredAfterHandoff(serving?.updatedAt, handoff) - ? t("pages.deployments.releaseevidence.currently.serving.targets.are", "Currently {value1} serving targets are visible, please check whether they match this command.", { value1: serving?.targets.length ?? 0 }) - : t("pages.deployments.releaseevidence.wait.for.serving.targets.3", "Wait for serving targets to be refreshed after this submission"), - ), - buildCheck( - 'traffic-split', - 'Traffic split', - occurredAfterHandoff(traffic?.updatedAt, handoff) - ? 'review' - : 'pending', - occurredAfterHandoff(traffic?.updatedAt, handoff) - ? t("pages.deployments.releaseevidence.traffic.endpoints.are.currently", "{value1} traffic endpoints are currently visible, please check whether they match this command.", { value1: traffic?.endpoints.length ?? 0 }) - : t("pages.deployments.releaseevidence.wait.for.the.traffic.2", "Wait for the traffic split to be refreshed after this submission"), - ), - ); - } - - const observedCount = checks.filter( - (check) => check.status === 'observed', - ).length; - const pendingCount = checks.filter( - (check) => check.status === 'pending', - ).length; - const reviewCount = checks.filter( - (check) => check.status === 'review', - ).length; - - return { - checks, - observedCount, - summary: - pendingCount > 0 - ? t("pages.deployments.releaseevidence.evidence.remains.to.be", "{value1} evidence remains to be seen, avoid treating submitted as completed.", { value1: pendingCount }) - : reviewCount > 0 - ? t("pages.deployments.releaseevidence.evidence.needs.to.be", "{value1} evidence needs to be manually checked to avoid treating the old readmodel as completed this time.", { value1: reviewCount }) - : t("pages.deployments.releaseevidence.all.key.evidence.has", "All key evidence has been observed following this submission."), - }; -} diff --git a/apps/aevatar-console-web/src/pages/Deployments/releaseHandoff.test.ts b/apps/aevatar-console-web/src/pages/Deployments/releaseHandoff.test.ts deleted file mode 100644 index be2a04d6e8..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/releaseHandoff.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { setLocale } from '@umijs/max'; -import { buildDeploymentReleaseHandoff } from './releaseHandoff'; - -describe('buildDeploymentReleaseHandoff', () => { - beforeEach(() => { - setLocale('zh-CN', false); - }); - - it('keeps submitted commands separate from observed serving state', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'deploy-candidate', - activeRevisionId: 'rev-11', - candidateRevisionId: 'rev-12', - receipt: { - commandId: 'cmd-1', - correlationId: 'corr-1', - targetActorId: 'actor-1', - }, - rolloutId: 'rollout-1', - rolloutStageLabel: '2/3', - serviceId: 'trade-agent', - targetCount: 2, - }); - - expect(handoff.pendingLabel).toBe('已提交,不代表已完成'); - expect(handoff.noticeMessage).toContain('等待发布推进/服务态证据刷新'); - expect(handoff.evidenceDescription).toContain( - '尚未说明候选修订已经被服务态观察到', - ); - expect(handoff.evidenceView).toBe('rollout'); - expect(handoff.summaryItems).toEqual( - expect.arrayContaining([ - { - label: '候选修订', - value: 'rev-12', - }, - { - label: '当前服务态', - value: 'rev-11', - }, - ]), - ); - }); - - it('routes serving replacement evidence to serving and traffic checks', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'replace-serving-targets', - activeRevisionId: 'rev-11', - endpointCount: 1, - receipt: { - commandId: 'cmd-2', - correlationId: 'corr-2', - }, - serviceId: 'trade-agent', - targetCount: 2, - }); - - expect(handoff.evidenceView).toBe('serving'); - expect(handoff.evidenceItems.join(' ')).toContain('服务态代次'); - expect(handoff.evidenceItems.join(' ')).toContain('流量 Endpoint'); - expect(handoff.noticeMessage).toContain('等待服务态/流量证据刷新'); - }); - - it('does not describe rollback as completed serving state', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'rollback-rollout', - activeRevisionId: 'rev-12', - receipt: { - commandId: 'cmd-6', - correlationId: 'corr-6', - }, - rolloutId: 'rollout-1', - serviceId: 'trade-agent', - }); - - expect(handoff.noticeTone).toBe('warning'); - expect(handoff.evidenceDescription).toContain( - '不代表服务态已经回到基线', - ); - expect(handoff.evidenceItems.join(' ')).toContain('基线'); - }); - - it('keeps deactivate handoff pointed at catalog and serving evidence', () => { - const handoff = buildDeploymentReleaseHandoff({ - action: 'deactivate-deployment', - deploymentId: 'dep-1', - receipt: { - commandId: 'cmd-7', - correlationId: 'corr-7', - }, - serviceId: 'trade-agent', - }); - - expect(handoff.evidenceView).toBe('catalog'); - expect(handoff.summaryItems).toEqual( - expect.arrayContaining([ - { - label: 'Deployment', - value: 'dep-1', - }, - ]), - ); - expect(handoff.evidenceItems.join(' ')).toContain('服务目标'); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/Deployments/releaseHandoff.ts b/apps/aevatar-console-web/src/pages/Deployments/releaseHandoff.ts deleted file mode 100644 index ea920bd113..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/releaseHandoff.ts +++ /dev/null @@ -1,500 +0,0 @@ -import type { ServiceCommandAcceptedReceipt } from '@/shared/models/services'; -import { - formatConsoleMessage, - t, - type ConsoleMessageDescriptor, -} from '@/shared/i18n/messages'; - -export type DeploymentReleaseHandoffAction = - | 'deploy-candidate' - | 'replace-serving-targets' - | 'advance-rollout' - | 'pause-rollout' - | 'resume-rollout' - | 'rollback-rollout' - | 'deactivate-deployment'; - -export type DeploymentReleaseEvidenceView = - | 'catalog' - | 'serving' - | 'rollout' - | 'traffic'; - -export type DeploymentReleaseHandoff = { - action: DeploymentReleaseHandoffAction; - actionLabel: string; - actionSummary: string; - commandId: string; - correlationId: string; - createdAt: string; - evidenceDescription: string; - evidenceItems: string[]; - evidenceView: DeploymentReleaseEvidenceView; - evidenceViewLabel: string; - id: string; - noticeMessage: string; - noticeTone: 'success' | 'warning'; - pendingLabel: string; - summaryItems: Array<{ - label: string; - value: string; - }>; - title: string; -}; - -export type DeploymentReleaseHandoffInput = { - action: DeploymentReleaseHandoffAction; - activeRevisionId?: string; - candidateRevisionId?: string; - createdAt?: string; - deploymentId?: string; - endpointCount?: number; - receipt?: Partial; - rolloutId?: string; - rolloutStageLabel?: string; - serviceId: string; - targetCount?: number; -}; - -type DeploymentReleaseHandoffCopy = Omit< - Pick< - DeploymentReleaseHandoff, - | 'actionLabel' - | 'actionSummary' - | 'evidenceDescription' - | 'evidenceItems' - | 'evidenceView' - | 'evidenceViewLabel' - | 'noticeMessage' - | 'noticeTone' - | 'title' - >, - | 'actionLabel' - | 'actionSummary' - | 'evidenceDescription' - | 'evidenceItems' - | 'evidenceViewLabel' - | 'noticeMessage' - | 'title' -> & { - actionLabel: ConsoleMessageDescriptor; - actionSummary: ConsoleMessageDescriptor; - evidenceDescription: ConsoleMessageDescriptor; - evidenceItems: readonly ConsoleMessageDescriptor[]; - evidenceViewLabel: ConsoleMessageDescriptor; - noticeMessage: ConsoleMessageDescriptor; - title: ConsoleMessageDescriptor; -}; - -const actionCopy: Record< - DeploymentReleaseHandoffAction, - DeploymentReleaseHandoffCopy -> = { - 'advance-rollout': { - actionLabel: { - defaultMessage: 'Advance rollout', - id: 'pages.deployments.releasehandoff.actions.advanceRollout.label', - }, - actionSummary: { - defaultMessage: 'Advance request entered the release control plane', - id: 'pages.deployments.releasehandoff.actions.advanceRollout.summary', - }, - evidenceDescription: { - defaultMessage: - 'This only means the rollout advance command was accepted. Wait for stage and traffic evidence before treating it as complete.', - id: 'pages.deployments.releasehandoff.actions.advanceRollout.evidenceDescription', - }, - evidenceItems: [ - { - defaultMessage: 'Rollout current stage or updatedAt changes', - id: 'pages.deployments.releasehandoff.actions.advanceRollout.evidence.stage', - }, - { - defaultMessage: 'Serving targets match the current stage targets', - id: 'pages.deployments.releasehandoff.actions.advanceRollout.evidence.serving', - }, - { - defaultMessage: 'Traffic allocation reflects the new stage weights', - id: 'pages.deployments.releasehandoff.actions.advanceRollout.evidence.traffic', - }, - ], - evidenceView: 'rollout', - evidenceViewLabel: { - defaultMessage: 'Rollout', - id: 'pages.deployments.releasehandoff.evidenceViews.rollout', - }, - noticeMessage: { - defaultMessage: - 'Rollout advance request was submitted. Waiting for stage evidence to refresh.', - id: 'pages.deployments.releasehandoff.actions.advanceRollout.notice', - }, - noticeTone: 'success', - title: { - defaultMessage: 'Rollout advance submitted', - id: 'pages.deployments.releasehandoff.actions.advanceRollout.title', - }, - }, - 'deactivate-deployment': { - actionLabel: { - defaultMessage: 'Deactivate deployment', - id: 'pages.deployments.releasehandoff.actions.deactivateDeployment.label', - }, - actionSummary: { - defaultMessage: 'Deactivate request entered the release control plane', - id: 'pages.deployments.releasehandoff.actions.deactivateDeployment.summary', - }, - evidenceDescription: { - defaultMessage: - 'This only means the deactivate command was accepted. It does not mean the deployment has disappeared from serving or catalog yet.', - id: 'pages.deployments.releasehandoff.actions.deactivateDeployment.evidenceDescription', - }, - evidenceItems: [ - { - defaultMessage: - 'The target deployment is no longer active in the deployment catalog', - id: 'pages.deployments.releasehandoff.actions.deactivateDeployment.evidence.catalog', - }, - { - defaultMessage: - 'Serving targets no longer route to the deactivated deployment', - id: 'pages.deployments.releasehandoff.actions.deactivateDeployment.evidence.serving', - }, - { - defaultMessage: - 'Traffic endpoints no longer allocate traffic to that revision/deployment', - id: 'pages.deployments.releasehandoff.actions.deactivateDeployment.evidence.traffic', - }, - ], - evidenceView: 'catalog', - evidenceViewLabel: { - defaultMessage: 'Deployment catalog', - id: 'pages.deployments.releasehandoff.evidenceViews.catalog', - }, - noticeMessage: { - defaultMessage: - 'Deployment deactivate request was submitted. Waiting for catalog/serving evidence to refresh.', - id: 'pages.deployments.releasehandoff.actions.deactivateDeployment.notice', - }, - noticeTone: 'warning', - title: { - defaultMessage: 'Deployment deactivation submitted', - id: 'pages.deployments.releasehandoff.actions.deactivateDeployment.title', - }, - }, - 'deploy-candidate': { - actionLabel: { - defaultMessage: 'Deploy candidate', - id: 'pages.deployments.releasehandoff.actions.deployCandidate.label', - }, - actionSummary: { - defaultMessage: 'Candidate request entered the release control plane', - id: 'pages.deployments.releasehandoff.actions.deployCandidate.summary', - }, - evidenceDescription: { - defaultMessage: - 'This only means the candidate deployment command was accepted. It does not mean serving has observed the candidate revision yet.', - id: 'pages.deployments.releasehandoff.actions.deployCandidate.evidenceDescription', - }, - evidenceItems: [ - { - defaultMessage: 'Rollout shows an active stage or changed stage targets', - id: 'pages.deployments.releasehandoff.actions.deployCandidate.evidence.rollout', - }, - { - defaultMessage: 'Serving targets include the candidate revision', - id: 'pages.deployments.releasehandoff.actions.deployCandidate.evidence.serving', - }, - { - defaultMessage: - 'Traffic allocation points to the candidate revision before it is treated as effective', - id: 'pages.deployments.releasehandoff.actions.deployCandidate.evidence.traffic', - }, - ], - evidenceView: 'rollout', - evidenceViewLabel: { - defaultMessage: 'Rollout', - id: 'pages.deployments.releasehandoff.evidenceViews.rollout', - }, - noticeMessage: { - defaultMessage: - 'Candidate version was submitted. Waiting for rollout/serving evidence to refresh.', - id: 'pages.deployments.releasehandoff.actions.deployCandidate.notice', - }, - noticeTone: 'success', - title: { - defaultMessage: 'Candidate deployment submitted', - id: 'pages.deployments.releasehandoff.actions.deployCandidate.title', - }, - }, - 'pause-rollout': { - actionLabel: { - defaultMessage: 'Pause rollout', - id: 'pages.deployments.releasehandoff.actions.pauseRollout.label', - }, - actionSummary: { - defaultMessage: 'Pause request entered the release control plane', - id: 'pages.deployments.releasehandoff.actions.pauseRollout.summary', - }, - evidenceDescription: { - defaultMessage: - 'This only means the pause command was accepted. Wait until rollout status shows paused before stopping follow-up operations.', - id: 'pages.deployments.releasehandoff.actions.pauseRollout.evidenceDescription', - }, - evidenceItems: [ - { - defaultMessage: 'Rollout status refreshes to paused or an equivalent state', - id: 'pages.deployments.releasehandoff.actions.pauseRollout.evidence.status', - }, - { - defaultMessage: - 'Serving targets remain at the last stable allocation before pause', - id: 'pages.deployments.releasehandoff.actions.pauseRollout.evidence.serving', - }, - { - defaultMessage: 'Traffic has not advanced to the next stage', - id: 'pages.deployments.releasehandoff.actions.pauseRollout.evidence.traffic', - }, - ], - evidenceView: 'rollout', - evidenceViewLabel: { - defaultMessage: 'Rollout', - id: 'pages.deployments.releasehandoff.evidenceViews.rollout', - }, - noticeMessage: { - defaultMessage: - 'Rollout pause request was submitted. Waiting for status evidence to refresh.', - id: 'pages.deployments.releasehandoff.actions.pauseRollout.notice', - }, - noticeTone: 'success', - title: { - defaultMessage: 'Rollout pause submitted', - id: 'pages.deployments.releasehandoff.actions.pauseRollout.title', - }, - }, - 'replace-serving-targets': { - actionLabel: { - defaultMessage: 'Apply weights', - id: 'pages.deployments.releasehandoff.actions.replaceServingTargets.label', - }, - actionSummary: { - defaultMessage: - 'Serving target replacement request entered the release control plane', - id: 'pages.deployments.releasehandoff.actions.replaceServingTargets.summary', - }, - evidenceDescription: { - defaultMessage: - 'This only means the weight replacement command was accepted. Wait for serving generation and traffic split to refresh.', - id: 'pages.deployments.releasehandoff.actions.replaceServingTargets.evidenceDescription', - }, - evidenceItems: [ - { - defaultMessage: 'Serving generation or updatedAt refreshes', - id: 'pages.deployments.releasehandoff.actions.replaceServingTargets.evidence.generation', - }, - { - defaultMessage: 'Serving targets show the new revision/weight allocation', - id: 'pages.deployments.releasehandoff.actions.replaceServingTargets.evidence.serving', - }, - { - defaultMessage: - 'Traffic endpoint split aligns with the new serving targets', - id: 'pages.deployments.releasehandoff.actions.replaceServingTargets.evidence.traffic', - }, - ], - evidenceView: 'serving', - evidenceViewLabel: { - defaultMessage: 'Serving', - id: 'pages.deployments.releasehandoff.evidenceViews.serving', - }, - noticeMessage: { - defaultMessage: - 'Serving targets were submitted. Waiting for serving/traffic evidence to refresh.', - id: 'pages.deployments.releasehandoff.actions.replaceServingTargets.notice', - }, - noticeTone: 'success', - title: { - defaultMessage: 'Serving targets replacement submitted', - id: 'pages.deployments.releasehandoff.actions.replaceServingTargets.title', - }, - }, - 'resume-rollout': { - actionLabel: { - defaultMessage: 'Resume rollout', - id: 'pages.deployments.releasehandoff.actions.resumeRollout.label', - }, - actionSummary: { - defaultMessage: 'Resume request entered the release control plane', - id: 'pages.deployments.releasehandoff.actions.resumeRollout.summary', - }, - evidenceDescription: { - defaultMessage: - 'This only means the resume command was accepted. Wait until rollout status re-enters active advancement.', - id: 'pages.deployments.releasehandoff.actions.resumeRollout.evidenceDescription', - }, - evidenceItems: [ - { - defaultMessage: 'Rollout status no longer remains paused', - id: 'pages.deployments.releasehandoff.actions.resumeRollout.evidence.status', - }, - { - defaultMessage: 'Current stage or updatedAt continues to refresh', - id: 'pages.deployments.releasehandoff.actions.resumeRollout.evidence.stage', - }, - { - defaultMessage: - 'Traffic allocation continues advancing by the stage plan', - id: 'pages.deployments.releasehandoff.actions.resumeRollout.evidence.traffic', - }, - ], - evidenceView: 'rollout', - evidenceViewLabel: { - defaultMessage: 'Rollout', - id: 'pages.deployments.releasehandoff.evidenceViews.rollout', - }, - noticeMessage: { - defaultMessage: - 'Rollout resume request was submitted. Waiting for status evidence to refresh.', - id: 'pages.deployments.releasehandoff.actions.resumeRollout.notice', - }, - noticeTone: 'success', - title: { - defaultMessage: 'Rollout resume submitted', - id: 'pages.deployments.releasehandoff.actions.resumeRollout.title', - }, - }, - 'rollback-rollout': { - actionLabel: { - defaultMessage: 'Rollback rollout', - id: 'pages.deployments.releasehandoff.actions.rollbackRollout.label', - }, - actionSummary: { - defaultMessage: 'Rollback request entered the release control plane', - id: 'pages.deployments.releasehandoff.actions.rollbackRollout.summary', - }, - evidenceDescription: { - defaultMessage: - 'This only means the rollback command was accepted. It does not mean serving has returned to baseline yet.', - id: 'pages.deployments.releasehandoff.actions.rollbackRollout.evidenceDescription', - }, - evidenceItems: [ - { - defaultMessage: - 'Rollout status shows rollback or returns to the baseline stage', - id: 'pages.deployments.releasehandoff.actions.rollbackRollout.evidence.status', - }, - { - defaultMessage: 'Serving targets align with baseline targets', - id: 'pages.deployments.releasehandoff.actions.rollbackRollout.evidence.serving', - }, - { - defaultMessage: - 'Traffic allocation no longer points to the rolled-back candidate revision', - id: 'pages.deployments.releasehandoff.actions.rollbackRollout.evidence.traffic', - }, - ], - evidenceView: 'rollout', - evidenceViewLabel: { - defaultMessage: 'Rollout', - id: 'pages.deployments.releasehandoff.evidenceViews.rollout', - }, - noticeMessage: { - defaultMessage: - 'Rollout rollback request was submitted. Waiting for baseline evidence to refresh.', - id: 'pages.deployments.releasehandoff.actions.rollbackRollout.notice', - }, - noticeTone: 'warning', - title: { - defaultMessage: 'Rollout rollback submitted', - id: 'pages.deployments.releasehandoff.actions.rollbackRollout.title', - }, - }, -}; - -export function buildDeploymentReleaseHandoff( - input: DeploymentReleaseHandoffInput, -): DeploymentReleaseHandoff { - const copy = actionCopy[input.action]; - const commandId = input.receipt?.commandId?.trim() || 'pending-command'; - const correlationId = - input.receipt?.correlationId?.trim() || 'pending-correlation'; - const createdAt = input.createdAt || new Date().toISOString(); - const summaryItems = [ - { - label: 'Service', - value: input.serviceId || t("pages.deployments.releasehandoff.not.selected", "Not selected"), - }, - { - label: 'Command', - value: commandId, - }, - { - label: 'Correlation', - value: correlationId, - }, - { - label: t("pages.deployments.releasehandoff.currently.serving", "currently serving"), - value: input.activeRevisionId || t("pages.deployments.releasehandoff.none.yet", "None yet"), - }, - ]; - - if (input.candidateRevisionId) { - summaryItems.push({ - label: t("pages.deployments.releasehandoff.candidate.revision", "Candidate revision"), - value: input.candidateRevisionId, - }); - } - - if (input.deploymentId) { - summaryItems.push({ - label: 'Deployment', - value: input.deploymentId, - }); - } - - if (input.rolloutId) { - summaryItems.push({ - label: 'Rollout', - value: input.rolloutId, - }); - } - - if (input.rolloutStageLabel) { - summaryItems.push({ - label: t("pages.deployments.releasehandoff.current.stage", "current stage"), - value: input.rolloutStageLabel, - }); - } - - if (typeof input.targetCount === 'number') { - summaryItems.push({ - label: t("pages.deployments.releasehandoff.serving.targets", "Serving targets"), - value: String(input.targetCount), - }); - } - - if (typeof input.endpointCount === 'number') { - summaryItems.push({ - label: t("pages.deployments.releasehandoff.traffic.endpoints", "Traffic endpoints"), - value: String(input.endpointCount), - }); - } - - return { - action: input.action, - actionLabel: formatConsoleMessage(copy.actionLabel), - actionSummary: formatConsoleMessage(copy.actionSummary), - commandId, - correlationId, - createdAt, - evidenceDescription: formatConsoleMessage(copy.evidenceDescription), - evidenceItems: copy.evidenceItems.map((item) => formatConsoleMessage(item)), - evidenceView: copy.evidenceView, - evidenceViewLabel: formatConsoleMessage(copy.evidenceViewLabel), - id: `${input.action}:${commandId}:${correlationId}`, - noticeMessage: formatConsoleMessage(copy.noticeMessage), - noticeTone: copy.noticeTone, - pendingLabel: t("pages.deployments.releasehandoff.submitted.does.not.mean", "Submitted, does not mean completed"), - summaryItems, - title: formatConsoleMessage(copy.title), - }; -} diff --git a/apps/aevatar-console-web/src/pages/Deployments/servingTargetPlan.test.ts b/apps/aevatar-console-web/src/pages/Deployments/servingTargetPlan.test.ts deleted file mode 100644 index 6487a9f380..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/servingTargetPlan.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { setLocale } from '@umijs/max'; -import { buildServingTargetPlanStatus } from './servingTargetPlan'; - -describe('buildServingTargetPlanStatus', () => { - beforeEach(() => { - setLocale('zh-CN', false); - }); - - it('disables empty serving target submissions', () => { - const status = buildServingTargetPlanStatus([]); - - expect(status.enabled).toBe(false); - expect(status.reason).toContain('不能提交空的流量计划'); - }); - - it('requires every target to identify a revision', () => { - const status = buildServingTargetPlanStatus([ - { - allocationWeight: 100, - revisionId: '', - }, - ]); - - expect(status.enabled).toBe(false); - expect(status.reason).toContain('缺少修订'); - }); - - it('requires allocation weights to add up to 100 percent', () => { - const status = buildServingTargetPlanStatus([ - { - allocationWeight: 60, - revisionId: 'rev-1', - }, - { - allocationWeight: 20, - revisionId: 'rev-2', - }, - ]); - - expect(status.enabled).toBe(false); - expect(status.totalWeight).toBe(80); - expect(status.reason).toContain('80%'); - }); - - it('rejects serving states that the API would silently coerce to active', () => { - const status = buildServingTargetPlanStatus([ - { - allocationWeight: 100, - revisionId: 'rev-1', - servingState: 'canary', - }, - ]); - - expect(status.enabled).toBe(false); - expect(status.reason).toContain('服务态状态只能选择'); - }); - - it('allows a complete 100 percent serving target plan', () => { - const status = buildServingTargetPlanStatus([ - { - allocationWeight: 90, - revisionId: 'rev-1', - servingState: 'active', - }, - { - allocationWeight: 10, - revisionId: 'rev-2', - servingState: 'draining', - }, - ]); - - expect(status.enabled).toBe(true); - expect(status.summary).toContain('100%'); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/Deployments/servingTargetPlan.ts b/apps/aevatar-console-web/src/pages/Deployments/servingTargetPlan.ts deleted file mode 100644 index 7418b6f697..0000000000 --- a/apps/aevatar-console-web/src/pages/Deployments/servingTargetPlan.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ServiceServingTargetInput } from '@/shared/models/services'; -import { t } from "@/shared/i18n/messages"; - -export type ServingTargetPlanStatus = { - enabled: boolean; - reason: string; - summary: string; - totalWeight: number; -}; - -const allowedServingStates = new Set([ - '', - 'active', - 'paused', - 'draining', - 'disabled', -]); - -export function buildServingTargetPlanStatus( - targets: readonly ServiceServingTargetInput[], -): ServingTargetPlanStatus { - const totalWeight = targets.reduce( - (total, target) => total + Number(target.allocationWeight || 0), - 0, - ); - - if (!targets.length) { - return { - enabled: false, - reason: t("pages.deployments.servingtargetplan.there.are.currently.no", "There are currently no serving targets, and you cannot submit an empty traffic plan."), - summary: t("pages.deployments.servingtargetplan.there.are.no.serving", "There are no serving targets to submit."), - totalWeight, - }; - } - - const missingRevision = targets.some((target) => !target.revisionId.trim()); - if (missingRevision) { - return { - enabled: false, - reason: - t("pages.deployments.servingtargetplan.each.serving.target.requires", "Each serving target requires a revision, and a plan that lacks a revision cannot be submitted."), - summary: t("pages.deployments.servingtargetplan.targets.with.total.weight", "{value1} targets, with a total weight of {value2}%.", { value1: targets.length, value2: totalWeight }), - totalWeight, - }; - } - - const invalidServingState = targets.some( - (target) => - !allowedServingStates.has( - (target.servingState ?? '').trim().toLowerCase(), - ), - ); - if (invalidServingState) { - return { - enabled: false, - reason: - t("pages.deployments.servingtargetplan.serving.status.can.only", "serving status can only be selected from active, paused, draining or disabled to avoid being silently rewritten by the backend after submission."), - summary: t("pages.deployments.servingtargetplan.targets.with.total.weight.2", "{value1} targets, with a total weight of {value2}%.", { value1: targets.length, value2: totalWeight }), - totalWeight, - }; - } - - if (totalWeight !== 100) { - return { - enabled: false, - reason: t("pages.deployments.servingtargetplan.the.current.weight.totals", "The current weight totals {value1}% and needs to be equal to 100% to submit.", { value1: totalWeight }), - summary: t("pages.deployments.servingtargetplan.targets.with.total.weight.3", "{value1} targets, with a total weight of {value2}%.", { value1: targets.length, value2: totalWeight }), - totalWeight, - }; - } - - return { - enabled: true, - reason: - t("pages.deployments.servingtargetplan.the.weight.plan.can", "The weight plan can be submitted; after submission, you still need to wait for the serving/traffic readmodel evidence to be refreshed."), - summary: t("pages.deployments.servingtargetplan.targets.with.total.weight.4", "{value1} targets, with a total weight of 100%.", { value1: targets.length }), - totalWeight, - }; -} diff --git a/apps/aevatar-console-web/src/pages/MissionControl/InspectorPanel.tsx b/apps/aevatar-console-web/src/pages/MissionControl/InspectorPanel.tsx deleted file mode 100644 index 3c82c4aed1..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/InspectorPanel.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import { - AlertOutlined, - PauseCircleOutlined, - ToolOutlined, -} from '@ant-design/icons'; -import { Alert, Button, Card, Empty, Input, Space, Tag, Typography, theme } from 'antd'; -import React, { useEffect, useMemo, useState } from 'react'; -import { - drawerBodyStyle, - drawerScrollStyle, - summaryFieldGridStyle, - summaryFieldLabelStyle, - summaryFieldStyle, -} from '@/shared/ui/proComponents'; -import type { - MissionActionFeedback, - MissionControlSnapshot, - MissionInterventionActionKind, - MissionInterventionActionRequest, - MissionInspectorMode, - MissionInspectorPresentation, - MissionRuntimeConnectionStatus, - MissionTopologyNode, -} from './models'; -import type { MissionOperatorHandoff } from './operatorHandoff'; -import { - formatConnectionLabel, - formatHandoffSeverityLabel, - formatInspectorPresentationLabel, - formatInterventionLabel, - formatMissionLabel, - renderMissionKindIcon, - resolveConnectionTagColor, - resolveFeedbackTagColor, - resolveHandoffTagColor, - resolveMissionStatusTone, - resolveObservationTone, -} from './presentation'; -import { t } from "@/shared/i18n/messages"; - -const monoStyle: React.CSSProperties = { - fontFamily: - "'SFMono-Regular', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace", -}; - -type InspectorPanelProps = { - actionFeedback?: MissionActionFeedback; - connectionStatus: MissionRuntimeConnectionStatus; - mode: MissionInspectorMode; - onSubmitAction?: (action: MissionInterventionActionRequest) => Promise; - operatorHandoff: MissionOperatorHandoff; - presentation: MissionInspectorPresentation; - selectedNode?: MissionTopologyNode; - snapshot: MissionControlSnapshot; - submittingActionKind?: MissionInterventionActionKind; -}; - -const InspectorPanel: React.FC = ({ - actionFeedback, - connectionStatus, - mode, - onSubmitAction, - operatorHandoff, - presentation, - selectedNode, - snapshot, - submittingActionKind, -}) => { - const { token } = theme.useToken(); - const [comment, setComment] = useState(''); - const [payload, setPayload] = useState(''); - const focusNode = - selectedNode || - snapshot.nodes.find((node) => node.id === snapshot.intervention?.nodeId); - const showIntervention = - mode === 'intervention' ? snapshot.intervention : undefined; - const isDisconnected = connectionStatus === 'disconnected'; - const actionHint = useMemo(() => { - if (!showIntervention) { - return undefined; - } - - switch (showIntervention.kind) { - case 'waiting_signal': - return 'Signal payload'; - case 'human_approval': - return 'Approval note'; - default: - return 'Operator note'; - } - }, [showIntervention]); - - useEffect(() => { - setComment(''); - setPayload(''); - }, [showIntervention?.key]); - - const handleSubmit = (kind: MissionInterventionActionKind) => { - if (!showIntervention || !onSubmitAction) { - return; - } - - void onSubmitAction({ - comment: comment.trim() || undefined, - kind, - payload: payload.trim() || undefined, - }); - }; - - return ( -
-
-
- - {t("pages.missioncontrol.inspectorpanel.node.insight", "Node Insight")} - - {t("pages.missioncontrol.inspectorpanel.inspect.state.calls.and.reasoning", "Inspect state, calls, and reasoning for the selected node; intervention automatically switches this panel into decision mode.")} -
- - {formatInspectorPresentationLabel(presentation)} - -
- - - Connection: {formatConnectionLabel(connectionStatus)} - - - {operatorHandoff.actionLabel} - - {actionFeedback ? ( - - {actionFeedback.message} - - ) : null} - -
- {actionFeedback ? ( - - ) : null} - {showIntervention ? ( - - - - - {formatInterventionLabel(showIntervention.kind)} - - {showIntervention.timeoutLabel ? ( - {showIntervention.timeoutLabel} - ) : null} - - - {showIntervention.title} - - - {showIntervention.summary} - -
- - {t("pages.missioncontrol.inspectorpanel.operator.handoff", "Operator handoff")} - - {operatorHandoff.inputLabel} - - - {operatorHandoff.connectionDetail} - - - {operatorHandoff.expectedResult} - -
-
- - {t("pages.missioncontrol.inspectorpanel.intervention.prompt", "Intervention prompt")} - - {showIntervention.prompt} - -
- { - if (showIntervention.kind === 'waiting_signal') { - setPayload(event.target.value); - return; - } - - setComment(event.target.value); - }} - /> - {actionHint ? ( - - {actionHint} - - ) : null} - - {showIntervention.kind === 'waiting_signal' ? ( - - ) : null} - {showIntervention.kind === 'human_input' ? ( - - ) : null} - {showIntervention.kind === 'human_approval' ? ( - <> - - - - ) : null} - -
-
- ) : null} - - {focusNode ? ( - - - - - {focusNode.lane} - - {formatMissionLabel(focusNode.status)} - - - Observation: {formatMissionLabel(focusNode.observationStatus)} - - - Handoff: {formatHandoffSeverityLabel(focusNode.handoff.severity)} - - -
-
- {renderMissionKindIcon(focusNode.kind)} -
-
- - {focusNode.label} - - - {focusNode.role} - -
-
- - {focusNode.summary} - -
- - {focusNode.handoff.title} - - - {focusNode.handoff.detail} - - - Evidence: {focusNode.handoff.evidence} - - - Next: {focusNode.handoff.nextStep} - -
-
-
- - - - - - - {t("pages.missioncontrol.inspectorpanel.state.snapshot", "State Snapshot")} - -
-
- {t("pages.missioncontrol.inspectorpanel.current.conclusion", "Current Conclusion")} - {focusNode.snapshot.headline} -
-
- {t("pages.missioncontrol.inspectorpanel.current.step", "Current Step")} - {focusNode.snapshot.currentStepId} -
-
- {t("pages.missioncontrol.inspectorpanel.state.version", "State Version")} - {focusNode.snapshot.stateVersion} -
-
- {t("pages.missioncontrol.inspectorpanel.captured.at", "Captured At")} - {focusNode.snapshot.capturedAt} -
-
-
-                  {JSON.stringify(focusNode.snapshot.items, null, 2)}
-                
-
-
- - - - - - - {t("pages.missioncontrol.inspectorpanel.tool.calls", "Tool Calls")} - - {focusNode.toolCalls.map((toolCall) => ( -
- - {toolCall.toolName} - {formatMissionLabel(toolCall.status)} - {toolCall.latencyMs} ms - - - {toolCall.endpoint} - - - {toolCall.summary} - - - {t("pages.missioncontrol.inspectorpanel.input.summary", "Input Summary:")}{toolCall.paramsSummary} - - - {t("pages.missioncontrol.inspectorpanel.output.summary", "Output Summary:")}{toolCall.resultSummary} - -
- ))} -
-
- - - - - {t("pages.missioncontrol.inspectorpanel.reasoning.summary", "Reasoning Summary")} - {focusNode.reasoningChain.map((insight) => ( -
- - {insight.title} - {typeof insight.confidence === 'number' ? ( - {Math.round(insight.confidence * 100)}{t("pages.missioncontrol.inspectorpanel.confidence", "% confidence")} - ) : null} - - - {insight.summary} - - - {insight.evidence.map((item) => ( - {item} - ))} - -
- ))} -
-
-
- ) : ( - - )} -
-
- ); -}; - -export default InspectorPanel; diff --git a/apps/aevatar-console-web/src/pages/MissionControl/TopologyCanvas.tsx b/apps/aevatar-console-web/src/pages/MissionControl/TopologyCanvas.tsx deleted file mode 100644 index 4a54e6e7d5..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/TopologyCanvas.tsx +++ /dev/null @@ -1,622 +0,0 @@ -import { - Background, - BackgroundVariant, - BaseEdge, - Controls, - EdgeLabelRenderer, - Handle, - MiniMap, - Position, - ReactFlow, - getSmoothStepPath, - type Edge, - type EdgeProps, - type Node, - type NodeProps, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; -import { Tag, Typography, theme } from 'antd'; -import React, { useMemo } from 'react'; -import AevatarTooltip from '@/shared/ui/AevatarTooltip'; -import type { - MissionControlSnapshot, - MissionObservationStatus, - MissionRunStatus, - MissionTopologyNode, -} from './models'; -import { - formatMissionLabel, - formatConnectionLabel, - formatHandoffSeverityLabel, - renderMissionKindIcon, - resolveConnectionTagColor, - resolveHandoffTagColor, - resolveMissionStatusTone, - resolveObservationTone, - type MissionThemeToken, -} from './presentation'; -import { t } from "@/shared/i18n/messages"; - -type TopologyCanvasProps = { - activeNodeId?: string; - connectionMessage?: string; - connectionStatus: 'idle' | 'connecting' | 'live' | 'degraded' | 'disconnected'; - onCanvasSelect?: () => void; - onNodeSelect: (nodeId: string) => void; - snapshot: MissionControlSnapshot; -}; - -type TopologyNodeData = { - node: MissionTopologyNode; - shouldPulse: boolean; -}; - -type TopologyEdgeData = { - observationStatus: MissionObservationStatus; - streaming: boolean; -}; - -function buildTopologyStyles(_token: MissionThemeToken) { - return ` - @keyframes missionTopologyFlowDash { - to { - stroke-dashoffset: -56; - } - } - - @keyframes missionTopologyPulse { - 0%, 100% { - box-shadow: var(--mission-topology-card-shadow); - transform: scale(1); - } - 50% { - box-shadow: - var(--mission-topology-card-shadow), - 0 0 0 2px var(--mission-topology-warning), - 0 0 18px var(--mission-topology-warning-glow); - transform: scale(1.02); - } - } - - @keyframes missionTopologyBeacon { - 0%, 100% { - transform: scale(1); - opacity: 0.9; - } - 50% { - transform: scale(1.45); - opacity: 0.4; - } - } - - .mission-topology-flow-edge { - animation: missionTopologyFlowDash 1.35s linear infinite; - filter: drop-shadow(0 0 5px var(--mission-topology-primary)); - stroke-linecap: round; - } - - .mission-topology-node-breathing { - animation: missionTopologyPulse 1.8s ease-in-out infinite; - } - - .mission-topology-freshness-alert::after { - animation: missionTopologyBeacon 1.8s ease-in-out infinite; - background: inherit; - border-radius: 999px; - content: ''; - inset: 0; - position: absolute; - } - `; -} - -function edgeTone( - token: MissionThemeToken, - observationStatus: MissionObservationStatus, -) { - return resolveObservationTone(token, observationStatus); -} - -function TopologyNodeCard({ - data, - selected, -}: NodeProps>) { - const { token } = theme.useToken(); - const node = data.node; - const observationTone = resolveObservationTone(token, node.observationStatus); - const statusTone = resolveMissionStatusTone(token, node.status); - const freshnessAlert = - node.observationStatus === 'delayed' || - node.observationStatus === 'snapshot_available'; - - return ( -
- - -
- -
-
- {renderMissionKindIcon(node.kind)} -
-
- - {node.label} - - - {node.role} - -
-
- - {node.summary} - - -
- - {node.handoff.title} - - - {node.handoff.evidence} - -
-
-
-
- - {t("pages.missioncontrol.topologycanvas.status", "Status")} - - {formatMissionLabel(node.status)} - -
-
- - {t("pages.missioncontrol.topologycanvas.freshness", "Freshness")} - - {node.freshnessLabel} - -
-
- - {t("pages.missioncontrol.topologycanvas.handoff", "Handoff")} - - {formatHandoffSeverityLabel(node.handoff.severity)} - -
-
- -
- ); -} - -function StreamingEdge({ - data, - label, - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, -}: EdgeProps>) { - const { token } = theme.useToken(); - const [edgePath, labelX, labelY] = getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - borderRadius: 18, - offset: 28, - }); - const tone = edgeTone(token, data?.observationStatus || 'streaming'); - - return ( - <> - - {data?.streaming ? ( - - ) : ( - - )} - {label ? ( - -
- {String(label)} -
-
- ) : null} - - ); -} - -function buildNodes( - snapshot: MissionControlSnapshot, - runStatus: MissionRunStatus, -): Node[] { - return snapshot.nodes.map((node) => ({ - id: node.id, - type: 'missionNode', - position: node.position, - data: { - node, - shouldPulse: - runStatus === 'waiting_approval' && - snapshot.intervention?.nodeId === node.id, - }, - draggable: false, - selectable: true, - })); -} - -function buildEdges(snapshot: MissionControlSnapshot): Edge[] { - return snapshot.edges.map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - type: 'streamingEdge', - label: edge.label, - animated: false, - data: { - observationStatus: edge.observationStatus, - streaming: edge.streaming, - }, - })); -} - -const nodeTypes = { - missionNode: React.memo(TopologyNodeCard), -}; - -const edgeTypes = { - streamingEdge: React.memo(StreamingEdge), -}; - -const TopologyCanvas: React.FC = ({ - activeNodeId, - connectionMessage, - connectionStatus, - onCanvasSelect, - onNodeSelect, - snapshot, -}) => { - const { token } = theme.useToken(); - const isDisconnected = connectionStatus === 'disconnected'; - const showConnectionOverlay = - connectionStatus === 'idle' || - connectionStatus === 'connecting' || - connectionStatus === 'degraded' || - connectionStatus === 'disconnected' || - snapshot.nodes.length === 0; - const nodes = useMemo( - () => buildNodes(snapshot, snapshot.summary.status), - [snapshot.intervention?.nodeId, snapshot.nodes, snapshot.summary.status], - ); - const edges = useMemo(() => buildEdges(snapshot), [snapshot.edges]); - const selectedNodes = useMemo( - () => - nodes.map((node) => ({ - ...node, - selected: node.id === activeNodeId, - })), - [activeNodeId, nodes], - ); - - return ( -
- -
- onNodeSelect(node.id)} - onPaneClick={onCanvasSelect} - panOnDrag - panOnScroll - proOptions={{ hideAttribution: true }} - > - - - - -
- {showConnectionOverlay ? ( -
- - {connectionStatus === 'idle' - ? t("pages.missioncontrol.topologycanvas.attach.live.run.first", "Attach a live run first") - : snapshot.nodes.length === 0 - ? t("pages.missioncontrol.topologycanvas.waiting.runtime.topology", "Waiting for runtime topology...") - : t("pages.missioncontrol.topologycanvas.runtime.connection", "Runtime: {connection}", { - connection: formatConnectionLabel(connectionStatus), - })} - - - {connectionMessage || - t("pages.missioncontrol.topologycanvas.synchronizing.runtime.state", "Synchronizing runtime state, topology, and key events.")} - - - {connectionStatus === 'idle' - ? t("pages.missioncontrol.topologycanvas.live.run.context.required", "Live Run Context Required") - : snapshot.nodes.length === 0 - ? t("pages.missioncontrol.topologycanvas.topology.pending", "Topology Pending") - : formatConnectionLabel(connectionStatus)} - -
- ) : null} -
- ); -}; - -export default TopologyCanvas; diff --git a/apps/aevatar-console-web/src/pages/MissionControl/hooks/useMissionControlRuntime.ts b/apps/aevatar-console-web/src/pages/MissionControl/hooks/useMissionControlRuntime.ts deleted file mode 100644 index f40de7c9ec..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/hooks/useMissionControlRuntime.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { - createRunSession, - reduceEvent, - type RunSessionState, -} from '@aevatar-react-sdk/agui'; -import { CustomEventName, type AGUIEvent } from '@aevatar-react-sdk/types'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { - startTransition, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { - getLatestCustomEventData, - parseRunContextData, -} from '@/shared/agui/customEventData'; -import type { - MissionActionFeedback, - MissionControlRouteContext, - MissionInterventionActionKind, - MissionInterventionActionRequest, - MissionInterventionState, - MissionRuntimeConnectionStatus, - MissionRuntimeViewState, -} from '../models'; -import { - buildMissionRuntimePlaceholderSnapshot, - buildMissionSnapshotFromRuntime, -} from '../runtimeAdapter'; -import { buildMissionActionFeedbackMessage } from '../runtimeHandoff'; -import type { MissionControlRuntimeArtifacts } from '../services/api'; -import { - fetchMissionControlRuntimeArtifacts, - hasMissionControlLiveContext, - readMissionControlRouteContext, - readMissionObservedRunContext, - streamMissionControlEvents, - submitMissionControlIntervention, -} from '../services/api'; - -const QUERY_KEY_PREFIX = 'mission-control-runtime'; -const STREAM_FLUSH_DELAY_MS = 120; -const STREAM_STALE_AFTER_MS = 15_000; -const TIMELINE_TICK_MS = 1_000; -const RUNTIME_REFETCH_INTERVAL_MS = 8_000; -const MAX_RECENT_EVENTS = 240; -const MAX_RECENT_MESSAGES = 48; - -export type UseMissionControlRuntimeResult = MissionRuntimeViewState & { - refresh: () => Promise; - routeContext: MissionControlRouteContext; - submitIntervention: ( - intervention: MissionInterventionState, - action: MissionInterventionActionRequest, - ) => Promise; -}; - -function buildMissionControlQueryKey(context: MissionControlRouteContext) { - return [ - QUERY_KEY_PREFIX, - context.actorId || '', - context.scopeId || '', - context.runId || '', - context.serviceId || '', - ] as const; -} - -function compactRunSession(session: RunSessionState): RunSessionState { - const nextEvents = - session.events.length > MAX_RECENT_EVENTS - ? session.events.slice(-MAX_RECENT_EVENTS) - : session.events; - const nextMessages = - session.messages.length > MAX_RECENT_MESSAGES - ? session.messages.slice(-MAX_RECENT_MESSAGES) - : session.messages; - - if (nextEvents === session.events && nextMessages === session.messages) { - return session; - } - - return { - ...session, - events: nextEvents, - messages: nextMessages, - }; -} - -function reduceQueuedEvents( - session: RunSessionState, - events: readonly AGUIEvent[], -): RunSessionState { - let next = session; - for (const event of events) { - next = reduceEvent(next, event); - } - - return compactRunSession(next); -} - -function buildConnectionMessage( - connectionStatus: MissionRuntimeConnectionStatus, - options: { - liveMode: boolean; - queryError?: string; - streamEnabled: boolean; - streamError?: string; - }, -): string { - if (!options.liveMode) { - return 'Mission Control needs a live run context before it can load a real decision path; open it from Runs.'; - } - - if (connectionStatus === 'connecting') { - return 'Connecting to runtime and loading the current run topology and key events.'; - } - - if (connectionStatus === 'disconnected') { - return options.queryError || options.streamError || 'Runtime connection lost. Waiting for service recovery.'; - } - - if (connectionStatus === 'degraded') { - return ( - options.streamError || - options.queryError || - 'The live event stream was interrupted. Showing the most recent successful snapshot.' - ); - } - - if (!options.streamEnabled) { - return 'Live streaming is disabled, so Mission Control is polling runtime state.'; - } - - return 'Runtime is streaming live; node freshness and edge flow update with each event.'; -} - -function buildAcceptedFeedback( - kind: MissionInterventionActionKind, - accepted: boolean, - commandId?: string, - runId?: string, - signalName?: string, -): MissionActionFeedback { - return { - message: buildMissionActionFeedbackMessage({ - accepted, - commandId, - kind, - runId, - signalName, - }), - tone: !accepted || kind === 'reject' ? 'warning' : 'success', - }; -} - -export function useMissionControlRuntime(): UseMissionControlRuntimeResult { - const queryClient = useQueryClient(); - const routeContext = useMemo(() => readMissionControlRouteContext(), []); - const [resolvedActorId, setResolvedActorId] = useState( - routeContext.actorId, - ); - const runtimeContext = useMemo( - () => ({ - ...routeContext, - actorId: resolvedActorId || routeContext.actorId, - }), - [resolvedActorId, routeContext], - ); - const liveMode = useMemo( - () => hasMissionControlLiveContext(routeContext), - [routeContext], - ); - const streamEnabled = Boolean( - liveMode && - routeContext.autoStream !== false && - routeContext.scopeId && - routeContext.prompt, - ); - - const [nowMs, setNowMs] = useState(() => Date.now()); - const [session, setSession] = useState(() => createRunSession()); - const [streamError, setStreamError] = useState(); - const [submittingActionKind, setSubmittingActionKind] = useState< - MissionInterventionActionKind | undefined - >(); - const [actionFeedback, setActionFeedback] = useState< - MissionActionFeedback | undefined - >(); - const eventQueueRef = useRef([]); - const flushTimerRef = useRef(undefined); - const lastStreamEventAtRef = useRef(undefined); - const lastArtifactsRef = useRef(undefined); - - const runtimeQuery = useQuery({ - queryKey: buildMissionControlQueryKey(runtimeContext), - queryFn: () => fetchMissionControlRuntimeArtifacts(runtimeContext), - enabled: liveMode, - refetchInterval: liveMode ? RUNTIME_REFETCH_INTERVAL_MS : false, - retry: 1, - staleTime: 2_000, - }); - - useEffect(() => { - const timerId = window.setInterval(() => { - setNowMs(Date.now()); - }, TIMELINE_TICK_MS); - - return () => { - window.clearInterval(timerId); - }; - }, []); - - useEffect(() => { - if (!runtimeQuery.data) { - return; - } - - lastArtifactsRef.current = runtimeQuery.data; - }, [runtimeQuery.data]); - - useEffect(() => { - const queryActorId = runtimeQuery.data?.actorId?.trim(); - if (!queryActorId || queryActorId === resolvedActorId) { - return; - } - - setResolvedActorId(queryActorId); - }, [resolvedActorId, runtimeQuery.data?.actorId]); - - const flushQueuedEvents = useCallback(() => { - flushTimerRef.current = undefined; - const queuedEvents = eventQueueRef.current.splice(0, eventQueueRef.current.length); - if (queuedEvents.length === 0) { - return; - } - - startTransition(() => { - setSession((previousSession) => reduceQueuedEvents(previousSession, queuedEvents)); - }); - }, []); - - const enqueueEvent = useCallback( - (event: AGUIEvent) => { - eventQueueRef.current.push(event); - if (flushTimerRef.current !== undefined) { - return; - } - - flushTimerRef.current = window.setTimeout( - flushQueuedEvents, - STREAM_FLUSH_DELAY_MS, - ); - }, - [flushQueuedEvents], - ); - - useEffect(() => { - return () => { - if (flushTimerRef.current !== undefined) { - window.clearTimeout(flushTimerRef.current); - } - }; - }, []); - - useEffect(() => { - eventQueueRef.current = []; - if (flushTimerRef.current !== undefined) { - window.clearTimeout(flushTimerRef.current); - flushTimerRef.current = undefined; - } - lastStreamEventAtRef.current = undefined; - setActionFeedback(undefined); - setResolvedActorId(routeContext.actorId); - setStreamError(undefined); - setSession(createRunSession()); - }, [ - liveMode, - routeContext.actorId, - routeContext.runId, - routeContext.scopeId, - routeContext.serviceId, - ]); - - useEffect(() => { - const observedContext = - session.context || - getLatestCustomEventData( - session.events, - CustomEventName.RunContext, - parseRunContextData, - ) || - (session.events.length > 0 - ? readMissionObservedRunContext(session.events[session.events.length - 1]) - : undefined); - const nextActorId = observedContext?.actorId?.trim(); - if (!nextActorId || nextActorId === resolvedActorId) { - return; - } - - setResolvedActorId(nextActorId); - }, [resolvedActorId, session.context, session.events]); - - useEffect(() => { - if (!streamEnabled) { - return; - } - - const controller = new AbortController(); - let disposed = false; - - const consume = async () => { - try { - for await (const event of streamMissionControlEvents(routeContext, controller.signal)) { - if (disposed) { - return; - } - - lastStreamEventAtRef.current = Date.now(); - setStreamError(undefined); - enqueueEvent(event); - } - } catch (error) { - if (disposed || controller.signal.aborted) { - return; - } - - setStreamError( - error instanceof Error ? error.message : 'The live event stream was interrupted.', - ); - } - }; - - void consume(); - - return () => { - disposed = true; - controller.abort(); - }; - }, [enqueueEvent, routeContext, streamEnabled]); - - const currentArtifacts = runtimeQuery.data ?? lastArtifactsRef.current; - const queryError = - runtimeQuery.error instanceof Error ? runtimeQuery.error.message : undefined; - const terminalRuntime = - currentArtifacts?.graph.snapshot.completionStatusValue === 1 || - currentArtifacts?.graph.snapshot.completionStatusValue === 3 || - currentArtifacts?.graph.snapshot.completionStatusValue === 4 || - session.status === 'finished' || - session.status === 'error'; - - const connectionStatus = useMemo(() => { - if (!liveMode) { - return 'idle'; - } - - if (!currentArtifacts && runtimeQuery.isLoading) { - return 'connecting'; - } - - if (!currentArtifacts && runtimeQuery.isError) { - return 'disconnected'; - } - - if (runtimeQuery.isError) { - return 'degraded'; - } - - if ( - streamEnabled && - !terminalRuntime && - lastStreamEventAtRef.current !== undefined && - nowMs - lastStreamEventAtRef.current > STREAM_STALE_AFTER_MS - ) { - return 'degraded'; - } - - if (streamEnabled && streamError) { - return currentArtifacts ? 'degraded' : 'disconnected'; - } - - return currentArtifacts ? 'live' : 'connecting'; - }, [ - currentArtifacts, - liveMode, - nowMs, - runtimeQuery.isError, - runtimeQuery.isLoading, - streamEnabled, - streamError, - terminalRuntime, - ]); - - const snapshot = useMemo(() => { - if (!liveMode) { - return buildMissionRuntimePlaceholderSnapshot({ - connectionStatus: 'idle', - context: runtimeContext, - nowMs, - }); - } - - if (!currentArtifacts) { - return buildMissionRuntimePlaceholderSnapshot({ - connectionStatus, - context: runtimeContext, - nowMs, - }); - } - - return buildMissionSnapshotFromRuntime({ - connectionStatus, - nowMs, - recentEvents: session.events, - resources: { - artifacts: currentArtifacts, - session, - }, - routeContext: runtimeContext, - }); - }, [ - connectionStatus, - currentArtifacts, - liveMode, - nowMs, - runtimeContext, - session, - ]); - - const refresh = useCallback(async () => { - if (!liveMode) { - return; - } - - await runtimeQuery.refetch(); - }, [liveMode, runtimeQuery]); - - const submitIntervention = useCallback( - async ( - intervention: MissionInterventionState, - action: MissionInterventionActionRequest, - ) => { - if (!liveMode) { - return; - } - - try { - setSubmittingActionKind(action.kind); - setActionFeedback(undefined); - const result = await submitMissionControlIntervention( - runtimeContext, - intervention, - action, - ); - setActionFeedback( - buildAcceptedFeedback( - action.kind, - result.accepted, - result.commandId, - result.runId, - result.signalName, - ), - ); - await queryClient.invalidateQueries({ - queryKey: buildMissionControlQueryKey(runtimeContext), - }); - await runtimeQuery.refetch(); - } catch (error) { - setActionFeedback({ - message: - error instanceof Error ? error.message : 'The intervention action failed.', - tone: 'error', - }); - } finally { - setSubmittingActionKind(undefined); - } - }, - [liveMode, queryClient, runtimeContext, runtimeQuery], - ); - - return { - actionFeedback, - connectionMessage: buildConnectionMessage(connectionStatus, { - liveMode, - queryError, - streamEnabled, - streamError, - }), - connectionStatus, - liveMode, - loading: liveMode ? runtimeQuery.isLoading && !currentArtifacts : false, - refresh, - resuming: - submittingActionKind === 'approve' || - submittingActionKind === 'reject' || - submittingActionKind === 'resume', - routeContext: runtimeContext, - signaling: submittingActionKind === 'signal', - snapshot, - submitIntervention, - submittingActionKind, - }; -} diff --git a/apps/aevatar-console-web/src/pages/MissionControl/index.tsx b/apps/aevatar-console-web/src/pages/MissionControl/index.tsx deleted file mode 100644 index 3c3d8319d4..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/index.tsx +++ /dev/null @@ -1,1029 +0,0 @@ -import { - BorderBottomOutlined, - ClockCircleOutlined, - ReloadOutlined, -} from '@ant-design/icons'; -import { - Badge, - Button, - Card, - Drawer, - Grid, - Segmented, - Space, - Tag, - Typography, - theme, -} from 'antd'; -import React, { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; -import AevatarTooltip from '@/shared/ui/AevatarTooltip'; -import { history } from '@/shared/navigation/history'; -import { buildRuntimeRunsHref } from '@/shared/navigation/runtimeRoutes'; -import { buildTeamDetailHref } from '@/shared/navigation/teamRoutes'; -import { - AevatarBackButton, - AevatarPageShell, - type AevatarBreadcrumbItem, -} from '@/shared/ui/aevatarPageShells'; -import { AEVATAR_INTERACTIVE_BUTTON_CLASS } from '@/shared/ui/interactionStandards'; -import { useMissionControlRuntime, type UseMissionControlRuntimeResult } from './hooks/useMissionControlRuntime'; -import InspectorPanel from './InspectorPanel'; -import type { - MissionControlSnapshot, - MissionInspectorMode, - MissionInspectorPresentation, - MissionInterventionState, -} from './models'; -import { - buildMissionOperatorHandoff, - type MissionOperatorHandoff, -} from './operatorHandoff'; -import { - formatInterventionLabel, - formatConnectionLabel, - formatHandoffSeverityLabel, - formatMissionLabel, - resolveConnectionTagColor, - resolveHandoffTagColor, - resolveMissionStatusTone, - resolveObservationTone, - type MissionThemeToken, -} from './presentation'; -import TopologyCanvas from './TopologyCanvas'; -import { t } from "@/shared/i18n/messages"; - -const scrollerStyle: React.CSSProperties = { - minHeight: 0, - overflowX: 'hidden', - overflowY: 'auto', -}; - -const monoStyle: React.CSSProperties = { - fontFamily: - "'SFMono-Regular', 'SFMono-Regular', Consolas, 'Liberation Mono', monospace", -}; - -type MissionStageView = 'topology' | 'execution_flow'; -type MissionDockTab = 'timeline' | 'logs'; - -type MissionControlUiContextValue = { - activeNodeId?: string; - closeInspector: () => void; - dockHeight: number; - inspectorMode: MissionInspectorMode; - inspectorPresentation: MissionInspectorPresentation; - inspectorWidth: number; - interventionRequired: boolean; - isDockCollapsed: boolean; - isInspectorOpen: boolean; - openInterventionPanel: () => void; - openNodeInspector: (nodeId: string) => void; - setDockCollapsed: (collapsed: boolean) => void; - setDockHeight: (height: number) => void; -}; - -const MissionControlUiContext = - createContext(null); - -function buildMissionShellStyle(token: MissionThemeToken): React.CSSProperties { - return { - background: `linear-gradient(180deg, ${token.colorBgLayout} 0%, ${token.colorBgContainer} 100%)`, - border: `1px solid ${token.colorBorderSecondary}`, - borderRadius: 4, - boxShadow: token.boxShadowSecondary, - display: 'flex', - flexDirection: 'column', - gap: 12, - flex: 1, - height: '100%', - minHeight: 0, - overflow: 'hidden', - padding: 12, - position: 'relative', - }; -} - -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} - -function useMissionControlUi() { - const value = useContext(MissionControlUiContext); - if (!value) { - throw new Error('MissionControlUiContext is not available.'); - } - - return value; -} - -function MissionControlUiProvider({ - children, - intervention, -}: { - children: React.ReactNode; - intervention?: MissionInterventionState; -}) { - const screens = Grid.useBreakpoint(); - const [isInspectorOpen, setIsInspectorOpen] = useState(false); - const [activeNodeId, setActiveNodeId] = useState(); - const [inspectorMode, setInspectorMode] = - useState('node'); - const [dismissedInterventionKey, setDismissedInterventionKey] = useState< - string | undefined - >(); - const [dockHeight, setDockHeight] = useState(248); - const [isDockCollapsed, setDockCollapsed] = useState(false); - - const interventionRequired = Boolean(intervention?.required); - const inspectorWidth = screens.xxl ? 420 : screens.xl ? 392 : 360; - - useEffect(() => { - if (!interventionRequired || !intervention) { - setDismissedInterventionKey(undefined); - return; - } - - if (dismissedInterventionKey === intervention.key) { - return; - } - - setActiveNodeId(intervention.nodeId); - setInspectorMode('intervention'); - setIsInspectorOpen(true); - }, [dismissedInterventionKey, intervention, interventionRequired]); - - const openNodeInspector = useCallback((nodeId: string) => { - setActiveNodeId(nodeId); - setInspectorMode('node'); - setIsInspectorOpen(true); - }, []); - - const openInterventionPanel = useCallback(() => { - if (!interventionRequired || !intervention) { - return; - } - - setActiveNodeId(intervention.nodeId); - setInspectorMode('intervention'); - setIsInspectorOpen(true); - setDismissedInterventionKey(undefined); - }, [intervention, interventionRequired]); - - const closeInspector = useCallback(() => { - if (interventionRequired && intervention) { - setDismissedInterventionKey(intervention.key); - } else { - setActiveNodeId(undefined); - } - - setIsInspectorOpen(false); - }, [intervention, interventionRequired]); - - const inspectorPresentation: MissionInspectorPresentation = - isInspectorOpen && screens.xxl && !interventionRequired ? 'push' : 'overlay'; - - const value = useMemo( - () => ({ - activeNodeId, - closeInspector, - dockHeight, - inspectorMode, - inspectorPresentation, - inspectorWidth, - interventionRequired, - isDockCollapsed, - isInspectorOpen, - openInterventionPanel, - openNodeInspector, - setDockCollapsed, - setDockHeight, - }), - [ - activeNodeId, - closeInspector, - dockHeight, - inspectorMode, - inspectorPresentation, - inspectorWidth, - interventionRequired, - isDockCollapsed, - isInspectorOpen, - openInterventionPanel, - openNodeInspector, - ], - ); - - return ( - - {children} - - ); -} - -function MissionHeaderBar({ - connectionMessage, - connectionStatus, - liveMode, - loading, - onRefresh, - routeContext, - snapshot, - stageView, - onStageViewChange, -}: { - connectionMessage?: string; - connectionStatus: UseMissionControlRuntimeResult['connectionStatus']; - liveMode: boolean; - loading: boolean; - onRefresh: () => void; - routeContext: UseMissionControlRuntimeResult['routeContext']; - snapshot: MissionControlSnapshot; - stageView: MissionStageView; - onStageViewChange: (value: MissionStageView) => void; -}) { - const ui = useMissionControlUi(); - const { token } = theme.useToken(); - const handoff = useMemo( - () => buildMissionOperatorHandoff(snapshot, connectionStatus), - [connectionStatus, snapshot], - ); - - return ( -
-
- - - {formatMissionLabel(snapshot.summary.status)} - - } - /> - - Observation: {formatMissionLabel(snapshot.summary.observationStatus)} - - - Connection: {formatConnectionLabel(connectionStatus)} - - {snapshot.summary.scriptEvolutionStatus ? ( - - {t("pages.missioncontrol.index.script.governance.2", "Script Governance:")}{formatMissionLabel(snapshot.summary.scriptEvolutionStatus)} - - ) : null} - {ui.interventionRequired ? ( - - {t("pages.missioncontrol.index.current.blocker.2", "Current Blocker:")}{formatInterventionLabel(snapshot.intervention?.kind || 'human_approval')} - - ) : null} - - - {snapshot.summary.workflowName} - - {connectionMessage ? ( - - {connectionMessage} - - ) : null} -
-
- - {handoff.actionLabel} - - - {handoff.actionDetail} - -
-
- - {handoff.connectionDetail} - -
-
-
- - - options={[ - { label: t("pages.missioncontrol.index.decision.path.2", "Decision Path"), value: 'topology' }, - { label: t("pages.missioncontrol.index.execution.flow.2", "Execution Flow"), value: 'execution_flow' }, - ]} - value={stageView} - onChange={(value) => onStageViewChange(value as MissionStageView)} - /> - {liveMode ? ( - - ) : ( - <> - {routeContext.scopeId ? ( - - history.push( - buildTeamDetailHref({ - scopeId: routeContext.scopeId ?? '', - tab: 'overview', - }), - ) - } - title={t("pages.missioncontrol.index.back.to.team.2", "Back to Team")} - /> - ) : null} - - - )} - {ui.interventionRequired ? ( - - ) : null} - -
- ); -} - -function MissionMetricStrip({ snapshot }: { snapshot: MissionControlSnapshot }) { - const { token } = theme.useToken(); - - return ( -
- {snapshot.metrics.map((metric) => ( - - - {metric.label} - -
- - {metric.value} - - - {metric.trend === 'down' - ? t("pages.missioncontrol.index.down.2", "Down") - : metric.trend === 'up' - ? 'Up' - : t("pages.missioncontrol.index.steady.2", "Steady")} - -
-
- ))} -
- ); -} - -function MissionStage({ - connectionMessage, - connectionStatus, - snapshot, - stageView, -}: { - connectionMessage?: string; - connectionStatus: UseMissionControlRuntimeResult['connectionStatus']; - snapshot: MissionControlSnapshot; - stageView: MissionStageView; -}) { - const ui = useMissionControlUi(); - const { token } = theme.useToken(); - - return ( - -
-
- - {stageView === 'topology' ? t("pages.missioncontrol.index.decision.path.3", "Decision Path") : t("pages.missioncontrol.index.execution.flow.3", "Execution Flow")} - - - {stageView === 'topology' - ? t("pages.missioncontrol.index.trace.the.evidence.chain.from.market", "Trace the evidence chain from market signal to execution decision, with data flow and freshness intact.") - : t("pages.missioncontrol.index.compress.multi.agent.execution.into.an", "Compress multi-agent execution into an operator-readable event narrative.")} - -
- - Stage: {snapshot.summary.activeStageLabel} - - Connection: {formatConnectionLabel(connectionStatus)} - - {snapshot.nodes.length} {t("pages.missioncontrol.index.nodes.2", "nodes")} - -
-
- {stageView === 'topology' ? ( -
- { - if (ui.interventionRequired) { - ui.openInterventionPanel(); - return; - } - - ui.closeInspector(); - }} - onNodeSelect={ui.openNodeInspector} - snapshot={snapshot} - /> -
- ) : ( -
-
- {snapshot.events.map((event) => ( - -
- - - {formatMissionLabel(event.type)} - - - {event.title} - - - - {event.timestamp} - -
- - {event.detail} - - - {event.stepId ? {event.stepId} : null} - - {formatHandoffSeverityLabel(event.handoff.severity)} - - - - {event.handoff.title}: {event.handoff.nextStep} - -
- ))} -
-
- )} -
-
- ); -} - -function MissionDock({ - activeTab, - handoff, - onTabChange, - snapshot, -}: { - activeTab: MissionDockTab; - handoff: MissionOperatorHandoff; - onTabChange: (key: MissionDockTab) => void; - snapshot: MissionControlSnapshot; -}) { - const ui = useMissionControlUi(); - const { token } = theme.useToken(); - - const startDockResize = useCallback( - (event: React.MouseEvent) => { - if (ui.isDockCollapsed) { - return; - } - - event.preventDefault(); - const startY = event.clientY; - const startHeight = ui.dockHeight; - - const handleMouseMove = (moveEvent: MouseEvent) => { - const nextHeight = clamp(startHeight + (startY - moveEvent.clientY), 188, 420); - ui.setDockHeight(nextHeight); - }; - - const handleMouseUp = () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - }, - [ui], - ); - - return ( -
- -
- - - - -
- {!ui.isDockCollapsed ? ( -
- -
- - {t("pages.missioncontrol.index.evidence.handoff", "Evidence handoff")} - - {handoff.evidenceDetail} - - - {handoff.expectedResult} - -
-
- {activeTab === 'timeline' ? ( -
- {snapshot.events.map((event) => ( -
- - {event.timestamp} - -
- - - {event.title} - - {event.stepId ? {event.stepId} : null} - - - {event.detail} - -
-
- ))} -
- ) : ( -
-              {snapshot.liveLogs.join('\n')}
-            
- )} -
- ) : null} -
- ); -} - -function MissionControlCanvas({ - runtime, -}: { - runtime: UseMissionControlRuntimeResult; -}) { - const ui = useMissionControlUi(); - const [stageView, setStageView] = useState('topology'); - const [dockTab, setDockTab] = useState('timeline'); - const snapshot = runtime.snapshot; - const selectedNode = useMemo( - () => snapshot.nodes.find((node) => node.id === ui.activeNodeId), - [snapshot.nodes, ui.activeNodeId], - ); - const handoff = useMemo( - () => buildMissionOperatorHandoff(snapshot, runtime.connectionStatus), - [runtime.connectionStatus, snapshot], - ); - - const shouldPushInspector = - ui.isInspectorOpen && ui.inspectorPresentation === 'push'; - - return ( - <> - { - void runtime.refresh(); - }} - routeContext={runtime.routeContext} - onStageViewChange={setStageView} - snapshot={snapshot} - stageView={stageView} - /> - -
-
- -
- {shouldPushInspector ? ( - - { - if (!snapshot.intervention) { - return Promise.resolve(); - } - - return runtime.submitIntervention(snapshot.intervention, action); - }} - presentation={ui.inspectorPresentation} - selectedNode={selectedNode} - submittingActionKind={runtime.submittingActionKind} - snapshot={snapshot} - /> - - ) : null} -
- - {!shouldPushInspector ? ( - - { - if (!snapshot.intervention) { - return Promise.resolve(); - } - - return runtime.submitIntervention(snapshot.intervention, action); - }} - presentation={ui.inspectorPresentation} - selectedNode={selectedNode} - submittingActionKind={runtime.submittingActionKind} - snapshot={snapshot} - /> - - ) : null} - - ); -} - -const MissionControlPage: React.FC = () => { - const { token } = theme.useToken(); - const runtime = useMissionControlRuntime(); - const shellStyle = useMemo(() => buildMissionShellStyle(token), [token]); - const missionControlBreadcrumbItems: AevatarBreadcrumbItem[] = [ - { - title: t('pages.missioncontrol.index.platformBreadcrumb', 'Platform'), - }, - { - current: true, - title: t('pages.missioncontrol.index.missionControlBreadcrumb', 'Mission Control'), - }, - ]; - - return ( - - -
- -
-
-
- ); -}; - -export default MissionControlPage; diff --git a/apps/aevatar-console-web/src/pages/MissionControl/models.ts b/apps/aevatar-console-web/src/pages/MissionControl/models.ts deleted file mode 100644 index c763f965f5..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/models.ts +++ /dev/null @@ -1,267 +0,0 @@ -export const workflowExecutionEventTypes = [ - 'workflow_run_execution_started', - 'step_requested', - 'step_completed', - 'workflow_suspended', - 'workflow_resumed', - 'workflow_completed', - 'workflow_stopped', - 'workflow_run_stopped', - 'waiting_for_signal', - 'workflow_signal_buffered', - 'workflow_role_reply_recorded', - 'workflow_role_actor_linked', -] as const; - -export type WorkflowExecutionEventType = - (typeof workflowExecutionEventTypes)[number]; - -export const scriptEvolutionStatuses = [ - 'pending', - 'proposed', - 'build_requested', - 'validated', - 'validation_failed', - 'rejected', - 'promotion_failed', - 'promoted', - 'rollback_requested', - 'rolled_back', -] as const; - -export type ScriptEvolutionStatus = (typeof scriptEvolutionStatuses)[number]; - -export type MissionRunStatus = - | 'idle' - | 'draft' - | 'published' - | 'running' - | 'waiting_signal' - | 'human_input' - | 'waiting_approval' - | 'suspended' - | 'completed' - | 'failed' - | 'stopped'; - -export type MissionObservationStatus = - | 'unavailable' - | 'streaming' - | 'snapshot_available' - | 'projection_settled' - | 'delayed'; - -export type MissionNodeStatus = - | 'idle' - | 'active' - | 'waiting' - | 'completed' - | 'failed'; - -export type MissionTopologyNodeKind = - | 'entrypoint' - | 'coordinator' - | 'research' - | 'tool' - | 'risk' - | 'approval' - | 'execution'; - -export type MissionInspectorMode = 'node' | 'intervention'; - -export type MissionInspectorPresentation = 'overlay' | 'push'; - -export type MissionInterventionKind = - | 'waiting_signal' - | 'human_input' - | 'human_approval'; - -export type MissionRuntimeConnectionStatus = - | 'idle' - | 'connecting' - | 'live' - | 'degraded' - | 'disconnected'; - -export type MissionInterventionActionKind = - | 'approve' - | 'reject' - | 'resume' - | 'signal'; - -export type MissionFeedbackTone = 'info' | 'success' | 'warning' | 'error'; - -export type MissionHandoffSeverity = - | 'observe' - | 'action' - | 'blocked' - | 'confirming'; - -export interface MissionHandoffCue { - detail: string; - evidence: string; - nextStep: string; - severity: MissionHandoffSeverity; - title: string; -} - -export interface MissionMetric { - key: string; - label: string; - value: string; - trend?: 'up' | 'down' | 'steady'; - tone?: 'default' | 'success' | 'warning' | 'danger'; -} - -export interface MissionToolCall { - id: string; - toolName: string; - endpoint: string; - status: 'queued' | 'running' | 'completed' | 'failed'; - latencyMs: number; - paramsSummary: string; - resultSummary: string; - summary: string; -} - -export interface MissionStateSnapshot { - headline: string; - currentStepId: string; - stateVersion: number; - capturedAt: string; - items: Record; -} - -export interface MissionReasoningInsight { - id: string; - title: string; - summary: string; - evidence: string[]; - confidence?: number; -} - -export interface MissionCanvasPosition { - x: number; - y: number; -} - -export interface MissionTopologyNode { - id: string; - label: string; - role: string; - lane: string; - kind: MissionTopologyNodeKind; - status: MissionNodeStatus; - observationStatus: MissionObservationStatus; - freshnessLabel: string; - freshnessSeconds: number; - handoff: MissionHandoffCue; - summary: string; - lastLatencyMs?: number; - confidence?: number; - position: MissionCanvasPosition; - snapshot: MissionStateSnapshot; - toolCalls: MissionToolCall[]; - reasoningChain: MissionReasoningInsight[]; -} - -export interface MissionTopologyEdge { - id: string; - source: string; - target: string; - label?: string; - observationStatus: MissionObservationStatus; - streaming: boolean; -} - -export interface MissionExecutionEvent { - id: string; - type: WorkflowExecutionEventType; - title: string; - detail: string; - stepId?: string; - actorId?: string; - handoff: MissionHandoffCue; - timestamp: string; - severity: 'info' | 'success' | 'warning' | 'error'; -} - -export interface MissionInterventionState { - required: boolean; - key: string; - kind: MissionInterventionKind; - nodeId: string; - signalName?: string; - title: string; - summary: string; - stepId: string; - prompt: string; - timeoutLabel?: string; - primaryActionLabel: string; - secondaryActionLabel?: string; -} - -export interface MissionRunSummary { - runId: string; - workflowName: string; - scopeId: string; - definitionActorId: string; - status: MissionRunStatus; - observationStatus: MissionObservationStatus; - startedAt: string; - updatedAt: string; - activeStageLabel: string; - scriptEvolutionStatus?: ScriptEvolutionStatus; -} - -export interface MissionControlSnapshot { - summary: MissionRunSummary; - metrics: MissionMetric[]; - nodes: MissionTopologyNode[]; - edges: MissionTopologyEdge[]; - events: MissionExecutionEvent[]; - liveLogs: string[]; - intervention?: MissionInterventionState; -} - -export interface MissionControlRouteContext { - actorId?: string; - endpointId?: string; - prompt?: string; - runId?: string; - scopeId?: string; - serviceId?: string; - autoStream?: boolean; -} - -export interface MissionInterventionActionRequest { - comment?: string; - kind: MissionInterventionActionKind; - payload?: string; -} - -export interface MissionInterventionActionResult { - accepted: boolean; - commandId?: string; - kind: MissionInterventionActionKind; - runId?: string; - signalName?: string; - stepId?: string; -} - -export interface MissionActionFeedback { - message: string; - tone: MissionFeedbackTone; -} - -export interface MissionRuntimeViewState { - actionFeedback?: MissionActionFeedback; - connectionMessage?: string; - connectionStatus: MissionRuntimeConnectionStatus; - liveMode: boolean; - loading: boolean; - resuming: boolean; - signaling: boolean; - snapshot: MissionControlSnapshot; - submittingActionKind?: MissionInterventionActionKind; -} diff --git a/apps/aevatar-console-web/src/pages/MissionControl/operatorHandoff.test.ts b/apps/aevatar-console-web/src/pages/MissionControl/operatorHandoff.test.ts deleted file mode 100644 index 0f87fa4704..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/operatorHandoff.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { MissionControlSnapshot } from './models'; -import { buildMissionOperatorHandoff } from './operatorHandoff'; - -function createSnapshot( - overrides: Partial = {}, -): MissionControlSnapshot { - return { - edges: [], - events: [ - { - detail: 'approval requested', - handoff: { - detail: 'Waiting for approval at step approval.', - evidence: 'approval requested', - nextStep: 'Open the intervention panel and decide with the latest event dock evidence.', - severity: 'action', - title: 'Action handoff', - }, - id: 'event-1', - severity: 'warning', - stepId: 'approval', - timestamp: '2026-03-30T08:00:20.000Z', - title: 'workflow.suspended', - type: 'workflow_suspended', - }, - ], - liveLogs: [], - metrics: [], - nodes: [], - summary: { - activeStageLabel: 'Waiting for approval', - definitionActorId: 'actor-1', - observationStatus: 'streaming', - runId: 'run-1', - scopeId: 'scope-1', - startedAt: '2026-03-30T08:00:00.000Z', - status: 'waiting_approval', - updatedAt: '2026-03-30T08:00:20.000Z', - workflowName: 'mission-workflow', - }, - ...overrides, - }; -} - -describe('buildMissionOperatorHandoff', () => { - it('describes observation-only runs as read-only evidence', () => { - const handoff = buildMissionOperatorHandoff( - createSnapshot({ - events: [], - summary: { - activeStageLabel: 'Execution running', - definitionActorId: 'actor-1', - observationStatus: 'streaming', - runId: 'run-1', - scopeId: 'scope-1', - startedAt: '2026-03-30T08:00:00.000Z', - status: 'running', - updatedAt: '2026-03-30T08:00:20.000Z', - workflowName: 'mission-workflow', - }, - }), - 'live', - ); - - expect(handoff.isActionable).toBe(false); - expect(handoff.actionLabel).toBe('No operator action'); - expect(handoff.evidenceDetail).toContain('read-only evidence'); - expect(handoff.expectedResult).toContain('Continue observing'); - }); - - it('blocks intervention actions while runtime is disconnected', () => { - const handoff = buildMissionOperatorHandoff( - createSnapshot({ - intervention: { - key: 'waiting-approval/approval', - kind: 'human_approval', - nodeId: 'node-approval', - primaryActionLabel: 'Approve', - prompt: 'Approve guarded execution.', - required: true, - secondaryActionLabel: 'Reject', - stepId: 'approval', - summary: 'Runtime is paused for approval.', - title: 'Waiting for approval', - }, - }), - 'disconnected', - ); - - expect(handoff.isActionable).toBe(false); - expect(handoff.actionLabel).toBe('Approval Required'); - expect(handoff.connectionDetail).toContain('blocked because runtime is disconnected'); - expect(handoff.inputLabel).toContain('Approve or reject'); - }); - - it('explains signal payload submission and waits for runtime confirmation', () => { - const handoff = buildMissionOperatorHandoff( - createSnapshot({ - intervention: { - key: 'waiting-signal/risk-gate', - kind: 'waiting_signal', - nodeId: 'node-risk', - primaryActionLabel: 'Send Signal', - prompt: 'Send market open signal.', - required: true, - signalName: 'market-open', - stepId: 'risk-gate', - summary: 'Runtime is waiting for a signal.', - title: 'Waiting for market-open', - }, - summary: { - activeStageLabel: 'Waiting for market-open', - definitionActorId: 'actor-1', - observationStatus: 'streaming', - runId: 'run-1', - scopeId: 'scope-1', - startedAt: '2026-03-30T08:00:00.000Z', - status: 'waiting_signal', - updatedAt: '2026-03-30T08:00:20.000Z', - workflowName: 'mission-workflow', - }, - }), - 'live', - ); - - expect(handoff.isActionable).toBe(true); - expect(handoff.actionLabel).toBe('Waiting for Signal'); - expect(handoff.inputLabel).toContain('signal payload'); - expect(handoff.expectedResult).toContain('next runtime snapshot'); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/MissionControl/operatorHandoff.ts b/apps/aevatar-console-web/src/pages/MissionControl/operatorHandoff.ts deleted file mode 100644 index e2e6deffc4..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/operatorHandoff.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { - MissionControlSnapshot, - MissionInterventionKind, - MissionRuntimeConnectionStatus, -} from './models'; -import { - formatConnectionLabel, - formatInterventionLabel, - formatMissionLabel, -} from './presentation'; -import { t } from '@/shared/i18n/messages'; - -export type MissionOperatorHandoff = { - readonly actionLabel: string; - readonly actionDetail: string; - readonly connectionDetail: string; - readonly evidenceDetail: string; - readonly expectedResult: string; - readonly inputLabel: string; - readonly isActionable: boolean; -}; - -function describeInterventionInput(kind: MissionInterventionKind): string { - switch (kind) { - case 'waiting_signal': - return t( - 'pages.missioncontrol.operatorhandoff.submit.the.signal.payload', - 'Submit the signal payload requested by the paused step.', - ); - case 'human_input': - return t( - 'pages.missioncontrol.operatorhandoff.add.the.missing.operator.context', - 'Add the missing operator context, then resume the run.', - ); - case 'human_approval': - return t( - 'pages.missioncontrol.operatorhandoff.approve.or.reject.with.a.short', - 'Approve or reject with a short decision note.', - ); - default: - return t( - 'pages.missioncontrol.operatorhandoff.review.the.operator.prompt.before', - 'Review the operator prompt before acting.', - ); - } -} - -function describeExpectedResult(kind: MissionInterventionKind): string { - switch (kind) { - case 'waiting_signal': - return t( - 'pages.missioncontrol.operatorhandoff.after.the.signal.is.accepted', - 'After the signal is accepted, Mission Control waits for the next runtime snapshot before showing progress.', - ); - case 'human_input': - return t( - 'pages.missioncontrol.operatorhandoff.after.resume.is.accepted', - 'After resume is accepted, the run should continue from the blocked step and new evidence will appear in the dock.', - ); - case 'human_approval': - return t( - 'pages.missioncontrol.operatorhandoff.after.approve.or.reject', - 'After approve or reject is accepted, Mission Control waits for runtime confirmation of advance, stop, or rollback.', - ); - default: - return t( - 'pages.missioncontrol.operatorhandoff.after.the.action.is.accepted', - 'After the action is accepted, wait for runtime confirmation before treating the run as advanced.', - ); - } -} - -function describeConnection( - status: MissionRuntimeConnectionStatus, - hasIntervention: boolean, -): string { - if (!hasIntervention) { - if (status === 'idle') { - return t( - 'pages.missioncontrol.operatorhandoff.attach.mission.control.to.a.live', - 'Attach Mission Control to a live run before taking action.', - ); - } - - if (status === 'disconnected') { - return t( - 'pages.missioncontrol.operatorhandoff.runtime.is.disconnected.this.view', - 'Runtime is disconnected; this view can show only the last known facts.', - ); - } - - if (status === 'degraded') { - return t( - 'pages.missioncontrol.operatorhandoff.live.stream.is.degraded.use', - 'Live stream is degraded; use the snapshot and dock as eventually consistent evidence.', - ); - } - - return t( - 'pages.missioncontrol.operatorhandoff.connection.is.no.operator.action', - 'Connection is {connectionStatus}; no operator action is currently required.', - { connectionStatus: formatConnectionLabel(status) }, - ); - } - - if (status === 'disconnected') { - return t( - 'pages.missioncontrol.operatorhandoff.action.is.blocked.because.runtime', - 'Action is blocked because runtime is disconnected. Keep the evidence visible and retry after recovery.', - ); - } - - if (status === 'degraded') { - return t( - 'pages.missioncontrol.operatorhandoff.action.is.available.but.evidence', - 'Action is available, but evidence may lag because the live stream is degraded.', - ); - } - - if (status === 'connecting') { - return t( - 'pages.missioncontrol.operatorhandoff.mission.control.is.still.connecting', - 'Mission Control is still connecting; wait for runtime state before submitting an action.', - ); - } - - return t( - 'pages.missioncontrol.operatorhandoff.runtime.is.reachable.submit.only', - 'Runtime is reachable; submit only after checking the prompt and recent evidence.', - ); -} - -export function buildMissionOperatorHandoff( - snapshot: MissionControlSnapshot, - connectionStatus: MissionRuntimeConnectionStatus, -): MissionOperatorHandoff { - const intervention = snapshot.intervention; - if (!intervention) { - const eventCount = snapshot.events.length; - const eventUnit = - eventCount === 1 - ? t('pages.missioncontrol.operatorhandoff.event', 'event') - : t('pages.missioncontrol.operatorhandoff.events', 'events'); - - return { - actionLabel: t( - 'pages.missioncontrol.operatorhandoff.no.operator.action', - 'No operator action', - ), - actionDetail: t( - 'pages.missioncontrol.operatorhandoff.status.stage', - '{status} - {stage}', - { - stage: snapshot.summary.activeStageLabel, - status: formatMissionLabel(snapshot.summary.status), - }, - ), - connectionDetail: describeConnection(connectionStatus, false), - evidenceDetail: t( - 'pages.missioncontrol.operatorhandoff.use.the.event.dock.as.read', - 'Use the event dock as read-only evidence. {eventCount} recent {eventUnit} are available.', - { eventCount, eventUnit }, - ), - expectedResult: t( - 'pages.missioncontrol.operatorhandoff.continue.observing.if.a.blocker', - 'Continue observing. If a blocker appears, Mission Control will open the intervention panel.', - ), - inputLabel: t( - 'pages.missioncontrol.operatorhandoff.observation.only', - 'Observation only', - ), - isActionable: false, - }; - } - - const actionBlocked = - connectionStatus === 'disconnected' || connectionStatus === 'connecting'; - - return { - actionLabel: formatInterventionLabel(intervention.kind), - actionDetail: t( - 'pages.missioncontrol.operatorhandoff.title.step', - '{title} - step {stepId}', - { stepId: intervention.stepId, title: intervention.title }, - ), - connectionDetail: describeConnection(connectionStatus, true), - evidenceDetail: t( - 'pages.missioncontrol.operatorhandoff.read.the.intervention.prompt', - 'Read the intervention prompt, selected node state, and event dock before submitting an action.', - ), - expectedResult: describeExpectedResult(intervention.kind), - inputLabel: describeInterventionInput(intervention.kind), - isActionable: !actionBlocked, - }; -} diff --git a/apps/aevatar-console-web/src/pages/MissionControl/presentation.tsx b/apps/aevatar-console-web/src/pages/MissionControl/presentation.tsx deleted file mode 100644 index ba628db38f..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/presentation.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { - ApartmentOutlined, - CodeOutlined, - DatabaseOutlined, - DeploymentUnitOutlined, - RadarChartOutlined, - RobotOutlined, - SafetyCertificateOutlined, - UserOutlined, -} from '@ant-design/icons'; -import type { theme } from 'antd'; -import React from 'react'; -import type { - MissionFeedbackTone, - MissionHandoffSeverity, - MissionInterventionKind, - MissionInspectorPresentation, - MissionNodeStatus, - MissionObservationStatus, - MissionRuntimeConnectionStatus, - MissionRunStatus, - MissionTopologyNodeKind, -} from './models'; -import { t } from '@/shared/i18n/messages'; - -export type MissionThemeToken = ReturnType['token']; - -const missionLabelMap: Record = { - active: 'Active', - build_requested: 'Build In Progress', - completed: 'Completed', - connecting: 'Connecting', - delayed: 'Delayed', - degraded: 'Fallback Sync', - disconnected: 'Disconnected', - draft: 'Draft', - failed: 'Failed', - human_approval: 'Approval Required', - human_input: 'Input Required', - idle: 'Detached', - pending: 'Pending', - promoted: 'Promoted', - promotion_failed: 'Promotion Failed', - proposed: 'Proposed', - projection_settled: 'Projection Settled', - published: 'Published', - rejected: 'Rejected', - rollback_requested: 'Rollback Requested', - rolled_back: 'Rolled Back', - running: 'Running', - snapshot_available: 'Snapshot Available', - stopped: 'Stopped', - streaming: 'Streaming', - suspended: 'Suspended', - unavailable: 'Unavailable', - validated: 'Validated', - validation_failed: 'Validation Failed', - waiting: 'Waiting', - waiting_approval: 'Approval Required', - waiting_signal: 'Waiting for Signal', - workflow_completed: 'Workflow Completed', - workflow_resumed: 'Workflow Resumed', - workflow_role_actor_linked: 'Role Linked', - workflow_role_reply_recorded: 'Role Reply Recorded', - workflow_run_execution_started: 'Run Started', - workflow_run_stopped: 'Run Stopped', - workflow_signal_buffered: 'Signal Buffered', - workflow_stopped: 'Workflow Stopped', - workflow_suspended: 'Workflow Suspended', - step_completed: 'Step Completed', - step_requested: 'Step Requested', - waiting_for_signal: 'Waiting for Signal', - queued: 'Queued', -}; - -export function formatMissionLabel(value: string) { - const normalized = value.trim().toLowerCase(); - if (missionLabelMap[normalized]) { - return t( - `pages.missioncontrol.presentation.${normalized.replace(/_/g, '.')}`, - missionLabelMap[normalized], - ); - } - - return value - .split('_') - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(' '); -} - -export function resolveMissionStatusTone( - token: MissionThemeToken, - status: MissionRunStatus | MissionNodeStatus, -) { - if (status === 'idle') { - return token.colorTextTertiary; - } - - if (status === 'completed') { - return token.colorSuccess; - } - - if (status === 'failed' || status === 'stopped') { - return token.colorError; - } - - if ( - status === 'waiting' || - status === 'waiting_signal' || - status === 'human_input' || - status === 'waiting_approval' || - status === 'suspended' - ) { - return token.colorWarning; - } - - if (status === 'active' || status === 'running') { - return token.colorPrimary; - } - - return token.colorTextTertiary; -} - -export function resolveObservationTone( - token: MissionThemeToken, - status: MissionObservationStatus, -) { - if (status === 'unavailable') { - return token.colorTextQuaternary; - } - - if (status === 'projection_settled') { - return token.colorSuccess; - } - - if (status === 'delayed') { - return token.colorError; - } - - if (status === 'snapshot_available') { - return token.colorWarning; - } - - return token.colorPrimary; -} - -export function renderMissionKindIcon(kind: MissionTopologyNodeKind) { - switch (kind) { - case 'entrypoint': - return ; - case 'coordinator': - return ; - case 'research': - return ; - case 'tool': - return ; - case 'risk': - return ; - case 'approval': - return ; - case 'execution': - return ; - default: - return ; - } -} - -export function formatInterventionLabel(kind: MissionInterventionKind) { - switch (kind) { - case 'waiting_signal': - return formatMissionLabel('waiting_for_signal'); - case 'human_input': - return formatMissionLabel('human_input'); - case 'human_approval': - return formatMissionLabel('human_approval'); - default: - return t('pages.missioncontrol.presentation.intervention', 'Intervention'); - } -} - -export function formatConnectionLabel(status: MissionRuntimeConnectionStatus) { - switch (status) { - case 'idle': - return formatMissionLabel('idle'); - case 'connecting': - return formatMissionLabel('connecting'); - case 'live': - return t('pages.missioncontrol.presentation.live', 'Live'); - case 'degraded': - return formatMissionLabel('degraded'); - case 'disconnected': - return formatMissionLabel('disconnected'); - default: - return t('pages.missioncontrol.presentation.runtime', 'Runtime'); - } -} - -export function formatInspectorPresentationLabel( - presentation: MissionInspectorPresentation, -) { - return presentation === 'push' - ? t('pages.missioncontrol.presentation.docked.panel', 'Docked Panel') - : t('pages.missioncontrol.presentation.overlay.panel', 'Overlay Panel'); -} - -export function resolveConnectionTagColor( - status: MissionRuntimeConnectionStatus, -): 'default' | 'processing' | 'success' | 'warning' | 'error' { - switch (status) { - case 'idle': - return 'default'; - case 'live': - return 'success'; - case 'connecting': - return 'processing'; - case 'degraded': - return 'warning'; - case 'disconnected': - return 'error'; - default: - return 'default'; - } -} - -export function resolveFeedbackTagColor( - tone: MissionFeedbackTone, -): 'processing' | 'success' | 'warning' | 'error' { - switch (tone) { - case 'success': - return 'success'; - case 'warning': - return 'warning'; - case 'error': - return 'error'; - default: - return 'processing'; - } -} - -export function formatHandoffSeverityLabel(severity: MissionHandoffSeverity) { - switch (severity) { - case 'action': - return 'Action'; - case 'blocked': - return 'Blocked'; - case 'confirming': - return 'Confirming'; - default: - return 'Observe'; - } -} - -export function resolveHandoffTagColor( - severity: MissionHandoffSeverity, -): 'default' | 'processing' | 'success' | 'warning' | 'error' { - switch (severity) { - case 'action': - return 'warning'; - case 'blocked': - return 'error'; - case 'confirming': - return 'processing'; - default: - return 'default'; - } -} diff --git a/apps/aevatar-console-web/src/pages/MissionControl/runtimeAdapter.test.ts b/apps/aevatar-console-web/src/pages/MissionControl/runtimeAdapter.test.ts deleted file mode 100644 index 85d31e7cd0..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/runtimeAdapter.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { AGUIEvent } from '@aevatar-react-sdk/types'; -import type { - WorkflowActorGraphEnrichedSnapshot, - WorkflowActorTimelineItem, -} from '@/shared/models/runtime/actors'; -import { - buildMissionRuntimePlaceholderSnapshot, - buildMissionSnapshotFromRuntime, -} from './runtimeAdapter'; - -describe('Mission Control runtimeAdapter', () => { - it('derives waiting approval and tool calls from committed runtime artifacts', () => { - const graph: WorkflowActorGraphEnrichedSnapshot = { - snapshot: { - actorId: 'root-actor', - completedSteps: 1, - completionStatusValue: 0, - lastCommandId: 'run-1', - lastError: '', - lastEventId: 'evt-4', - lastOutput: 'buy 600519 with guarded size', - lastSuccess: null, - lastUpdatedAt: '2026-03-30T08:00:20.000Z', - requestedSteps: 2, - roleReplyCount: 1, - stateVersion: 7, - totalSteps: 3, - workflowName: 'wf-a-share', - }, - subgraph: { - rootNodeId: 'run:root-actor:run-1', - nodes: [ - { - nodeId: 'root-actor', - nodeType: 'Actor', - updatedAt: '2026-03-30T08:00:20.000Z', - properties: { - workflowName: 'wf-a-share', - }, - }, - { - nodeId: 'research-agent', - nodeType: 'Actor', - updatedAt: '2026-03-30T08:00:18.000Z', - properties: { - workflowName: 'wf-a-share', - }, - }, - { - nodeId: 'run:root-actor:run-1', - nodeType: 'WorkflowRun', - updatedAt: '2026-03-30T08:00:20.000Z', - properties: { - commandId: 'run-1', - input: 'Analyze Kweichow Moutai and size the trade.', - rootActorId: 'root-actor', - workflowName: 'wf-a-share', - }, - }, - { - nodeId: 'step:root-actor:run-1:research', - nodeType: 'WorkflowStep', - updatedAt: '2026-03-30T08:00:16.000Z', - properties: { - commandId: 'run-1', - rootActorId: 'root-actor', - stepId: 'research', - stepType: 'llm_call', - success: 'true', - targetRole: 'strategy_researcher', - workerId: 'research-agent', - }, - }, - { - nodeId: 'step:root-actor:run-1:approval', - nodeType: 'WorkflowStep', - updatedAt: '2026-03-30T08:00:20.000Z', - properties: { - commandId: 'run-1', - rootActorId: 'root-actor', - stepId: 'approval', - stepType: 'human_approval', - success: '', - targetRole: 'approval_board', - workerId: '', - }, - }, - ], - edges: [ - { - edgeId: 'edge-1', - edgeType: 'OWNS', - fromNodeId: 'root-actor', - properties: {}, - toNodeId: 'run:root-actor:run-1', - updatedAt: '2026-03-30T08:00:20.000Z', - }, - { - edgeId: 'edge-2', - edgeType: 'CONTAINS_STEP', - fromNodeId: 'run:root-actor:run-1', - properties: { - stepId: 'research', - stepType: 'llm_call', - }, - toNodeId: 'step:root-actor:run-1:research', - updatedAt: '2026-03-30T08:00:16.000Z', - }, - { - edgeId: 'edge-3', - edgeType: 'CONTAINS_STEP', - fromNodeId: 'run:root-actor:run-1', - properties: { - stepId: 'approval', - stepType: 'human_approval', - }, - toNodeId: 'step:root-actor:run-1:approval', - updatedAt: '2026-03-30T08:00:20.000Z', - }, - { - edgeId: 'edge-4', - edgeType: 'CHILD_OF', - fromNodeId: 'root-actor', - properties: {}, - toNodeId: 'research-agent', - updatedAt: '2026-03-30T08:00:18.000Z', - }, - ], - }, - }; - - const timeline: WorkflowActorTimelineItem[] = [ - { - agentId: 'root-actor', - data: {}, - eventType: 'WorkflowRunExecutionStartedEvent', - message: 'command=run-1', - stage: 'workflow.start', - stepId: '', - stepType: '', - timestamp: '2026-03-30T08:00:10.000Z', - }, - { - agentId: 'root-actor', - data: { - temperature: '0.2', - }, - eventType: 'StepRequestEvent', - message: 'research (llm_call)', - stage: 'step.request', - stepId: 'research', - stepType: 'llm_call', - timestamp: '2026-03-30T08:00:12.000Z', - }, - { - agentId: 'research-agent', - data: { - call_id: 'call-1', - endpoint: 'market.quote', - latency_ms: '128', - tool_name: 'market.quote', - }, - eventType: 'WorkflowRoleReplyRecordedEvent', - message: 'market.quote', - stage: 'tool.call', - stepId: 'research', - stepType: 'llm_call', - timestamp: '2026-03-30T08:00:14.000Z', - }, - { - agentId: 'research-agent', - data: { - session_id: 'sess-1', - }, - eventType: 'WorkflowRoleReplyRecordedEvent', - message: 'strategy_researcher', - stage: 'role.reply', - stepId: '', - stepType: '', - timestamp: '2026-03-30T08:00:15.000Z', - }, - { - agentId: 'research-agent', - data: { - confidence: '0.76', - }, - eventType: 'StepCompletedEvent', - message: 'research (success)', - stage: 'step.completed', - stepId: 'research', - stepType: 'llm_call', - timestamp: '2026-03-30T08:00:16.000Z', - }, - { - agentId: 'root-actor', - data: { - prompt: 'Approve guarded buy on 600519 before order routing.', - }, - eventType: 'WorkflowSuspendedEvent', - message: 'approval (human_approval)', - stage: 'workflow.suspended', - stepId: 'approval', - stepType: 'human_approval', - timestamp: '2026-03-30T08:00:20.000Z', - }, - ]; - - const snapshot = buildMissionSnapshotFromRuntime({ - connectionStatus: 'live', - nowMs: Date.parse('2026-03-30T08:00:22.000Z'), - recentEvents: [] as AGUIEvent[], - resources: { - artifacts: { - fetchedAtMs: Date.parse('2026-03-30T08:00:21.000Z'), - graph, - timeline, - }, - session: { - runId: 'run-1', - status: 'running', - }, - }, - routeContext: { - runId: 'run-1', - scopeId: 'scope-a', - serviceId: 'svc-1', - }, - }); - - expect(snapshot.summary.status).toBe('waiting_approval'); - expect(snapshot.summary.observationStatus).toBe('streaming'); - expect(snapshot.summary.activeStageLabel).toBe('Waiting for approval'); - expect(snapshot.summary.scriptEvolutionStatus).toBeUndefined(); - expect(snapshot.intervention).toMatchObject({ - kind: 'human_approval', - nodeId: 'step:root-actor:run-1:approval', - stepId: 'approval', - }); - - const researchActor = snapshot.nodes.find((node) => node.id === 'research-agent'); - expect(researchActor?.toolCalls).toHaveLength(1); - expect(researchActor?.toolCalls[0]).toMatchObject({ - endpoint: 'market.quote', - latencyMs: 128, - toolName: 'market.quote', - }); - - const approvalStep = snapshot.nodes.find( - (node) => node.id === 'step:root-actor:run-1:approval', - ); - expect(approvalStep?.status).toBe('waiting'); - expect(approvalStep?.handoff).toMatchObject({ - severity: 'action', - title: 'Operator handoff', - }); - expect(approvalStep?.handoff.nextStep).toContain('approve or reject'); - expect(approvalStep?.reasoningChain[0]).toMatchObject({ - title: 'Workflow suspended', - }); - - const suspendedEvent = snapshot.events.find( - (event) => event.type === 'workflow_suspended', - ); - expect(suspendedEvent?.handoff).toMatchObject({ - severity: 'action', - title: 'Action handoff', - }); - expect(suspendedEvent?.handoff.nextStep).toContain('intervention panel'); - }); - - it('builds an honest empty snapshot when runtime context is missing', () => { - const snapshot = buildMissionRuntimePlaceholderSnapshot({ - connectionStatus: 'idle', - context: {}, - nowMs: Date.parse('2026-03-30T08:00:22.000Z'), - }); - - expect(snapshot.summary.status).toBe('idle'); - expect(snapshot.summary.observationStatus).toBe('unavailable'); - expect(snapshot.summary.activeStageLabel).toBe('Awaiting runtime context'); - expect(snapshot.summary.scriptEvolutionStatus).toBeUndefined(); - expect(snapshot.nodes).toHaveLength(0); - expect(snapshot.edges).toHaveLength(0); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/MissionControl/runtimeAdapter.ts b/apps/aevatar-console-web/src/pages/MissionControl/runtimeAdapter.ts deleted file mode 100644 index 27871ea521..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/runtimeAdapter.ts +++ /dev/null @@ -1,1158 +0,0 @@ -import { AGUIEventType, CustomEventName, type AGUIEvent } from '@aevatar-react-sdk/types'; -import type { - WorkflowActorGraphEnrichedSnapshot, - WorkflowActorGraphNode, - WorkflowActorTimelineItem, -} from '@/shared/models/runtime/actors'; -import { - getLatestCustomEventData, - parseStepCompletedData, - parseStepRequestData, - parseWaitingSignalData, -} from '@/shared/agui/customEventData'; -import type { - MissionControlSnapshot, - MissionControlRouteContext, - MissionExecutionEvent, - MissionInterventionState, - MissionNodeStatus, - MissionObservationStatus, - MissionRuntimeConnectionStatus, - MissionRunStatus, - MissionTopologyEdge, - MissionTopologyNode, - MissionTopologyNodeKind, -} from './models'; -import { - buildMissionEventHandoffCue, - buildMissionNodeHandoffCue, -} from './runtimeHandoff'; -import { t } from "@/shared/i18n/messages"; - -type MissionSessionLike = { - context?: { - actorId?: string; - commandId?: string; - workflowName?: string; - }; - error?: { - code?: string; - message: string; - }; - lastSnapshot?: unknown; - pendingHumanInput?: { - metadata?: Record; - prompt?: string; - runId?: string; - stepId?: string; - suspensionType?: string; - timeoutSeconds?: number; - }; - runId?: string; - status: 'idle' | 'running' | 'finished' | 'error'; -}; - -type BuildRuntimeSnapshotInput = { - connectionStatus: MissionRuntimeConnectionStatus; - nowMs: number; - recentEvents: AGUIEvent[]; - routeContext?: MissionControlRouteContext; - resources?: { - artifacts: { - fetchedAtMs: number; - graph: WorkflowActorGraphEnrichedSnapshot; - timeline: WorkflowActorTimelineItem[]; - }; - session: MissionSessionLike; - }; -}; - -const COMPLETION_STATUS = { - completed: 1, - failed: 3, - stopped: 4, -} as const; - -const BLOCKING_TIMELINE_STAGES = new Set(['signal.waiting', 'workflow.suspended']); -const CLEARING_TIMELINE_STAGES = new Set([ - 'signal.buffered', - 'workflow.resumed', - 'workflow.completed', - 'workflow.failed', - 'workflow.stopped', -]); - -function trimOptional(value?: string): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - -function mapTimelineEventType(eventType: string): MissionExecutionEvent['type'] { - const normalized = eventType.trim().toLowerCase(); - if (!normalized) { - return 'step_requested'; - } - - if (normalized.includes('execution_started') || normalized.includes('run_started')) { - return 'workflow_run_execution_started'; - } - - if (normalized.includes('role_reply')) { - return 'workflow_role_reply_recorded'; - } - - if (normalized.includes('actor_link')) { - return 'workflow_role_actor_linked'; - } - - if (normalized.includes('signal_buffered')) { - return 'workflow_signal_buffered'; - } - - if (normalized.includes('waiting_signal') || normalized.includes('wait_signal')) { - return 'waiting_for_signal'; - } - - if (normalized.includes('suspend')) { - return 'workflow_suspended'; - } - - if (normalized.includes('resume')) { - return 'workflow_resumed'; - } - - if (normalized.includes('completed')) { - return normalized.includes('workflow') ? 'workflow_completed' : 'step_completed'; - } - - if (normalized.includes('stopped')) { - return normalized.includes('run') ? 'workflow_run_stopped' : 'workflow_stopped'; - } - - return 'step_requested'; -} - -function parseDateMs(value?: string): number | undefined { - if (!value) { - return undefined; - } - - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function parseTimelineTimestampMs(item: WorkflowActorTimelineItem): number { - return parseDateMs(item.timestamp) ?? 0; -} - -function normalizeTimelineStage(item: WorkflowActorTimelineItem): string { - return item.stage.trim().toLowerCase(); -} - -function formatFreshness(ageSeconds?: number): string { - if (ageSeconds === undefined || !Number.isFinite(ageSeconds)) { - return 'n/a'; - } - - if (ageSeconds < 60) { - return `${Math.max(1, Math.round(ageSeconds))}s`; - } - - if (ageSeconds < 3600) { - return `${Math.round(ageSeconds / 60)}m`; - } - - return `${Math.round(ageSeconds / 3600)}h`; -} - -function laneForKind(kind: MissionTopologyNodeKind) { - switch (kind) { - case 'entrypoint': - return 'Observe'; - case 'research': - case 'tool': - return 'Analyze'; - case 'risk': - case 'approval': - return 'Decide'; - case 'execution': - return 'Execute'; - default: - return 'Control'; - } -} - -function inferKind(node: WorkflowActorGraphNode): MissionTopologyNodeKind { - const nodeType = node.nodeType.toLowerCase(); - const stepType = (node.properties.stepType || '').toLowerCase(); - const targetRole = (node.properties.targetRole || '').toLowerCase(); - const stepId = (node.properties.stepId || node.nodeId).toLowerCase(); - - if (nodeType === 'workflowrun') { - return 'entrypoint'; - } - - if (nodeType === 'actor') { - return 'coordinator'; - } - - const signalHints = ['wait_signal', 'signal', 'checkpoint']; - if (signalHints.some((value) => stepType.includes(value) || stepId.includes(value))) { - return 'risk'; - } - - const approvalHints = ['approval', 'approve']; - if ( - approvalHints.some((value) => stepType.includes(value) || targetRole.includes(value) || stepId.includes(value)) - ) { - return 'approval'; - } - - const executionHints = ['execute', 'dispatch', 'route', 'trade']; - if ( - executionHints.some((value) => stepType.includes(value) || targetRole.includes(value) || stepId.includes(value)) - ) { - return 'execution'; - } - - const toolHints = ['tool', 'api', 'connector', 'query']; - if ( - toolHints.some((value) => stepType.includes(value) || targetRole.includes(value) || stepId.includes(value)) - ) { - return 'tool'; - } - - return 'research'; -} - -function observationStatusFromAge( - connectionStatus: MissionRuntimeConnectionStatus, - ageSeconds?: number, - terminal = false, -): MissionObservationStatus { - if (connectionStatus === 'idle') { - return 'unavailable'; - } - - if (connectionStatus === 'disconnected') { - return 'delayed'; - } - - if (terminal) { - return 'projection_settled'; - } - - if (ageSeconds === undefined || !Number.isFinite(ageSeconds)) { - return connectionStatus === 'live' ? 'streaming' : 'snapshot_available'; - } - - if (ageSeconds <= 6) { - return 'streaming'; - } - - if (ageSeconds <= 30) { - return 'snapshot_available'; - } - - return 'delayed'; -} - -function eventTimestampMs(event: AGUIEvent): number { - return typeof event.timestamp === 'number' && Number.isFinite(event.timestamp) - ? event.timestamp - : Date.now(); -} - -function buildActivityMap( - graph: WorkflowActorGraphEnrichedSnapshot, - timeline: WorkflowActorTimelineItem[], - events: AGUIEvent[], -): Map { - const activity = new Map(); - const stepNodeIds = new Map(); - const graphNodeIds = new Set(); - - graph.subgraph.nodes.forEach((node) => { - graphNodeIds.add(node.nodeId); - const stepId = node.properties.stepId?.trim(); - if (stepId) { - stepNodeIds.set(stepId, node.nodeId); - } - }); - - timeline.forEach((item) => { - const stamp = parseTimelineTimestampMs(item); - const agentId = trimOptional(item.agentId); - const stepId = trimOptional(item.stepId); - - if (agentId && graphNodeIds.has(agentId)) { - activity.set(agentId, stamp); - } - - if (stepId) { - const stepNodeId = stepNodeIds.get(stepId); - if (stepNodeId) { - activity.set(stepNodeId, stamp); - } - } - - activity.set(graph.snapshot.actorId, stamp); - activity.set(graph.subgraph.rootNodeId, stamp); - }); - - events.forEach((event) => { - const stamp = eventTimestampMs(event); - - if (event.type === AGUIEventType.HUMAN_INPUT_REQUEST) { - const stepNodeId = stepNodeIds.get(event.stepId); - if (stepNodeId) { - activity.set(stepNodeId, stamp); - } - return; - } - - if (event.type !== AGUIEventType.CUSTOM) { - return; - } - - const stepRequest = parseStepRequestData(event.value); - if (stepRequest?.stepId) { - const nodeId = stepNodeIds.get(stepRequest.stepId); - if (nodeId) { - activity.set(nodeId, stamp); - } - } - - const stepCompleted = parseStepCompletedData(event.value); - if (stepCompleted?.stepId) { - const nodeId = stepNodeIds.get(stepCompleted.stepId); - if (nodeId) { - activity.set(nodeId, stamp); - } - } - - const waitingSignal = parseWaitingSignalData(event.value); - if (waitingSignal?.stepId) { - const nodeId = stepNodeIds.get(waitingSignal.stepId); - if (nodeId) { - activity.set(nodeId, stamp); - } - } - }); - - return activity; -} - -function findLatestBlockingTimelineItem( - timeline: WorkflowActorTimelineItem[], -): WorkflowActorTimelineItem | undefined { - let latestBlocking: WorkflowActorTimelineItem | undefined; - let latestBlockingMs = -1; - let latestClearingMs = -1; - - for (const item of timeline) { - const stage = normalizeTimelineStage(item); - const stamp = parseTimelineTimestampMs(item); - - if (BLOCKING_TIMELINE_STAGES.has(stage) && stamp >= latestBlockingMs) { - latestBlocking = item; - latestBlockingMs = stamp; - } - - if (CLEARING_TIMELINE_STAGES.has(stage) && stamp >= latestClearingMs) { - latestClearingMs = stamp; - } - } - - if (!latestBlocking) { - return undefined; - } - - return latestClearingMs > latestBlockingMs ? undefined : latestBlocking; -} - -function parseSuspensionType(item: WorkflowActorTimelineItem): string | undefined { - const direct = - trimOptional(item.data.suspension_type) || - trimOptional(item.data.suspensionType) || - trimOptional(item.data.type); - if (direct) { - return direct; - } - - const match = item.message.match(/\(([^)]+)\)\s*$/); - return trimOptional(match?.[1]); -} - -function buildTimelineIntervention( - graph: WorkflowActorGraphEnrichedSnapshot, - timeline: WorkflowActorTimelineItem[], -): MissionInterventionState | undefined { - const latestBlocking = findLatestBlockingTimelineItem(timeline); - if (!latestBlocking) { - return undefined; - } - - const stepNodeIds = new Map(); - graph.subgraph.nodes.forEach((node) => { - const stepId = trimOptional(node.properties.stepId); - if (stepId) { - stepNodeIds.set(stepId, node.nodeId); - } - }); - - const stage = normalizeTimelineStage(latestBlocking); - const stepId = trimOptional(latestBlocking.stepId) || 'runtime-gate'; - const nodeId = stepNodeIds.get(stepId) ?? graph.subgraph.rootNodeId; - - if (stage === 'signal.waiting') { - const signalName = trimOptional(latestBlocking.data.signal_name) || trimOptional(latestBlocking.message) || 'continue'; - const timeoutMs = Number(latestBlocking.data.timeout_ms); - - return { - required: true, - key: `waiting-signal/${stepId}`, - kind: 'waiting_signal', - nodeId, - prompt: - trimOptional(latestBlocking.data.prompt) || - `Runtime is waiting for signal ${signalName} before ${stepId} can continue.`, - signalName, - stepId, - summary: t("pages.missioncontrol.runtimeadapter.runtime.is.paused.at.an.external", "Runtime is paused at an external signal gate and cannot continue until the signal arrives."), - timeoutLabel: - Number.isFinite(timeoutMs) && timeoutMs > 0 - ? `Times out in ${Math.max(1, Math.round(timeoutMs / 1000))}s` - : undefined, - title: t("pages.missioncontrol.runtimeadapter.waiting.for", "Waiting for {value1}", { value1: signalName }), - primaryActionLabel: t("pages.missioncontrol.runtimeadapter.send.signal.2", "Send Signal"), - secondaryActionLabel: t("pages.missioncontrol.runtimeadapter.inspect.gate.2", "Inspect Gate"), - }; - } - - const suspensionType = (parseSuspensionType(latestBlocking) || '').toLowerCase(); - const isApproval = suspensionType.includes('approval') || suspensionType.includes('approve'); - const timeoutSeconds = Number( - latestBlocking.data.timeout_seconds || latestBlocking.data.timeoutSeconds || '', - ); - - return { - required: true, - key: `${isApproval ? 'waiting-approval' : 'human-input'}/${stepId}`, - kind: isApproval ? 'human_approval' : 'human_input', - nodeId, - prompt: - trimOptional(latestBlocking.data.prompt) || - trimOptional(latestBlocking.data.reason) || - trimOptional(latestBlocking.data.variable_name) || - `${stepId} needs operator input before runtime can continue.`, - stepId, - summary: isApproval - ? 'Runtime is paused and waiting for approval before it can enter the execution path.' - : 'Runtime is paused and waiting for additional operator context before it can continue.', - timeoutLabel: - Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 - ? `Times out in ${Math.max(1, Math.round(timeoutSeconds))}s` - : undefined, - title: isApproval ? 'Waiting for approval' : 'Input required', - primaryActionLabel: isApproval ? 'Approve' : 'Resume', - secondaryActionLabel: isApproval ? 'Reject' : 'Inspect Gate', - }; -} - -function buildIntervention( - _runId: string, - graph: WorkflowActorGraphEnrichedSnapshot, - session: MissionSessionLike, - recentEvents: AGUIEvent[], - timeline: WorkflowActorTimelineItem[], -): MissionInterventionState | undefined { - const stepNodeIds = new Map(); - graph.subgraph.nodes.forEach((node) => { - if (node.properties.stepId) { - stepNodeIds.set(node.properties.stepId, node.nodeId); - } - }); - - const waitingSignal = getLatestCustomEventData( - recentEvents, - CustomEventName.WaitingSignal, - parseWaitingSignalData, - ); - if (waitingSignal?.stepId) { - return { - required: true, - key: `waiting-signal/${waitingSignal.stepId}`, - kind: 'waiting_signal', - nodeId: stepNodeIds.get(waitingSignal.stepId) ?? graph.subgraph.rootNodeId, - prompt: waitingSignal.prompt ?? 'Runtime is waiting for an external signal.', - signalName: waitingSignal.signalName ?? 'continue', - stepId: waitingSignal.stepId, - summary: t("pages.missioncontrol.runtimeadapter.runtime.is.paused.and.waiting.for", "Runtime is paused and waiting for an external signal to resume control flow."), - timeoutLabel: - typeof waitingSignal.timeoutMs === 'number' - ? `Times out in ${Math.max(1, Math.round(waitingSignal.timeoutMs / 1000))}s` - : undefined, - title: t("pages.missioncontrol.runtimeadapter.waiting.for.2", "Waiting for {value1}", { value1: waitingSignal.signalName ?? 'signal' }), - primaryActionLabel: t("pages.missioncontrol.runtimeadapter.send.signal.3", "Send Signal"), - secondaryActionLabel: t("pages.missioncontrol.runtimeadapter.inspect.gate.3", "Inspect Gate"), - }; - } - - if (!session.pendingHumanInput?.stepId) { - return buildTimelineIntervention(graph, timeline); - } - - const suspensionType = (session.pendingHumanInput.suspensionType || '').toLowerCase(); - const isApproval = - suspensionType.includes('approval') || suspensionType.includes('approve'); - - return { - required: true, - key: `${isApproval ? 'waiting-approval' : 'human-input'}/${session.pendingHumanInput.stepId}`, - kind: isApproval ? 'human_approval' : 'human_input', - nodeId: - stepNodeIds.get(session.pendingHumanInput.stepId) ?? graph.subgraph.rootNodeId, - prompt: session.pendingHumanInput.prompt || 'This step requires operator intervention.', - stepId: session.pendingHumanInput.stepId, - summary: isApproval - ? 'Runtime requires approval before it can continue into execution.' - : 'Runtime requires additional operator context before it can continue.', - timeoutLabel: - typeof session.pendingHumanInput.timeoutSeconds === 'number' - ? `Times out in ${session.pendingHumanInput.timeoutSeconds}s` - : undefined, - title: isApproval ? 'Waiting for approval' : 'Input required', - primaryActionLabel: isApproval ? 'Approve' : 'Resume', - secondaryActionLabel: isApproval ? 'Reject' : 'Pause', - }; -} - -function completionStatusToRunStatus( - completionStatusValue: number, - session: MissionSessionLike, - intervention?: MissionInterventionState, -): MissionRunStatus { - if (intervention?.kind === 'waiting_signal') { - return 'waiting_signal'; - } - - if (intervention?.kind === 'human_input') { - return 'human_input'; - } - - if (intervention?.kind === 'human_approval') { - return 'waiting_approval'; - } - - if (session.status === 'error' || completionStatusValue === COMPLETION_STATUS.failed) { - return 'failed'; - } - - if (completionStatusValue === COMPLETION_STATUS.stopped) { - return 'stopped'; - } - - if (completionStatusValue === COMPLETION_STATUS.completed || session.status === 'finished') { - return 'completed'; - } - - return 'running'; -} - -function nodeStatusFromRuntime( - node: WorkflowActorGraphNode, - runStatus: MissionRunStatus, - activityMap: Map, - nowMs: number, - intervention?: MissionInterventionState, -): MissionNodeStatus { - if (intervention?.nodeId === node.nodeId) { - return 'waiting'; - } - - if (runStatus === 'completed') { - return 'completed'; - } - - const successValue = node.properties.success?.toLowerCase(); - if (successValue === 'true') { - return 'completed'; - } - - if (successValue === 'false' || runStatus === 'failed') { - return 'failed'; - } - - const activityAt = activityMap.get(node.nodeId); - if (activityAt && nowMs - activityAt <= 8_000) { - return 'active'; - } - - return node.nodeType === 'WorkflowStep' ? 'idle' : 'active'; -} - -function buildReasoningChain( - node: WorkflowActorGraphNode, - relatedTimeline: WorkflowActorTimelineItem[], -): MissionTopologyNode['reasoningChain'] { - const entries = relatedTimeline.slice(-3); - if (entries.length === 0) { - return [ - { - id: `${node.nodeId}/reasoning/fallback`, - title: t("pages.missioncontrol.runtimeadapter.no.standalone.reasoning.yet.2", "No standalone reasoning yet"), - summary: t("pages.missioncontrol.runtimeadapter.this.node.does.not.have.independent", "This node does not have independent timeline evidence yet, so the inspector is showing graph properties and the latest synchronized state."), - evidence: Object.entries(node.properties) - .filter(([, value]) => value.length > 0) - .slice(0, 4) - .map(([key, value]) => `${key}: ${value}`), - }, - ]; - } - - return entries.map((item, index) => { - const stage = normalizeTimelineStage(item); - const nodeLabel = node.properties.stepId || node.properties.targetRole || node.nodeId; - const evidence = [ - trimOptional(item.stepId) ? `stepId: ${item.stepId}` : undefined, - trimOptional(item.stepType) ? `stepType: ${item.stepType}` : undefined, - trimOptional(item.agentId) ? `agentId: ${item.agentId}` : undefined, - ...Object.entries(item.data) - .slice(0, 3) - .map(([key, value]) => `${key}: ${value}`), - ].filter((entry): entry is string => Boolean(entry)); - - let title = item.stage || item.eventType || 'Runtime insight'; - let summary = item.message || 'Runtime recorded a topology update.'; - - if (stage === 'step.request') { - title = 'Step requested'; - summary = `${item.stepId || nodeLabel} entered the runtime queue as ${item.stepType || 'workflow step'}.`; - } else if (stage === 'step.completed') { - title = 'Step completed'; - summary = `${item.stepId || nodeLabel} finished successfully and advanced the workflow.`; - } else if (stage === 'step.failed') { - title = 'Step failed'; - summary = `${item.stepId || nodeLabel} failed and propagated an error signal downstream.`; - } else if (stage === 'workflow.suspended') { - title = 'Workflow suspended'; - summary = `${item.stepId || nodeLabel} paused for operator input or approval before continuing.`; - } else if (stage === 'signal.waiting') { - title = 'Signal gate waiting'; - summary = `Runtime is blocked on signal ${item.data.signal_name || item.message || 'continue'} before the next branch can continue.`; - } else if (stage === 'signal.buffered') { - title = 'Signal buffered'; - summary = `Signal ${item.data.signal_name || item.message || 'continue'} was accepted and queued for workflow resumption.`; - } else if (stage === 'tool.call') { - title = 'Tool call recorded'; - summary = `${item.message || 'A tool call'} was materialized by runtime and linked back to this node.`; - } else if (stage === 'role.reply') { - title = 'Role reply recorded'; - summary = `Role ${item.message || nodeLabel} produced a reply that can influence downstream decisions.`; - } else if (stage === 'workflow.completed') { - title = 'Workflow completed'; - summary = 'Workflow reached a committed terminal state and published a final output.'; - } else if (stage === 'workflow.failed') { - title = 'Workflow failed'; - summary = 'Workflow ended in a committed failure state and exposed the terminal error.'; - } - - return { - id: `${node.nodeId}/reasoning/${index}`, - title, - summary, - evidence, - }; - }); -} - -function buildToolCalls( - node: WorkflowActorGraphNode, - relatedTimeline: WorkflowActorTimelineItem[], -): MissionTopologyNode['toolCalls'] { - const candidates = relatedTimeline.filter((item) => { - const normalizedStage = normalizeTimelineStage(item); - const normalizedEventType = item.eventType.toLowerCase(); - return normalizedStage === 'tool.call' || normalizedEventType.includes('tool'); - }); - - if (candidates.length === 0) { - return []; - } - - return candidates.slice(-3).map((item, index) => ({ - id: `${node.nodeId}/tool/${index}`, - toolName: - trimOptional(item.data.tool_name) || - trimOptional(item.data.toolName) || - trimOptional(item.message) || - item.eventType || - 'runtime.tool', - endpoint: - trimOptional(item.data.endpoint) || - trimOptional(item.data.connector) || - trimOptional(item.data.call_id) || - 'runtime.timeline', - latencyMs: - Number( - item.data.latency_ms || - item.data.latencyMs || - item.data.duration_ms || - item.data.durationMs || - 0, - ) || 0, - paramsSummary: - Object.entries(item.data) - .filter(([key]) => key !== 'call_id') - .slice(0, 3) - .map(([key, value]) => `${key}: ${value}`) - .join(' · ') || 'Recorded by runtime timeline', - resultSummary: item.message || 'Tool call recorded.', - status: item.eventType.toLowerCase().includes('error') ? 'failed' : 'completed', - summary: item.message || 'Runtime captured a tool invocation.', - })); -} - -function buildNodeSummary( - node: WorkflowActorGraphNode, - relatedTimeline: WorkflowActorTimelineItem[], - snapshot: WorkflowActorGraphEnrichedSnapshot['snapshot'], -): string { - const latest = relatedTimeline[relatedTimeline.length - 1]; - if (latest?.message) { - const stage = normalizeTimelineStage(latest); - if (stage === 'tool.call') { - return `${latest.message} was invoked and persisted in the runtime timeline.`; - } - - if (stage === 'role.reply') { - return `Role ${latest.message} produced a reply for downstream steps.`; - } - - return latest.message; - } - - if (snapshot.lastError) { - return `Runtime exposed terminal error: ${snapshot.lastError}`; - } - - if (snapshot.lastOutput && (node.nodeId === snapshot.actorId || node.nodeType === 'WorkflowRun')) { - return `Latest committed output: ${snapshot.lastOutput}`; - } - - if (node.properties.success?.toLowerCase() === 'true') { - return `${node.properties.stepId || node.nodeId} completed successfully in the committed graph.`; - } - - if (node.properties.success?.toLowerCase() === 'false') { - return `${node.properties.stepId || node.nodeId} finished with a failure signal.`; - } - - if (node.nodeType === 'WorkflowStep') { - return `${node.properties.stepType || 'Step'} is materialized in the topology and awaiting newer runtime evidence.`; - } - - if (node.nodeType === 'Actor') { - return `Actor ${node.nodeId} is participating in the committed workflow topology.`; - } - - return `${node.properties.workflowName || node.nodeType} runtime state synchronized.`; -} - -function buildNodeSnapshot( - node: WorkflowActorGraphNode, - graph: WorkflowActorGraphEnrichedSnapshot, - session: MissionSessionLike, -): MissionTopologyNode['snapshot'] { - return { - headline: - node.nodeType === 'WorkflowStep' - ? `${node.properties.stepId || node.nodeId} current state` - : `${node.nodeId} runtime state`, - capturedAt: node.updatedAt || graph.snapshot.lastUpdatedAt, - currentStepId: node.properties.stepId || graph.snapshot.lastCommandId, - items: { - ...node.properties, - actorId: graph.snapshot.actorId, - completionStatusValue: graph.snapshot.completionStatusValue, - lastSnapshot: session.lastSnapshot, - }, - stateVersion: graph.snapshot.stateVersion, - }; -} - -function layoutNodes(nodes: MissionTopologyNode[]): MissionTopologyNode[] { - const laneOrder = ['Observe', 'Control', 'Analyze', 'Decide', 'Execute']; - const laneY: Record = { - Execute: [170], - Control: [70], - Decide: [280], - Observe: [170], - Analyze: [70, 280], - }; - const laneCounts = new Map(); - - return nodes.map((node) => { - const index = laneCounts.get(node.lane) ?? 0; - laneCounts.set(node.lane, index + 1); - const column = laneOrder.indexOf(node.lane); - const yVariants = laneY[node.lane] ?? [170]; - return { - ...node, - position: { - x: 60 + Math.max(0, column) * 280, - y: yVariants[index % yVariants.length], - }, - }; - }); -} - -function mapTimelineSeverity(eventType: string): MissionExecutionEvent['severity'] { - const normalized = eventType.toLowerCase(); - if (normalized.includes('error') || normalized.includes('failed')) { - return 'error'; - } - - if ( - normalized.includes('wait') || - normalized.includes('signal') || - normalized.includes('approval') - ) { - return 'warning'; - } - - if (normalized.includes('completed') || normalized.includes('finished')) { - return 'success'; - } - - return 'info'; -} - -function formatTimelineStageLabel(value: string): string { - const normalized = value.trim().toLowerCase(); - switch (normalized) { - case 'tool.call': - return 'Tool Call'; - case 'role.reply': - return 'Role Reply'; - case 'signal.waiting': - return 'Waiting for Signal'; - case 'signal.buffered': - return 'Signal Buffered'; - case 'workflow.suspended': - return 'Workflow Suspended'; - case 'workflow.completed': - return 'Workflow Completed'; - case 'workflow.failed': - return 'Workflow Failed'; - case 'step.request': - return 'Step Requested'; - case 'step.completed': - return 'Step Completed'; - default: - return value; - } -} - -function buildActiveStageLabel( - timeline: WorkflowActorTimelineItem[], - intervention: MissionInterventionState | undefined, - fallback: string, -): string { - if (intervention?.title) { - return intervention.title; - } - - const latest = timeline[timeline.length - 1]; - if (!latest) { - return fallback; - } - - const stage = normalizeTimelineStage(latest); - if (stage === 'tool.call') { - return latest.message || 'Tool Call'; - } - - if (stage === 'role.reply') { - return `Role Reply · ${latest.message || 'captured'}`; - } - - if (trimOptional(latest.stepId)) { - return `${formatTimelineStageLabel(latest.stage || latest.eventType)} · ${latest.stepId}`; - } - - return formatTimelineStageLabel(latest.stage || latest.eventType || fallback); -} - -export function buildMissionSnapshotFromRuntime( - input: BuildRuntimeSnapshotInput, -): MissionControlSnapshot { - if (!input.resources) { - return buildMissionRuntimePlaceholderSnapshot({ - connectionStatus: input.connectionStatus, - context: input.routeContext, - nowMs: input.nowMs, - }); - } - - const { artifacts, session } = input.resources; - const intervention = buildIntervention( - session.runId || artifacts.graph.snapshot.lastCommandId, - artifacts.graph, - session, - input.recentEvents, - artifacts.timeline, - ); - const runStatus = completionStatusToRunStatus( - artifacts.graph.snapshot.completionStatusValue, - session, - intervention, - ); - const activityMap = buildActivityMap(artifacts.graph, artifacts.timeline, input.recentEvents); - const terminal = runStatus === 'completed'; - - const nodes = layoutNodes( - artifacts.graph.subgraph.nodes.map((node) => { - const updatedAtMs = parseDateMs(node.updatedAt); - const ageSeconds = - updatedAtMs !== undefined ? Math.max(0, (input.nowMs - updatedAtMs) / 1000) : undefined; - const kind = inferKind(node); - const relatedTimeline = artifacts.timeline.filter((item) => { - const stepId = node.properties.stepId; - if (stepId && item.stepId === stepId) { - return true; - } - - return item.agentId === node.nodeId; - }); - const observationStatus = observationStatusFromAge( - input.connectionStatus, - ageSeconds, - terminal && node.nodeType !== 'WorkflowStep', - ); - - const status = nodeStatusFromRuntime( - node, - runStatus, - activityMap, - input.nowMs, - intervention, - ); - const label = - node.properties.targetRole || - node.properties.stepId || - node.properties.workflowName || - node.nodeId; - - return { - id: node.nodeId, - kind, - confidence: - typeof node.properties.success === 'string' - ? node.properties.success === 'true' - ? 0.92 - : node.properties.success === 'false' - ? 0.24 - : undefined - : undefined, - freshnessLabel: formatFreshness(ageSeconds), - freshnessSeconds: ageSeconds ?? Number.POSITIVE_INFINITY, - handoff: buildMissionNodeHandoffCue({ - connectionStatus: input.connectionStatus, - freshnessLabel: formatFreshness(ageSeconds), - isInterventionNode: intervention?.nodeId === node.nodeId, - kind, - label, - observationStatus, - status, - }), - lane: laneForKind(kind), - label, - lastLatencyMs: - Number( - relatedTimeline[relatedTimeline.length - 1]?.data.durationMs || - relatedTimeline[relatedTimeline.length - 1]?.data.latencyMs || - 0, - ) || undefined, - observationStatus, - position: { x: 0, y: 0 }, - reasoningChain: buildReasoningChain(node, relatedTimeline), - role: - node.properties.stepType || node.properties.targetRole || node.nodeType, - snapshot: buildNodeSnapshot(node, artifacts.graph, session), - status, - summary: buildNodeSummary(node, relatedTimeline, artifacts.graph.snapshot), - toolCalls: buildToolCalls(node, relatedTimeline), - } satisfies MissionTopologyNode; - }), - ); - - const nodeById = new Map(nodes.map((node) => [node.id, node])); - - const edges: MissionTopologyEdge[] = artifacts.graph.subgraph.edges.map((edge) => { - const sourceNode = nodeById.get(edge.fromNodeId); - const targetNode = nodeById.get(edge.toNodeId); - const streaming = - input.connectionStatus === 'live' && - ((activityMap.get(edge.fromNodeId) ?? 0) > input.nowMs - 6_000 || - (activityMap.get(edge.toNodeId) ?? 0) > input.nowMs - 6_000); - - return { - id: edge.edgeId, - label: edge.properties.stepType || edge.edgeType, - observationStatus: - targetNode?.observationStatus || - sourceNode?.observationStatus || - observationStatusFromAge( - input.connectionStatus, - parseDateMs(edge.updatedAt) - ? (input.nowMs - (parseDateMs(edge.updatedAt) || input.nowMs)) / 1000 - : undefined, - terminal, - ), - source: edge.fromNodeId, - streaming, - target: edge.toNodeId, - }; - }); - - const timelineTail = artifacts.timeline.slice(-24); - const events: MissionExecutionEvent[] = timelineTail.map((item, index) => { - const type = mapTimelineEventType(item.eventType); - const actorId = item.agentId || undefined; - const stepId = item.stepId || undefined; - return { - id: `timeline-${index}-${item.timestamp}`, - actorId, - detail: item.message, - handoff: buildMissionEventHandoffCue({ - actorId, - detail: item.message, - intervention, - runStatus, - stepId, - type, - }), - severity: mapTimelineSeverity(item.eventType), - stepId, - timestamp: item.timestamp, - title: item.stage || item.eventType || 'Runtime event', - type, - }; - }); - - return { - summary: { - activeStageLabel: buildActiveStageLabel( - artifacts.timeline, - intervention, - nodes.find((node) => node.status === 'active')?.label || artifacts.graph.snapshot.workflowName, - ), - definitionActorId: artifacts.graph.snapshot.actorId, - observationStatus: observationStatusFromAge( - input.connectionStatus, - Math.max(0, (input.nowMs - artifacts.fetchedAtMs) / 1000), - terminal, - ), - runId: session.runId || artifacts.graph.snapshot.lastCommandId, - scopeId: input.routeContext?.scopeId || 'runtime', - startedAt: artifacts.timeline[0]?.timestamp || artifacts.graph.snapshot.lastUpdatedAt, - status: runStatus, - updatedAt: artifacts.graph.snapshot.lastUpdatedAt, - workflowName: artifacts.graph.snapshot.workflowName, - }, - metrics: [ - { - key: 'steps', - label: t("pages.missioncontrol.runtimeadapter.completed.steps.3", "Completed Steps"), - trend: 'steady', - value: `${artifacts.graph.snapshot.completedSteps}/${artifacts.graph.snapshot.totalSteps}`, - }, - { - key: 'replies', - label: t("pages.missioncontrol.runtimeadapter.role.replies.3", "Role Replies"), - trend: 'up', - value: String(artifacts.graph.snapshot.roleReplyCount), - }, - { - key: 'state-version', - label: t("pages.missioncontrol.runtimeadapter.state.version.3", "State Version"), - trend: 'steady', - value: String(artifacts.graph.snapshot.stateVersion), - }, - { - key: 'last-success', - label: t("pages.missioncontrol.runtimeadapter.last.success.2", "Last Success"), - tone: artifacts.graph.snapshot.lastSuccess === false ? 'warning' : 'success', - trend: 'steady', - value: - artifacts.graph.snapshot.lastSuccess === null - ? 'n/a' - : artifacts.graph.snapshot.lastSuccess - ? 'true' - : 'false', - }, - ], - nodes, - edges, - events, - intervention, - liveLogs: timelineTail.map( - (item) => `[${item.timestamp}] ${item.stage || item.eventType} -> ${item.message}`, - ), - }; -} - -export function buildMissionRuntimePlaceholderSnapshot(input: { - connectionStatus: MissionRuntimeConnectionStatus; - context?: MissionControlRouteContext; - nowMs: number; -}): MissionControlSnapshot { - const timestamp = new Date(input.nowMs).toISOString(); - const idle = input.connectionStatus === 'idle'; - const observationStatus: MissionObservationStatus = idle - ? 'unavailable' - : input.connectionStatus === 'disconnected' - ? 'delayed' - : 'snapshot_available'; - - return { - summary: { - activeStageLabel: idle ? 'Awaiting runtime context' : 'Runtime connection pending', - definitionActorId: input.context?.actorId || 'n/a', - observationStatus, - runId: input.context?.runId || (idle ? 'attach-run' : 'pending'), - scopeId: input.context?.scopeId || (idle ? 'attach-scope' : 'runtime'), - startedAt: timestamp, - status: idle ? 'idle' : 'running', - updatedAt: timestamp, - workflowName: idle ? 'Mission Control' : 'Mission Control Runtime', - }, - metrics: [ - { key: 'steps', label: t("pages.missioncontrol.runtimeadapter.completed.steps.4", "Completed Steps"), trend: 'steady', value: '--' }, - { key: 'replies', label: t("pages.missioncontrol.runtimeadapter.role.replies.4", "Role Replies"), trend: 'steady', value: '--' }, - { key: 'state-version', label: t("pages.missioncontrol.runtimeadapter.state.version.4", "State Version"), trend: 'steady', value: '--' }, - { - key: 'connection', - label: 'Connection', - tone: input.connectionStatus === 'disconnected' ? 'warning' : 'default', - trend: 'steady', - value: - input.connectionStatus === 'idle' - ? 'Detached' - : input.connectionStatus === 'connecting' - ? 'Connecting' - : input.connectionStatus === 'live' - ? 'Live' - : input.connectionStatus === 'degraded' - ? 'Fallback Sync' - : 'Disconnected', - }, - ], - nodes: [], - edges: [], - events: [], - liveLogs: [], - }; -} diff --git a/apps/aevatar-console-web/src/pages/MissionControl/runtimeHandoff.test.ts b/apps/aevatar-console-web/src/pages/MissionControl/runtimeHandoff.test.ts deleted file mode 100644 index 9d75be8c2c..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/runtimeHandoff.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { - buildMissionActionFeedbackMessage, - buildMissionEventHandoffCue, - buildMissionNodeHandoffCue, -} from './runtimeHandoff'; - -describe('Mission Control runtime handoff cues', () => { - it('marks the intervention node as the actionable handoff', () => { - const cue = buildMissionNodeHandoffCue({ - connectionStatus: 'live', - freshnessLabel: '2s', - isInterventionNode: true, - kind: 'approval', - label: 'approval', - observationStatus: 'streaming', - status: 'waiting', - }); - - expect(cue.severity).toBe('action'); - expect(cue.title).toBe('Operator handoff'); - expect(cue.nextStep).toContain('approve or reject'); - }); - - it('keeps disconnected nodes blocked as last-known evidence', () => { - const cue = buildMissionNodeHandoffCue({ - connectionStatus: 'disconnected', - freshnessLabel: '4m', - isInterventionNode: false, - kind: 'research', - label: 'research', - observationStatus: 'delayed', - status: 'active', - }); - - expect(cue.severity).toBe('blocked'); - expect(cue.evidence).toContain('delayed'); - expect(cue.nextStep).toContain('newer runtime snapshot'); - }); - - it('blocks connecting intervention nodes until runtime state is available', () => { - const cue = buildMissionNodeHandoffCue({ - connectionStatus: 'connecting', - freshnessLabel: 'unavailable', - isInterventionNode: true, - kind: 'execution', - label: 'input', - observationStatus: 'unavailable', - status: 'waiting', - }); - - expect(cue.severity).toBe('blocked'); - expect(cue.title).toBe('Operator handoff'); - expect(cue.nextStep).toContain('signal or context'); - }); - - it('marks active streaming nodes as awaiting runtime confirmation', () => { - const cue = buildMissionNodeHandoffCue({ - connectionStatus: 'live', - freshnessLabel: '1s', - isInterventionNode: false, - kind: 'tool', - label: 'tool-call', - observationStatus: 'streaming', - status: 'active', - }); - - expect(cue.severity).toBe('confirming'); - expect(cue.title).toBe('Runtime confirmation'); - expect(cue.nextStep).toContain('next runtime event'); - }); - - it('turns blocking runtime events into event dock action handoffs', () => { - const cue = buildMissionEventHandoffCue({ - detail: 'approval (human_approval)', - intervention: { - key: 'waiting-approval/approval', - kind: 'human_approval', - nodeId: 'node-approval', - primaryActionLabel: 'Approve', - prompt: 'Approve guarded execution.', - required: true, - secondaryActionLabel: 'Reject', - stepId: 'approval', - summary: 'Runtime is paused for approval.', - title: 'Waiting for approval', - }, - runStatus: 'waiting_approval', - stepId: 'approval', - type: 'workflow_suspended', - }); - - expect(cue.severity).toBe('action'); - expect(cue.title).toBe('Action handoff'); - expect(cue.nextStep).toContain('intervention panel'); - }); - - it('keeps terminal runtime events as settled evidence', () => { - const cue = buildMissionEventHandoffCue({ - detail: 'workflow completed', - runStatus: 'completed', - type: 'workflow_completed', - }); - - expect(cue.severity).toBe('observe'); - expect(cue.title).toBe('Settled evidence'); - expect(cue.nextStep).toContain('no operator action'); - }); - - it('marks execution-start runtime events as awaiting confirmation', () => { - const cue = buildMissionEventHandoffCue({ - detail: 'step requested', - runStatus: 'running', - stepId: 'research', - type: 'step_requested', - }); - - expect(cue.severity).toBe('confirming'); - expect(cue.title).toBe('Await confirmation'); - expect(cue.nextStep).toContain('matching completion'); - }); - - it('keeps actor-linked non-blocking events observational', () => { - const cue = buildMissionEventHandoffCue({ - actorId: 'actor-1', - detail: 'role reply recorded', - runStatus: 'running', - type: 'workflow_role_reply_recorded', - }); - - expect(cue.severity).toBe('observe'); - expect(cue.title).toBe('Event evidence'); - expect(cue.detail).toContain('current runtime actor'); - expect(cue.detail).not.toContain('actor-1'); - expect(cue.nextStep).toContain('Keep observing'); - }); - - it('keeps run-linked non-blocking events observational', () => { - const cue = buildMissionEventHandoffCue({ - detail: 'signal buffered', - runStatus: 'running', - type: 'workflow_signal_buffered', - }); - - expect(cue.severity).toBe('observe'); - expect(cue.detail).toContain('current run'); - }); - - it('tells the operator when runtime rejects an action request', () => { - const message = buildMissionActionFeedbackMessage({ - accepted: false, - kind: 'resume', - }); - - expect(message).toContain('did not accept'); - expect(message).toContain('retry after checking connection state'); - }); - - it('keeps accepted actions honest until runtime publishes new evidence', () => { - const message = buildMissionActionFeedbackMessage({ - accepted: true, - commandId: 'cmd-1', - kind: 'approve', - runId: 'run-1', - }); - - expect(message).toContain('Wait for runtime to confirm'); - expect(message).toContain('Command observation is pending'); - expect(message).toContain('current run remains the evidence source'); - expect(message).not.toContain('cmd-1'); - expect(message).not.toContain('run-1'); - }); - - it('keeps accepted signals honest until a new runtime snapshot arrives', () => { - const message = buildMissionActionFeedbackMessage({ - accepted: true, - kind: 'signal', - signalName: 'continue', - }); - - expect(message).toContain('Signal continue was accepted'); - expect(message).toContain('next runtime snapshot'); - }); - - it('keeps accepted rejections honest until runtime confirms stop or rollback', () => { - const message = buildMissionActionFeedbackMessage({ - accepted: true, - kind: 'reject', - }); - - expect(message).toContain('Rejection was submitted'); - expect(message).toContain('confirm stop or rollback'); - }); - - it('keeps accepted resumes honest until the blocked step publishes evidence', () => { - const message = buildMissionActionFeedbackMessage({ - accepted: true, - kind: 'resume', - }); - - expect(message).toContain('Resume was accepted'); - expect(message).toContain('blocked step to publish new evidence'); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/MissionControl/runtimeHandoff.ts b/apps/aevatar-console-web/src/pages/MissionControl/runtimeHandoff.ts deleted file mode 100644 index 1c87077bde..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/runtimeHandoff.ts +++ /dev/null @@ -1,339 +0,0 @@ -import type { - MissionHandoffCue, - MissionHandoffSeverity, - MissionInterventionActionKind, - MissionInterventionState, - MissionNodeStatus, - MissionObservationStatus, - MissionRuntimeConnectionStatus, - MissionRunStatus, - MissionTopologyNodeKind, - WorkflowExecutionEventType, -} from './models'; -import { formatMissionLabel } from './presentation'; -import { t } from '@/shared/i18n/messages'; - -function observationEvidence(status: MissionObservationStatus, freshnessLabel: string) { - switch (status) { - case 'streaming': - return t( - 'pages.missioncontrol.runtimehandoff.live.runtime.evidence.fresh', - 'Live runtime evidence, fresh {freshnessLabel}.', - { freshnessLabel }, - ); - case 'snapshot_available': - return t( - 'pages.missioncontrol.runtimehandoff.snapshot.evidence.is.available', - 'Snapshot evidence is available, fresh {freshnessLabel}.', - { freshnessLabel }, - ); - case 'projection_settled': - return t( - 'pages.missioncontrol.runtimehandoff.committed.terminal.evidence.fresh', - 'Committed terminal evidence, fresh {freshnessLabel}.', - { freshnessLabel }, - ); - case 'delayed': - return t( - 'pages.missioncontrol.runtimehandoff.last.known.evidence.is.delayed', - 'Last known evidence is delayed, fresh {freshnessLabel}.', - { freshnessLabel }, - ); - default: - return t( - 'pages.missioncontrol.runtimehandoff.no.runtime.evidence.is.attached', - 'No runtime evidence is attached to this node yet.', - ); - } -} - -function severityForNode( - connectionStatus: MissionRuntimeConnectionStatus, - observationStatus: MissionObservationStatus, - nodeStatus: MissionNodeStatus, - isInterventionNode: boolean, -): MissionHandoffSeverity { - if (connectionStatus === 'disconnected') { - return 'blocked'; - } - - if (isInterventionNode) { - return connectionStatus === 'connecting' ? 'blocked' : 'action'; - } - - if (nodeStatus === 'waiting' || observationStatus === 'delayed') { - return 'blocked'; - } - - if (nodeStatus === 'active' || observationStatus === 'streaming') { - return 'confirming'; - } - - return 'observe'; -} - -function nextStepForNode( - severity: MissionHandoffSeverity, - kind: MissionTopologyNodeKind, - isInterventionNode: boolean, -) { - if (isInterventionNode) { - return kind === 'approval' - ? t( - 'pages.missioncontrol.runtimehandoff.open.the.intervention.panel.review', - 'Open the intervention panel, review recent evidence, then approve or reject.', - ) - : t( - 'pages.missioncontrol.runtimehandoff.open.the.intervention.panel.provide', - 'Open the intervention panel, provide the requested signal or context, then wait for runtime confirmation.', - ); - } - - switch (severity) { - case 'blocked': - return t( - 'pages.missioncontrol.runtimehandoff.keep.this.as.evidence.and.wait', - 'Keep this as evidence and wait for a newer runtime snapshot before acting.', - ); - case 'confirming': - return t( - 'pages.missioncontrol.runtimehandoff.observe.the.next.runtime.event', - 'Observe the next runtime event before treating this step as complete.', - ); - default: - return t( - 'pages.missioncontrol.runtimehandoff.use.this.node.as.read.only', - 'Use this node as read-only evidence for the current run.', - ); - } -} - -export function buildMissionNodeHandoffCue(input: { - connectionStatus: MissionRuntimeConnectionStatus; - freshnessLabel: string; - isInterventionNode: boolean; - kind: MissionTopologyNodeKind; - label: string; - observationStatus: MissionObservationStatus; - status: MissionNodeStatus; -}): MissionHandoffCue { - const severity = severityForNode( - input.connectionStatus, - input.observationStatus, - input.status, - input.isInterventionNode, - ); - const title = input.isInterventionNode - ? t('pages.missioncontrol.runtimehandoff.operator.handoff', 'Operator handoff') - : severity === 'blocked' - ? t('pages.missioncontrol.runtimehandoff.evidence.blocked', 'Evidence blocked') - : severity === 'confirming' - ? t( - 'pages.missioncontrol.runtimehandoff.runtime.confirmation', - 'Runtime confirmation', - ) - : t( - 'pages.missioncontrol.runtimehandoff.observation.evidence', - 'Observation evidence', - ); - - return { - detail: t( - 'pages.missioncontrol.runtimehandoff.is.with.evidence', - '{value1} is {value2} with {value3} evidence.', - { - value1: input.label, - value2: formatMissionLabel(input.status), - value3: formatMissionLabel(input.observationStatus), - }, - ), - evidence: observationEvidence(input.observationStatus, input.freshnessLabel), - nextStep: nextStepForNode(severity, input.kind, input.isInterventionNode), - severity, - title, - }; -} - -export function buildMissionEventHandoffCue(input: { - actorId?: string; - detail: string; - intervention?: MissionInterventionState; - runStatus: MissionRunStatus; - stepId?: string; - type: WorkflowExecutionEventType; -}): MissionHandoffCue { - const isInterventionEvent = - input.type === 'waiting_for_signal' || - input.type === 'workflow_suspended' || - input.stepId === input.intervention?.stepId; - const terminal = - input.type === 'workflow_completed' || - input.type === 'workflow_stopped' || - input.runStatus === 'completed' || - input.runStatus === 'failed' || - input.runStatus === 'stopped'; - - if (isInterventionEvent && input.intervention) { - return { - detail: t( - 'pages.missioncontrol.runtimehandoff.at.step', - '{value1} at step {value2}.', - { value1: input.intervention.title, value2: input.intervention.stepId }, - ), - evidence: - input.detail || - t( - 'pages.missioncontrol.runtimehandoff.runtime.published.a.blocking.event', - 'Runtime published a blocking event.', - ), - nextStep: - input.intervention.kind === 'human_approval' - ? t( - 'pages.missioncontrol.runtimehandoff.open.the.intervention.panel.and.decide', - 'Open the intervention panel and decide with the latest event dock evidence.', - ) - : t( - 'pages.missioncontrol.runtimehandoff.open.the.intervention.panel.and.submit', - 'Open the intervention panel and submit the requested context or signal.', - ), - severity: 'action', - title: t( - 'pages.missioncontrol.runtimehandoff.action.handoff', - 'Action handoff', - ), - }; - } - - if (terminal) { - return { - detail: t( - 'pages.missioncontrol.runtimehandoff.this.event.reflects.terminal.or.settled', - 'This event reflects a terminal or settled runtime fact.', - ), - evidence: - input.detail || - t( - 'pages.missioncontrol.runtimehandoff.runtime.emitted.terminal.evidence', - 'Runtime emitted terminal evidence.', - ), - nextStep: t( - 'pages.missioncontrol.runtimehandoff.use.this.event.as.committed', - 'Use this event as committed evidence; no operator action is implied.', - ), - severity: 'observe', - title: t( - 'pages.missioncontrol.runtimehandoff.settled.evidence', - 'Settled evidence', - ), - }; - } - - if (input.type === 'step_requested' || input.type === 'workflow_run_execution_started') { - return { - detail: t( - 'pages.missioncontrol.runtimehandoff.runtime.accepted.work.and.queued.the', - 'Runtime accepted work and queued the next step.', - ), - evidence: - input.detail || - t( - 'pages.missioncontrol.runtimehandoff.runtime.emitted.an.execution.start', - 'Runtime emitted an execution start event.', - ), - nextStep: t( - 'pages.missioncontrol.runtimehandoff.wait.for.the.matching.completion', - 'Wait for the matching completion, suspension, or signal event.', - ), - severity: 'confirming', - title: t( - 'pages.missioncontrol.runtimehandoff.await.confirmation', - 'Await confirmation', - ), - }; - } - - return { - detail: input.actorId - ? t( - 'pages.missioncontrol.runtimehandoff.evidence.is.linked.to.actor', - 'Evidence is linked to the current runtime actor.', - ) - : t( - 'pages.missioncontrol.runtimehandoff.evidence.is.linked.to.the.current', - 'Evidence is linked to the current run.', - ), - evidence: - input.detail || - t( - 'pages.missioncontrol.runtimehandoff.runtime.emitted.an.observable.event', - 'Runtime emitted an observable event.', - ), - nextStep: t( - 'pages.missioncontrol.runtimehandoff.keep.observing.unless.a.blocker', - 'Keep observing unless a blocker card appears.', - ), - severity: 'observe', - title: t('pages.missioncontrol.runtimehandoff.event.evidence', 'Event evidence'), - }; -} - -export function buildMissionActionFeedbackMessage(input: { - accepted: boolean; - commandId?: string; - kind: MissionInterventionActionKind; - runId?: string; - signalName?: string; -}) { - if (!input.accepted) { - return t( - 'pages.missioncontrol.runtimehandoff.runtime.did.not.accept.the.intervention', - 'Runtime did not accept the intervention request. Keep the blocker open and retry after checking connection state.', - ); - } - - const commandSuffix = input.commandId - ? t( - 'pages.missioncontrol.runtimehandoff.command.is.pending.observation', - ' Command observation is pending.', - ) - : ''; - const runSuffix = input.runId - ? t( - 'pages.missioncontrol.runtimehandoff.run.remains.the.evidence.source', - ' The current run remains the evidence source.', - ) - : ''; - - switch (input.kind) { - case 'signal': - return t( - 'pages.missioncontrol.runtimehandoff.signal.was.accepted.wait', - 'Signal {signalName} was accepted. Wait for the next runtime snapshot before marking the gate resolved.{commandSuffix}{runSuffix}', - { - commandSuffix, - runSuffix, - signalName: - input.signalName || - t('pages.missioncontrol.runtimehandoff.continue', 'continue'), - }, - ); - case 'approve': - return t( - 'pages.missioncontrol.runtimehandoff.approval.was.accepted.wait', - 'Approval was accepted. Wait for runtime to confirm advance, stop, or rollback before treating the decision as complete.{commandSuffix}{runSuffix}', - { commandSuffix, runSuffix }, - ); - case 'reject': - return t( - 'pages.missioncontrol.runtimehandoff.rejection.was.submitted.wait', - 'Rejection was submitted. Wait for runtime to confirm stop or rollback before closing the blocker.{commandSuffix}{runSuffix}', - { commandSuffix, runSuffix }, - ); - default: - return t( - 'pages.missioncontrol.runtimehandoff.resume.was.accepted.wait', - 'Resume was accepted. Wait for the blocked step to publish new evidence.{commandSuffix}{runSuffix}', - { commandSuffix, runSuffix }, - ); - } -} diff --git a/apps/aevatar-console-web/src/pages/MissionControl/services/api.ts b/apps/aevatar-console-web/src/pages/MissionControl/services/api.ts deleted file mode 100644 index d866fc68da..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionControl/services/api.ts +++ /dev/null @@ -1,248 +0,0 @@ -import type { - AGUIEvent, - ChatRunRequest, - RunContextData, - WorkflowResumeRequest, - WorkflowSignalRequest, -} from '@aevatar-react-sdk/types'; -import type { - WorkflowActorGraphEnrichedSnapshot, - WorkflowActorTimelineItem, -} from '@/shared/models/runtime/actors'; -import { parseBackendSSEStream } from '@/shared/agui/sseFrameNormalizer'; -import { runtimeActorsApi } from '@/shared/api/runtimeActorsApi'; -import { runtimeRunsApi } from '@/shared/api/runtimeRunsApi'; -import type { - MissionControlRouteContext, - MissionInterventionActionRequest, - MissionInterventionActionResult, - MissionInterventionState, -} from '../models'; - -export interface MissionControlRuntimeArtifacts { - actorId: string; - graph: WorkflowActorGraphEnrichedSnapshot; - fetchedAtMs: number; - timeline: WorkflowActorTimelineItem[]; -} - -export interface MissionObservedRunContext { - actorId?: string; - commandId?: string; - workflowName?: string; -} - -function readBoolean(value: string | null): boolean | undefined { - if (value === null) { - return undefined; - } - - const normalized = value.trim().toLowerCase(); - if (normalized === 'true' || normalized === '1') { - return true; - } - - if (normalized === 'false' || normalized === '0') { - return false; - } - - return undefined; -} - -function trimOptional(value: string | null): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - -export function readMissionControlRouteContext( - search = typeof window === 'undefined' ? '' : window.location.search, -): MissionControlRouteContext { - const params = new URLSearchParams(search); - return { - actorId: trimOptional(params.get('actorId')), - autoStream: readBoolean(params.get('autoStream')), - endpointId: trimOptional(params.get('endpointId')) || 'chat', - prompt: trimOptional(params.get('prompt')), - runId: trimOptional(params.get('runId')), - scopeId: trimOptional(params.get('scopeId')), - serviceId: trimOptional(params.get('serviceId')), - }; -} - -export function hasMissionControlLiveContext( - context: MissionControlRouteContext, -): boolean { - return Boolean(context.actorId || (context.scopeId && context.runId)); -} - -export async function fetchMissionControlRuntimeArtifacts( - context: MissionControlRouteContext, -): Promise { - const actorId = - context.actorId || - ( - await (async () => { - if (!context.scopeId || !context.runId) { - return undefined; - } - - const summary = await runtimeRunsApi.getRunSummary( - context.scopeId, - context.runId, - { - actorId: context.actorId, - serviceId: context.serviceId, - }, - ); - return summary.actorId?.trim() || undefined; - })() - ); - if (!actorId) { - throw new Error('Missing actor identity. Mission Control cannot load runtime data.'); - } - - const [graph, timeline] = await Promise.all([ - runtimeActorsApi.getActorGraphEnriched(actorId, { - depth: 4, - direction: 'Both', - take: 240, - }), - runtimeActorsApi.getActorTimeline(actorId, { - take: 240, - }), - ]); - - return { - actorId, - fetchedAtMs: Date.now(), - graph, - timeline, - }; -} - -export async function* streamMissionControlEvents( - context: MissionControlRouteContext, - signal: AbortSignal, -): AsyncGenerator { - if (!context.scopeId || !context.prompt) { - return; - } - - const response = await runtimeRunsApi.streamChat( - context.scopeId, - { - prompt: context.prompt, - metadata: undefined, - } satisfies ChatRunRequest, - signal, - { - serviceId: context.serviceId, - }, - ); - - for await (const event of parseBackendSSEStream(response, { signal })) { - yield event; - } -} - -export function readMissionObservedRunContext( - event: AGUIEvent, -): MissionObservedRunContext | undefined { - if (event.type !== 'CUSTOM' || event.name !== 'aevatar.run.context') { - return undefined; - } - - const value = event.value as RunContextData | undefined; - if (!value) { - return undefined; - } - - return { - actorId: value.actorId?.trim() || undefined, - commandId: value.commandId?.trim() || undefined, - workflowName: value.workflowName?.trim() || undefined, - }; -} - -function buildResumeRequest( - context: MissionControlRouteContext, - intervention: MissionInterventionState, - action: MissionInterventionActionRequest, -): WorkflowResumeRequest { - return { - actorId: context.actorId || '', - approved: action.kind !== 'reject', - commandId: undefined, - metadata: undefined, - runId: context.runId ?? '', - stepId: intervention.stepId, - userInput: action.comment?.trim() || undefined, - }; -} - -function buildSignalRequest( - context: MissionControlRouteContext, - intervention: MissionInterventionState, - action: MissionInterventionActionRequest, -): WorkflowSignalRequest { - return { - actorId: context.actorId || '', - commandId: undefined, - payload: action.payload?.trim() || undefined, - runId: context.runId ?? '', - signalName: intervention.signalName ?? 'continue', - stepId: intervention.stepId, - }; -} - -export async function submitMissionControlIntervention( - context: MissionControlRouteContext, - intervention: MissionInterventionState, - action: MissionInterventionActionRequest, -): Promise { - if (!context.actorId) { - throw new Error('Missing actor identity. Mission Control cannot submit actions.'); - } - - if (!context.scopeId) { - throw new Error('Missing scope identity. Mission Control cannot submit actions.'); - } - - if (!context.runId) { - throw new Error('Missing run identity. Mission Control cannot submit actions.'); - } - - if (action.kind === 'signal') { - const result = await runtimeRunsApi.signal( - context.scopeId, - buildSignalRequest(context, intervention, action), - { - serviceId: context.serviceId, - }, - ); - return { - accepted: result.accepted, - commandId: result.commandId, - kind: action.kind, - runId: result.runId, - signalName: result.signalName, - stepId: result.stepId, - }; - } - - const result = await runtimeRunsApi.resume( - context.scopeId, - buildResumeRequest(context, intervention, action), - { - serviceId: context.serviceId, - }, - ); - - return { - accepted: result.accepted, - commandId: result.commandId, - kind: action.kind, - runId: result.runId, - stepId: result.stepId, - }; -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/components/MissionStage.tsx b/apps/aevatar-console-web/src/pages/MissionWall/components/MissionStage.tsx deleted file mode 100644 index d8d0efc3c3..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/components/MissionStage.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from "react"; -import { t } from "@/shared/i18n/messages"; -import AevatarContentSkeleton from "@/shared/ui/AevatarContentSkeleton"; -import type { MissionWallRun, MissionWallSnapshot } from "../models"; -import { WorkflowReplayCanvas } from "./WorkflowReplayCanvas"; - -export function MissionStage({ - focusRun, - isRuntimeLoading, - snapshot, -}: { - readonly focusRun?: MissionWallRun; - readonly isRuntimeLoading?: boolean; - readonly snapshot: MissionWallSnapshot; -}) { - const graph = snapshot.topology.workflowGraph; - const graphHasNodes = Boolean(graph?.nodes.length); - const selectedPublishedWorkflowWithoutRun = - focusRun?.hasRuntimeRun === false || - focusRun?.visibilityReason === "published_workflow"; - - return ( -
-
-
-

- {isRuntimeLoading - ? t("pages.missionwall.stepFlow", "Step Flow") - : focusRun - ? t( - "pages.missionwall.stageTitle", - "{workflowName} · Step Flow", - { workflowName: focusRun.workflowName }, - ) - : t("pages.missionwall.noFocusRun", "No focus run")} -

- {isRuntimeLoading ? null : ( -
- {focusRun - ? t( - "pages.missionwall.stageSubtitle", - "Team {teamName} · {memberName}", - { - memberName: - focusRun.entryMemberName || - t( - "pages.missionwall.unknownEntryMember", - "Unknown entry member", - ), - teamName: - focusRun.teamName || - t("pages.missionwall.unknownTeam", "Unknown team"), - }, - ) - : t( - "pages.missionwall.noFocusExplain", - "Select a workflow.", - )} -
- )} -
-
- {isRuntimeLoading ? ( - - ) : !focusRun || !graphHasNodes ? ( -
-
- {t("pages.missionwall.state.emptyKicker", "Waiting for runs")} -
-
- {focusRun - ? selectedPublishedWorkflowWithoutRun - ? t( - "pages.missionwall.state.publishedWorkflowTitle", - "No visible run", - ) - : t( - "pages.missionwall.state.auditPendingTitle", - "No step flow for this run yet", - ) - : t( - "pages.missionwall.state.emptyTitle", - "No published workflows are visible", - )} -
-
- ) : ( - - )} -
- ); -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/components/PublishedRunCard.tsx b/apps/aevatar-console-web/src/pages/MissionWall/components/PublishedRunCard.tsx deleted file mode 100644 index c8d7184341..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/components/PublishedRunCard.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from "react"; -import { t } from "@/shared/i18n/messages"; -import type { MissionWallRun } from "../models"; -import { - formatDuration, - formatRunStage, - formatRunStatus, - priorityTone, -} from "../missionWallFormatters"; - -function progressPercent(run: MissionWallRun): number { - if (!run.progress?.totalSteps) { - return 0; - } - - return Math.min( - 100, - Math.max( - 0, - Math.round((run.progress.completedSteps / run.progress.totalSteps) * 100), - ), - ); -} - -export function PublishedRunCard({ - focus, - onSelect, - run, -}: { - readonly focus: boolean; - readonly onSelect: (runId: string) => void; - readonly run: MissionWallRun; -}) { - const tone = priorityTone(run.priorityLevel, run.status); - const cardClassName = [ - "mission-wall-run-card", - `mission-wall-tone--${tone}`, - focus ? "mission-wall-run-card--focus" : "", - ] - .filter(Boolean) - .join(" "); - const completedSteps = run.progress?.completedSteps ?? 0; - const totalSteps = run.progress?.totalSteps ?? 0; - const hasRuntimeRun = run.hasRuntimeRun !== false; - - return ( - - ); -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/components/PublishedRunWindow.tsx b/apps/aevatar-console-web/src/pages/MissionWall/components/PublishedRunWindow.tsx deleted file mode 100644 index 41e49e13e5..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/components/PublishedRunWindow.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from "react"; -import { t } from "@/shared/i18n/messages"; -import type { MissionWallRun } from "../models"; -import { PublishedRunCard } from "./PublishedRunCard"; - -export function PublishedRunWindow({ - focusRunId, - onSelectRun, - runs, -}: { - readonly focusRunId?: string; - readonly onSelectRun: (runId: string) => void; - readonly runs: readonly MissionWallRun[]; -}) { - return ( - - ); -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/components/TopStatusStrip.tsx b/apps/aevatar-console-web/src/pages/MissionWall/components/TopStatusStrip.tsx deleted file mode 100644 index fa4e154692..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/components/TopStatusStrip.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from "react"; -import { t } from "@/shared/i18n/messages"; -import { ConsoleHeaderActions } from "@/shared/ui/ConsoleHeaderActions"; -import type { MissionWallSnapshot } from "../models"; -import { formatLiveStatus } from "../missionWallFormatters"; - -function Metric({ - label, - tone, - value, -}: { - readonly label: string; - readonly tone?: "live" | "red" | "yellow"; - readonly value: React.ReactNode; -}) { - const valueClassName = [ - "mission-wall-metric__value", - tone ? `mission-wall-metric__value--${tone}` : "", - ] - .filter(Boolean) - .join(" "); - - return ( -
- {label} - {value} -
- ); -} - -export function TopStatusStrip({ - snapshot, -}: { - readonly snapshot: MissionWallSnapshot; -}) { - return ( -
-
-
- {t( - "pages.missionwall.runtimeKicker", - "AEVATAR WORKFLOW RUNTIME", - )} -
-

- {t( - "pages.missionwall.title", - "Published Run Mission Wall", - )} -

-
- - - {formatLiveStatus(snapshot.live.status)} - - } - /> - - - - - -
- ); -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/components/WorkflowReplayCanvas.test.tsx b/apps/aevatar-console-web/src/pages/MissionWall/components/WorkflowReplayCanvas.test.tsx deleted file mode 100644 index 221a6957d0..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/components/WorkflowReplayCanvas.test.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import React from "react"; -import { WorkflowReplayCanvas } from "./WorkflowReplayCanvas"; - -type MissionWallWorkflowGraph = import("../models").MissionWallWorkflowGraph; - -const mockReactFlowRender = jest.fn(); -const mockFitView = jest.fn(); -let mockFrameId = 0; -const mockRequestAnimationFrame = jest.fn((callback: FrameRequestCallback): number => { - callback(0); - mockFrameId += 1; - return mockFrameId; -}); -const FIT_VIEW_ATTEMPT_COUNT = 4; - -jest.mock("@xyflow/react", () => { - const React = require("react"); - - return { - __esModule: true, - Background: () => null, - BackgroundVariant: { - Lines: "lines", - }, - Controls: () => null, - Handle: () => null, - MarkerType: { - ArrowClosed: "arrowclosed", - }, - Position: { - Left: "left", - Right: "right", - }, - ReactFlow: (props: any) => { - mockReactFlowRender(props); - React.useEffect(() => { - props.onInit?.({ - fitView: mockFitView, - }); - }, []); - return React.createElement( - "div", - null, - props.nodes?.map((node: any) => - React.createElement("div", { key: node.id }, node.data.node.stepId), - ), - ); - }, - }; -}); - -function graphFixture(): MissionWallWorkflowGraph { - return { - edges: [ - { - focused: false, - fromStepId: "validate_input", - id: "edge:validate_input:normalize_input:next", - kind: "next", - toStepId: "normalize_input", - traversed: true, - }, - { - focused: true, - fromStepId: "normalize_input", - id: "edge:normalize_input:capture_brief:next", - kind: "next", - toStepId: "capture_brief", - traversed: false, - }, - { - focused: true, - fromStepId: "capture_brief", - id: "edge:capture_brief:validate_report:next", - kind: "next", - toStepId: "validate_report", - traversed: false, - }, - ], - layout: { - direction: "right", - engine: "manual", - stepOverview: [ - { index: 0, status: "completed", stepId: "validate_input" }, - { index: 1, status: "completed", stepId: "normalize_input" }, - { index: 2, status: "active", stepId: "capture_brief" }, - { index: 3, status: "failed", stepId: "validate_report" }, - ], - totalSteps: 4, - viewportStepIds: [ - "validate_input", - "normalize_input", - "capture_brief", - "validate_report", - ], - windowEndIndex: 3, - windowStartIndex: 0, - }, - nodes: [ - { - focused: false, - id: "step:validate_input", - runId: "run-alpha", - status: "completed", - stepId: "validate_input", - stepType: "guard", - }, - { - focused: false, - id: "step:normalize_input", - runId: "run-alpha", - status: "completed", - stepId: "normalize_input", - stepType: "transform", - }, - { - focused: true, - id: "step:capture_brief", - runId: "run-alpha", - status: "active", - stepId: "capture_brief", - stepType: "assign", - }, - { - error: "max length exceeded", - focused: false, - id: "step:validate_report", - runId: "run-alpha", - status: "failed", - stepId: "validate_report", - stepType: "guard", - }, - ], - selectedStepId: "capture_brief", - }; -} - -describe("WorkflowReplayCanvas", () => { - beforeEach(() => { - jest.useRealTimers(); - Object.defineProperty(window, "requestAnimationFrame", { - configurable: true, - value: mockRequestAnimationFrame, - }); - Object.defineProperty(window, "cancelAnimationFrame", { - configurable: true, - value: jest.fn(), - }); - mockFitView.mockClear(); - mockReactFlowRender.mockClear(); - mockRequestAnimationFrame.mockClear(); - mockFrameId = 0; - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("renders the workflow flow with directional runtime edges", () => { - render(React.createElement(WorkflowReplayCanvas, { graph: graphFixture() })); - - expect(screen.getByText("validate_input")).toBeInTheDocument(); - expect(screen.getByText("validate_report")).toBeInTheDocument(); - expect(screen.queryByText(/Focused steps/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/readmodel/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/viewport steps/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/current execution/i)).not.toBeInTheDocument(); - - const reactFlowProps = mockReactFlowRender.mock.calls.at(-1)?.[0] as any; - expect(reactFlowProps.nodes).toHaveLength(4); - expect(reactFlowProps.edges).toHaveLength(3); - expect(reactFlowProps.nodeTypes).toHaveProperty("missionWallWorkflowStep"); - const nodeIds = new Set(reactFlowProps.nodes.map((node: any) => node.id)); - expect( - reactFlowProps.edges.every( - (edge: any) => nodeIds.has(edge.source) && nodeIds.has(edge.target), - ), - ).toBe(true); - - const focusedEdge = reactFlowProps.edges.find( - (edge: any) => edge.id === "edge:normalize_input:capture_brief:next", - ); - expect(focusedEdge.animated).toBe(false); - expect(focusedEdge.className).toBe("mission-wall-flow-edge--focused"); - expect(focusedEdge.markerEnd.type).toBe("arrowclosed"); - expect(focusedEdge.style.stroke).toBe("#2dd4bf"); - - const failedEdge = reactFlowProps.edges.find( - (edge: any) => edge.id === "edge:capture_brief:validate_report:next", - ); - expect(failedEdge.style.stroke).toBe("#f87171"); - expect(failedEdge.markerEnd.color).toBe("#f87171"); - }); - - it("refits after audit refreshes so nodes cannot remain stranded off-screen", async () => { - const { rerender } = render( - React.createElement(WorkflowReplayCanvas, { graph: graphFixture() }), - ); - - await waitFor(() => - expect(mockFitView).toHaveBeenCalledTimes(FIT_VIEW_ATTEMPT_COUNT), - ); - - const reactFlowProps = mockReactFlowRender.mock.calls.at(-1)?.[0] as any; - expect(reactFlowProps.fitView).toBe(true); - - reactFlowProps.onMove?.({ type: "mousemove" }, { x: 120, y: 0, zoom: 1 }); - - const refreshedGraph = { - ...graphFixture(), - nodes: graphFixture().nodes.map((node) => - node.stepId === "capture_brief" - ? { - ...node, - latencyMs: 1240, - outputPreview: "refreshed output", - } - : node, - ), - }; - - rerender( - React.createElement(WorkflowReplayCanvas, { graph: refreshedGraph }), - ); - - expect(mockFitView).toHaveBeenCalledTimes(FIT_VIEW_ATTEMPT_COUNT * 2); - }); - - it("refits the graph when the focused step changes after a viewport move", async () => { - const { rerender } = render( - React.createElement(WorkflowReplayCanvas, { graph: graphFixture() }), - ); - - await waitFor(() => - expect(mockFitView).toHaveBeenCalledTimes(FIT_VIEW_ATTEMPT_COUNT), - ); - - const reactFlowProps = mockReactFlowRender.mock.calls.at(-1)?.[0] as any; - reactFlowProps.onMove?.({ type: "mousemove" }, { x: 120, y: 0, zoom: 1 }); - - rerender( - React.createElement(WorkflowReplayCanvas, { - graph: { - ...graphFixture(), - selectedStepId: "validate_report", - }, - }), - ); - - expect(mockFitView).toHaveBeenCalledTimes(FIT_VIEW_ATTEMPT_COUNT * 2); - }); - - it("retries the initial fit across several animation frames so late node measurement cannot leave a blank grid", async () => { - render(React.createElement(WorkflowReplayCanvas, { graph: graphFixture() })); - - await waitFor(() => - expect(mockFitView).toHaveBeenCalledTimes(FIT_VIEW_ATTEMPT_COUNT), - ); - - expect(mockRequestAnimationFrame).toHaveBeenCalledTimes(FIT_VIEW_ATTEMPT_COUNT); - expect(mockFitView).toHaveBeenLastCalledWith( - expect.objectContaining({ - nodes: expect.arrayContaining([ - expect.objectContaining({ id: "step:validate_input" }), - expect.objectContaining({ id: "step:capture_brief" }), - ]), - }), - ); - }); - - it("keeps React Flow auto-fit queued for the focused window", () => { - render(React.createElement(WorkflowReplayCanvas, { graph: graphFixture() })); - - const reactFlowProps = mockReactFlowRender.mock.calls.at(-1)?.[0] as any; - - expect(reactFlowProps.fitView).toBe(true); - expect(reactFlowProps.fitViewOptions).toEqual({ - duration: 0, - maxZoom: 1.05, - minZoom: 0.36, - nodes: expect.arrayContaining([ - expect.objectContaining({ id: "step:validate_input" }), - expect.objectContaining({ id: "step:capture_brief" }), - ]), - padding: 0.24, - }); - }); - -}); diff --git a/apps/aevatar-console-web/src/pages/MissionWall/components/WorkflowReplayCanvas.tsx b/apps/aevatar-console-web/src/pages/MissionWall/components/WorkflowReplayCanvas.tsx deleted file mode 100644 index ee1137879b..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/components/WorkflowReplayCanvas.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import { - Background, - BackgroundVariant, - Controls, - MarkerType, - Position, - ReactFlow, - type Edge, - type Node, - type ReactFlowInstance, - type FitViewOptions, -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; -import React from "react"; -import type { - MissionWallWorkflowGraph, - MissionWallWorkflowStepEdge, - MissionWallWorkflowStepNode, -} from "../models"; -import { WorkflowStepNode } from "./WorkflowStepNode"; - -const NODE_WIDTH = 260; -const NODE_HEIGHT = 112; -const NODE_X_GAP = 340; -const NODE_Y_TOP = 118; -const NODE_Y_BOTTOM = 258; -const FOCUS_WINDOW_SIZE = 5; - -type WorkflowReplayNodeData = { - readonly node: MissionWallWorkflowStepNode; -}; - -type WorkflowReplayNode = Node; -type WorkflowReplayEdge = Edge<{ readonly edge: MissionWallWorkflowStepEdge }>; - -const nodeTypes = { - missionWallWorkflowStep: WorkflowStepNode, -}; - -const FIT_VIEW_BASE_OPTIONS = { - duration: 0, - maxZoom: 1.05, - minZoom: 0.36, - padding: 0.24, -} as const; -const FIT_VIEW_ATTEMPT_COUNT = 4; - -function focusWindowStepIds( - graph: MissionWallWorkflowGraph | undefined, -): string[] { - const nodes = graph?.nodes ?? []; - if (!nodes.length) { - return []; - } - - const selectedIndex = Math.max( - 0, - nodes.findIndex((node) => node.stepId === graph?.selectedStepId), - ); - const maxStart = Math.max(0, nodes.length - FOCUS_WINDOW_SIZE); - const start = - nodes.length <= FOCUS_WINDOW_SIZE - ? 0 - : Math.min(Math.max(0, selectedIndex - 2), maxStart); - - return nodes.slice(start, start + FOCUS_WINDOW_SIZE).map((node) => node.id); -} - -function graphIdentityKey(graph: MissionWallWorkflowGraph | undefined): string { - const nodeIds = (graph?.nodes ?? []).map((node) => node.id).join("|"); - const runId = graph?.nodes.find((node) => node.runId)?.runId ?? ""; - return `${runId}:${nodeIds}`; -} - -function graphViewportKey(graph: MissionWallWorkflowGraph | undefined): string { - return `${graphIdentityKey(graph)}:${graph?.selectedStepId ?? ""}`; -} - -function graphRevisionKey(graph: MissionWallWorkflowGraph | undefined): string { - return (graph?.nodes ?? []) - .map( - (node) => - [ - node.id, - node.status, - node.focused ? "focused" : "", - node.latencyMs ?? "", - node.outputPreview ?? "", - node.error ?? "", - ].join(":"), - ) - .join("|"); -} - -function toFlowNodes(graph: MissionWallWorkflowGraph | undefined): WorkflowReplayNode[] { - return (graph?.nodes ?? []).map((node, index) => ({ - data: { node }, - id: node.id, - position: { - x: index * NODE_X_GAP, - y: - node.status === "failed" || node.status === "waiting" - ? NODE_Y_BOTTOM - : index % 2 === 0 - ? NODE_Y_TOP - : NODE_Y_BOTTOM, - }, - sourcePosition: Position.Right, - targetPosition: Position.Left, - type: "missionWallWorkflowStep", - })); -} - -function edgeTone( - edge: MissionWallWorkflowStepEdge, - targetNode?: MissionWallWorkflowStepNode, -): { - readonly animated: boolean; - readonly color: string; - readonly dash?: string; - readonly width: number; -} { - if (targetNode?.status === "failed") { - return { - animated: false, - color: "#f87171", - width: 4, - }; - } - - if (edge.traversed) { - return { - animated: false, - color: "#86efac", - width: 3, - }; - } - - if (edge.focused) { - return { - animated: false, - color: "#2dd4bf", - width: 4, - }; - } - - if (edge.kind === "branch") { - return { - animated: false, - color: "#fbbf24", - dash: "7 6", - width: 3, - }; - } - - return { - animated: false, - color: "rgba(174, 187, 180, 0.44)", - dash: "8 7", - width: 2.4, - }; -} - -function toFlowEdges(graph: MissionWallWorkflowGraph | undefined): WorkflowReplayEdge[] { - const nodeByStepId = new Map( - (graph?.nodes ?? []).map((node) => [node.stepId, node] as const), - ); - const nodeById = new Map( - (graph?.nodes ?? []).map((node) => [node.id, node] as const), - ); - const resolveFlowNodeId = (stepOrNodeId: string): string | undefined => - nodeByStepId.get(stepOrNodeId)?.id ?? nodeById.get(stepOrNodeId)?.id; - - return (graph?.edges ?? []).flatMap((edge) => { - const source = resolveFlowNodeId(edge.fromStepId); - const target = resolveFlowNodeId(edge.toStepId); - if (!source || !target) { - return []; - } - - const targetNode = nodeByStepId.get(edge.toStepId) ?? nodeById.get(edge.toStepId); - const tone = edgeTone(edge, targetNode); - - return [{ - animated: tone.animated, - className: edge.focused ? "mission-wall-flow-edge--focused" : undefined, - data: { edge }, - id: edge.id, - label: edge.branchLabel, - labelBgBorderRadius: 6, - labelBgPadding: [8, 4], - labelBgStyle: { - fill: "rgba(9, 17, 15, 0.92)", - }, - labelStyle: { - fill: tone.color, - fontSize: 12, - fontWeight: 760, - }, - markerEnd: { - color: tone.color, - height: 16, - type: MarkerType.ArrowClosed, - width: 16, - }, - source, - style: { - filter: - targetNode?.status === "failed" || edge.focused - ? `drop-shadow(0 0 10px ${tone.color}66)` - : undefined, - stroke: tone.color, - strokeDasharray: tone.dash, - strokeWidth: tone.width, - }, - target, - type: "smoothstep", - zIndex: edge.focused || targetNode?.status === "failed" ? 8 : 4, - }]; - }); -} - -export function WorkflowReplayCanvas({ - graph, -}: { - readonly graph?: MissionWallWorkflowGraph; -}) { - const [flowInstance, setFlowInstance] = - React.useState | null>( - null, - ); - const nodes = React.useMemo(() => toFlowNodes(graph), [graph]); - const edges = React.useMemo(() => toFlowEdges(graph), [graph]); - const focusNodeIds = React.useMemo(() => focusWindowStepIds(graph), [graph]); - const identityKey = React.useMemo(() => graphIdentityKey(graph), [graph]); - const viewportKey = React.useMemo(() => graphViewportKey(graph), [graph]); - const revisionKey = React.useMemo(() => graphRevisionKey(graph), [graph]); - const fitViewKey = `${viewportKey}:${revisionKey}`; - const lastFitKeyRef = React.useRef(undefined); - const fitNodeIds = React.useMemo( - () => (focusNodeIds.length ? focusNodeIds : nodes.map((node) => node.id)), - [focusNodeIds, nodes], - ); - const fitViewOptions = React.useMemo>( - () => ({ - ...FIT_VIEW_BASE_OPTIONS, - nodes: fitNodeIds.map((id) => ({ id })), - }), - [fitNodeIds], - ); - - React.useLayoutEffect(() => { - if (!flowInstance || !nodes.length) { - return undefined; - } - if (lastFitKeyRef.current === fitViewKey) { - return undefined; - } - - const readyFlowInstance = flowInstance; - lastFitKeyRef.current = fitViewKey; - - const focusNodes = focusNodeIds.length - ? nodes.filter((node) => focusNodeIds.includes(node.id)) - : nodes; - const fitNodes = focusNodes.length ? focusNodes : nodes; - const animationFrameIds: number[] = []; - const timeoutIds: number[] = []; - let cancelled = false; - let attemptCount = 0; - - function scheduleFit() { - if (typeof window.requestAnimationFrame === "function") { - const frameId = window.requestAnimationFrame(fit); - animationFrameIds.push(frameId); - return; - } - - const timeoutId = window.setTimeout(fit, 16); - timeoutIds.push(timeoutId); - } - - function fit() { - if (cancelled) { - return; - } - - void readyFlowInstance.fitView({ - ...FIT_VIEW_BASE_OPTIONS, - nodes: fitNodes, - }); - attemptCount += 1; - - if (attemptCount < FIT_VIEW_ATTEMPT_COUNT) { - scheduleFit(); - } - } - - scheduleFit(); - - return () => { - cancelled = true; - animationFrameIds.forEach((frameId) => { - window.cancelAnimationFrame(frameId); - }); - timeoutIds.forEach((timeoutId) => { - window.clearTimeout(timeoutId); - }); - }; - }, [fitViewKey, flowInstance, focusNodeIds, nodes]); - - return ( -
-
- - - - -
-
- ); -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/components/WorkflowStepNode.tsx b/apps/aevatar-console-web/src/pages/MissionWall/components/WorkflowStepNode.tsx deleted file mode 100644 index 58f28320e6..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/components/WorkflowStepNode.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { - Handle, - Position, - type NodeProps, - type Node, -} from "@xyflow/react"; -import React from "react"; -import type { MissionWallWorkflowStepNode } from "../models"; -import { - formatLatency, - formatStepStatus, - stepTone, -} from "../missionWallFormatters"; - -type WorkflowReplayNodeData = { - readonly node: MissionWallWorkflowStepNode; -}; - -type WorkflowReplayNode = Node; - -function stepInitial(stepType: string): string { - if (stepType === "connector_call" || stepType === "tool_call") { - return "API"; - } - - if (stepType === "human_approval") { - return "HM"; - } - - if (stepType === "emit") { - return "EV"; - } - - if (stepType === "retrieve_facts") { - return "DB"; - } - - return "AI"; -} - -export function WorkflowStepNode({ - data, -}: NodeProps) { - const node = data.node; - const tone = stepTone(node.status); - const latency = formatLatency(node.latencyMs); - const className = [ - "mission-wall-step-node", - node.focused ? "mission-wall-step-node--focused" : "", - node.status === "active" ? "mission-wall-step-node--active" : "", - node.status === "waiting" ? "mission-wall-step-node--waiting" : "", - node.status === "failed" ? "mission-wall-step-node--failed" : "", - ] - .filter(Boolean) - .join(" "); - - return ( -
- -
- - {stepInitial(node.stepType)} - -
-
{node.stepId}
-
{node.stepType}
-
- - {formatStepStatus(node.status)} - -
-
- {node.targetRole ? ( - {node.targetRole} - ) : ( - {node.parametersSummary || node.stepType} - )} - {latency ? {latency} : null} -
- -
- ); -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/hooks/useMissionWallData.ts b/apps/aevatar-console-web/src/pages/MissionWall/hooks/useMissionWallData.ts deleted file mode 100644 index ae738fac0b..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/hooks/useMissionWallData.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import React from "react"; -import { t } from "@/shared/i18n/messages"; -import { - getLocationSnapshot, - subscribeToLocationChanges, -} from "@/shared/navigation/history"; -import { resolveStudioScopeContext } from "@/shared/scope/context"; -import { studioApi } from "@/shared/studio/api"; -import type { StudioWorkflowBoardSnapshot } from "@/shared/studio/models"; -import type { - MissionWallLiveState, - MissionWallSource, -} from "../models"; -import { - buildMissionWallSourceFromWorkflowBoardSnapshot, - freshnessSecondsSince, -} from "../missionWallRuntimeData"; - -type MissionWallRouteOptions = { - readonly focusRunId?: string; - readonly scopeId?: string; - readonly teamId?: string; -}; - -export const MISSION_WALL_RUN_REFETCH_INTERVAL_MS = 5_000; -export const MISSION_WALL_SNAPSHOT_TAKE = 100; -export const MISSION_WALL_STALE_SNAPSHOT_FALLBACK_MS = 60_000; - -type MissionWallSnapshotCache = { - readonly cachedAtMs: number; - readonly key: string; - readonly snapshot: StudioWorkflowBoardSnapshot; -}; - -export interface MissionWallRuntimeData { - readonly buildSource: () => MissionWallSource; - readonly generatedAt: string; - readonly isLoading: boolean; - readonly live: MissionWallLiveState; - readonly nowMs: number; - readonly routeFocusRunId?: string; - readonly scopeId?: string; - readonly teamId?: string; -} - -function trimOptional(value: string | null | undefined): string { - return value?.trim() ?? ""; -} - -function parseRouteOptions(locationSnapshot: string): MissionWallRouteOptions { - const queryIndex = locationSnapshot.indexOf("?"); - const hashIndex = locationSnapshot.indexOf("#"); - const search = - queryIndex >= 0 - ? locationSnapshot.slice( - queryIndex, - hashIndex > queryIndex ? hashIndex : undefined, - ) - : ""; - const params = new URLSearchParams(search); - - return { - focusRunId: trimOptional(params.get("focusRunId")) || undefined, - scopeId: trimOptional(params.get("scopeId")) || undefined, - teamId: trimOptional(params.get("teamId")) || undefined, - }; -} - -function missionWallRefetchInterval(intervalMs: number): number | false { - const isTest = - typeof process !== "undefined" && process.env.NODE_ENV === "test"; - return isTest ? false : intervalMs; -} - -function workflowBoardSnapshotMemberCount( - snapshot: StudioWorkflowBoardSnapshot | undefined, -): number { - return ( - snapshot?.teams.reduce((count, team) => count + team.members.length, 0) ?? 0 - ); -} - -function buildSnapshotCacheKey(input: { - readonly scopeId?: string; - readonly teamId?: string; -}): string { - return `${input.scopeId ?? ""}:${input.teamId ?? ""}`; -} - -function useNowMs(): number { - const [nowMs, setNowMs] = React.useState(() => Date.now()); - - React.useEffect(() => { - const intervalId = window.setInterval(() => { - setNowMs(Date.now()); - }, 1000); - - return () => { - window.clearInterval(intervalId); - }; - }, []); - - return nowMs; -} - -function buildLiveState(input: { - readonly allRunsLoaded: boolean; - readonly generatedAt: string; - readonly hasCriticalError: boolean; - readonly hasPartialRunError: boolean; - readonly hasSnapshotUnavailable: boolean; - readonly isLoading: boolean; - readonly latestObservedAt?: string; - readonly nowMs: number; - readonly runCount: number; - readonly scopeId?: string; -}): MissionWallLiveState { - const durableFreshnessSeconds = freshnessSecondsSince( - input.latestObservedAt, - input.nowMs, - ); - - if (input.isLoading) { - return { - durableFreshnessSeconds, - lastObservedAt: input.latestObservedAt, - message: t( - "pages.missionwall.liveState.loading", - "Loading workflow board snapshot.", - ), - status: "idle", - }; - } - - if (input.hasCriticalError || !input.scopeId) { - return { - durableFreshnessSeconds, - lastObservedAt: input.latestObservedAt, - message: t( - "pages.missionwall.liveState.scopeUnavailable", - "Mission wall could not load the authenticated scope.", - ), - status: "disconnected", - }; - } - - if (input.hasSnapshotUnavailable) { - return { - durableFreshnessSeconds, - lastObservedAt: input.latestObservedAt, - message: t( - "pages.missionwall.liveState.snapshotUnavailable", - "Mission wall snapshot could not be loaded.", - ), - status: "disconnected", - }; - } - - if (input.hasPartialRunError) { - return { - durableFreshnessSeconds, - lastObservedAt: input.latestObservedAt, - message: t( - "pages.missionwall.liveState.partialRunError", - "Mission wall snapshot could not be loaded.", - ), - status: "degraded", - }; - } - - if (input.allRunsLoaded && input.runCount === 0) { - return { - durableFreshnessSeconds, - lastObservedAt: input.latestObservedAt, - message: t( - "pages.missionwall.liveState.empty", - "No workflow board members are visible yet.", - ), - status: "idle", - }; - } - - return { - durableFreshnessSeconds, - lastObservedAt: input.latestObservedAt, - message: t( - "pages.missionwall.liveState.connected", - "Connected to workflow board read model.", - ), - status: "live", - }; -} - -export function useMissionWallRuntimeData(): MissionWallRuntimeData { - const nowMs = useNowMs(); - const locationSnapshot = React.useSyncExternalStore( - subscribeToLocationChanges, - getLocationSnapshot, - getLocationSnapshot, - ); - const routeOptions = React.useMemo( - () => parseRouteOptions(locationSnapshot), - [locationSnapshot], - ); - const authSessionQuery = useQuery({ - queryFn: () => studioApi.getAuthSession(), - queryKey: ["mission-wall", "auth-session"], - retry: false, - }); - const sessionScopeContext = React.useMemo( - () => resolveStudioScopeContext(authSessionQuery.data), - [authSessionQuery.data], - ); - const scopeId = routeOptions.scopeId ?? sessionScopeContext?.scopeId; - const snapshotCacheRef = - React.useRef(undefined); - const snapshotQuery = useQuery({ - enabled: Boolean(scopeId), - queryFn: () => - studioApi.getWorkflowBoardSnapshot(scopeId ?? "", { - take: MISSION_WALL_SNAPSHOT_TAKE, - teamId: routeOptions.teamId, - }), - queryKey: [ - "mission-wall", - "workflow-board-snapshot", - scopeId, - routeOptions.teamId, - ], - refetchInterval: missionWallRefetchInterval( - MISSION_WALL_RUN_REFETCH_INTERVAL_MS, - ), - refetchIntervalInBackground: true, - retry: false, - }); - const snapshotCacheKey = React.useMemo( - () => - buildSnapshotCacheKey({ - scopeId, - teamId: routeOptions.teamId, - }), - [routeOptions.teamId, scopeId], - ); - const queriedSnapshot = snapshotQuery.data; - const queriedRunCount = workflowBoardSnapshotMemberCount(queriedSnapshot); - const hasCachedSnapshotForRoute = - snapshotCacheRef.current?.key === snapshotCacheKey; - const cachedSnapshot = hasCachedSnapshotForRoute - ? snapshotCacheRef.current?.snapshot - : undefined; - const shouldUseCachedSnapshot = - Boolean(cachedSnapshot) && - snapshotCacheRef.current !== undefined && - nowMs - snapshotCacheRef.current.cachedAtMs <= - MISSION_WALL_STALE_SNAPSHOT_FALLBACK_MS && - ((snapshotQuery.isSuccess && queriedRunCount === 0) || snapshotQuery.isError); - const effectiveSnapshot = shouldUseCachedSnapshot - ? cachedSnapshot - : snapshotQuery.isError - ? undefined - : queriedSnapshot; - React.useEffect(() => { - if ( - !snapshotQuery.isSuccess || - snapshotQuery.dataUpdatedAt <= 0 || - !queriedSnapshot || - queriedRunCount === 0 - ) { - return; - } - - snapshotCacheRef.current = { - cachedAtMs: snapshotQuery.dataUpdatedAt, - key: snapshotCacheKey, - snapshot: queriedSnapshot, - }; - }, [ - queriedRunCount, - queriedSnapshot, - snapshotCacheKey, - snapshotQuery.dataUpdatedAt, - snapshotQuery.isSuccess, - ]); - const generatedAt = React.useMemo( - () => effectiveSnapshot?.generatedAt ?? new Date().toISOString(), - [ - authSessionQuery.dataUpdatedAt, - effectiveSnapshot?.generatedAt, - snapshotQuery.dataUpdatedAt, - snapshotQuery.errorUpdatedAt, - snapshotQuery.fetchStatus, - ], - ); - const latestObservedAt = - trimOptional(effectiveSnapshot?.lastNodeUpdatedAt) || undefined; - const runCount = workflowBoardSnapshotMemberCount(effectiveSnapshot); - const hasCriticalError = authSessionQuery.isError; - const hasStaleSnapshotFallback = Boolean(shouldUseCachedSnapshot); - const hasEffectiveSnapshot = Boolean(effectiveSnapshot); - const hasSnapshotUnavailable = - snapshotQuery.isError && !hasStaleSnapshotFallback && !hasEffectiveSnapshot; - const isLoading = - authSessionQuery.isLoading || - (Boolean(scopeId) && snapshotQuery.isLoading && !effectiveSnapshot); - const live = React.useMemo( - () => - buildLiveState({ - allRunsLoaded: snapshotQuery.isSuccess || snapshotQuery.isError, - generatedAt, - hasCriticalError, - hasPartialRunError: snapshotQuery.isError || hasStaleSnapshotFallback, - hasSnapshotUnavailable, - isLoading, - latestObservedAt, - nowMs, - runCount, - scopeId, - }), - [ - generatedAt, - hasCriticalError, - hasSnapshotUnavailable, - hasStaleSnapshotFallback, - isLoading, - latestObservedAt, - nowMs, - runCount, - snapshotQuery.isError, - snapshotQuery.isSuccess, - scopeId, - ], - ); - const buildSource = React.useCallback( - () => - buildMissionWallSourceFromWorkflowBoardSnapshot({ - generatedAt, - live, - snapshot: effectiveSnapshot, - }), - [effectiveSnapshot, generatedAt, live], - ); - - return { - buildSource, - generatedAt, - isLoading, - live, - nowMs, - routeFocusRunId: routeOptions.focusRunId, - scopeId, - teamId: routeOptions.teamId, - }; -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/hooks/usePublishedRunWindow.test.ts b/apps/aevatar-console-web/src/pages/MissionWall/hooks/usePublishedRunWindow.test.ts deleted file mode 100644 index b60c9140af..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/hooks/usePublishedRunWindow.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { MissionWallRun } from "../models"; -import { - mergePublishedRunWindowRuns, - reducePublishedRunWindowModel, -} from "./usePublishedRunWindow"; - -function run( - runId: string, - overrides: Partial = {}, -): MissionWallRun { - return { - focusPriority: 500, - id: runId, - priorityLevel: "none", - progress: { - completedSteps: 0, - totalSteps: 0, - }, - runId, - status: "running", - visibilityReason: "running", - workflowName: runId, - ...overrides, - }; -} - -describe("Published Run Window state", () => { - it("adds newly observed runs to the top without moving existing runs", () => { - const first = run("run-first"); - const second = run("run-second"); - const third = run("run-third"); - - expect( - mergePublishedRunWindowRuns([first, second], [third, first, second]).map( - (item) => item.runId, - ), - ).toEqual(["run-third", "run-first", "run-second"]); - }); - - it("keeps the current selection when no new run arrives", () => { - const first = run("run-first"); - const second = run("run-second"); - - const model = reducePublishedRunWindowModel( - { - manualSelection: false, - runs: [first, second], - selectedRunId: "run-second", - }, - [first, second], - ); - - expect(model.runs.map((item) => item.runId)).toEqual([ - "run-first", - "run-second", - ]); - expect(model.selectedRunId).toBe("run-second"); - }); - - it("keeps the currently selected live run when a new live run appears", () => { - const first = run("run-first"); - const second = run("run-second"); - const third = run("run-third"); - - const model = reducePublishedRunWindowModel( - { - manualSelection: true, - runs: [first, second], - selectedRunId: "run-second", - }, - [third, first, second], - ); - - expect(model.runs.map((item) => item.runId)).toEqual([ - "run-third", - "run-first", - "run-second", - ]); - expect(model.selectedRunId).toBe("run-second"); - }); - - it("moves automatic focus to the preferred run when a new live run appears", () => { - const first = run("run-first"); - const second = run("run-second"); - const third = run("run-third"); - - const model = reducePublishedRunWindowModel( - { - manualSelection: false, - runs: [first, second], - selectedRunId: "run-second", - }, - [third, first, second], - "run-third", - ); - - expect(model.runs.map((item) => item.runId)).toEqual([ - "run-third", - "run-first", - "run-second", - ]); - expect(model.selectedRunId).toBe("run-third"); - }); - - it("selects a newly observed run after the current selection is done", () => { - const first = run("run-first"); - const second = run("run-second", { - status: "completed", - visibilityReason: "recently_completed", - }); - const third = run("run-third"); - - const model = reducePublishedRunWindowModel( - { - manualSelection: false, - runs: [first, second], - selectedRunId: "run-second", - }, - [third, first, second], - ); - - expect(model.runs.map((item) => item.runId)).toEqual([ - "run-third", - "run-first", - "run-second", - ]); - expect(model.selectedRunId).toBe("run-third"); - }); - - it("updates existing run progress without moving the manual list order", () => { - const first = run("run-first"); - const second = run("run-second"); - const updatedFirst: MissionWallRun = { - ...first, - durationMs: 8200, - progress: { - completedSteps: 5, - totalSteps: 5, - }, - }; - - const model = reducePublishedRunWindowModel( - { - manualSelection: false, - runs: [first, second], - selectedRunId: "run-second", - }, - [updatedFirst, second], - ); - - expect(model.runs.map((item) => item.runId)).toEqual([ - "run-first", - "run-second", - ]); - expect(model.runs[0].progress).toEqual({ - completedSteps: 5, - totalSteps: 5, - }); - expect(model.runs[0].durationMs).toBe(8200); - expect(model.selectedRunId).toBe("run-second"); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/MissionWall/hooks/usePublishedRunWindow.ts b/apps/aevatar-console-web/src/pages/MissionWall/hooks/usePublishedRunWindow.ts deleted file mode 100644 index 862a3ec715..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/hooks/usePublishedRunWindow.ts +++ /dev/null @@ -1,149 +0,0 @@ -import React from "react"; -import type { MissionWallRun } from "../models"; - -export interface PublishedRunWindowState { - readonly runs: readonly MissionWallRun[]; - readonly selectRun: (runId: string) => void; - readonly selectedRun?: MissionWallRun; - readonly selectedRunId?: string; -} - -interface PublishedRunWindowModel { - readonly manualSelection: boolean; - readonly runs: readonly MissionWallRun[]; - readonly selectedRunId?: string; -} - -function sameWindowRuns( - leftRuns: readonly MissionWallRun[], - rightRuns: readonly MissionWallRun[], -): boolean { - if (leftRuns.length !== rightRuns.length) { - return false; - } - - return leftRuns.every((leftRun, index) => { - const rightRun = rightRuns[index]; - return ( - rightRun !== undefined && - leftRun.runId === rightRun.runId && - leftRun.status === rightRun.status && - leftRun.updatedAt === rightRun.updatedAt && - leftRun.durationMs === rightRun.durationMs && - leftRun.progress?.completedSteps === rightRun.progress?.completedSteps && - leftRun.progress?.totalSteps === rightRun.progress?.totalSteps && - leftRun.currentStepId === rightRun.currentStepId && - leftRun.currentStepLabel === rightRun.currentStepLabel && - leftRun.stateVersion === rightRun.stateVersion && - leftRun.lastEventId === rightRun.lastEventId - ); - }); -} - -function isLiveRun(run: MissionWallRun | undefined): boolean { - return ( - run?.status === "running" || - run?.status === "waiting" || - run?.status === "retrying" - ); -} - -export function mergePublishedRunWindowRuns( - previousRuns: readonly MissionWallRun[], - nextRuns: readonly MissionWallRun[], -): MissionWallRun[] { - const nextRunById = new Map(nextRuns.map((run) => [run.runId, run])); - const previousRunIds = new Set(previousRuns.map((run) => run.runId)); - const newRuns = nextRuns.filter((run) => !previousRunIds.has(run.runId)); - const retainedRuns = previousRuns - .map((run) => nextRunById.get(run.runId)) - .filter((run): run is MissionWallRun => Boolean(run)); - - return [...newRuns, ...retainedRuns]; -} - -export function reducePublishedRunWindowModel( - previousModel: PublishedRunWindowModel, - nextRuns: readonly MissionWallRun[], - preferredRunId?: string, -): PublishedRunWindowModel { - const previousRunIds = new Set(previousModel.runs.map((run) => run.runId)); - const nextWindowRuns = mergePublishedRunWindowRuns( - previousModel.runs, - nextRuns, - ); - const newlyAddedRun = nextRuns.find( - (run) => run.hasRuntimeRun !== false && !previousRunIds.has(run.runId), - ); - const selectedRunStillVisible = - previousModel.selectedRunId && - nextWindowRuns.some((run) => run.runId === previousModel.selectedRunId); - const selectedRun = previousModel.selectedRunId - ? nextWindowRuns.find((run) => run.runId === previousModel.selectedRunId) - : undefined; - const preferredRun = preferredRunId - ? nextWindowRuns.find((run) => run.runId === preferredRunId) - : undefined; - const firstLiveRun = nextWindowRuns.find(isLiveRun); - const manualSelection = - previousModel.manualSelection && Boolean(selectedRunStillVisible); - - const nextModel = { - manualSelection, - runs: nextWindowRuns, - selectedRunId: - (manualSelection ? previousModel.selectedRunId : undefined) ?? - preferredRun?.runId ?? - (selectedRunStillVisible && isLiveRun(selectedRun) - ? previousModel.selectedRunId - : undefined) ?? - firstLiveRun?.runId ?? - newlyAddedRun?.runId ?? - (selectedRunStillVisible ? previousModel.selectedRunId : undefined) ?? - nextWindowRuns[0]?.runId, - }; - - if ( - previousModel.manualSelection === nextModel.manualSelection && - previousModel.selectedRunId === nextModel.selectedRunId && - sameWindowRuns(previousModel.runs, nextModel.runs) - ) { - return previousModel; - } - - return nextModel; -} - -export function usePublishedRunWindow( - runs: readonly MissionWallRun[], - initialSelectedRunId?: string, -): PublishedRunWindowState { - const [model, setModel] = React.useState(() => ({ - manualSelection: false, - runs, - selectedRunId: initialSelectedRunId ?? runs[0]?.runId, - })); - - React.useEffect(() => { - setModel((previousModel) => - reducePublishedRunWindowModel(previousModel, runs, initialSelectedRunId), - ); - }, [initialSelectedRunId, runs]); - - const selectedRun = - model.runs.find((run) => run.runId === model.selectedRunId) ?? - model.runs[0]; - - return { - runs: model.runs, - selectRun: (runId) => { - setModel((previousModel) => ({ - ...previousModel, - manualSelection: true, - selectedRunId: runId, - })); - }, - selectedRun, - selectedRunId: selectedRun?.runId, - }; -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/index.test.tsx b/apps/aevatar-console-web/src/pages/MissionWall/index.test.tsx deleted file mode 100644 index 217e600d20..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/index.test.tsx +++ /dev/null @@ -1,1088 +0,0 @@ -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; -import React from "react"; -import { scopeRuntimeApi } from "@/shared/api/scopeRuntimeApi"; -import { - clearStoredAuthSession, - persistAuthSession, -} from "@/shared/auth/session"; -import { studioApi } from "@/shared/studio/api"; -import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; -import MissionWallPage from "./index"; -import { MISSION_WALL_STALE_SNAPSHOT_FALLBACK_MS } from "./hooks/useMissionWallData"; - -type StudioAuthSession = import("@/shared/studio/models").StudioAuthSession; -type StudioMemberSummary = import("@/shared/studio/models").StudioMemberSummary; -type StudioWorkflowBoardSnapshot = - import("@/shared/studio/models").StudioWorkflowBoardSnapshot; - -jest.mock("@/shared/api/scopeRuntimeApi", () => ({ - scopeRuntimeApi: { - getMemberRunAudit: jest.fn(), - getServiceRunAudit: jest.fn(), - listMemberRuns: jest.fn(), - listServiceRuns: jest.fn(), - listServices: jest.fn(), - }, -})); - -jest.mock("@/shared/studio/api", () => ({ - studioApi: { - getAuthSession: jest.fn(), - getWorkflowBoardSnapshot: jest.fn(), - listMembers: jest.fn(), - listTeams: jest.fn(), - }, -})); - -const NOW = "2026-06-30T05:00:00.000Z"; - -function workflowMember(input: { - readonly displayName: string; - readonly memberId: string; - readonly publishedServiceId: string; - readonly teamId?: string; - readonly workflowId: string; -}): StudioMemberSummary { - return { - createdAt: "2026-06-29T01:00:00.000Z", - description: "", - displayName: input.displayName, - implementationKind: "workflow", - implementationRef: { - implementationKind: "workflow", - workflowId: input.workflowId, - workflowRevision: "wf-rev-1", - }, - lastBoundRevisionId: "member-rev-1", - lifecycleStage: "bind_ready", - memberId: input.memberId, - publishedServiceId: input.publishedServiceId, - scopeId: "scope-real", - teamId: input.teamId, - updatedAt: "2026-06-29T01:15:00.000Z", - }; -} - -function workflowBoardSnapshot( - members: readonly StudioWorkflowBoardSnapshot["teams"][number]["members"][number][], - overrides?: Partial, -): StudioWorkflowBoardSnapshot { - return { - counts: { - completed: members.filter((member) => member.executionStatus === "completed") - .length, - failed: members.filter((member) => member.executionStatus === "failed") - .length, - retrying: members.filter((member) => member.executionStatus === "retrying") - .length, - running: members.filter((member) => member.executionStatus === "running") - .length, - waiting: members.filter((member) => member.executionStatus === "waiting") - .length, - }, - generatedAt: NOW, - lastNodeUpdatedAt: "2026-06-30T04:59:20.000Z", - scopeId: "scope-real", - teams: [ - { - members, - teamId: "team-alpha", - teamName: "Alpha Team", - totalMemberCount: 8, - }, - ], - watermark: "workflow-board:v2:test:facts", - ...overrides, - }; -} - -function workflowBoardCurrentNodeStatus( - status: StudioWorkflowBoardSnapshot["teams"][number]["members"][number]["executionStatus"], -): NonNullable< - StudioWorkflowBoardSnapshot["teams"][number]["members"][number]["currentNode"] ->["status"] { - switch (status) { - case "completed": - case "failed": - case "running": - case "waiting": - return status; - default: - return "unknown"; - } -} - -function workflowBoardMember(input: { - readonly actorId: string; - readonly completedSteps: number; - readonly currentNode?: NonNullable< - StudioWorkflowBoardSnapshot["teams"][number]["members"][number]["currentNode"] - > | null; - readonly currentNodeStatus?: NonNullable< - StudioWorkflowBoardSnapshot["teams"][number]["members"][number]["currentNode"] - >["status"]; - readonly durationMs?: number; - readonly executionStatus: StudioWorkflowBoardSnapshot["teams"][number]["members"][number]["executionStatus"]; - readonly lastNodeUpdatedAt: string; - readonly member: StudioMemberSummary; - readonly runId: string; - readonly totalSteps: number; - readonly workflowName: string; -}): StudioWorkflowBoardSnapshot["teams"][number]["members"][number] { - const currentNodeName = - input.currentNodeStatus === "waiting" || input.executionStatus === "waiting" - ? "approval_gate" - : "Current"; - - return { - actorId: input.actorId, - completedNodes: Array.from({ length: input.completedSteps }, (_, index) => ({ - completedAt: "2026-06-30T04:58:20.000Z", - durationMs: 1000, - name: `Completed ${index + 1}`, - nodeId: `completed_${index + 1}`, - })), - currentExecutionId: input.runId, - currentNode: - input.currentNode === null - ? null - : input.currentNode ?? { - durationMs: input.durationMs, - name: currentNodeName, - nodeId: currentNodeName, - startedAt: "2026-06-30T04:58:00.000Z", - status: - input.currentNodeStatus ?? - workflowBoardCurrentNodeStatus(input.executionStatus), - updatedAt: input.lastNodeUpdatedAt, - }, - displayName: input.member.displayName, - executionAvailability: "available", - executionStatus: input.executionStatus, - failedNodes: - input.executionStatus === "failed" - ? [ - { - failedAt: input.lastNodeUpdatedAt, - name: "record_validation", - nodeId: "record_validation", - }, - ] - : [], - lastNodeUpdatedAt: input.lastNodeUpdatedAt, - memberId: input.member.memberId, - pendingNodes: - input.currentNodeStatus === "waiting" || input.executionStatus === "waiting" - ? [ - { - name: "approval_gate", - nodeId: "approval_gate", - reason: "waiting for input", - status: "waiting", - }, - ] - : [], - progress: { - completedSteps: input.completedSteps, - totalSteps: input.totalSteps, - }, - publishedServiceId: input.member.publishedServiceId, - roleSummary: input.member.displayName, - workflowId: input.member.implementationRef?.workflowId, - workflowName: input.workflowName, - }; -} - -describe("MissionWallPage", () => { - const alphaMember = workflowMember({ - displayName: "Alpha member", - memberId: "m-alpha", - publishedServiceId: "svc-alpha", - teamId: "team-alpha", - workflowId: "wf-alpha-draft", - }); - const betaMember = workflowMember({ - displayName: "Beta member", - memberId: "m-beta", - publishedServiceId: "svc-beta", - teamId: "team-alpha", - workflowId: "wf-beta-draft", - }); - const idleMember = workflowMember({ - displayName: "Idle member", - memberId: "m-idle", - publishedServiceId: "svc-idle", - teamId: "team-alpha", - workflowId: "wf-idle-draft", - }); - - beforeEach(() => { - clearStoredAuthSession(); - jest.clearAllMocks(); - window.history.replaceState({}, "", "/runtime/mission-wall"); - jest.spyOn(Date, "now").mockReturnValue(Date.parse(NOW)); - (studioApi.getAuthSession as jest.Mock).mockResolvedValue({ - authenticated: true, - enabled: true, - scopeId: "scope-real", - scopeSource: "session", - } satisfies StudioAuthSession); - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockResolvedValue( - workflowBoardSnapshot([ - workflowBoardMember({ - actorId: "actor-alpha-run", - completedSteps: 1, - currentNodeStatus: "waiting", - executionStatus: "running", - lastNodeUpdatedAt: "2026-06-30T04:59:20.000Z", - member: alphaMember, - runId: "run-alpha", - totalSteps: 3, - workflowName: "Workflow Alpha", - }), - workflowBoardMember({ - actorId: "actor-beta-run", - completedSteps: 2, - executionStatus: "failed", - lastNodeUpdatedAt: "2026-06-29T21:53:00.000Z", - member: betaMember, - runId: "run-beta", - totalSteps: 3, - workflowName: "Workflow Beta", - }), - { - actorId: undefined, - completedNodes: [], - currentExecutionId: undefined, - currentNode: undefined, - displayName: "Idle member", - executionAvailability: "unavailable", - executionStatus: "unknown", - failedNodes: [], - lastNodeUpdatedAt: "2026-06-29T01:15:00.000Z", - memberId: idleMember.memberId, - pendingNodes: [], - progress: { - completedSteps: 0, - totalSteps: 0, - }, - publishedServiceId: idleMember.publishedServiceId, - roleSummary: idleMember.displayName, - workflowId: idleMember.implementationRef?.workflowId, - workflowName: "Idle member", - }, - ]), - ); - }); - - afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); - clearStoredAuthSession(); - }); - - it("renders a dark canvas skeleton while runtime data is loading", async () => { - (studioApi.getAuthSession as jest.Mock).mockImplementationOnce( - () => new Promise(() => {}), - ); - - renderWithQueryClient(React.createElement(MissionWallPage)); - - const loadingStage = await screen.findByRole("status"); - expect(loadingStage).toHaveAttribute("data-variant", "canvas"); - expect(loadingStage).toHaveClass("mission-wall-stage-skeleton"); - expect(screen.getByText("Loading workflow runs")).toHaveClass( - "aevatar-loading-visually-hidden", - ); - expect(screen.queryByText("Loading runtime")).toBeNull(); - expect(screen.queryByText("No focus run")).toBeNull(); - expect(screen.queryByText("Select a workflow.")).toBeNull(); - expect( - screen.getByRole("heading", { name: "Step Flow" }), - ).toBeInTheDocument(); - }); - - it("renders the shared language switch and authenticated user entry in fullscreen mode", async () => { - persistAuthSession({ - tokens: { - accessToken: "token", - expiresAt: Date.now() + 60_000, - expiresIn: 60, - tokenType: "Bearer", - }, - user: { - email: "abigail@example.com", - name: "Abigail Deng", - picture: "https://example.com/avatar.png", - sub: "user-abigail", - }, - }); - - renderWithQueryClient(React.createElement(MissionWallPage)); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Switch language" }), - ).toBeInTheDocument(); - expect(screen.getByText("English")).toBeInTheDocument(); - expect(screen.getByText("Abigail Deng")).toBeInTheDocument(); - }); - - it("themes the fullscreen header actions with mission wall colors", async () => { - persistAuthSession({ - tokens: { - accessToken: "token", - expiresAt: Date.now() + 60_000, - expiresIn: 60, - tokenType: "Bearer", - }, - user: { - email: "abigail@example.com", - name: "Abigail Deng", - picture: "https://example.com/avatar.png", - sub: "user-abigail", - }, - }); - - renderWithQueryClient(React.createElement(MissionWallPage)); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - const actions = document.querySelector(".mission-wall-header-actions"); - expect(actions).toBeInstanceOf(HTMLElement); - expect(actions).toHaveAttribute( - "data-dropdown-root-class-name", - "mission-wall-header-menu", - ); - - const missionWallStyle = Array.from(document.querySelectorAll("style")) - .map((style) => style.textContent ?? "") - .join("\n"); - - const dropdownRootRule = - missionWallStyle.match(/\.mission-wall-header-menu\s*{[^}]*}/)?.[0] ?? - ""; - expect(dropdownRootRule).toContain("--wall-text: #f8faf8;"); - expect(dropdownRootRule).toContain("--wall-live: #2dd4bf;"); - expect(missionWallStyle).toContain( - ".mission-wall-header-actions .console-header-actions__language", - ); - expect(missionWallStyle).toContain("rgba(45, 212, 191, 0.14)"); - expect(missionWallStyle).toContain("var(--wall-live)"); - }); - - it("loads one latest execution row per workflow member from the backend snapshot", async () => { - renderWithQueryClient(React.createElement(MissionWallPage)); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - expect(screen.getByText("Workflow Beta")).toBeInTheDocument(); - expect(screen.queryByText("Script member")).not.toBeInTheDocument(); - - await waitFor(() => { - expect(studioApi.getWorkflowBoardSnapshot).toHaveBeenCalledWith( - "scope-real", - { - take: 100, - }, - ); - }); - - expect(scopeRuntimeApi.listServices).not.toHaveBeenCalled(); - expect(scopeRuntimeApi.listServiceRuns).not.toHaveBeenCalled(); - expect(scopeRuntimeApi.listMemberRuns).not.toHaveBeenCalled(); - }); - - it("does not render a refresh freshness metric in the fullscreen header", async () => { - renderWithQueryClient(React.createElement(MissionWallPage)); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - const topStrip = document.querySelector(".mission-wall-top-strip"); - expect(topStrip).toBeInstanceOf(HTMLElement); - expect(within(topStrip as HTMLElement).queryByText("Fresh")).toBeNull(); - }); - - it("does not expand multiple service catalog runs into duplicate member rows", async () => { - renderWithQueryClient(React.createElement(MissionWallPage)); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - - const list = screen.getByTestId("mission-wall-run-list"); - expect(within(list).getAllByText("Workflow Alpha")).toHaveLength(1); - expect(within(list).queryByText("Older Workflow Alpha")).toBeNull(); - expect(within(list).queryByText("run-alpha-old")).toBeNull(); - expect(scopeRuntimeApi.listServiceRuns).not.toHaveBeenCalled(); - }); - - it("filters the backend snapshot by route team without submitting member ids", async () => { - window.history.replaceState( - {}, - "", - "/runtime/mission-wall?scopeId=scope-real&teamId=team-alpha", - ); - - renderWithQueryClient(React.createElement(MissionWallPage)); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - - await waitFor(() => { - expect(studioApi.getWorkflowBoardSnapshot).toHaveBeenCalledWith( - "scope-real", - { - take: 100, - teamId: "team-alpha", - }, - ); - }); - }); - - it("keeps ellipsized mission wall labels passive without full-text reveal affordances", async () => { - renderWithQueryClient(React.createElement(MissionWallPage)); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - expect( - await screen.findByText(/Workflow Alpha · Step Flow/), - ).toBeInTheDocument(); - - const root = document.querySelector(".mission-wall"); - expect(root).toBeTruthy(); - - const clippedLabels = Array.from( - root!.querySelectorAll( - [ - ".mission-wall-brand__title", - ".mission-wall-run-card__name", - ".mission-wall-run-card__stage", - ".mission-wall-stage-title", - ".mission-wall-stage-subtitle", - ".mission-wall-step-node__name", - ".mission-wall-step-node__type", - ".mission-wall-step-node__meta span", - ].join(", "), - ), - ); - - expect(clippedLabels.length).toBeGreaterThan(6); - - for (const label of clippedLabels) { - expect(label).not.toHaveAttribute("title"); - expect(label).not.toHaveAttribute("aria-expanded"); - expect(label).not.toHaveAttribute("aria-controls"); - expect(window.getComputedStyle(label).pointerEvents).toBe("none"); - expect(window.getComputedStyle(label).userSelect).toBe("none"); - } - }); - - it("does not repeat team and member context inside run cards", async () => { - renderWithQueryClient(React.createElement(MissionWallPage)); - - const alphaCard = (await screen.findByText("Workflow Alpha")).closest( - "button", - ); - expect(alphaCard).toBeTruthy(); - expect(alphaCard).not.toHaveTextContent("Alpha Team · Alpha member"); - expect( - alphaCard!.querySelector(".mission-wall-run-card__team"), - ).not.toBeInTheDocument(); - }); - - it("highlights the selected run card without adding another shadow layer", async () => { - renderWithQueryClient(React.createElement(MissionWallPage)); - - const alphaCard = (await screen.findByText("Workflow Alpha")).closest( - "button", - ); - expect(alphaCard).toBeTruthy(); - expect(alphaCard).toHaveClass("mission-wall-run-card--focus"); - - const missionWallStyle = Array.from(document.querySelectorAll("style")) - .map((style) => style.textContent ?? "") - .join("\n"); - const focusRule = - missionWallStyle.match(/\.mission-wall-run-card--focus\s*{[^}]*}/)?.[0] ?? - ""; - - expect(focusRule).toContain("outline"); - expect(focusRule).not.toContain("border-color"); - expect(focusRule).not.toContain("box-shadow"); - }); - - it("refreshes the left run window when a new workflow member appears", async () => { - const freshMember = workflowMember({ - displayName: "Fresh workflow member", - memberId: "m-fresh", - publishedServiceId: "svc-fresh", - teamId: "team-alpha", - workflowId: "wf-fresh-draft", - }); - let includeFreshMember = false; - let alphaExecutionStatus: "running" | "completed" = "running"; - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockImplementation( - async () => - workflowBoardSnapshot([ - workflowBoardMember({ - actorId: "actor-alpha-run", - completedSteps: alphaExecutionStatus === "completed" ? 3 : 1, - currentNodeStatus: - alphaExecutionStatus === "completed" ? "completed" : "waiting", - executionStatus: alphaExecutionStatus, - lastNodeUpdatedAt: "2026-06-30T04:59:20.000Z", - member: alphaMember, - runId: "run-alpha", - totalSteps: 3, - workflowName: "Workflow Alpha", - }), - workflowBoardMember({ - actorId: "actor-beta-run", - completedSteps: 2, - executionStatus: "failed", - lastNodeUpdatedAt: "2026-06-29T21:53:00.000Z", - member: betaMember, - runId: "run-beta", - totalSteps: 3, - workflowName: "Workflow Beta", - }), - ...(includeFreshMember - ? [ - workflowBoardMember({ - actorId: "actor-fresh-run", - completedSteps: 0, - executionStatus: "running", - lastNodeUpdatedAt: "2026-06-30T04:59:58.000Z", - member: freshMember, - runId: "run-fresh", - totalSteps: 15, - workflowName: "Fresh Workflow", - }), - ] - : []), - ]), - ); - - const { queryClient } = renderWithQueryClient( - React.createElement(MissionWallPage), - ); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - expect(screen.queryByText("Fresh Workflow")).not.toBeInTheDocument(); - - includeFreshMember = true; - await queryClient.invalidateQueries({ queryKey: ["mission-wall"] }); - - expect(await screen.findByText("Fresh Workflow")).toBeInTheDocument(); - - const list = screen.getByTestId("mission-wall-run-list"); - const cards = within(list).getAllByRole("button"); - expect(cards[0]).toHaveTextContent("Fresh Workflow"); - expect(cards[0]).toHaveTextContent("0 / 15 steps"); - expect( - await screen.findByText(/Fresh Workflow · Step Flow/), - ).toBeInTheDocument(); - expect(cards[0]).toHaveAttribute("aria-pressed", "true"); - expect(scopeRuntimeApi.listServiceRuns).not.toHaveBeenCalled(); - expect(scopeRuntimeApi.getMemberRunAudit).not.toHaveBeenCalled(); - - alphaExecutionStatus = "completed"; - await queryClient.invalidateQueries({ queryKey: ["mission-wall"] }); - - expect( - await screen.findByText(/Fresh Workflow · Step Flow/), - ).toBeInTheDocument(); - expect(cards[0]).toHaveAttribute("aria-pressed", "true"); - }); - - it("keeps the last visible workflow board when a refetch briefly returns an empty snapshot", async () => { - let returnEmptySnapshot = false; - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockImplementation( - async () => - returnEmptySnapshot - ? workflowBoardSnapshot([], { - generatedAt: "2026-06-30T05:00:05.000Z", - lastNodeUpdatedAt: "2026-06-30T05:00:05.000Z", - }) - : workflowBoardSnapshot([ - workflowBoardMember({ - actorId: "actor-alpha-run", - completedSteps: 1, - currentNodeStatus: "waiting", - executionStatus: "running", - lastNodeUpdatedAt: "2026-06-30T04:59:20.000Z", - member: alphaMember, - runId: "run-alpha", - totalSteps: 3, - workflowName: "Workflow Alpha", - }), - ]), - ); - - const { queryClient } = renderWithQueryClient( - React.createElement(MissionWallPage), - ); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - expect(await screen.findAllByText("approval_gate")).not.toHaveLength(0); - - returnEmptySnapshot = true; - await queryClient.invalidateQueries({ queryKey: ["mission-wall"] }); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - expect(await screen.findAllByText("approval_gate")).not.toHaveLength(0); - expect(screen.getByText("Live").closest(".mission-wall-metric")) - .toHaveTextContent("Degraded"); - expect(screen.getByTestId("mission-wall-run-list")) - .toHaveTextContent("Workflow Alpha"); - expect(screen.queryByText("No focus run")).not.toBeInTheDocument(); - }); - - it("expires the stale workflow board after repeated refetch errors exceed the fallback window", async () => { - jest.useFakeTimers(); - let nowMs = Date.parse(NOW); - jest.setSystemTime(nowMs); - jest.spyOn(Date, "now").mockImplementation(() => nowMs); - let failSnapshot = false; - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockImplementation( - async () => { - if (failSnapshot) { - throw new Error("workflow board unavailable"); - } - - return workflowBoardSnapshot([ - workflowBoardMember({ - actorId: "actor-alpha-run", - completedSteps: 1, - currentNodeStatus: "waiting", - executionStatus: "running", - lastNodeUpdatedAt: "2026-06-30T04:59:20.000Z", - member: alphaMember, - runId: "run-alpha", - totalSteps: 3, - workflowName: "Workflow Alpha", - }), - ]); - }, - ); - - const { queryClient } = renderWithQueryClient( - React.createElement(MissionWallPage), - ); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - - failSnapshot = true; - nowMs += 1_000; - jest.setSystemTime(nowMs); - await queryClient.invalidateQueries({ queryKey: ["mission-wall"] }); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - - nowMs += MISSION_WALL_STALE_SNAPSHOT_FALLBACK_MS + 1_000; - jest.setSystemTime(nowMs); - await jest.advanceTimersByTimeAsync( - MISSION_WALL_STALE_SNAPSHOT_FALLBACK_MS + 1_000, - ); - await waitFor(() => { - expect(screen.queryByText("Workflow Alpha")).not.toBeInTheDocument(); - }); - expect(screen.getByText("Live").closest(".mission-wall-metric")) - .toHaveTextContent("Disconnected"); - }); - - it("auto-focuses a newly observed workflow run so its topology appears without a page reload", async () => { - const freshMember = workflowMember({ - displayName: "Fresh workflow member", - memberId: "m-fresh", - publishedServiceId: "svc-fresh", - teamId: "team-alpha", - workflowId: "wf-fresh-draft", - }); - let includeFreshMember = false; - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockImplementation( - async () => - workflowBoardSnapshot([ - workflowBoardMember({ - actorId: "actor-alpha-run", - completedSteps: 1, - currentNodeStatus: "waiting", - executionStatus: "running", - lastNodeUpdatedAt: "2026-06-30T04:59:20.000Z", - member: alphaMember, - runId: "run-alpha", - totalSteps: 3, - workflowName: "Workflow Alpha", - }), - ...(includeFreshMember - ? [ - workflowBoardMember({ - actorId: "actor-fresh-run", - completedSteps: 0, - currentNodeStatus: "running", - executionStatus: "running", - lastNodeUpdatedAt: "2026-06-30T04:59:58.000Z", - member: freshMember, - runId: "run-fresh", - totalSteps: 4, - workflowName: "Fresh Workflow", - }), - ] - : []), - ]), - ); - - const { queryClient } = renderWithQueryClient( - React.createElement(MissionWallPage), - ); - - expect( - await screen.findByText(/Workflow Alpha · Step Flow/), - ).toBeInTheDocument(); - - includeFreshMember = true; - await queryClient.invalidateQueries({ queryKey: ["mission-wall"] }); - - expect( - await screen.findByText(/Fresh Workflow · Step Flow/), - ).toBeInTheDocument(); - - const list = screen.getByTestId("mission-wall-run-list"); - const cards = within(list).getAllByRole("button"); - expect(cards[0]).toHaveTextContent("Fresh Workflow"); - expect(cards[0]).toHaveAttribute("aria-pressed", "true"); - expect(await screen.findAllByText("Current")).not.toHaveLength(0); - }); - - it("keeps a manually selected run focused when another workflow run appears", async () => { - const freshMember = workflowMember({ - displayName: "Fresh workflow member", - memberId: "m-fresh", - publishedServiceId: "svc-fresh", - teamId: "team-alpha", - workflowId: "wf-fresh-draft", - }); - let includeFreshMember = false; - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockImplementation( - async () => - workflowBoardSnapshot([ - workflowBoardMember({ - actorId: "actor-alpha-run", - completedSteps: 1, - currentNodeStatus: "waiting", - executionStatus: "running", - lastNodeUpdatedAt: "2026-06-30T04:59:20.000Z", - member: alphaMember, - runId: "run-alpha", - totalSteps: 3, - workflowName: "Workflow Alpha", - }), - ...(includeFreshMember - ? [ - workflowBoardMember({ - actorId: "actor-fresh-run", - completedSteps: 0, - currentNodeStatus: "running", - executionStatus: "running", - lastNodeUpdatedAt: "2026-06-30T04:59:58.000Z", - member: freshMember, - runId: "run-fresh", - totalSteps: 4, - workflowName: "Fresh Workflow", - }), - ] - : []), - ]), - ); - - const { queryClient } = renderWithQueryClient( - React.createElement(MissionWallPage), - ); - - const alphaCard = (await screen.findByText("Workflow Alpha")).closest( - "button", - ); - expect(alphaCard).toBeTruthy(); - fireEvent.click(alphaCard as HTMLButtonElement); - - includeFreshMember = true; - await queryClient.invalidateQueries({ queryKey: ["mission-wall"] }); - - expect(await screen.findByText("Fresh Workflow")).toBeInTheDocument(); - expect( - await screen.findByText(/Workflow Alpha · Step Flow/), - ).toBeInTheDocument(); - expect(alphaCard).toHaveAttribute("aria-pressed", "true"); - }); - - it("shows the selected member snapshot nodes in the right workflow graph", async () => { - renderWithQueryClient(React.createElement(MissionWallPage)); - - const alphaCard = (await screen.findByText("Workflow Alpha")).closest( - "button", - ); - expect(alphaCard).toBeTruthy(); - - fireEvent.click(alphaCard as HTMLButtonElement); - - expect( - await screen.findByText(/Workflow Alpha · Step Flow/), - ).toBeInTheDocument(); - expect(await screen.findAllByText("approval_gate")).not.toHaveLength(0); - - const betaCard = screen.getByText("Workflow Beta").closest("button"); - expect(betaCard).toBeTruthy(); - - fireEvent.click(betaCard as HTMLButtonElement); - - expect( - await screen.findByText(/Workflow Beta · Step Flow/), - ).toBeInTheDocument(); - expect(await screen.findAllByText("record_validation")).not.toHaveLength(0); - expect(betaCard).toHaveTextContent("2 / 3 steps"); - expect(betaCard).not.toHaveTextContent("0 / 0 steps"); - expect(scopeRuntimeApi.getServiceRunAudit).not.toHaveBeenCalled(); - expect(scopeRuntimeApi.getMemberRunAudit).not.toHaveBeenCalled(); - }); - - it("uses the workflow-board snapshot for card progress and duration", async () => { - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockResolvedValue( - workflowBoardSnapshot([ - workflowBoardMember({ - actorId: "actor-probe-run", - completedSteps: 5, - durationMs: 8000, - executionStatus: "completed", - lastNodeUpdatedAt: "2026-06-30T04:58:36.000Z", - member: alphaMember, - runId: "run-probe", - totalSteps: 5, - workflowName: "Mission Wall Probe", - }), - workflowBoardMember({ - actorId: "actor-beta-run", - completedSteps: 2, - executionStatus: "failed", - lastNodeUpdatedAt: "2026-06-29T21:53:00.000Z", - member: betaMember, - runId: "run-beta", - totalSteps: 3, - workflowName: "Workflow Beta", - }), - ]), - ); - window.history.replaceState( - {}, - "", - "/runtime/mission-wall?focusRunId=run-beta", - ); - - renderWithQueryClient(React.createElement(MissionWallPage)); - - const probeCard = ( - await screen.findByText("Mission Wall Probe") - ).closest("button"); - expect(probeCard).toBeTruthy(); - expect(probeCard).toHaveAttribute("aria-pressed", "false"); - expect( - await screen.findByText(/Workflow Beta · Step Flow/), - ).toBeInTheDocument(); - - expect(probeCard).toHaveTextContent("5 / 5 steps"); - expect(probeCard).toHaveTextContent("00:08"); - expect(probeCard).toHaveTextContent("DONE"); - expect(probeCard).not.toHaveTextContent("0 / 0 steps"); - expect(probeCard).not.toHaveTextContent("00:00"); - expect(scopeRuntimeApi.getMemberRunAudit).not.toHaveBeenCalled(); - }); - - it("uses completed node durations when completed workflow-board snapshots omit current node duration", async () => { - const completedMember = workflowBoardMember({ - actorId: "actor-probe-run", - completedSteps: 5, - currentNode: null, - executionStatus: "completed", - lastNodeUpdatedAt: "2026-07-07T12:58:22.000Z", - member: alphaMember, - runId: "run-probe", - totalSteps: 5, - workflowName: "weekly_report_five_nodes", - }); - - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockResolvedValue( - workflowBoardSnapshot([completedMember]), - ); - - renderWithQueryClient(React.createElement(MissionWallPage)); - - const probeCard = ( - await screen.findByText("weekly_report_five_nodes") - ).closest("button"); - expect(probeCard).toBeTruthy(); - expect(probeCard).toHaveTextContent("5 / 5 steps"); - expect(probeCard).toHaveTextContent("00:05"); - expect(probeCard).not.toHaveTextContent("--"); - }); - - it("keeps a run card duration stable when focus moves between workflows", async () => { - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockResolvedValue( - workflowBoardSnapshot([ - workflowBoardMember({ - actorId: "actor-extract-run", - completedSteps: 1, - durationMs: 1000, - executionStatus: "completed", - lastNodeUpdatedAt: "2026-06-30T04:58:36.000Z", - member: alphaMember, - runId: "run-extract", - totalSteps: 1, - workflowName: "Document Extract Run", - }), - workflowBoardMember({ - actorId: "actor-probe-run", - completedSteps: 15, - durationMs: 24_000, - executionStatus: "completed", - lastNodeUpdatedAt: "2026-06-30T04:58:40.000Z", - member: betaMember, - runId: "run-probe", - totalSteps: 15, - workflowName: "Mission Wall Probe", - }), - ]), - ); - window.history.replaceState( - {}, - "", - "/runtime/mission-wall?focusRunId=run-extract", - ); - - renderWithQueryClient(React.createElement(MissionWallPage)); - - const extractCard = ( - await screen.findByText("Document Extract Run") - ).closest("button"); - const probeCard = ( - await screen.findByText("Mission Wall Probe") - ).closest("button"); - expect(extractCard).toBeTruthy(); - expect(probeCard).toBeTruthy(); - - expect(extractCard).toHaveTextContent("1 / 1 steps"); - expect(extractCard).toHaveTextContent("00:01"); - expect(probeCard).toHaveTextContent("15 / 15 steps"); - expect(probeCard).toHaveTextContent("00:24"); - - fireEvent.click(probeCard as HTMLButtonElement); - - expect( - await screen.findByText(/Mission Wall Probe · Step Flow/), - ).toBeInTheDocument(); - expect(extractCard).toHaveTextContent("1 / 1 steps"); - expect(extractCard).toHaveTextContent("00:01"); - - fireEvent.click(extractCard as HTMLButtonElement); - - expect( - await screen.findByText(/Document Extract Run · Step Flow/), - ).toBeInTheDocument(); - expect(extractCard).toHaveTextContent("1 / 1 steps"); - expect(extractCard).toHaveTextContent("00:01"); - expect(probeCard).toHaveTextContent("15 / 15 steps"); - expect(probeCard).toHaveTextContent("00:24"); - expect(scopeRuntimeApi.getMemberRunAudit).not.toHaveBeenCalled(); - }); - - it("keeps every workflow node in the graph while focusing the default big-screen view", async () => { - (studioApi.getWorkflowBoardSnapshot as jest.Mock).mockResolvedValue( - workflowBoardSnapshot([ - workflowBoardMember({ - actorId: "actor-alpha-run", - completedSteps: 7, - currentNodeStatus: "waiting", - executionStatus: "running", - lastNodeUpdatedAt: "2026-06-30T04:59:20.000Z", - member: alphaMember, - runId: "run-alpha", - totalSteps: 8, - workflowName: "Workflow Alpha", - }), - workflowBoardMember({ - actorId: "actor-beta-run", - completedSteps: 2, - executionStatus: "failed", - lastNodeUpdatedAt: "2026-06-29T21:53:00.000Z", - member: betaMember, - runId: "run-beta", - totalSteps: 3, - workflowName: "Workflow Beta", - }), - ]), - ); - - renderWithQueryClient(React.createElement(MissionWallPage)); - - const alphaCard = (await screen.findByText("Workflow Alpha")).closest( - "button", - ); - expect(alphaCard).toBeTruthy(); - - fireEvent.click(alphaCard as HTMLButtonElement); - - expect( - await screen.findByText(/Workflow Alpha · Step Flow/), - ).toBeInTheDocument(); - - const graph = screen.getByTestId("mission-wall-graph"); - expect(within(graph).getByText("completed_1")).toBeInTheDocument(); - expect(within(graph).getByText("completed_7")).toBeInTheDocument(); - expect(scopeRuntimeApi.getMemberRunAudit).not.toHaveBeenCalled(); - expect(screen.queryByText(/Focused steps/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/current execution/i)).not.toBeInTheDocument(); - }); - - it("keeps published workflow members visible even when their latest run is outside the focus window", async () => { - renderWithQueryClient(React.createElement(MissionWallPage)); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - - const list = screen.getByTestId("mission-wall-run-list"); - expect(within(list).getByText("Workflow Beta")).toBeInTheDocument(); - expect(within(list).getByText("Idle member")).toBeInTheDocument(); - expect( - screen.getByText("Failed").closest(".mission-wall-metric"), - ).toHaveTextContent("1"); - - const idleCard = within(list).getByText("Idle member").closest("button"); - expect(idleCard).toBeTruthy(); - - fireEvent.click(idleCard as HTMLButtonElement); - - expect( - await screen.findByText(/Idle member · Step Flow/), - ).toBeInTheDocument(); - expect( - await screen.findAllByText("No visible run"), - ).not.toHaveLength(0); - expect(scopeRuntimeApi.getServiceRunAudit).not.toHaveBeenCalledWith( - "scope-real", - "svc-idle", - "published:svc-idle", - expect.anything(), - ); - }); - - it("renders the published run window as one stable manually scrollable list", async () => { - renderWithQueryClient(React.createElement(MissionWallPage)); - - expect(await screen.findByText("Workflow Alpha")).toBeInTheDocument(); - - const viewport = screen.getByTestId("mission-wall-run-window-viewport"); - const list = screen.getByTestId("mission-wall-run-list"); - - expect(viewport.className).toContain("mission-wall-run-window__viewport"); - expect(list.className).toContain("mission-wall-run-list"); - expect(within(list).getAllByText("Workflow Alpha")).toHaveLength(1); - expect(within(list).getAllByText("Workflow Beta")).toHaveLength(1); - expect(within(list).getAllByText("Idle member")).toHaveLength(1); - - const cards = within(list).getAllByRole("button"); - expect(cards[0]).toHaveTextContent("Workflow Alpha"); - expect(cards[1]).toHaveTextContent("Workflow Beta"); - expect(cards[2]).toHaveTextContent("Idle member"); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/MissionWall/index.tsx b/apps/aevatar-console-web/src/pages/MissionWall/index.tsx deleted file mode 100644 index 299ff39ee1..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/index.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -import { MissionStage } from "./components/MissionStage"; -import { PublishedRunWindow } from "./components/PublishedRunWindow"; -import { TopStatusStrip } from "./components/TopStatusStrip"; -import { useMissionWallRuntimeData } from "./hooks/useMissionWallData"; -import { usePublishedRunWindow } from "./hooks/usePublishedRunWindow"; -import { missionWallStyles } from "./missionWallStyles"; -import { buildMissionWallSnapshot } from "./wallDirector"; - -const MissionWallPage: React.FC = () => { - const runtimeData = useMissionWallRuntimeData(); - const { - buildSource, - isLoading, - nowMs, - routeFocusRunId, - } = runtimeData; - const missionWallSource = React.useMemo( - () => buildSource(), - [buildSource], - ); - const snapshot = React.useMemo( - () => - buildMissionWallSnapshot(missionWallSource, { - focusRunId: routeFocusRunId, - nowMs, - }), - [missionWallSource, nowMs, routeFocusRunId], - ); - const publishedRunWindow = usePublishedRunWindow( - snapshot.runs, - snapshot.focus.runId, - ); - const focusSnapshot = React.useMemo( - () => - buildMissionWallSnapshot(missionWallSource, { - focusRunId: publishedRunWindow.selectedRunId, - nowMs, - }), - [missionWallSource, nowMs, publishedRunWindow.selectedRunId], - ); - const focusRun = - focusSnapshot.runs.find( - (run) => run.runId === publishedRunWindow.selectedRunId, - ) ?? publishedRunWindow.selectedRun; - const publishedRuns = React.useMemo( - () => - publishedRunWindow.runs.map((run) => - run.runId === focusRun?.runId ? focusRun : run, - ), - [focusRun, publishedRunWindow.runs], - ); - - return ( -
- - -
- - -
-
- ); -}; - -export default MissionWallPage; diff --git a/apps/aevatar-console-web/src/pages/MissionWall/missionWallFormatters.ts b/apps/aevatar-console-web/src/pages/MissionWall/missionWallFormatters.ts deleted file mode 100644 index 94b02ddbee..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/missionWallFormatters.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { t } from "@/shared/i18n/messages"; -import type { - MissionWallFocusReason, - MissionWallLiveStatus, - MissionWallPriorityLevel, - MissionWallRun, - MissionWallRunStatus, - MissionWallStepStatus, -} from "./models"; - -export function formatDuration(ms?: number): string { - if (ms === undefined || !Number.isFinite(ms)) { - return "--"; - } - - const totalSeconds = Math.max(0, Math.round(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; -} - -export function formatLatency(ms?: number): string | undefined { - if (ms === undefined || !Number.isFinite(ms) || ms <= 0) { - return undefined; - } - - if (ms < 1000) { - return t("pages.missionwall.latencyMs", "{latency}ms", { - latency: String(Math.round(ms)), - }); - } - - return t("pages.missionwall.latencySeconds", "{latency}s", { - latency: (ms / 1000).toFixed(1), - }); -} - -export function formatLiveStatus(status: MissionWallLiveStatus): string { - switch (status) { - case "live": - return t("pages.missionwall.liveStatus.live", "On"); - case "degraded": - return t("pages.missionwall.liveStatus.degraded", "Degraded"); - case "disconnected": - return t("pages.missionwall.liveStatus.disconnected", "Disconnected"); - case "idle": - return t("pages.missionwall.liveStatus.idle", "Idle"); - default: - return t("pages.missionwall.liveStatus.unknown", "Unknown"); - } -} - -export function formatRunStatus(status: MissionWallRunStatus): string { - switch (status) { - case "running": - return t("pages.missionwall.status.running", "LIVE"); - case "completed": - return t("pages.missionwall.status.completed", "DONE"); - case "waiting": - return t("pages.missionwall.status.waiting", "WAIT"); - case "failed": - return t("pages.missionwall.status.failed", "FAILED"); - case "timed_out": - return t("pages.missionwall.status.timedOut", "TIMEOUT"); - case "retrying": - return t("pages.missionwall.status.retrying", "RETRY"); - case "stale": - return t("pages.missionwall.status.stale", "STALE"); - case "stopped": - return t("pages.missionwall.status.stopped", "STOP"); - case "unknown": - return t("pages.missionwall.status.published", "PUBLISHED"); - default: - return t("pages.missionwall.status.unknown", "UNKNOWN"); - } -} - -export function formatStepStatus(status: MissionWallStepStatus): string { - switch (status) { - case "active": - return t("pages.missionwall.stepStatus.active", "ACTIVE"); - case "completed": - return t("pages.missionwall.stepStatus.completed", "COMPLETED"); - case "failed": - return t("pages.missionwall.stepStatus.failed", "FAILED"); - case "retrying": - return t("pages.missionwall.stepStatus.retrying", "RETRYING"); - case "waiting": - return t("pages.missionwall.stepStatus.waiting", "WAITING"); - case "idle": - return t("pages.missionwall.stepStatus.idle", "NEXT"); - default: - return t("pages.missionwall.stepStatus.unknown", "UNKNOWN"); - } -} - -export function formatFocusReason(reason?: MissionWallFocusReason): string { - if (!reason) { - return t("pages.missionwall.focusReason.none", "No focus"); - } - - switch (reason) { - case "failed": - return t("pages.missionwall.focusReason.failed", "failed"); - case "timed_out": - return t("pages.missionwall.focusReason.timedOut", "timed out"); - case "waiting_human": - return t( - "pages.missionwall.focusReason.waitingHuman", - "waiting approval", - ); - case "stale_projection": - return t("pages.missionwall.focusReason.staleProjection", "stale"); - case "stale_live": - return t("pages.missionwall.focusReason.staleLive", "stale"); - case "retrying": - return t("pages.missionwall.focusReason.retrying", "retrying"); - case "latest_running": - return t("pages.missionwall.focusReason.latestRunning", "latest running"); - case "recently_completed": - return t( - "pages.missionwall.focusReason.recentlyCompleted", - "recently completed", - ); - default: - return t("pages.missionwall.focusReason.unknown", "unknown"); - } -} - -export function priorityTone( - priorityLevel: MissionWallPriorityLevel, - status?: MissionWallRunStatus, -): "blue" | "green" | "grey" | "red" | "teal" | "yellow" { - if (priorityLevel === "error") { - return "red"; - } - - if (priorityLevel === "warning") { - return status === "retrying" ? "red" : "yellow"; - } - - if (status === "completed") { - return "green"; - } - - if (status === "running") { - return "blue"; - } - - if (status === "unknown") { - return "teal"; - } - - return "grey"; -} - -export function stepTone( - status: MissionWallStepStatus, -): "blue" | "green" | "grey" | "red" | "teal" | "yellow" { - switch (status) { - case "active": - return "teal"; - case "completed": - return "green"; - case "failed": - return "red"; - case "retrying": - case "waiting": - return "yellow"; - default: - return "grey"; - } -} - -export function formatRunStage(run: MissionWallRun): string { - const step = run.currentStepLabel || run.currentStepId; - - if (run.hasRuntimeRun === false || run.status === "unknown") { - return t("pages.missionwall.runtimeData.noRuntimeRun", "No visible run"); - } - - if (run.status === "failed") { - return step - ? t("pages.missionwall.runStage.failedAtStep", "{step} failed", { - step, - }) - : formatRunStatus(run.status); - } - - if (run.status === "timed_out") { - return step - ? t("pages.missionwall.runStage.timedOutAtStep", "{step} timed out", { - step, - }) - : formatRunStatus(run.status); - } - - if (run.status === "waiting") { - return step - ? t("pages.missionwall.runStage.waitingAtStep", "Waiting at {step}", { - step, - }) - : formatRunStatus(run.status); - } - - if (run.status === "retrying") { - return step - ? t("pages.missionwall.runStage.retryingAtStep", "Retrying {step}", { - step, - }) - : formatRunStatus(run.status); - } - - if (run.status === "running") { - return step - ? t("pages.missionwall.runStage.runningAtStep", "Running {step}", { - step, - }) - : formatRunStatus(run.status); - } - - if (run.status === "stale") { - return step - ? t("pages.missionwall.runStage.staleAtStep", "Stale at {step}", { - step, - }) - : formatRunStatus(run.status); - } - - return formatRunStatus(run.status); -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/missionWallRuntimeData.ts b/apps/aevatar-console-web/src/pages/MissionWall/missionWallRuntimeData.ts deleted file mode 100644 index 4bdfb9db76..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/missionWallRuntimeData.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { t } from "@/shared/i18n/messages"; -import type { - StudioWorkflowBoardCompletedNode, - StudioWorkflowBoardCurrentNode, - StudioWorkflowBoardFailedNode, - StudioWorkflowBoardMemberSnapshot, - StudioWorkflowBoardPendingNode, - StudioWorkflowBoardSnapshot, - StudioWorkflowBoardTeamSnapshot, -} from "@/shared/studio/models"; -import type { - MissionWallLiveState, - MissionWallRunSource, - MissionWallRunStatus, - MissionWallSource, - MissionWallStepSource, - MissionWallStepStatus, -} from "./models"; - -type MissionWallWorkflowBoardSourceInput = { - readonly generatedAt: string; - readonly live: MissionWallLiveState; - readonly snapshot?: StudioWorkflowBoardSnapshot; -}; - -function trimOptional(value: string | null | undefined): string { - return value?.trim() ?? ""; -} - -function normalizeStatus(value: string | null | undefined): string { - return ( - trimOptional(value) - .toLowerCase() - .replace(/[\s-]+/g, "_") || "unknown" - ); -} - -function parseTimeMs(value: string | null | undefined): number | undefined { - const parsed = Date.parse(trimOptional(value)); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function positiveFiniteNumber( - value: number | null | undefined, -): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? value - : undefined; -} - -function positiveDurationBetween( - startedAt: string | null | undefined, - endedAt: string | null | undefined, -): number | undefined { - const startedAtMs = parseTimeMs(startedAt); - const endedAtMs = parseTimeMs(endedAt); - if (startedAtMs === undefined || endedAtMs === undefined) { - return undefined; - } - - return positiveFiniteNumber(endedAtMs - startedAtMs); -} - -export function toMissionWallRunStatus( - completionStatus: string | null | undefined, -): MissionWallRunStatus { - switch (normalizeStatus(completionStatus)) { - case "completed": - case "done": - case "succeeded": - case "success": - return "completed"; - case "failed": - case "not_found": - return "failed"; - case "timed_out": - case "timeout": - return "timed_out"; - case "retrying": - case "retry_pending": - return "retrying"; - case "waiting": - case "awaiting_input": - case "human_input_required": - case "suspended": - case "suspension": - return "waiting"; - case "stopped": - case "cancelled": - case "canceled": - case "disabled": - return "stopped"; - case "running": - case "active": - case "in_progress": - return "running"; - default: - return "unknown"; - } -} - -function statusLabel(status: MissionWallRunStatus): string { - switch (status) { - case "completed": - return t("pages.missionwall.runtimeData.completed", "Completed"); - case "failed": - return t("pages.missionwall.runtimeData.failed", "Failed"); - case "retrying": - return t("pages.missionwall.runtimeData.retrying", "Retrying"); - case "running": - return t("pages.missionwall.runtimeData.running", "Running"); - case "stopped": - return t("pages.missionwall.runtimeData.stopped", "Stopped"); - case "timed_out": - return t("pages.missionwall.runtimeData.timedOut", "Timed out"); - case "waiting": - return t("pages.missionwall.runtimeData.waiting", "Waiting"); - default: - return t("pages.missionwall.runtimeData.unknown", "Unknown"); - } -} - -function resolveCurrentStep( - steps: readonly MissionWallStepSource[], -): MissionWallStepSource | undefined { - return ( - steps.find((step) => step.status === "failed") ?? - steps.find((step) => step.status === "waiting") ?? - steps.find((step) => step.status === "active") ?? - [...steps].reverse().find((step) => step.status === "completed") ?? - steps[0] - ); -} - -function toWorkflowBoardStepStatus( - status: string | null | undefined, -): MissionWallStepStatus { - switch (normalizeStatus(status)) { - case "completed": - case "done": - case "succeeded": - return "completed"; - case "failed": - return "failed"; - case "retrying": - return "retrying"; - case "running": - case "active": - case "in_progress": - return "active"; - case "waiting": - case "pending": - case "queued": - return "waiting"; - default: - return "unknown"; - } -} - -function toCompletedNodeStep( - node: StudioWorkflowBoardCompletedNode, -): MissionWallStepSource { - return { - latencyMs: positiveFiniteNumber(node.durationMs), - status: "completed", - stepId: node.nodeId, - stepType: "workflow_node", - targetRole: trimOptional(node.name) || undefined, - }; -} - -function toPendingNodeStep( - node: StudioWorkflowBoardPendingNode, -): MissionWallStepSource { - return { - parametersSummary: trimOptional(node.reason) || undefined, - status: toWorkflowBoardStepStatus(node.status), - stepId: node.nodeId, - stepType: "workflow_node", - targetRole: trimOptional(node.name) || undefined, - }; -} - -function toFailedNodeStep( - node: StudioWorkflowBoardFailedNode, -): MissionWallStepSource { - return { - status: "failed", - stepId: node.nodeId, - stepType: "workflow_node", - targetRole: trimOptional(node.name) || undefined, - }; -} - -function toCurrentNodeStep( - node: StudioWorkflowBoardCurrentNode, -): MissionWallStepSource { - return { - latencyMs: positiveFiniteNumber(node.durationMs), - status: toWorkflowBoardStepStatus(node.status), - stepId: node.nodeId, - stepType: "workflow_node", - targetRole: trimOptional(node.name) || undefined, - }; -} - -function withSequentialNextSteps( - steps: readonly MissionWallStepSource[], -): MissionWallStepSource[] { - return steps.map((step, index) => { - if (step.nextStepId || step.branchTargets) { - return step; - } - - const nextStepId = steps[index + 1]?.stepId; - return nextStepId ? { ...step, nextStepId } : step; - }); -} - -function buildWorkflowBoardSteps( - member: StudioWorkflowBoardMemberSnapshot, -): MissionWallStepSource[] { - const stepsById = new Map(); - const pushStep = (step: MissionWallStepSource): void => { - const stepId = trimOptional(step.stepId); - if (!stepId) { - return; - } - - stepsById.set(stepId, { ...step, stepId }); - }; - - member.completedNodes.forEach((node) => pushStep(toCompletedNodeStep(node))); - if (member.currentNode) { - pushStep(toCurrentNodeStep(member.currentNode)); - } - member.pendingNodes.forEach((node) => pushStep(toPendingNodeStep(node))); - member.failedNodes.forEach((node) => pushStep(toFailedNodeStep(node))); - - return withSequentialNextSteps([...stepsById.values()]); -} - -function workflowBoardMemberUpdatedAt( - member: StudioWorkflowBoardMemberSnapshot, - generatedAt: string, -): string | undefined { - return ( - trimOptional(member.lastNodeUpdatedAt) || - trimOptional(member.currentNode?.updatedAt) || - trimOptional(member.currentNode?.startedAt) || - trimOptional(generatedAt) || - undefined - ); -} - -function calculateWorkflowBoardDurationMs( - member: StudioWorkflowBoardMemberSnapshot, -): number | undefined { - const completedNodesDurationMs = positiveFiniteNumber( - member.completedNodes.reduce( - (total, node) => total + (positiveFiniteNumber(node.durationMs) ?? 0), - 0, - ), - ); - - return ( - positiveFiniteNumber(member.currentNode?.durationMs) ?? - completedNodesDurationMs ?? - positiveDurationBetween( - member.currentNode?.startedAt, - member.currentNode?.updatedAt ?? member.lastNodeUpdatedAt, - ) - ); -} - -function workflowBoardRunId( - member: StudioWorkflowBoardMemberSnapshot, -): string { - const currentExecutionId = trimOptional(member.currentExecutionId); - if (currentExecutionId) { - return currentExecutionId; - } - - const publishedServiceId = trimOptional(member.publishedServiceId); - if (publishedServiceId) { - return `published:${publishedServiceId}`; - } - - return `member:${member.memberId}`; -} - -function toWorkflowBoardSource(input: { - readonly generatedAt: string; - readonly scopeId: string; - readonly team: StudioWorkflowBoardTeamSnapshot; - readonly member: StudioWorkflowBoardMemberSnapshot; -}): MissionWallRunSource { - const status = toMissionWallRunStatus(input.member.executionStatus); - const steps = buildWorkflowBoardSteps(input.member); - const currentStep = - steps.find((step) => step.stepId === input.member.currentNode?.nodeId) ?? - resolveCurrentStep(steps); - const hasRuntimeRun = Boolean(trimOptional(input.member.currentExecutionId)); - const displayName = - trimOptional(input.member.displayName) || - trimOptional(input.member.roleSummary) || - t("pages.missionwall.runtimeData.unnamedWorkflow", "Unnamed workflow"); - const workflowName = - trimOptional(input.member.workflowName) || - displayName; - const durationMs = calculateWorkflowBoardDurationMs(input.member); - const updatedAt = workflowBoardMemberUpdatedAt(input.member, input.generatedAt); - - return { - completedSteps: Math.max(0, input.member.progress.completedSteps), - currentMemberId: trimOptional(currentStep?.targetRole) || undefined, - currentMemberName: trimOptional(currentStep?.targetRole) || undefined, - currentStepId: - trimOptional(input.member.currentNode?.nodeId) || - currentStep?.stepId, - currentStepLabel: - trimOptional(input.member.currentNode?.name) || - currentStep?.stepId || - (hasRuntimeRun - ? statusLabel(status) - : t( - "pages.missionwall.runtimeData.noRuntimeRun", - "No visible run", - )), - durationMs, - entryMemberId: trimOptional(input.member.memberId) || undefined, - entryMemberName: displayName, - hasRuntimeRun, - publishedServiceId: - trimOptional(input.member.publishedServiceId) || undefined, - runId: workflowBoardRunId(input.member), - runtimeActorId: trimOptional(input.member.actorId) || undefined, - scopeId: input.scopeId, - startedAt: trimOptional(input.member.currentNode?.startedAt) || undefined, - status, - steps, - teamId: trimOptional(input.team.teamId) || undefined, - teamName: - trimOptional(input.team.teamName) || - trimOptional(input.team.teamId) || - undefined, - totalSteps: Math.max(0, input.member.progress.totalSteps), - updatedAt, - workflowName, - }; -} - -export function buildMissionWallSourceFromWorkflowBoardSnapshot( - input: MissionWallWorkflowBoardSourceInput, -): MissionWallSource { - const scopeId = trimOptional(input.snapshot?.scopeId); - const runs = - input.snapshot?.teams.flatMap((team) => - team.members.map((member) => - toWorkflowBoardSource({ - generatedAt: input.generatedAt, - member, - scopeId: scopeId || "", - team, - }), - ), - ) ?? []; - - return { - generatedAt: input.generatedAt, - live: input.live, - runs, - }; -} - -export function freshnessSecondsSince( - observedAt: string | undefined, - nowMs: number, -): number | undefined { - const observedAtMs = parseTimeMs(observedAt); - if (observedAtMs === undefined) { - return undefined; - } - - return Math.max(0, Math.round((nowMs - observedAtMs) / 1000)); -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/missionWallStyles.ts b/apps/aevatar-console-web/src/pages/MissionWall/missionWallStyles.ts deleted file mode 100644 index 6029d6aa99..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/missionWallStyles.ts +++ /dev/null @@ -1,849 +0,0 @@ -export const missionWallStyles = ` -.mission-wall { - --wall-bg: #09110f; - --wall-panel: #101916; - --wall-panel-strong: #14211d; - --wall-panel-soft: #16231f; - --wall-line: rgba(201, 213, 206, 0.16); - --wall-line-strong: rgba(201, 213, 206, 0.28); - --wall-text: #f8faf8; - --wall-muted: #aebbb4; - --wall-faint: #74847c; - --wall-live: #2dd4bf; - --wall-blue: #2563eb; - --wall-blue-soft: #93c5fd; - --wall-green: #16a34a; - --wall-green-soft: #86efac; - --wall-yellow: #d97706; - --wall-yellow-soft: #fbbf24; - --wall-red: #dc2626; - --wall-red-soft: #f87171; - --wall-canvas: #0d1714; - --wall-grid: rgba(201, 213, 206, 0.055); - background: - linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.024) 1px, transparent 1px), - linear-gradient(145deg, #09110f 0%, #111916 58%, #090d0b 100%); - background-size: 48px 48px, 48px 48px, auto; - color: var(--wall-text); - display: grid; - font-family: "SF Pro Display", "Aptos Display", "Segoe UI", sans-serif; - grid-template-rows: 98px minmax(0, 1fr); - height: 100vh; - letter-spacing: 0; - min-height: 760px; - overflow: hidden; - padding: 18px; - width: 100%; -} - -.mission-wall, -.mission-wall * { - box-sizing: border-box; -} - -.mission-wall-top-strip { - align-items: center; - background: rgba(16, 25, 22, 0.94); - border: 1px solid rgba(45, 212, 191, 0.22); - border-radius: 8px; - box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32); - display: grid; - gap: 18px; - grid-template-columns: - minmax(300px, 1.15fr) - repeat(5, minmax(104px, 0.48fr)) - minmax(210px, auto); - min-width: 0; - padding: 16px 18px; -} - -.mission-wall-brand { - min-width: 0; -} - -.mission-wall-brand__kicker { - color: var(--wall-live); - font-size: 12px; - font-weight: 820; - line-height: 1; - text-transform: uppercase; -} - -.mission-wall-brand__title { - color: var(--wall-text); - font-size: 30px; - font-weight: 780; - line-height: 1.06; - margin: 6px 0 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mission-wall-metric { - border-left: 1px solid rgba(201, 213, 206, 0.16); - min-width: 0; - padding-left: 16px; -} - -.mission-wall-metric__label { - color: var(--wall-muted); - display: block; - font-size: 12px; - font-weight: 760; - line-height: 1.2; - text-transform: uppercase; -} - -.mission-wall-metric__value { - align-items: center; - color: var(--wall-text); - display: flex; - font-size: 30px; - font-weight: 780; - gap: 9px; - line-height: 1.08; - margin-top: 7px; - min-width: 0; - white-space: nowrap; -} - -.mission-wall-brand__title, -.mission-wall-run-card__name, -.mission-wall-run-card__stage, -.mission-wall-stage-title, -.mission-wall-stage-subtitle, -.mission-wall-step-node__name, -.mission-wall-step-node__type, -.mission-wall-step-node__meta span { - pointer-events: none; - user-select: none; -} - -.mission-wall-metric__value--live { - color: var(--wall-text); -} - -.mission-wall-header-actions { - align-items: center; - align-self: center; - display: inline-flex; - gap: 10px; - justify-content: flex-end; - min-width: 0; -} - -.mission-wall-header-actions .console-header-actions__language, -.mission-wall-header-actions .console-header-actions__login { - background: rgba(45, 212, 191, 0.08); - border: 1px solid rgba(45, 212, 191, 0.18); - border-radius: 999px; - color: var(--wall-text) !important; - font-weight: 720; - padding-inline: 13px; - text-shadow: 0 0 18px rgba(45, 212, 191, 0.18); -} - -.mission-wall-header-actions .console-header-actions__language .anticon, -.mission-wall-header-actions .console-header-actions__login .anticon { - color: var(--wall-live); -} - -.mission-wall-header-actions .console-header-actions__language:hover, -.mission-wall-header-actions .console-header-actions__language:focus-visible, -.mission-wall-header-actions .console-header-actions__login:hover, -.mission-wall-header-actions .console-header-actions__login:focus-visible { - background: rgba(45, 212, 191, 0.14) !important; - border-color: rgba(45, 212, 191, 0.42) !important; - color: var(--wall-live) !important; -} - -.mission-wall-header-actions .console-header-actions__user { - background: rgba(45, 212, 191, 0.08) !important; - border-color: rgba(45, 212, 191, 0.22) !important; - box-shadow: inset 0 0 0 1px rgba(201, 213, 206, 0.06); - color: var(--wall-text) !important; - transition: - background-color 160ms ease, - border-color 160ms ease, - color 160ms ease; -} - -.mission-wall-header-actions .console-header-actions__user:hover { - background: rgba(45, 212, 191, 0.14) !important; - border-color: rgba(45, 212, 191, 0.42) !important; -} - -.mission-wall-header-actions .console-header-actions__user .ant-avatar { - background: rgba(45, 212, 191, 0.14); - border: 1px solid rgba(45, 212, 191, 0.34); -} - -.mission-wall-header-actions .console-header-actions__user-name { - color: var(--wall-text) !important; -} - -.mission-wall-header-actions .console-header-actions__user-caret { - color: rgba(201, 213, 206, 0.78) !important; -} - -.mission-wall-header-menu { - --wall-text: #f8faf8; - --wall-muted: #aebbb4; - --wall-live: #2dd4bf; -} - -.mission-wall-header-menu .ant-dropdown-menu { - background: rgba(13, 22, 19, 0.98); - border: 1px solid rgba(45, 212, 191, 0.28); - border-radius: 8px; - box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); - padding: 6px; -} - -.mission-wall-header-menu .ant-dropdown-menu-item, -.mission-wall-header-menu .ant-dropdown-menu-submenu-title { - border-radius: 6px; - color: var(--wall-text) !important; - font-weight: 680; -} - -.mission-wall-header-menu .ant-dropdown-menu-item .ant-dropdown-menu-title-content, -.mission-wall-header-menu .ant-dropdown-menu-item .anticon { - color: inherit !important; -} - -.mission-wall-header-menu .ant-dropdown-menu-item-disabled, -.mission-wall-header-menu .ant-dropdown-menu-item-disabled .ant-dropdown-menu-title-content, -.mission-wall-header-menu .ant-dropdown-menu-item-disabled .anticon { - color: rgba(174, 187, 180, 0.5) !important; -} - -.mission-wall-header-menu .ant-dropdown-menu-item-selected, -.mission-wall-header-menu .ant-dropdown-menu-item-active, -.mission-wall-header-menu .ant-dropdown-menu-item:hover { - background: rgba(45, 212, 191, 0.16) !important; - color: var(--wall-live) !important; -} - -.mission-wall-metric__value--red { - color: var(--wall-red-soft); -} - -.mission-wall-metric__value--yellow { - color: var(--wall-yellow-soft); -} - -.mission-wall-live-dot { - background: var(--wall-live); - border-radius: 999px; - box-shadow: 0 0 18px rgba(45, 212, 191, 0.72); - display: inline-block; - flex: 0 0 auto; - height: 12px; - width: 12px; -} - -.mission-wall-screen { - display: grid; - gap: 16px; - grid-template-columns: 404px minmax(0, 1fr); - min-height: 0; - padding-top: 16px; -} - -.mission-wall-panel { - background: rgba(16, 25, 22, 0.92); - border: 1px solid var(--wall-line); - border-radius: 8px; - box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32); - min-height: 0; - overflow: hidden; -} - -.mission-wall-run-window { - display: flex; - flex-direction: column; -} - -.mission-wall-panel-head { - align-items: center; - border-bottom: 1px solid var(--wall-line); - display: flex; - gap: 12px; - justify-content: space-between; - min-height: 58px; - padding: 14px 16px; -} - -.mission-wall-panel-title { - color: var(--wall-text); - font-size: 14px; - font-weight: 820; - line-height: 1; - text-transform: uppercase; -} - -.mission-wall-panel-count { - align-items: center; - border: 1px solid rgba(201, 213, 206, 0.24); - border-radius: 999px; - color: var(--wall-muted); - display: flex; - font-size: 12px; - font-weight: 820; - height: 26px; - justify-content: center; - min-width: 34px; - padding: 0 9px; -} - -.mission-wall-run-window__viewport { - flex: 1; - min-height: 0; - overflow-x: hidden; - overflow-y: auto; - position: relative; - scrollbar-color: rgba(174, 187, 180, 0.44) transparent; - scrollbar-gutter: stable; - scrollbar-width: thin; -} - -.mission-wall-run-window__viewport::-webkit-scrollbar { - width: 8px; -} - -.mission-wall-run-window__viewport::-webkit-scrollbar-thumb { - background: rgba(174, 187, 180, 0.36); - border-radius: 999px; -} - -.mission-wall-run-window__viewport::-webkit-scrollbar-track { - background: transparent; -} - -.mission-wall-run-window__viewport::after { - background: linear-gradient(180deg, transparent, rgba(9, 17, 15, 0.84)); - bottom: 0; - content: ""; - height: 42px; - left: 0; - pointer-events: none; - position: absolute; - right: 0; - z-index: 2; -} - -.mission-wall-run-list { - display: flex; - flex-direction: column; - gap: 12px; - padding: 14px; -} - -.mission-wall-run-card { - appearance: none; - background: linear-gradient(180deg, rgba(22, 35, 31, 0.98), rgba(13, 22, 19, 0.98)); - --mission-wall-card-focus-ring: rgba(116, 132, 124, 0.62); - border: 1px solid var(--wall-line); - border-left: 4px solid var(--wall-faint); - border-radius: 8px; - color: inherit; - cursor: pointer; - display: block; - font: inherit; - min-height: 126px; - padding: 14px; - text-align: left; - transition: border-color 140ms ease, transform 140ms ease; - width: 100%; -} - -.mission-wall-run-card--focus { - outline: 2px solid var(--mission-wall-card-focus-ring); - outline-offset: 3px; -} - -.mission-wall-run-card:focus-visible { - outline: 2px solid var(--mission-wall-card-focus-ring); - outline-offset: 3px; -} - -.mission-wall-run-card:hover { - transform: translateY(-1px); -} - -.mission-wall-tone--blue { - --mission-wall-card-focus-ring: rgba(147, 197, 253, 0.9); - border-left-color: var(--wall-blue); -} - -.mission-wall-tone--green { - --mission-wall-card-focus-ring: rgba(134, 239, 172, 0.88); - border-left-color: var(--wall-green); -} - -.mission-wall-tone--grey { - --mission-wall-card-focus-ring: rgba(174, 187, 180, 0.7); - border-left-color: var(--wall-faint); -} - -.mission-wall-tone--red { - --mission-wall-card-focus-ring: rgba(248, 113, 113, 0.88); - border-left-color: var(--wall-red); -} - -.mission-wall-tone--teal { - --mission-wall-card-focus-ring: rgba(45, 212, 191, 0.86); - border-left-color: var(--wall-live); -} - -.mission-wall-tone--yellow { - --mission-wall-card-focus-ring: rgba(251, 191, 36, 0.88); - border-left-color: var(--wall-yellow); -} - -.mission-wall-row { - align-items: center; - display: flex; - gap: 10px; - justify-content: space-between; - min-width: 0; -} - -.mission-wall-run-card__name { - color: var(--wall-text); - font-size: 20px; - font-weight: 760; - line-height: 1.15; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mission-wall-pill { - border: 1px solid currentColor; - border-radius: 999px; - color: var(--wall-muted); - flex: 0 0 auto; - font-size: 11px; - font-weight: 820; - line-height: 1; - padding: 6px 9px; - text-transform: uppercase; - white-space: nowrap; -} - -.mission-wall-pill--blue { - color: var(--wall-blue-soft); -} - -.mission-wall-pill--green { - color: var(--wall-green-soft); -} - -.mission-wall-pill--grey { - color: var(--wall-muted); -} - -.mission-wall-pill--red { - color: var(--wall-red-soft); -} - -.mission-wall-pill--teal { - color: var(--wall-live); -} - -.mission-wall-pill--yellow { - color: var(--wall-yellow-soft); -} - -.mission-wall-run-card__stage { - color: var(--wall-muted); - font-size: 14px; - line-height: 1.35; - margin-top: 14px; - min-height: 19px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mission-wall-run-card__progress-row { - align-items: center; - display: flex; - gap: 12px; - justify-content: space-between; - margin-top: 14px; -} - -.mission-wall-run-card__progress-row--single { - justify-content: flex-start; -} - -.mission-wall-run-card__progress-label, -.mission-wall-run-card__duration { - color: var(--wall-muted); - font-size: 14px; - font-weight: 760; - line-height: 1; - white-space: nowrap; -} - -.mission-wall-progress { - background: rgba(201, 213, 206, 0.14); - border-radius: 999px; - height: 8px; - margin-top: 12px; - overflow: hidden; - width: 100%; -} - -.mission-wall-progress__bar { - background: var(--wall-live); - border-radius: inherit; - height: 100%; - min-width: 4px; -} - -.mission-wall-progress__bar--blue { - background: linear-gradient(90deg, #2563eb, #2dd4bf); -} - -.mission-wall-progress__bar--green { - background: linear-gradient(90deg, #16a34a, #86efac); -} - -.mission-wall-progress__bar--grey { - background: linear-gradient(90deg, #74847c, #aebbb4); -} - -.mission-wall-progress__bar--red { - background: linear-gradient(90deg, #dc2626, #f87171); -} - -.mission-wall-progress__bar--yellow { - background: linear-gradient(90deg, #d97706, #fbbf24); -} - -.mission-wall-stage { - display: grid; - grid-template-rows: 78px minmax(0, 1fr); -} - -.mission-wall-stage-head { - align-items: center; - border-bottom: 1px solid var(--wall-line); - display: block; - min-width: 0; - padding: 12px 18px; -} - -.mission-wall-stage-title { - color: var(--wall-text); - font-size: 22px; - font-weight: 780; - line-height: 1.15; - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mission-wall-stage-subtitle { - color: var(--wall-muted); - font-size: 13px; - font-weight: 680; - line-height: 1.25; - margin-top: 6px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mission-wall-canvas { - background: - linear-gradient(var(--wall-grid) 1px, transparent 1px), - linear-gradient(90deg, var(--wall-grid) 1px, transparent 1px), - radial-gradient(circle at 50% 44%, rgba(45, 212, 191, 0.08), transparent 36%), - var(--wall-canvas); - background-size: 34px 34px, 34px 34px, auto, auto; - display: grid; - grid-template-rows: minmax(0, 1fr); - min-height: 0; - overflow: hidden; -} - -.mission-wall-state-panel { - align-content: center; - background: - linear-gradient(var(--wall-grid) 1px, transparent 1px), - linear-gradient(90deg, var(--wall-grid) 1px, transparent 1px), - var(--wall-canvas); - background-size: 34px 34px, 34px 34px, auto; - display: grid; - gap: 12px; - min-height: 0; - padding: 56px; -} - -.mission-wall-stage-skeleton { - background: - linear-gradient(var(--wall-grid) 1px, transparent 1px), - linear-gradient(90deg, var(--wall-grid) 1px, transparent 1px), - var(--wall-canvas); - background-size: 34px 34px, 34px 34px, auto; - min-height: 0; - overflow: hidden; - padding: 18px; -} - -.mission-wall-stage-skeleton > div[aria-hidden="true"], -.mission-wall-stage-skeleton .aevatar-content-skeleton-canvas { - height: 100%; - min-height: 0; -} - -.mission-wall-stage-skeleton .aevatar-content-skeleton-canvas-surface { - background: var(--wall-panel-soft) !important; - border-color: var(--wall-line) !important; - flex: 1; - min-height: 0 !important; -} - -.mission-wall-stage-skeleton .aevatar-content-skeleton-node { - background: var(--wall-panel-strong) !important; - border-color: var(--wall-line-strong) !important; -} - -.mission-wall-stage-skeleton .aevatar-content-skeleton-connector, -.mission-wall-stage-skeleton .ant-skeleton-button, -.mission-wall-stage-skeleton .ant-skeleton-input { - background: var(--wall-line-strong) !important; -} - -.mission-wall-state-panel__kicker { - color: var(--wall-live); - font-size: 12px; - font-weight: 820; - letter-spacing: 0; - line-height: 1; - text-transform: uppercase; -} - -.mission-wall-state-panel__title { - color: var(--wall-text); - font-size: 34px; - font-weight: 780; - line-height: 1.12; - max-width: 760px; -} - -.mission-wall-graph { - min-height: 0; - overflow: hidden; - position: relative; -} - -.mission-wall-react-flow { - background: transparent; - height: 100%; - width: 100%; -} - -.mission-wall-react-flow .react-flow__pane { - cursor: grab; -} - -.mission-wall-react-flow .react-flow__pane:active { - cursor: grabbing; -} - -.mission-wall-react-flow .react-flow__edge-path { - stroke-linecap: round; - filter: drop-shadow(0 0 8px rgba(45, 212, 191, 0.12)); -} - -.mission-wall-react-flow .mission-wall-flow-edge--focused .react-flow__edge-path { - animation: mission-wall-flow-drift 1.8s linear infinite; - stroke-dasharray: 14 10; -} - -@keyframes mission-wall-flow-drift { - from { - stroke-dashoffset: 0; - } - - to { - stroke-dashoffset: -24; - } -} - -.mission-wall-react-flow .react-flow__controls { - background: rgba(16, 25, 22, 0.88); - border: 1px solid rgba(201, 213, 206, 0.18); - border-radius: 8px; - box-shadow: none; - overflow: hidden; -} - -.mission-wall-react-flow .react-flow__controls-button { - background: rgba(16, 25, 22, 0.96); - border-bottom: 1px solid rgba(201, 213, 206, 0.12); - color: var(--wall-muted); -} - -.mission-wall-react-flow .react-flow__controls-button svg { - fill: var(--wall-muted); -} - -.mission-wall-step-node { - background: linear-gradient(180deg, rgba(20, 33, 29, 0.98), rgba(15, 25, 22, 0.98)); - border: 1px solid var(--wall-line-strong); - border-radius: 14px; - box-shadow: 0 18px 46px rgba(0, 0, 0, 0.34); - display: grid; - gap: 10px; - min-height: 112px; - padding: 14px; - width: 260px; -} - -.mission-wall-step-node--focused { - border-color: rgba(45, 212, 191, 0.78); - box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.36), 0 24px 58px rgba(0, 0, 0, 0.42); -} - -.mission-wall-step-node--active { - border-color: rgba(45, 212, 191, 0.78); - box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.32), 0 0 26px rgba(45, 212, 191, 0.18), 0 24px 58px rgba(0, 0, 0, 0.42); -} - -.mission-wall-step-node--waiting { - border-color: rgba(217, 119, 6, 0.82); - box-shadow: 0 0 0 1px rgba(217, 119, 6, 0.28), 0 24px 58px rgba(0, 0, 0, 0.44); -} - -.mission-wall-step-node--failed { - border-color: rgba(220, 38, 38, 0.86); - box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.34), 0 24px 58px rgba(0, 0, 0, 0.44); -} - -.mission-wall-step-node__top { - align-items: center; - display: grid; - gap: 10px; - grid-template-columns: auto minmax(0, 1fr) auto; - min-width: 0; -} - -.mission-wall-step-node__identity { - min-width: 0; -} - -.mission-wall-step-node__icon { - align-items: center; - background: rgba(45, 212, 191, 0.12); - border-radius: 999px; - color: var(--wall-live); - display: flex; - font-size: 12px; - font-weight: 820; - height: 32px; - justify-content: center; - width: 32px; -} - -.mission-wall-step-node--failed .mission-wall-step-node__icon { - background: rgba(220, 38, 38, 0.16); - color: var(--wall-red-soft); -} - -.mission-wall-step-node__name { - color: var(--wall-text); - font-size: 15px; - font-weight: 780; - line-height: 1.2; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mission-wall-step-node__type { - color: var(--wall-muted); - font-size: 12px; - line-height: 1.25; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mission-wall-step-node__meta { - align-items: center; - color: var(--wall-muted); - display: flex; - font-size: 12px; - font-weight: 700; - gap: 10px; - justify-content: space-between; - line-height: 1.2; - min-width: 0; -} - -.mission-wall-step-node__meta span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mission-wall-step-node__handle { - background: var(--wall-live); - border: 2px solid rgba(9, 17, 15, 0.96); - height: 12px; - width: 12px; -} - -.mission-wall-step-node__handle--target { - left: -6px; -} - -.mission-wall-step-node__handle--source { - right: -6px; -} - -@media (max-width: 1500px) { - .mission-wall { - min-height: 700px; - padding: 14px; - } - - .mission-wall-top-strip { - grid-template-columns: - minmax(260px, 0.95fr) - repeat(5, minmax(82px, 0.42fr)) - minmax(190px, auto); - } - - .mission-wall-screen { - grid-template-columns: 340px minmax(0, 1fr); - } - - .mission-wall-brand__title, - .mission-wall-metric__value { - font-size: 24px; - } - - .mission-wall-stage-title { - font-size: 19px; - } - - .mission-wall-step-node { - min-height: 148px; - width: 310px; - } -} -`; diff --git a/apps/aevatar-console-web/src/pages/MissionWall/models.ts b/apps/aevatar-console-web/src/pages/MissionWall/models.ts deleted file mode 100644 index 3847f64904..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/models.ts +++ /dev/null @@ -1,241 +0,0 @@ -export type MissionWallLiveStatus = - "live" | "degraded" | "disconnected" | "idle"; - -export type MissionWallRunStatus = - | "running" - | "completed" - | "waiting" - | "failed" - | "timed_out" - | "retrying" - | "stopped" - | "stale" - | "unknown"; - -export type MissionWallVisibilityReason = - "running" | "recently_completed" | "priority_pinned" | "published_workflow"; - -export type MissionWallPriorityLevel = "none" | "info" | "warning" | "error"; - -export type MissionWallFocusReason = - | "failed" - | "timed_out" - | "waiting_human" - | "stale_projection" - | "stale_live" - | "retrying" - | "latest_running" - | "recently_completed"; - -export type MissionWallTopologyMode = - "workflow_step_graph" | "runtime_topology"; - -export type MissionWallStepStatus = - | "idle" - | "active" - | "completed" - | "waiting" - | "failed" - | "retrying" - | "unknown"; - -export interface MissionWallLiveState { - readonly status: MissionWallLiveStatus; - readonly message: string; - readonly lastObservedAt?: string; - readonly durableFreshnessSeconds?: number; -} - -export interface MissionWallSummary { - readonly runningRuns: number; - readonly wallVisibleRuns: number; - readonly waitingHuman: number; - readonly failedRuns: number; - readonly retryingRuns: number; - readonly recentlyCompletedRuns?: number; - readonly completedToday?: number; - readonly avgLatencyMs?: number; - readonly projectionFreshnessSeconds?: number; -} - -export interface MissionWallProgress { - readonly completedSteps: number; - readonly totalSteps: number; -} - -export interface MissionWallRun { - readonly id: string; - readonly runId: string; - readonly commandId?: string; - readonly scopeId?: string; - readonly teamId?: string; - readonly teamName?: string; - readonly entryMemberId?: string; - readonly entryMemberName?: string; - readonly publishedServiceId?: string; - readonly workflowName: string; - readonly status: MissionWallRunStatus; - readonly currentStepId?: string; - readonly currentStepLabel?: string; - readonly currentMemberId?: string; - readonly currentMemberName?: string; - readonly currentInformationCategory?: string; - readonly startedAt?: string; - readonly updatedAt?: string; - readonly durationMs?: number; - readonly progress?: MissionWallProgress; - readonly stateVersion?: number; - readonly lastEventId?: string; - readonly runtimeActorId?: string; - readonly hasRuntimeRun?: boolean; - readonly visibilityReason: MissionWallVisibilityReason; - readonly visibleUntil?: string; - readonly priorityLevel: MissionWallPriorityLevel; - readonly focusPriority: number; - readonly focusReason?: MissionWallFocusReason; -} - -export interface MissionWallFocus { - readonly runId?: string; - readonly reason?: MissionWallFocusReason; - readonly selectedAt?: string; -} - -export interface MissionWallWorkflowOverviewStep { - readonly stepId: string; - readonly index: number; - readonly status: MissionWallStepStatus; -} - -export interface MissionWallWorkflowLayout { - readonly engine: "manual" | "elk_layered"; - readonly direction: "right" | "down"; - readonly totalSteps?: number; - readonly windowStartIndex?: number; - readonly windowEndIndex?: number; - readonly viewportStepIds?: readonly string[]; - readonly stepOverview?: readonly MissionWallWorkflowOverviewStep[]; -} - -export interface MissionWallWorkflowStepNode { - readonly id: string; - readonly stepId: string; - readonly stepType: string; - readonly targetRole?: string; - readonly parametersSummary?: string; - readonly status: MissionWallStepStatus; - readonly focused?: boolean; - readonly runId?: string; - readonly runtimeActorId?: string; - readonly outputPreview?: string; - readonly error?: string; - readonly latencyMs?: number; - readonly position?: { - readonly x: number; - readonly y: number; - }; -} - -export interface MissionWallWorkflowStepEdge { - readonly id: string; - readonly fromStepId: string; - readonly toStepId: string; - readonly kind: "next" | "branch"; - readonly branchLabel?: string; - readonly traversed?: boolean; - readonly focused?: boolean; -} - -export interface MissionWallWorkflowGraph { - readonly nodes: readonly MissionWallWorkflowStepNode[]; - readonly edges: readonly MissionWallWorkflowStepEdge[]; - readonly layout?: MissionWallWorkflowLayout; - readonly selectedStepId?: string; -} - -export interface MissionWallRuntimeNode { - readonly id: string; - readonly label: string; - readonly kind: string; - readonly status: MissionWallStepStatus; - readonly runtimeActorId?: string; - readonly summary?: string; -} - -export interface MissionWallRuntimeEdge { - readonly id: string; - readonly source: string; - readonly target: string; - readonly label?: string; - readonly streaming?: boolean; -} - -export interface MissionWallRuntimeTopology { - readonly nodes: readonly MissionWallRuntimeNode[]; - readonly edges: readonly MissionWallRuntimeEdge[]; -} - -export interface MissionWallTopology { - readonly scope: "global" | "team" | "run"; - readonly mode: MissionWallTopologyMode; - readonly selectedRunId?: string; - readonly workflowGraph?: MissionWallWorkflowGraph; - readonly runtimeTopology?: MissionWallRuntimeTopology; -} - -export interface MissionWallSnapshot { - readonly generatedAt: string; - readonly live: MissionWallLiveState; - readonly summary: MissionWallSummary; - readonly runs: readonly MissionWallRun[]; - readonly focus: MissionWallFocus; - readonly topology: MissionWallTopology; -} - -export interface MissionWallStepSource { - readonly stepId: string; - readonly stepType: string; - readonly targetRole?: string; - readonly parametersSummary?: string; - readonly status: MissionWallStepStatus; - readonly outputPreview?: string; - readonly error?: string; - readonly latencyMs?: number; - readonly nextStepId?: string; - readonly branchTargets?: Readonly>; -} - -export interface MissionWallRunSource { - readonly runId: string; - readonly commandId?: string; - readonly scopeId?: string; - readonly teamId?: string; - readonly teamName?: string; - readonly entryMemberId?: string; - readonly entryMemberName?: string; - readonly publishedServiceId?: string; - readonly workflowName: string; - readonly status: MissionWallRunStatus; - readonly currentStepId?: string; - readonly currentStepLabel?: string; - readonly currentMemberId?: string; - readonly currentMemberName?: string; - readonly currentInformationCategory?: string; - readonly startedAt?: string; - readonly updatedAt?: string; - readonly durationMs?: number; - readonly completedSteps: number; - readonly totalSteps: number; - readonly windowStartIndex?: number; - readonly stateVersion?: number; - readonly lastEventId?: string; - readonly runtimeActorId?: string; - readonly hasRuntimeRun?: boolean; - readonly steps: readonly MissionWallStepSource[]; -} - -export interface MissionWallSource { - readonly generatedAt: string; - readonly live: MissionWallLiveState; - readonly runs: readonly MissionWallRunSource[]; -} diff --git a/apps/aevatar-console-web/src/pages/MissionWall/wallDirector.test.ts b/apps/aevatar-console-web/src/pages/MissionWall/wallDirector.test.ts deleted file mode 100644 index 8c4f1fde13..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/wallDirector.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { MissionWallRunSource, MissionWallSource } from "./models"; -import { buildMissionWallSnapshot, chooseFocusRun } from "./wallDirector"; - -const NOW = Date.parse("2026-06-30T08:30:24Z"); - -function buildRun( - overrides: Partial = {}, -): MissionWallRunSource { - return { - completedSteps: 1, - runId: "run-alpha", - status: "running", - steps: [ - { - nextStepId: "finish", - status: "active", - stepId: "start", - stepType: "llm_call", - }, - { - status: "idle", - stepId: "finish", - stepType: "emit", - }, - ], - totalSteps: 2, - updatedAt: "2026-06-30T08:30:10Z", - workflowName: "Alpha Workflow", - ...overrides, - }; -} - -function buildSource(runs: readonly MissionWallRunSource[]): MissionWallSource { - return { - generatedAt: "2026-06-30T08:30:24Z", - live: { - durableFreshnessSeconds: 2, - message: "live", - status: "live", - }, - runs, - }; -} - -function buildSteps(count: number) { - return Array.from({ length: count }, (_, index) => { - const stepNumber = index + 1; - return { - nextStepId: stepNumber < count ? `step-${stepNumber + 1}` : undefined, - status: index === count - 1 ? ("active" as const) : ("completed" as const), - stepId: `step-${stepNumber}`, - stepType: "llm_call" as const, - }; - }); -} - -describe("Mission Wall director", () => { - it("keeps published workflow entries after the focus retention window", () => { - const snapshot = buildMissionWallSnapshot( - buildSource([ - buildRun({ - runId: "run-live", - status: "running", - updatedAt: "2026-06-30T08:30:10Z", - }), - buildRun({ - runId: "run-recent", - status: "completed", - updatedAt: "2026-06-30T08:27:10Z", - }), - buildRun({ - runId: "run-old-complete", - status: "completed", - updatedAt: "2026-06-30T08:10:10Z", - }), - buildRun({ - runId: "run-failed", - status: "failed", - updatedAt: "2026-06-30T08:29:10Z", - }), - ]), - { nowMs: NOW }, - ); - - expect(snapshot.runs.map((run) => run.runId)).toEqual([ - "run-live", - "run-failed", - "run-recent", - "run-old-complete", - ]); - expect(snapshot.runs.at(-1)?.visibilityReason).toBe("published_workflow"); - expect(snapshot.summary.wallVisibleRuns).toBe(4); - expect(snapshot.summary.failedRuns).toBe(1); - expect(snapshot.summary.recentlyCompletedRuns).toBe(1); - }); - - it("does not auto-focus a published workflow entry without a current run", () => { - const snapshot = buildMissionWallSnapshot( - buildSource([ - buildRun({ - hasRuntimeRun: false, - runId: "published:svc-idle", - status: "unknown", - updatedAt: "2026-06-30T08:30:18Z", - }), - ]), - { nowMs: NOW }, - ); - - expect(snapshot.runs).toHaveLength(1); - expect(snapshot.runs[0].visibilityReason).toBe("published_workflow"); - expect(snapshot.focus.runId).toBeUndefined(); - expect(snapshot.topology.workflowGraph?.nodes).toHaveLength(0); - }); - - it("chooses failed runs before waiting and running runs", () => { - const snapshot = buildMissionWallSnapshot( - buildSource([ - buildRun({ - runId: "run-waiting", - status: "waiting", - updatedAt: "2026-06-30T08:30:18Z", - }), - buildRun({ - runId: "run-running", - status: "running", - updatedAt: "2026-06-30T08:30:21Z", - }), - buildRun({ - runId: "run-failed", - status: "failed", - updatedAt: "2026-06-30T08:30:12Z", - }), - ]), - { nowMs: NOW }, - ); - - expect(snapshot.focus.runId).toBe("run-failed"); - expect(snapshot.focus.reason).toBe("failed"); - }); - - it("honors a manual focus run override without changing visibility rules", () => { - const snapshot = buildMissionWallSnapshot( - buildSource([ - buildRun({ - runId: "run-waiting", - status: "waiting", - updatedAt: "2026-06-30T08:30:18Z", - }), - buildRun({ - runId: "run-failed", - status: "failed", - updatedAt: "2026-06-30T08:30:12Z", - }), - ]), - { - focusRunId: "run-waiting", - nowMs: NOW, - }, - ); - - expect(snapshot.focus.runId).toBe("run-waiting"); - expect(snapshot.topology.selectedRunId).toBe("run-waiting"); - }); - - it("chooses the highest-priority run for initial selection", () => { - const snapshot = buildMissionWallSnapshot( - buildSource([ - buildRun({ - runId: "run-waiting", - status: "waiting", - updatedAt: "2026-06-30T08:30:18Z", - }), - buildRun({ - runId: "run-running", - status: "running", - updatedAt: "2026-06-30T08:30:21Z", - }), - ]), - { nowMs: NOW }, - ); - - const selected = chooseFocusRun(snapshot.runs); - - expect(selected?.runId).toBe("run-waiting"); - expect(snapshot.focus.selectedAt).toBe("2026-06-30T08:30:24.000Z"); - }); - - it("shows all workflow nodes when the workflow fits in the graph window", () => { - const snapshot = buildMissionWallSnapshot( - buildSource([ - buildRun({ - currentStepId: "step-5", - steps: buildSteps(5), - totalSteps: 5, - }), - ]), - { nowMs: NOW }, - ); - - const graph = snapshot.topology.workflowGraph; - - expect(graph?.nodes.map((node) => node.stepId)).toEqual([ - "step-1", - "step-2", - "step-3", - "step-4", - "step-5", - ]); - expect(graph?.layout?.windowStartIndex).toBe(0); - expect(graph?.layout?.windowEndIndex).toBe(4); - }); - - it("connects audit steps in execution order when the audit omits explicit next links", () => { - const snapshot = buildMissionWallSnapshot( - buildSource([ - buildRun({ - currentStepId: "step-3", - steps: buildSteps(5).map(({ nextStepId: _nextStepId, ...step }) => step), - totalSteps: 5, - }), - ]), - { nowMs: NOW }, - ); - - expect(snapshot.topology.workflowGraph?.edges.map((edge) => [ - edge.fromStepId, - edge.toStepId, - ])).toEqual([ - ["step-1", "step-2"], - ["step-2", "step-3"], - ["step-3", "step-4"], - ["step-4", "step-5"], - ]); - }); - - it("does not mark completed run edges as live flow", () => { - const snapshot = buildMissionWallSnapshot( - buildSource([ - buildRun({ - completedSteps: 5, - currentStepId: "step-5", - status: "completed", - steps: buildSteps(5).map((step) => ({ - ...step, - status: "completed" as const, - })), - totalSteps: 5, - }), - ]), - { nowMs: NOW }, - ); - - const edges = snapshot.topology.workflowGraph?.edges ?? []; - - expect(edges).toHaveLength(4); - expect(edges.every((edge) => edge.traversed === true)).toBe(true); - expect(edges.every((edge) => edge.focused === false)).toBe(true); - }); - - it("keeps all workflow nodes while marking the focused big-screen window", () => { - const snapshot = buildMissionWallSnapshot( - buildSource([ - buildRun({ - currentStepId: "step-7", - steps: buildSteps(7), - totalSteps: 7, - }), - ]), - { nowMs: NOW }, - ); - - const graph = snapshot.topology.workflowGraph; - - expect(graph?.nodes.map((node) => node.stepId)).toEqual([ - "step-1", - "step-2", - "step-3", - "step-4", - "step-5", - "step-6", - "step-7", - ]); - expect(graph?.layout?.viewportStepIds).toEqual([ - "step-3", - "step-4", - "step-5", - "step-6", - "step-7", - ]); - expect(graph?.layout?.windowStartIndex).toBe(2); - expect(graph?.layout?.windowEndIndex).toBe(6); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/MissionWall/wallDirector.ts b/apps/aevatar-console-web/src/pages/MissionWall/wallDirector.ts deleted file mode 100644 index 97cbb79d0e..0000000000 --- a/apps/aevatar-console-web/src/pages/MissionWall/wallDirector.ts +++ /dev/null @@ -1,533 +0,0 @@ -import { t } from "@/shared/i18n/messages"; -import type { - MissionWallFocusReason, - MissionWallRun, - MissionWallRunSource, - MissionWallRunStatus, - MissionWallSnapshot, - MissionWallSource, - MissionWallStepStatus, - MissionWallSummary, - MissionWallTopology, - MissionWallVisibilityReason, - MissionWallWorkflowGraph, - MissionWallWorkflowStepEdge, - MissionWallWorkflowStepNode, -} from "./models"; - -export const COMPLETED_RETENTION_MS = 5 * 60 * 1000; -export const PRIORITY_PIN_RETENTION_MS = 30 * 60 * 1000; -const WORKFLOW_GRAPH_WINDOW_SIZE = 5; - -const FOCUS_PRIORITY: Record = { - failed: 1000, - timed_out: 1000, - waiting_human: 900, - stale_projection: 800, - stale_live: 800, - retrying: 700, - latest_running: 500, - recently_completed: 300, -}; - -function parseTimeMs(value?: string): number | undefined { - if (!value) { - return undefined; - } - - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function isPriorityStatus(status: MissionWallRunStatus): boolean { - return ( - status === "failed" || - status === "timed_out" || - status === "waiting" || - status === "retrying" || - status === "stale" - ); -} - -function resolveVisibilityReason( - source: MissionWallRunSource, - nowMs: number, -): MissionWallVisibilityReason { - const updatedAtMs = parseTimeMs(source.updatedAt); - - if (source.hasRuntimeRun === false) { - return "published_workflow"; - } - - if (source.status === "running") { - return "running"; - } - - if ( - source.status === "completed" && - updatedAtMs !== undefined && - nowMs - updatedAtMs <= COMPLETED_RETENTION_MS - ) { - return "recently_completed"; - } - - if ( - isPriorityStatus(source.status) && - (updatedAtMs === undefined || - nowMs - updatedAtMs <= PRIORITY_PIN_RETENTION_MS) - ) { - return "priority_pinned"; - } - - return "published_workflow"; -} - -function resolveFocusReason( - source: MissionWallRunSource, -): MissionWallFocusReason | undefined { - switch (source.status) { - case "failed": - return "failed"; - case "timed_out": - return "timed_out"; - case "waiting": - return "waiting_human"; - case "stale": - return "stale_projection"; - case "retrying": - return "retrying"; - case "running": - return "latest_running"; - case "completed": - return "recently_completed"; - default: - return undefined; - } -} - -function priorityLevelForStatus( - status: MissionWallRunStatus, -): MissionWallRun["priorityLevel"] { - if (status === "failed" || status === "timed_out") { - return "error"; - } - - if (status === "waiting" || status === "retrying" || status === "stale") { - return "warning"; - } - - if (status === "completed") { - return "info"; - } - - return "none"; -} - -function visibleUntilFor( - source: MissionWallRunSource, - visibilityReason: MissionWallVisibilityReason, -): string | undefined { - const updatedAtMs = parseTimeMs(source.updatedAt); - if (updatedAtMs === undefined) { - return undefined; - } - - if (visibilityReason === "recently_completed") { - return new Date(updatedAtMs + COMPLETED_RETENTION_MS).toISOString(); - } - - if (visibilityReason === "priority_pinned") { - return new Date(updatedAtMs + PRIORITY_PIN_RETENTION_MS).toISOString(); - } - - return undefined; -} - -function toWallRun( - source: MissionWallRunSource, - nowMs: number, -): MissionWallRun { - const visibilityReason = resolveVisibilityReason(source, nowMs); - const focusReason = resolveFocusReason(source); - - return { - commandId: source.commandId, - currentInformationCategory: source.currentInformationCategory, - currentMemberId: source.currentMemberId, - currentMemberName: source.currentMemberName, - currentStepId: source.currentStepId, - currentStepLabel: source.currentStepLabel, - durationMs: source.durationMs, - entryMemberId: source.entryMemberId, - entryMemberName: source.entryMemberName, - focusPriority: focusReason ? FOCUS_PRIORITY[focusReason] : 0, - focusReason, - hasRuntimeRun: source.hasRuntimeRun, - id: source.runId, - lastEventId: source.lastEventId, - progress: { - completedSteps: source.completedSteps, - totalSteps: source.totalSteps, - }, - priorityLevel: priorityLevelForStatus(source.status), - publishedServiceId: source.publishedServiceId, - runId: source.runId, - runtimeActorId: source.runtimeActorId, - scopeId: source.scopeId, - startedAt: source.startedAt, - stateVersion: source.stateVersion, - status: source.status, - teamId: source.teamId, - teamName: source.teamName, - updatedAt: source.updatedAt, - visibilityReason, - visibleUntil: visibleUntilFor(source, visibilityReason), - workflowName: source.workflowName, - }; -} - -function sortWallRuns(left: MissionWallRun, right: MissionWallRun): number { - const leftRunningRank = left.status === "running" ? 1 : 0; - const rightRunningRank = right.status === "running" ? 1 : 0; - const runningDelta = rightRunningRank - leftRunningRank; - if (runningDelta !== 0) { - return runningDelta; - } - - const priorityDelta = right.focusPriority - left.focusPriority; - if (priorityDelta !== 0) { - return priorityDelta; - } - - return ( - (parseTimeMs(right.updatedAt) ?? 0) - (parseTimeMs(left.updatedAt) ?? 0) - ); -} - -function newest(runs: readonly MissionWallRun[]): MissionWallRun | undefined { - return [...runs].sort( - (left, right) => - (parseTimeMs(right.updatedAt) ?? 0) - (parseTimeMs(left.updatedAt) ?? 0), - )[0]; -} - -function oldest(runs: readonly MissionWallRun[]): MissionWallRun | undefined { - return [...runs].sort( - (left, right) => - (parseTimeMs(left.updatedAt) ?? 0) - (parseTimeMs(right.updatedAt) ?? 0), - )[0]; -} - -function stalest(runs: readonly MissionWallRun[]): MissionWallRun | undefined { - return oldest(runs); -} - -export function isWallVisible(run: MissionWallRun): boolean { - return ( - run.visibilityReason === "running" || - run.visibilityReason === "recently_completed" || - run.visibilityReason === "priority_pinned" || - run.visibilityReason === "published_workflow" - ); -} - -function isFocusCandidate(run: MissionWallRun): boolean { - return ( - run.hasRuntimeRun !== false && run.visibilityReason !== "published_workflow" - ); -} - -export function chooseFocusRun( - runs: readonly MissionWallRun[], -): MissionWallRun | undefined { - const visible = runs.filter(isFocusCandidate); - return ( - newest( - visible.filter( - (run) => - run.focusReason === "failed" || run.focusReason === "timed_out", - ), - ) ?? - oldest(visible.filter((run) => run.focusReason === "waiting_human")) ?? - stalest( - visible.filter( - (run) => - run.focusReason === "stale_projection" || - run.focusReason === "stale_live", - ), - ) ?? - newest(visible.filter((run) => run.focusReason === "retrying")) ?? - newest(visible.filter((run) => run.focusReason === "latest_running")) ?? - newest(visible.filter((run) => run.focusReason === "recently_completed")) - ); -} - -function buildSummary( - runs: readonly MissionWallRun[], - source: MissionWallSource, -): MissionWallSummary { - const wallVisibleRuns = runs.filter(isWallVisible).length; - const runningRuns = source.runs.filter( - (run) => run.status === "running", - ).length; - const waitingHuman = source.runs.filter( - (run) => run.status === "waiting", - ).length; - const failedRuns = source.runs.filter( - (run) => run.status === "failed" || run.status === "timed_out", - ).length; - const retryingRuns = source.runs.filter( - (run) => run.status === "retrying", - ).length; - const recentlyCompletedRuns = runs.filter( - (run) => run.visibilityReason === "recently_completed", - ).length; - const latencySamples = runs - .filter((run) => run.hasRuntimeRun !== false) - .map((run) => run.durationMs) - .filter((value): value is number => typeof value === "number"); - const avgLatencyMs = latencySamples.length - ? Math.round( - latencySamples.reduce((sum, value) => sum + value, 0) / - latencySamples.length, - ) - : undefined; - - return { - avgLatencyMs, - failedRuns, - projectionFreshnessSeconds: source.live.durableFreshnessSeconds, - recentlyCompletedRuns, - retryingRuns, - runningRuns, - waitingHuman, - wallVisibleRuns, - }; -} - -function stepStatusToExecutionStatus( - status: MissionWallStepStatus, -): "idle" | "active" | "waiting" | "completed" | "failed" { - if (status === "active") return "active"; - if (status === "waiting") return "waiting"; - if (status === "completed") return "completed"; - if (status === "failed") return "failed"; - if (status === "retrying") return "active"; - return "idle"; -} - -function isLiveFlowStatus(status: MissionWallRunStatus): boolean { - return status === "running" || status === "waiting" || status === "retrying"; -} - -function isFocusedLiveEdge( - source: MissionWallRunSource, - fromStepId: string, - toStepId: string, -): boolean { - return ( - isLiveFlowStatus(source.status) && - (source.currentStepId === fromStepId || source.currentStepId === toStepId) - ); -} - -function buildStepEdges( - nodes: readonly MissionWallWorkflowStepNode[], - source: MissionWallRunSource, -): MissionWallWorkflowStepEdge[] { - const nodeIds = new Set(nodes.map((node) => node.stepId)); - const edges: MissionWallWorkflowStepEdge[] = []; - const connectedStepIds = new Set(); - - for (const [index, step] of source.steps.entries()) { - if (!nodeIds.has(step.stepId)) { - continue; - } - - let hasExplicitOutgoingEdge = false; - - if (step.nextStepId && nodeIds.has(step.nextStepId)) { - edges.push({ - focused: isFocusedLiveEdge(source, step.stepId, step.nextStepId), - fromStepId: step.stepId, - id: `edge:${step.stepId}:${step.nextStepId}:next`, - kind: "next", - toStepId: step.nextStepId, - traversed: step.status === "completed", - }); - connectedStepIds.add(`${step.stepId}->${step.nextStepId}`); - hasExplicitOutgoingEdge = true; - } - - for (const [branchLabel, targetStepId] of Object.entries( - step.branchTargets ?? {}, - )) { - if (!nodeIds.has(targetStepId)) { - continue; - } - - edges.push({ - branchLabel, - focused: isFocusedLiveEdge(source, step.stepId, targetStepId), - fromStepId: step.stepId, - id: `edge:${step.stepId}:${targetStepId}:branch:${branchLabel}`, - kind: "branch", - toStepId: targetStepId, - traversed: step.status === "completed", - }); - connectedStepIds.add(`${step.stepId}->${targetStepId}`); - hasExplicitOutgoingEdge = true; - } - - const nextStep = source.steps[index + 1]; - const shouldUseSequentialFallback = - !hasExplicitOutgoingEdge && - nextStep && - nodeIds.has(nextStep.stepId) && - !connectedStepIds.has(`${step.stepId}->${nextStep.stepId}`); - - if (shouldUseSequentialFallback) { - edges.push({ - focused: isFocusedLiveEdge(source, step.stepId, nextStep.stepId), - fromStepId: step.stepId, - id: `edge:${step.stepId}:${nextStep.stepId}:sequence`, - kind: "next", - toStepId: nextStep.stepId, - traversed: step.status === "completed", - }); - connectedStepIds.add(`${step.stepId}->${nextStep.stepId}`); - } - } - - return edges; -} - -function buildWorkflowGraph( - source?: MissionWallRunSource, -): MissionWallWorkflowGraph { - if (!source) { - return { - edges: [], - nodes: [], - }; - } - - const activeStepIndex = source.steps.findIndex( - (step) => step.stepId === source.currentStepId, - ); - const activeIndex = activeStepIndex >= 0 ? activeStepIndex : 0; - const maxWindowStartIndex = Math.max( - 0, - source.steps.length - WORKFLOW_GRAPH_WINDOW_SIZE, - ); - const requestedWindowStartIndex = - typeof source.windowStartIndex === "number" - ? Math.max(0, Math.min(source.windowStartIndex, maxWindowStartIndex)) - : undefined; - const centeredWindowStartIndex = Math.max( - 0, - activeIndex - Math.floor(WORKFLOW_GRAPH_WINDOW_SIZE / 2), - ); - const windowStartIndex = - source.steps.length <= WORKFLOW_GRAPH_WINDOW_SIZE - ? 0 - : (requestedWindowStartIndex ?? - Math.min(centeredWindowStartIndex, maxWindowStartIndex)); - const windowEndIndex = Math.min( - source.steps.length - 1, - windowStartIndex + WORKFLOW_GRAPH_WINDOW_SIZE - 1, - ); - const defaultViewportSteps = source.steps.slice( - windowStartIndex, - windowEndIndex + 1, - ); - const nodes: MissionWallWorkflowStepNode[] = source.steps.map( - (step, index) => ({ - error: step.error, - focused: step.stepId === source.currentStepId, - id: `step:${step.stepId}`, - latencyMs: step.latencyMs, - outputPreview: step.outputPreview, - parametersSummary: step.parametersSummary, - position: { - x: index * 372, - y: - step.status === "waiting" || step.status === "failed" - ? 168 - : index % 2 === 0 - ? 36 - : 92, - }, - runId: source.runId, - runtimeActorId: source.runtimeActorId, - status: step.status, - stepId: step.stepId, - stepType: step.stepType, - targetRole: step.targetRole, - }), - ); - - return { - edges: buildStepEdges(nodes, source), - layout: { - direction: "right", - engine: "manual", - stepOverview: source.steps.map((step, index) => ({ - index, - status: step.status, - stepId: step.stepId, - })), - totalSteps: source.steps.length, - viewportStepIds: defaultViewportSteps.map((step) => step.stepId), - windowEndIndex, - windowStartIndex, - }, - nodes, - selectedStepId: source.currentStepId, - }; -} - -export function buildMissionWallSnapshot( - source: MissionWallSource, - options?: { - focusRunId?: string; - nowMs?: number; - }, -): MissionWallSnapshot { - const nowMs = options?.nowMs ?? Date.parse(source.generatedAt); - const runs = source.runs - .map((run) => toWallRun(run, nowMs)) - .sort(sortWallRuns); - const focusRun = - runs.find((run) => run.runId === options?.focusRunId) ?? - chooseFocusRun(runs); - const sourceFocusRun = source.runs.find( - (run) => run.runId === focusRun?.runId, - ); - const selectedAt = new Date(nowMs).toISOString(); - const topology: MissionWallTopology = { - mode: "workflow_step_graph", - scope: sourceFocusRun?.teamId ? "team" : "global", - selectedRunId: focusRun?.runId, - workflowGraph: buildWorkflowGraph(sourceFocusRun), - }; - - return { - focus: focusRun - ? { - reason: focusRun.focusReason, - runId: focusRun.runId, - selectedAt, - } - : {}, - generatedAt: source.generatedAt, - live: source.live, - runs, - summary: buildSummary(runs, source), - topology, - }; -} - -export function toStudioExecutionStatus(status: MissionWallStepStatus) { - return stepStatusToExecutionStatus(status); -} diff --git a/apps/aevatar-console-web/src/pages/actors/actorPresentation.test.ts b/apps/aevatar-console-web/src/pages/actors/actorPresentation.test.ts deleted file mode 100644 index b45a940e7a..0000000000 --- a/apps/aevatar-console-web/src/pages/actors/actorPresentation.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - buildTimelineRows, - deriveSubgraphFromEdges, - filterTimelineRows, - type ActorTimelineFilters, -} from './actorPresentation'; - -function createFilters( - overrides?: Partial, -): ActorTimelineFilters { - return { - stages: [], - eventTypes: [], - stepTypes: [], - query: '', - errorsOnly: false, - ...overrides, - }; -} - -describe('actorPresentation', () => { - it('builds timeline rows with derived status and data summary', () => { - const rows = buildTimelineRows([ - { - timestamp: '2026-03-12T10:00:00Z', - stage: 'workflow.failed', - message: 'Step failed', - agentId: 'actor-1', - stepId: 'review', - stepType: 'human_input', - eventType: 'run.error', - data: { - reason: 'timeout', - }, - }, - ]); - - expect(rows).toHaveLength(1); - expect(rows[0].timelineStatus).toBe('error'); - expect(rows[0].dataSummary).toContain('timeout'); - }); - - it('filters timeline rows by stage, event type, query, and errors-only', () => { - const rows = buildTimelineRows([ - { - timestamp: '2026-03-12T10:00:00Z', - stage: 'workflow.start', - message: 'Started actor run', - agentId: 'actor-1', - stepId: 'start', - stepType: 'llm_call', - eventType: 'run.started', - data: {}, - }, - { - timestamp: '2026-03-12T10:01:00Z', - stage: 'workflow.failed', - message: 'Approval rejected', - agentId: 'actor-1', - stepId: 'approve', - stepType: 'human_approval', - eventType: 'run.error', - data: { - approver: 'ops', - }, - }, - ]); - - const filtered = filterTimelineRows( - rows, - createFilters({ - stages: ['workflow.failed'], - eventTypes: ['run.error'], - stepTypes: ['human_approval'], - query: 'ops', - errorsOnly: true, - }), - ); - - expect(filtered).toHaveLength(1); - expect(filtered[0].stepId).toBe('approve'); - }); - - it('derives a synthetic subgraph from edges-only payloads', () => { - const subgraph = deriveSubgraphFromEdges( - [ - { - edgeId: 'edge-1', - fromNodeId: 'actor-1', - toNodeId: 'actor-2', - edgeType: 'CHILD_OF', - updatedAt: '', - properties: {}, - }, - { - edgeId: 'edge-2', - fromNodeId: 'actor-2', - toNodeId: 'actor-3', - edgeType: 'OWNS', - updatedAt: '', - properties: {}, - }, - ], - 'actor-1', - ); - - expect(subgraph.rootNodeId).toBe('actor-1'); - expect(subgraph.nodes.map((node) => node.nodeId)).toEqual([ - 'actor-1', - 'actor-2', - 'actor-3', - ]); - expect(subgraph.edges).toHaveLength(2); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/actors/actorPresentation.ts b/apps/aevatar-console-web/src/pages/actors/actorPresentation.ts deleted file mode 100644 index fdafd99fcb..0000000000 --- a/apps/aevatar-console-web/src/pages/actors/actorPresentation.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { - WorkflowActorGraphEdge, - WorkflowActorGraphNode, - WorkflowActorGraphSubgraph, - WorkflowActorTimelineItem, -} from "@/shared/models/runtime/actors"; - -export type TimelineStatus = "processing" | "success" | "error" | "default"; - -export type ActorTimelineRow = WorkflowActorTimelineItem & { - key: string; - timelineStatus: TimelineStatus; - dataSummary: string; - dataCount: number; -}; - -export type ActorTimelineFilters = { - stages: string[]; - eventTypes: string[]; - stepTypes: string[]; - query: string; - errorsOnly: boolean; -}; - -export function deriveTimelineStatus(stage: string): TimelineStatus { - const normalized = stage.toLowerCase(); - if (normalized.includes("error") || normalized.includes("failed")) { - return "error"; - } - if ( - normalized.includes("completed") || - normalized.includes("finish") || - normalized.includes("end") - ) { - return "success"; - } - if ( - normalized.includes("start") || - normalized.includes("running") || - normalized.includes("wait") - ) { - return "processing"; - } - return "default"; -} - -function summarizeTimelineData(data: Record): { - dataSummary: string; - dataCount: number; -} { - const entries = Object.entries(data); - if (entries.length === 0) { - return { - dataSummary: "", - dataCount: 0, - }; - } - - const preview = entries - .slice(0, 2) - .map(([key, value]) => `${key}=${value}`) - .join(" · "); - - return { - dataSummary: - entries.length > 2 ? `${preview} · +${entries.length - 2} more` : preview, - dataCount: entries.length, - }; -} - -export function buildTimelineRows( - items: WorkflowActorTimelineItem[] -): ActorTimelineRow[] { - return items.map((item, index) => ({ - ...item, - key: `${item.timestamp}-${index}`, - timelineStatus: deriveTimelineStatus(item.stage), - ...summarizeTimelineData(item.data), - })); -} - -export function filterTimelineRows( - rows: ActorTimelineRow[], - filters: ActorTimelineFilters -): ActorTimelineRow[] { - const query = filters.query.trim().toLowerCase(); - - return rows.filter((row) => { - if (filters.errorsOnly && row.timelineStatus !== "error") { - return false; - } - - if (filters.stages.length > 0 && !filters.stages.includes(row.stage)) { - return false; - } - - if ( - filters.eventTypes.length > 0 && - !filters.eventTypes.includes(row.eventType) - ) { - return false; - } - - if ( - filters.stepTypes.length > 0 && - !filters.stepTypes.includes(row.stepType) - ) { - return false; - } - - if (!query) { - return true; - } - - return [ - row.stage, - row.message, - row.stepId, - row.stepType, - row.agentId, - row.eventType, - row.dataSummary, - ] - .join(" ") - .toLowerCase() - .includes(query); - }); -} - -export function deriveSubgraphFromEdges( - edges: WorkflowActorGraphEdge[], - rootNodeId: string -): WorkflowActorGraphSubgraph { - const nodesById = new Map(); - - function ensureNode(nodeId: string): void { - if (!nodeId || nodesById.has(nodeId)) { - return; - } - - nodesById.set(nodeId, { - nodeId, - nodeType: nodeId === rootNodeId ? "RootActor" : "DerivedActor", - updatedAt: "", - properties: { - nodeId, - source: "graph-edges", - }, - }); - } - - ensureNode(rootNodeId); - - for (const edge of edges) { - ensureNode(edge.fromNodeId); - ensureNode(edge.toNodeId); - } - - return { - rootNodeId, - nodes: [...nodesById.values()], - edges, - }; -} diff --git a/apps/aevatar-console-web/src/pages/actors/detail.test.tsx b/apps/aevatar-console-web/src/pages/actors/detail.test.tsx deleted file mode 100644 index bf3e98c7ff..0000000000 --- a/apps/aevatar-console-web/src/pages/actors/detail.test.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { setLocale } from '@umijs/max'; -import React from 'react'; -import { renderWithQueryClient } from '../../../tests/reactQueryTestUtils'; -import TopologyDetailPage from './detail'; - -jest.mock('@/shared/api/runtimeActorsApi', () => ({ - runtimeActorsApi: { - getActorSnapshot: jest.fn(), - getActorTimeline: jest.fn(), - getActorGraphEnriched: jest.fn(), - getActorGraphEdges: jest.fn(), - getActorGraphSubgraph: jest.fn(), - }, -})); - -jest.mock('@/shared/api/runtimeQueryApi', () => ({ - runtimeQueryApi: { - listAgents: jest.fn(async () => []), - }, -})); - -jest.mock('@/shared/graphs/GraphCanvas', () => ({ - __esModule: true, - default: () => { - const React = require('react'); - return React.createElement('div', null, 'GraphCanvas'); - }, -})); - -describe('TopologyDetailPage', () => { - beforeEach(() => { - setLocale('zh-CN', false); - window.localStorage.clear(); - }); - - afterEach(() => { - setLocale('en-US', false); - }); - - it('loads live actor topology in the dedicated detail page', async () => { - const { runtimeActorsApi } = jest.requireMock('@/shared/api/runtimeActorsApi') as { - runtimeActorsApi: { - getActorGraphEnriched: jest.Mock; - getActorSnapshot: jest.Mock; - getActorTimeline: jest.Mock; - }; - }; - - window.history.replaceState( - {}, - '', - '/runtime/explorer/detail?actorId=actor://selected&runId=run-current&scopeId=scope-route-a&serviceId=default', - ); - - runtimeActorsApi.getActorSnapshot.mockResolvedValue({ - actorId: 'actor://selected', - completedSteps: 4, - completionStatusValue: 1, - lastCommandId: 'cmd-1', - lastError: '', - lastEventId: 'evt-1', - lastOutput: 'Completed successfully.', - lastSuccess: true, - lastUpdatedAt: '2026-03-26T00:00:00Z', - requestedSteps: 4, - roleReplyCount: 2, - stateVersion: 7, - totalSteps: 4, - workflowName: 'SupportWorkflow', - }); - runtimeActorsApi.getActorTimeline.mockResolvedValue([ - { - agentId: 'actor://selected', - data: {}, - eventType: 'StepStarted', - message: 'Step started', - stage: 'workflow.started', - stepId: 'step-1', - stepType: 'chat', - timestamp: '2026-03-26T00:00:01Z', - }, - ]); - runtimeActorsApi.getActorGraphEnriched.mockResolvedValue({ - snapshot: { - actorId: 'actor://selected', - }, - subgraph: { - edges: [], - nodes: [ - { - nodeId: 'actor://selected', - nodeType: 'Actor', - properties: { - workflowName: 'SupportWorkflow', - }, - updatedAt: '2026-03-26T00:00:00Z', - }, - ], - rootNodeId: 'actor://selected', - }, - }); - - renderWithQueryClient(React.createElement(TopologyDetailPage)); - - await waitFor(() => { - expect(runtimeActorsApi.getActorSnapshot).toHaveBeenCalledWith( - 'actor://selected', - ); - }); - expect(await screen.findByText('追查工作区')).toBeTruthy(); - expect(screen.getByText('最近事件')).toBeTruthy(); - expect(screen.getAllByText('SupportWorkflow').length).toBeGreaterThan(0); - expect(screen.getByText('GraphCanvas')).toBeTruthy(); - - fireEvent.click(screen.getByRole('tab', { name: '快照' })); - - expect(screen.getByText('最近输出')).toBeTruthy(); - expect(screen.getByText('Completed successfully.')).toBeTruthy(); - }); - - it('opens the fullscreen graph workspace from the detail page', async () => { - const { runtimeActorsApi } = jest.requireMock('@/shared/api/runtimeActorsApi') as { - runtimeActorsApi: { - getActorGraphEnriched: jest.Mock; - getActorSnapshot: jest.Mock; - getActorTimeline: jest.Mock; - }; - }; - - window.history.replaceState( - {}, - '', - '/runtime/explorer/detail?actorId=actor://selected&runId=run-current&scopeId=scope-route-a&serviceId=default', - ); - - runtimeActorsApi.getActorSnapshot.mockResolvedValue({ - actorId: 'actor://selected', - completedSteps: 4, - completionStatusValue: 1, - lastCommandId: 'cmd-1', - lastError: '', - lastEventId: 'evt-1', - lastOutput: 'Completed successfully.', - lastSuccess: true, - lastUpdatedAt: '2026-03-26T00:00:00Z', - requestedSteps: 4, - roleReplyCount: 2, - stateVersion: 7, - totalSteps: 4, - workflowName: 'SupportWorkflow', - }); - runtimeActorsApi.getActorTimeline.mockResolvedValue([]); - runtimeActorsApi.getActorGraphEnriched.mockResolvedValue({ - snapshot: { - actorId: 'actor://selected', - }, - subgraph: { - edges: [], - nodes: [ - { - nodeId: 'actor://selected', - nodeType: 'Actor', - properties: { - workflowName: 'SupportWorkflow', - }, - updatedAt: '2026-03-26T00:00:00Z', - }, - ], - rootNodeId: 'actor://selected', - }, - }); - - renderWithQueryClient(React.createElement(TopologyDetailPage)); - - expect(await screen.findByRole('button', { name: '全屏查看关系图' })).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '全屏查看关系图' })); - - expect(await screen.findByText('全屏关系图')).toBeTruthy(); - }); - - it('preserves playback explorer detail context from the incoming route', async () => { - window.history.replaceState( - {}, - '', - '/runtime/explorer/detail?actorId=actor-route-a&runId=run-current&scopeId=scope-route-a&serviceId=default', - ); - - renderWithQueryClient(React.createElement(TopologyDetailPage)); - - await waitFor(() => { - expect(window.location.pathname).toBe('/runtime/explorer/detail'); - }); - - 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'); - }); - - it('shows a dedicated unavailable state when the actor snapshot no longer exists', async () => { - const { runtimeActorsApi } = jest.requireMock('@/shared/api/runtimeActorsApi') as { - runtimeActorsApi: { - getActorGraphEnriched: jest.Mock; - getActorSnapshot: jest.Mock; - getActorTimeline: jest.Mock; - }; - }; - - window.history.replaceState( - {}, - '', - '/runtime/explorer/detail?actorId=Workflow%3A10f7e5d3%3Arun%3A7d14565e04d34c28a613051a17ae4a77&runId=run-current&scopeId=scope-route-a&serviceId=default', - ); - - runtimeActorsApi.getActorSnapshot.mockRejectedValue(new Error('HTTP 404 Not Found')); - runtimeActorsApi.getActorTimeline.mockResolvedValue([]); - runtimeActorsApi.getActorGraphEnriched.mockRejectedValue(new Error('HTTP 404 Not Found')); - - renderWithQueryClient(React.createElement(TopologyDetailPage)); - - expect(await screen.findByText('当前 Actor 不可查询')).toBeTruthy(); - expect( - screen.getByText( - '当前后端还能引用这个 Actor,但已经查不到它的 Snapshot。常见原因是后端重启、运行态已清理,或这是历史绑定残留。', - ), - ).toBeTruthy(); - expect(screen.getAllByRole('button', { name: '返回对象列表' }).length).toBeGreaterThan(0); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/actors/detail.tsx b/apps/aevatar-console-web/src/pages/actors/detail.tsx deleted file mode 100644 index aea96cafd9..0000000000 --- a/apps/aevatar-console-web/src/pages/actors/detail.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import React from "react"; -import { TopologyExplorerPage } from "./index"; - -const TopologyDetailPage: React.FC = () => ; - -export default TopologyDetailPage; diff --git a/apps/aevatar-console-web/src/pages/actors/index.test.tsx b/apps/aevatar-console-web/src/pages/actors/index.test.tsx deleted file mode 100644 index 916634d714..0000000000 --- a/apps/aevatar-console-web/src/pages/actors/index.test.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; -import { setLocale } from "@umijs/max"; -import React from "react"; -import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; -import { runtimeQueryApi } from "@/shared/api/runtimeQueryApi"; -import { renderWithQueryClient } from "../../../tests/reactQueryTestUtils"; -import ActorsPage from "./index"; - -jest.mock("@/shared/api/runtimeActorsApi", () => ({ - runtimeActorsApi: { - getActorSnapshot: jest.fn(), - getActorTimeline: jest.fn(), - getActorGraphEnriched: jest.fn(), - getActorGraphEdges: jest.fn(), - getActorGraphSubgraph: jest.fn(), - }, -})); - -jest.mock("@/shared/api/runtimeQueryApi", () => ({ - runtimeQueryApi: { - listAgents: jest.fn(async () => []), - }, -})); - -jest.mock("@/shared/graphs/GraphCanvas", () => ({ - __esModule: true, - default: () => { - const React = require("react"); - return React.createElement("div", null, "GraphCanvas"); - }, -})); - -const actorCatalog = [ - { - description: "WorkflowRunGAgent[SupportRoot]", - id: "actor://workflow/customer-support/root-supervisor", - type: "WorkflowRunGAgent", - }, - { - description: "WorkflowRunGAgent[SupportPlanner]", - id: "actor://workflow/customer-support/planner", - type: "WorkflowRunGAgent", - }, -]; - -function buildActorSnapshot(actorId: string) { - return { - actorId, - completedSteps: actorId.endsWith("/planner") ? 4 : 2, - completionStatusValue: actorId.endsWith("/planner") ? 1 : 0, - lastCommandId: "cmd-customer-support", - lastError: "", - lastEventId: "evt-customer-support", - lastOutput: actorId.endsWith("/planner") - ? "Planner completed routing." - : "Supervisor is waiting for downstream checks.", - lastSuccess: actorId.endsWith("/planner"), - lastUpdatedAt: "2026-04-16T05:40:12Z", - requestedSteps: 4, - roleReplyCount: actorId.endsWith("/planner") ? 2 : 1, - stateVersion: actorId.endsWith("/planner") ? 7 : 5, - totalSteps: 4, - workflowName: actorId.endsWith("/planner") ? "SupportPlanner" : "SupportRoot", - }; -} - -function buildActorTimeline(actorId: string) { - return [ - { - agentId: actorId, - data: {}, - eventType: "StepCompleted", - message: actorId.endsWith("/planner") - ? "Classification completed and plan published." - : "Supervisor received escalation request.", - stage: actorId.endsWith("/planner") ? "workflow.completed" : "workflow.running", - stepId: actorId.endsWith("/planner") ? "plan" : "receive-request", - stepType: actorId.endsWith("/planner") ? "tool_call" : "message_ingress", - timestamp: "2026-04-16T05:40:12Z", - }, - ]; -} - -function buildActorGraph(actorId: string) { - return { - snapshot: { - actorId, - }, - subgraph: { - edges: [ - { - edgeId: "edge-owns", - edgeType: "OWNS", - fromNodeId: actorId, - toNodeId: "run://customer-support/current", - updatedAt: "2026-04-16T05:40:12Z", - }, - ], - nodes: [ - { - nodeId: actorId, - nodeType: "Actor", - properties: { - role: actorId.endsWith("/planner") ? "planner" : "supervisor", - workflowName: actorId.endsWith("/planner") - ? "CustomerSupportPlanner" - : "CustomerSupportTriage", - }, - updatedAt: "2026-04-16T05:40:12Z", - }, - { - nodeId: "run://customer-support/current", - nodeType: "WorkflowRun", - properties: { - commandId: "cmd-customer-support", - workflowName: "CustomerSupportTriage", - }, - updatedAt: "2026-04-16T05:40:12Z", - }, - ], - rootNodeId: actorId, - }, - }; -} - -describe("ActorsPage", () => { - const findActorRow = (needle: string) => - screen.getAllByRole("row").find((row) => row.textContent?.includes(needle)) ?? null; - - beforeEach(() => { - setLocale("zh-CN", false); - window.localStorage.clear(); - window.history.replaceState({}, "", "/runtime/explorer"); - (runtimeQueryApi.listAgents as jest.Mock).mockReset(); - (runtimeActorsApi.getActorSnapshot as jest.Mock).mockReset(); - (runtimeActorsApi.getActorTimeline as jest.Mock).mockReset(); - (runtimeActorsApi.getActorGraphEnriched as jest.Mock).mockReset(); - - (runtimeQueryApi.listAgents as jest.Mock).mockResolvedValue(actorCatalog); - (runtimeActorsApi.getActorSnapshot as jest.Mock).mockImplementation( - async (actorId: string) => buildActorSnapshot(actorId), - ); - (runtimeActorsApi.getActorTimeline as jest.Mock).mockImplementation( - async (actorId: string) => buildActorTimeline(actorId), - ); - (runtimeActorsApi.getActorGraphEnriched as jest.Mock).mockImplementation( - async (actorId: string) => buildActorGraph(actorId), - ); - }); - - afterEach(() => { - setLocale("en-US", false); - }); - - it("renders a table skeleton before deciding whether traceable actors are empty", async () => { - let resolveActors: (value: unknown[]) => void = () => {}; - (runtimeQueryApi.listAgents as jest.Mock).mockImplementationOnce( - () => - new Promise((resolve) => { - resolveActors = resolve; - }), - ); - - renderWithQueryClient(React.createElement(ActorsPage)); - - expect(await screen.findByText("选择追查对象")).toBeTruthy(); - expect(await screen.findByRole("status")).toHaveAttribute( - "data-variant", - "table", - ); - expect(screen.queryByText("暂无可追查对象")).toBeNull(); - - resolveActors([]); - - expect(await screen.findByText("暂无可追查对象")).toBeTruthy(); - expect(screen.queryByRole("status")).toBeNull(); - }); - - it("renders the live runtime explorer shell and actor list", async () => { - const { container } = renderWithQueryClient(React.createElement(ActorsPage)); - - await screen.findByText("SupportRoot"); - - expect(container.textContent).toContain("Platform"); - expect(container.textContent).toContain("Topology"); - expect(container.textContent).toContain("真实数据"); - expect(container.textContent).toContain("选择追查对象"); - expect(screen.getByPlaceholderText("输入 Actor ID")).toBeTruthy(); - expect(screen.getByPlaceholderText("筛选 Actor")).toBeTruthy(); - expect(screen.getByRole("button", { name: "刷新列表" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "打开追查详情" })).toBeTruthy(); - expect(screen.queryByText("示例数据")).toBeNull(); - expect(container.textContent).toContain("数据源"); - expect(container.textContent).toContain("Actor 查询"); - expect(container.textContent).toContain("SupportRoot"); - expect(screen.getAllByRole("button", { name: "查看概览" }).length).toBeGreaterThan(0); - }); - - it("opens the dedicated detail page when an actor id is entered directly", async () => { - renderWithQueryClient(React.createElement(ActorsPage)); - - fireEvent.change(screen.getByPlaceholderText("输入 Actor ID"), { - target: { value: "actor://workflow/customer-support/planner" }, - }); - fireEvent.click(screen.getByRole("button", { name: "打开追查详情" })); - - await waitFor(() => { - expect(window.location.pathname).toBe("/runtime/explorer/detail"); - }); - - const params = new URLSearchParams(window.location.search); - expect(params.get("actorId")).toBe("actor://workflow/customer-support/planner"); - }); - - it("opens a live preview drawer from the real actor list", async () => { - renderWithQueryClient(React.createElement(ActorsPage)); - - await screen.findByText("SupportPlanner"); - const plannerRow = findActorRow("SupportPlanner"); - - expect(plannerRow).toBeTruthy(); - - fireEvent.click( - within(plannerRow as HTMLElement).getByRole("button", { name: "查看概览" }), - ); - - expect(await screen.findByText("对象快速概览")).toBeTruthy(); - await waitFor(() => { - expect(runtimeActorsApi.getActorSnapshot).toHaveBeenCalledWith( - "actor://workflow/customer-support/planner", - ); - }); - }); - - it("does not open the preview drawer when only the row is clicked", async () => { - renderWithQueryClient(React.createElement(ActorsPage)); - - await screen.findByText("SupportPlanner"); - const plannerRow = findActorRow("SupportPlanner"); - - expect(plannerRow).toBeTruthy(); - - fireEvent.click(plannerRow as HTMLElement); - - expect(screen.queryByText("对象快速概览")).toBeNull(); - - fireEvent.click( - within(plannerRow as HTMLElement).getByRole("button", { name: "查看概览" }), - ); - - expect(await screen.findByText("对象快速概览")).toBeTruthy(); - }); - - it("opens runtime runs from the preview drawer using the preview actor context", async () => { - window.history.replaceState( - {}, - "", - "/runtime/explorer?scopeId=scope-team-1&serviceId=service-draft&runId=run-123", - ); - - renderWithQueryClient(React.createElement(ActorsPage)); - - await screen.findByText("SupportPlanner"); - const plannerRow = findActorRow("SupportPlanner"); - - expect(plannerRow).toBeTruthy(); - - fireEvent.click( - within(plannerRow as HTMLElement).getByRole("button", { name: "查看概览" }), - ); - - expect(await screen.findByText("对象快速概览")).toBeTruthy(); - - fireEvent.click(await screen.findByRole("button", { name: "查看运行" })); - - await waitFor(() => { - expect(window.location.pathname).toBe("/runtime/runs"); - }); - - const params = new URLSearchParams(window.location.search); - expect(params.get("actorId")).toBe("actor://workflow/customer-support/planner"); - expect(params.get("scopeId")).toBe("scope-team-1"); - expect(params.get("serviceOverrideId")).toBe("service-draft"); - expect(params.get("route")).toBe("SupportPlanner"); - expect(params.get("returnTo")).toContain("/runtime/explorer/detail"); - expect(params.get("returnTo")).toContain("actorId=actor%3A%2F%2Fworkflow%2Fcustomer-support%2Fplanner"); - }); - - it("shows a dedicated unavailable message when preview actor snapshot is gone", async () => { - (runtimeActorsApi.getActorSnapshot as jest.Mock).mockRejectedValueOnce( - new Error("HTTP 404 Not Found"), - ); - (runtimeActorsApi.getActorGraphEnriched as jest.Mock).mockRejectedValueOnce( - new Error("HTTP 404 Not Found"), - ); - - renderWithQueryClient(React.createElement(ActorsPage)); - - await screen.findByText("SupportPlanner"); - const plannerRow = findActorRow("SupportPlanner"); - - expect(plannerRow).toBeTruthy(); - - fireEvent.click( - within(plannerRow as HTMLElement).getByRole("button", { name: "查看概览" }), - ); - - expect(await screen.findByText("这个 Actor 当前不可预览")).toBeTruthy(); - expect(screen.getByText("当前后端还能引用这个 Actor,但已经查不到它的 Snapshot。常见原因是后端重启、运行态已清理,或这是历史绑定残留。")).toBeTruthy(); - }); - - it("keeps the list page route without a detail actor selection", async () => { - renderWithQueryClient(React.createElement(ActorsPage)); - - await waitFor(() => { - expect(window.location.pathname).toBe("/runtime/explorer"); - }); - expect(window.location.search).toBe(""); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/actors/index.tsx b/apps/aevatar-console-web/src/pages/actors/index.tsx deleted file mode 100644 index 49a007691e..0000000000 --- a/apps/aevatar-console-web/src/pages/actors/index.tsx +++ /dev/null @@ -1,1998 +0,0 @@ -import { - FullscreenOutlined, - RadarChartOutlined, -} from "@ant-design/icons"; -import { useQuery } from "@tanstack/react-query"; -import { useIntl } from "@umijs/max"; -import type { Edge, Node } from "@xyflow/react"; -import { Position } from "@xyflow/react"; -import { - Alert, - Button, - Drawer, - Empty, - Input, - Modal, - Select, - Space, - Table, - Tabs, - Tag, - Typography, - theme, -} from "antd"; -import type { ColumnsType } from "antd/es/table"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import GraphCanvas from "@/shared/graphs/GraphCanvas"; -import { runtimeActorsApi, type ActorGraphDirection } from "@/shared/api/runtimeActorsApi"; -import { runtimeQueryApi } from "@/shared/api/runtimeQueryApi"; -import type { - WorkflowActorGraphEdge, - WorkflowActorGraphNode, - WorkflowActorGraphSubgraph, - WorkflowActorTimelineItem, -} from "@/shared/models/runtime/actors"; -import type { WorkflowAgentSummary } from "@/shared/models/runtime/query"; -import { formatDateTime } from "@/shared/datetime/dateTime"; -import { history } from "@/shared/navigation/history"; -import { - buildRuntimeExplorerHref, - buildRuntimeRunsHref, -} from "@/shared/navigation/runtimeRoutes"; -import { - type AevatarBreadcrumbItem, - AevatarInspectorEmpty, - AevatarPanel, - AevatarStatusTag, -} from "@/shared/ui/aevatarPageShells"; -import { - AevatarCompactTag, - AevatarCompactText, - truncateMiddle, -} from "@/shared/ui/compactText"; -import ConsoleMenuPageShell from "@/shared/ui/ConsoleMenuPageShell"; -import AevatarContentSkeleton from "@/shared/ui/AevatarContentSkeleton"; -import { - buildAevatarPanelStyle, - buildAevatarTagStyle, - formatAevatarStatusLabel, - type AevatarThemeSurfaceToken, -} from "@/shared/ui/aevatarWorkbench"; -import { t } from "@/shared/i18n/messages"; - -type ExplorerRouteSelection = { - actorId: string; - runId: string; - scopeId: string; - serviceId: string; -}; - -type TopologyTabKey = "graph" | "timeline" | "edges" | "snapshot"; - -type DisplayActorRecord = { - description: string; - id: string; - subtitle?: string; - type?: string; - workflowName?: string; -}; - -const actorTableMaxHeight = "min(52vh, 440px)"; -const actorTableBodyMaxHeight = `calc(${actorTableMaxHeight} - 64px)`; -const actorTableMinWidth = 1120; -const actorPageStackStyle: React.CSSProperties = { - display: "flex", - flexDirection: "column", - gap: 18, - maxWidth: "100%", - minWidth: 0, - width: "100%", -}; -const platformBreadcrumbItems: AevatarBreadcrumbItem[] = [ - { - title: "Platform", - }, - { - current: true, - title: "Topology", - }, -]; -const actorTableShellStyle: React.CSSProperties = { - maxWidth: "100%", - minWidth: 0, - overflowX: "auto", - overflowY: "hidden", - width: "100%", -}; -const actorTableStyle: React.CSSProperties = { - maxHeight: actorTableMaxHeight, - maxWidth: "100%", - minWidth: 0, - overflow: "hidden", - width: "100%", -}; - -function readExplorerSelection(): ExplorerRouteSelection { - if (typeof window === "undefined") { - return { - actorId: "", - runId: "", - scopeId: "", - serviceId: "", - }; - } - - 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() ?? "", - }; -} - -function readAgentWorkflowName(actor: WorkflowAgentSummary): string { - const match = actor.description.match(/\[(.+)\]$/); - return match?.[1]?.trim() || actor.description || actor.type || "Actor"; -} - -function statusKeyFromCompletionValue(value?: number): string { - switch (value) { - case 0: - return "running"; - case 1: - return "completed"; - case 2: - return "timed_out"; - case 3: - return "failed"; - case 4: - return "stopped"; - case 5: - return "not_found"; - case 6: - return "disabled"; - default: - return "unknown"; - } -} - -function buildContextLabel(scopeId?: string, serviceId?: string, runId?: string): string { - const segments = [ - scopeId ? truncateMiddle(scopeId, 6, 4) : "", - serviceId || "", - runId ? truncateMiddle(runId, 8, 6) : "", - ].filter(Boolean); - return segments.length > 0 ? segments.join(" / ") : t("pages.actors.index.entry.context.not.brought", "Entry context not brought in"); -} - -function filterSubgraph( - subgraph: WorkflowActorGraphSubgraph, - direction: ActorGraphDirection, - edgeTypes: readonly string[], - depth: number, -): WorkflowActorGraphSubgraph { - const boundedDepth = Math.max(1, depth); - const normalizedEdgeTypes = edgeTypes.filter(Boolean); - const filteredEdges = subgraph.edges.filter( - (edge) => - normalizedEdgeTypes.length === 0 || - normalizedEdgeTypes.includes(edge.edgeType), - ); - - const outboundMap = new Map(); - const inboundMap = new Map(); - for (const edge of filteredEdges) { - const outbound = outboundMap.get(edge.fromNodeId) ?? []; - outbound.push(edge); - outboundMap.set(edge.fromNodeId, outbound); - - const inbound = inboundMap.get(edge.toNodeId) ?? []; - inbound.push(edge); - inboundMap.set(edge.toNodeId, inbound); - } - - const visitedNodes = new Set([subgraph.rootNodeId]); - const visitedEdges = new Set(); - const queue: Array<{ depth: number; nodeId: string }> = [ - { depth: 0, nodeId: subgraph.rootNodeId }, - ]; - - while (queue.length > 0) { - const current = queue.shift(); - if (!current) { - continue; - } - - if (current.depth >= boundedDepth) { - continue; - } - - const neighbors: WorkflowActorGraphEdge[] = []; - if (direction === "Both" || direction === "Outbound") { - neighbors.push(...(outboundMap.get(current.nodeId) ?? [])); - } - if (direction === "Both" || direction === "Inbound") { - neighbors.push(...(inboundMap.get(current.nodeId) ?? [])); - } - - for (const edge of neighbors) { - visitedEdges.add(edge.edgeId); - const neighborId = - edge.fromNodeId === current.nodeId ? edge.toNodeId : edge.fromNodeId; - if (visitedNodes.has(neighborId)) { - continue; - } - - visitedNodes.add(neighborId); - queue.push({ depth: current.depth + 1, nodeId: neighborId }); - } - } - - const nodes = subgraph.nodes.filter((node) => visitedNodes.has(node.nodeId)); - const edges = filteredEdges.filter((edge) => visitedEdges.has(edge.edgeId)); - return { - edges, - nodes, - rootNodeId: subgraph.rootNodeId, - }; -} - -function edgeTypeLabel(edgeType: string): string { - switch (edgeType) { - case "OWNS": - return "Run owns"; - case "CONTAINS_STEP": - return "Contains step"; - case "CHILD_OF": - return "Child actor"; - default: - return formatAevatarStatusLabel(edgeType); - } -} - -function graphNodeTitle(node: WorkflowActorGraphNode): string { - if (node.nodeType === "WorkflowRun") { - return node.properties.workflowName || "Workflow Run"; - } - - if (node.nodeType === "WorkflowStep") { - return node.properties.stepId || node.nodeId; - } - - return node.properties.role || node.nodeId; -} - -function graphNodeSubtitle(node: WorkflowActorGraphNode): string { - if (node.nodeType === "WorkflowRun") { - return truncateMiddle(node.properties.commandId || node.nodeId, 8, 6); - } - - if (node.nodeType === "WorkflowStep") { - return node.properties.stepType || node.nodeType; - } - - return truncateMiddle(node.nodeId, 8, 6); -} - -function isHttp404Error(error: unknown): boolean { - return ( - error instanceof Error && - /^HTTP 404\b/i.test(error.message.trim()) - ); -} - -function graphNodeTone(nodeType: string): { - background: string; - border: string; - text: string; -} { - switch (nodeType) { - case "WorkflowRun": - return { - background: "rgba(59, 130, 246, 0.10)", - border: "rgba(59, 130, 246, 0.28)", - text: "#2563EB", - }; - case "WorkflowStep": - return { - background: "rgba(34, 197, 94, 0.10)", - border: "rgba(34, 197, 94, 0.26)", - text: "#15803D", - }; - default: - return { - background: "rgba(249, 115, 22, 0.10)", - border: "rgba(249, 115, 22, 0.24)", - text: "#C2410C", - }; - } -} - -function graphLane(nodeType: string): number { - switch (nodeType) { - case "Actor": - return 0; - case "WorkflowRun": - return 1; - case "WorkflowStep": - return 2; - default: - return 3; - } -} - -const TopologyInlineToken: React.FC<{ - head?: number; - maxWidth?: React.CSSProperties["maxWidth"]; - monospace?: boolean; - strong?: boolean; - tail?: number; - value: string; -}> = ({ - head = 8, - maxWidth = "100%", - monospace = false, - strong = false, - tail = 6, - value, -}) => ( - -); - -const TopologyMetricCard: React.FC<{ - compact?: boolean; - label: string; - value: React.ReactNode; -}> = ({ compact = false, label, value }) => ( -
- - {label} - - - {value} - -
-); - -const TopologyCompactLabelText: React.FC<{ - color?: string; - maxWidth?: React.CSSProperties["maxWidth"]; - strong?: boolean; - value: string; -}> = ({ color, maxWidth = 180, strong = false, value }) => { - return ( - - ); -}; - -const TopologyCompactIdentifierTag: React.FC<{ - color?: string; - value: string; -}> = ({ color, value }) => { - return ( - - ); -}; - -const TopologyStatusPill: React.FC<{ - status: string; -}> = ({ status }) => { - const { token } = theme.useToken(); - - return ( - - {formatAevatarStatusLabel(status)} - - ); -}; - -const topologySelectionPanelStyle: React.CSSProperties = { - background: "rgba(248, 250, 252, 0.92)", - border: "1px solid var(--ant-color-border-secondary)", - borderRadius: 16, - display: "flex", - flexDirection: "column", - gap: 12, - padding: 16, -}; - -const topologyGraphManagedSelectionClassName = - "graph-canvas-self-managed-selection"; - -const TopologyNodeCard: React.FC<{ - node: WorkflowActorGraphNode; - selected?: boolean; -}> = ({ node, selected = false }) => { - const tone = graphNodeTone(node.nodeType); - - return ( -
- - - {node.nodeType} - - - - {graphNodeTitle(node)} - - - {graphNodeSubtitle(node)} - -
- ); -}; - -function buildGraphCanvasNodes( - subgraph: WorkflowActorGraphSubgraph, - selectedNodeId?: string, -): Node[] { - const groups = new Map(); - for (const node of subgraph.nodes) { - const lane = graphLane(node.nodeType); - const laneNodes = groups.get(lane) ?? []; - laneNodes.push(node); - groups.set(lane, laneNodes); - } - - for (const laneNodes of groups.values()) { - laneNodes.sort((left, right) => - left.nodeId === subgraph.rootNodeId - ? -1 - : right.nodeId === subgraph.rootNodeId - ? 1 - : left.nodeId.localeCompare(right.nodeId), - ); - } - - return subgraph.nodes.map((node) => { - const lane = graphLane(node.nodeType); - const laneNodes = groups.get(lane) ?? []; - const index = laneNodes.findIndex((item) => item.nodeId === node.nodeId); - - return { - className: topologyGraphManagedSelectionClassName, - data: { - label: React.createElement(TopologyNodeCard, { - node, - selected: node.nodeId === selectedNodeId, - }), - }, - id: node.nodeId, - position: { - x: lane * 320, - y: index * 164, - }, - sourcePosition: Position.Right, - style: { - background: "transparent", - border: "none", - boxShadow: "none", - padding: 0, - width: 232, - }, - targetPosition: Position.Left, - type: "default", - }; - }); -} - -function buildGraphCanvasEdges(subgraph: WorkflowActorGraphSubgraph): Edge[] { - return subgraph.edges.map((edge) => { - const color = - edge.edgeType === "OWNS" - ? "#2563EB" - : edge.edgeType === "CONTAINS_STEP" - ? "#16A34A" - : "#D97706"; - - return { - animated: edge.edgeType === "CHILD_OF", - id: edge.edgeId, - label: edgeTypeLabel(edge.edgeType), - labelStyle: { - fill: "#475467", - fontSize: 11, - fontWeight: 600, - }, - source: edge.fromNodeId, - style: { - stroke: color, - strokeWidth: 2, - }, - target: edge.toNodeId, - type: "smoothstep", - }; - }); -} - -export const TopologyExplorerPage: React.FC<{ - detailOnly?: boolean; -}> = ({ detailOnly = false }) => { - const intl = useIntl(); - const { token } = theme.useToken(); - const surfaceToken = token as AevatarThemeSurfaceToken; - const initialRouteRef = useRef(readExplorerSelection()); - const workbenchRef = useRef(null); - const initialActorId = initialRouteRef.current.actorId || ""; - const [activeTab, setActiveTab] = useState("graph"); - const [actorKeyword, setActorKeyword] = useState(""); - const [actorInput, setActorInput] = useState(initialActorId); - const [selectedActorId, setSelectedActorId] = useState(initialActorId); - const [direction, setDirection] = useState("Both"); - const [depth, setDepth] = useState(2); - const [edgeTypes, setEdgeTypes] = useState([]); - const [selectedNodeId, setSelectedNodeId] = useState(initialActorId); - const [selectedEdgeId, setSelectedEdgeId] = useState(""); - const [previewActorId, setPreviewActorId] = useState(initialActorId); - const [previewOpen, setPreviewOpen] = useState(false); - const [graphFullscreenOpen, setGraphFullscreenOpen] = useState(false); - - const actorsQuery = useQuery({ - enabled: !detailOnly, - queryFn: () => runtimeQueryApi.listAgents(), - queryKey: ["runtime-agents"], - }); - const selectedSnapshotQuery = useQuery({ - enabled: detailOnly && selectedActorId.trim().length > 0, - queryFn: () => runtimeActorsApi.getActorSnapshot(selectedActorId), - queryKey: ["runtime-actor-snapshot", selectedActorId], - }); - const timelineQuery = useQuery({ - enabled: detailOnly && selectedActorId.trim().length > 0, - queryFn: () => runtimeActorsApi.getActorTimeline(selectedActorId, { take: 40 }), - queryKey: ["runtime-actor-timeline", selectedActorId], - }); - const graphQuery = useQuery({ - enabled: detailOnly && selectedActorId.trim().length > 0, - queryFn: () => - runtimeActorsApi.getActorGraphEnriched(selectedActorId, { - depth, - direction, - edgeTypes, - take: 120, - }), - queryKey: [ - "runtime-actor-graph", - selectedActorId, - depth, - direction, - edgeTypes.join("|"), - ], - }); - const previewSnapshotQuery = useQuery({ - enabled: previewOpen && previewActorId.trim().length > 0, - queryFn: () => runtimeActorsApi.getActorSnapshot(previewActorId), - queryKey: ["runtime-actor-snapshot", previewActorId, "preview"], - }); - const previewTimelineQuery = useQuery({ - enabled: previewOpen && previewActorId.trim().length > 0, - queryFn: () => runtimeActorsApi.getActorTimeline(previewActorId, { take: 8 }), - queryKey: ["runtime-actor-timeline", previewActorId, "preview"], - }); - const previewGraphQuery = useQuery({ - enabled: previewOpen && previewActorId.trim().length > 0, - queryFn: () => - runtimeActorsApi.getActorGraphEnriched(previewActorId, { - depth: 2, - direction: "Both", - edgeTypes: [], - take: 60, - }), - queryKey: ["runtime-actor-graph", previewActorId, "preview"], - }); - - const liveActors = useMemo(() => { - const keyword = actorKeyword.trim().toLowerCase(); - const actors = actorsQuery.data ?? []; - const filtered = keyword - ? actors.filter((actor) => - [actor.id, actor.type, actor.description] - .join(" ") - .toLowerCase() - .includes(keyword), - ) - : actors; - - return filtered.map((actor) => ({ - description: actor.type || "WorkflowRunGAgent", - id: actor.id, - subtitle: actor.type, - type: actor.type, - workflowName: readAgentWorkflowName(actor), - })); - }, [actorKeyword, actorsQuery.data]); - - const displayActors = liveActors; - const selectedDisplayActor = - displayActors.find((actor) => actor.id === selectedActorId) ?? null; - const previewDisplayActor = - displayActors.find((actor) => actor.id === previewActorId) ?? null; - const selectedSnapshot = selectedSnapshotQuery.data ?? null; - const selectedTimeline = timelineQuery.data ?? []; - const rawSubgraph = graphQuery.data?.subgraph ?? null; - const previewSnapshot = previewSnapshotQuery.data ?? null; - const previewTimelineRecords = previewTimelineQuery.data ?? []; - const previewSubgraph = previewGraphQuery.data?.subgraph ?? { - edges: [], - nodes: [], - rootNodeId: previewActorId, - }; - const selectedSubgraph = useMemo(() => { - if (!rawSubgraph) { - return { - edges: [], - nodes: [], - rootNodeId: selectedActorId, - } satisfies WorkflowActorGraphSubgraph; - } - - return filterSubgraph(rawSubgraph, direction, edgeTypes, depth); - }, [depth, direction, edgeTypes, rawSubgraph, selectedActorId]); - - const selectedNode = useMemo( - () => - selectedSubgraph.nodes.find((node) => node.nodeId === selectedNodeId) ?? - selectedSubgraph.nodes.find((node) => node.nodeId === selectedSubgraph.rootNodeId) ?? - null, - [selectedNodeId, selectedSubgraph], - ); - - const selectedEdge = useMemo( - () => - selectedSubgraph.edges.find((edge) => edge.edgeId === selectedEdgeId) ?? null, - [selectedEdgeId, selectedSubgraph.edges], - ); - - useEffect(() => { - if (!selectedSubgraph.nodes.length) { - setSelectedNodeId(""); - setSelectedEdgeId(""); - return; - } - - if ( - selectedNodeId && - selectedSubgraph.nodes.some((node) => node.nodeId === selectedNodeId) - ) { - return; - } - - setSelectedNodeId(selectedSubgraph.rootNodeId); - setSelectedEdgeId(""); - }, [selectedNodeId, selectedSubgraph]); - - useEffect(() => { - const routeSelection = readExplorerSelection(); - history.replace( - buildRuntimeExplorerHref({ - actorId: detailOnly ? selectedActorId || undefined : undefined, - runId: - detailOnly && - routeSelection.actorId && - routeSelection.actorId === selectedActorId - ? routeSelection.runId || undefined - : detailOnly - ? initialRouteRef.current.runId || undefined - : undefined, - scopeId: detailOnly ? routeSelection.scopeId || undefined : undefined, - serviceId: detailOnly ? routeSelection.serviceId || undefined : undefined, - }), - ); - }, [detailOnly, selectedActorId]); - - const currentContextLabel = useMemo( - () => - buildContextLabel( - initialRouteRef.current.scopeId, - initialRouteRef.current.serviceId, - initialRouteRef.current.runId, - ), - [], - ); - - const focusStatus = statusKeyFromCompletionValue( - selectedSnapshot?.completionStatusValue, - ); - const focusStatusLabel = formatAevatarStatusLabel(focusStatus); - const previewStatus = statusKeyFromCompletionValue( - previewSnapshot?.completionStatusValue, - ); - const previewTimeline = previewTimelineRecords.slice(0, 4); - const previewUpdatedAt = - previewSnapshot?.lastUpdatedAt || - ""; - const previewContextLabel = buildContextLabel( - initialRouteRef.current.scopeId, - initialRouteRef.current.serviceId, - initialRouteRef.current.runId, - ); - const graphCanvasNodes = useMemo( - () => buildGraphCanvasNodes(selectedSubgraph, selectedNode?.nodeId), - [selectedNode?.nodeId, selectedSubgraph], - ); - const graphCanvasEdges = useMemo( - () => buildGraphCanvasEdges(selectedSubgraph), - [selectedSubgraph], - ); - const availableEdgeTypes = useMemo( - () => - Array.from( - new Set(selectedSubgraph.edges.map((edge) => edge.edgeType).filter(Boolean)), - ).sort((left, right) => left.localeCompare(right)), - [selectedSubgraph.edges], - ); - - const actorTableColumns = useMemo>( - () => [ - { - dataIndex: "timestamp", - key: "timestamp", - title: t("pages.actors.index.time", "time"), - width: 170, - render: (value: string) => formatDateTime(value), - }, - { - dataIndex: "stage", - key: "stage", - title: t("pages.actors.index.stage", "stage"), - width: 132, - render: (value: string) => , - }, - { - dataIndex: "message", - key: "message", - title: t("pages.actors.index.event", "event"), - render: (value: string, record) => ( -
- {value} - - {record.stepId || record.eventType} · {record.stepType || "n/a"} - -
- ), - }, - { - dataIndex: "agentId", - key: "agentId", - title: "Actor", - width: 220, - render: (value: string) => , - }, - ], - [token.colorTextSecondary], - ); - - const edgeTableColumns = useMemo>( - () => [ - { - dataIndex: "edgeType", - key: "edgeType", - title: intl.formatMessage({ - id: "pages.actors.index.relation", - defaultMessage: "relation", - }), - width: 140, - render: (value: string) => {edgeTypeLabel(value)}, - }, - { - dataIndex: "fromNodeId", - key: "fromNodeId", - title: "From", - render: (value: string) => , - }, - { - dataIndex: "toNodeId", - key: "toNodeId", - title: "To", - render: (value: string) => , - }, - { - dataIndex: "updatedAt", - key: "updatedAt", - title: intl.formatMessage({ - id: "pages.actors.index.latest.updates", - defaultMessage: "Latest updates", - }), - width: 170, - render: (value: string) => formatDateTime(value), - }, - ], - [intl], - ); - - const commitWorkbenchActor = useCallback((actorId: string) => { - setActorInput(actorId); - setSelectedActorId(actorId); - setSelectedNodeId(actorId); - setSelectedEdgeId(""); - setPreviewActorId(actorId); - }, []); - - const handleLoadFocus = useCallback(() => { - const nextValue = actorInput.trim(); - if (!nextValue) { - if (detailOnly) { - setActorInput(""); - setSelectedActorId(""); - setSelectedNodeId(""); - setPreviewActorId(""); - setSelectedEdgeId(""); - } - return; - } - - if (!detailOnly) { - history.push( - buildRuntimeExplorerHref({ - actorId: nextValue, - runId: initialRouteRef.current.runId || undefined, - scopeId: initialRouteRef.current.scopeId || undefined, - serviceId: initialRouteRef.current.serviceId || undefined, - }), - ); - return; - } - - commitWorkbenchActor(nextValue); - }, [actorInput, commitWorkbenchActor, detailOnly]); - - const handleOpenPreview = useCallback( - (actorId: string) => { - setPreviewActorId(actorId); - setPreviewOpen(true); - }, - [], - ); - - const handleEnterWorkbench = useCallback( - () => { - const nextActorId = previewActorId.trim(); - if (!nextActorId) { - return; - } - - history.push( - buildRuntimeExplorerHref({ - actorId: nextActorId, - runId: initialRouteRef.current.runId || undefined, - scopeId: initialRouteRef.current.scopeId || undefined, - serviceId: initialRouteRef.current.serviceId || undefined, - }), - ); - setPreviewOpen(false); - }, - [previewActorId], - ); - - const handleOpenRuns = useCallback((targetActorId?: string, workflowName?: string) => { - const nextActorId = (targetActorId ?? selectedActorId).trim(); - if (!nextActorId) { - return; - } - - history.push( - buildRuntimeRunsHref({ - actorId: nextActorId, - route: workflowName?.trim() || undefined, - scopeId: initialRouteRef.current.scopeId || undefined, - serviceId: initialRouteRef.current.serviceId || undefined, - returnTo: buildRuntimeExplorerHref({ - actorId: nextActorId || undefined, - runId: initialRouteRef.current.runId || undefined, - scopeId: initialRouteRef.current.scopeId || undefined, - serviceId: initialRouteRef.current.serviceId || undefined, - }), - }), - ); - }, [selectedActorId]); - - const handleBackToExplorerList = useCallback(() => { - history.push(buildRuntimeExplorerHref()); - }, []); - - const loadingLiveTopology = - detailOnly && - (selectedSnapshotQuery.isLoading || timelineQuery.isLoading || graphQuery.isLoading); - const loadingPreviewTopology = - previewOpen && - (previewSnapshotQuery.isLoading || previewTimelineQuery.isLoading || previewGraphQuery.isLoading); - const selectedActorUnavailable = - detailOnly && - selectedActorId.trim().length > 0 && - (isHttp404Error(selectedSnapshotQuery.error) || - isHttp404Error(graphQuery.error)); - const previewActorUnavailable = - previewOpen && - previewActorId.trim().length > 0 && - (isHttp404Error(previewSnapshotQuery.error) || - isHttp404Error(previewGraphQuery.error)); - const liveError = - actorsQuery.error || - (selectedActorUnavailable - ? null - : selectedSnapshotQuery.error || timelineQuery.error || graphQuery.error); - const previewError = - previewActorUnavailable - ? null - : previewSnapshotQuery.error || previewTimelineQuery.error || previewGraphQuery.error; - - const actorListColumns = useMemo>( - () => [ - { - key: "workflow", - title: t("pages.actors.index.workflow.object", "workflow/Object"), - width: 280, - render: (_value, record) => ( -
- - -
- ), - }, - { - dataIndex: "id", - key: "id", - title: t("pages.actors.index.actor.id", "Actor ID"), - width: 180, - render: (value: string) => ( - - ), - }, - { - dataIndex: "type", - key: "type", - title: t("pages.actors.index.actor.type", "actor type"), - width: 196, - render: (value?: string) => ( -
- - -
- ), - }, - { - key: "context", - title: t("pages.actors.index.entry.context", "Entry context"), - width: 220, - render: () => ( - - ), - }, - { - key: "actions", - title: t("pages.actors.index.operate", "operate"), - width: 108, - render: (_value, record) => ( - - ), - }, - ], - [currentContextLabel, handleOpenPreview, token.colorTextSecondary], - ); - - const graphControlLabelStyle: React.CSSProperties = { - color: token.colorTextSecondary, - fontSize: 12, - fontWeight: 600, - }; - - const graphControls = ( -
-
- {t("pages.actors.index.figure.direction", "Figure direction")} - setDepth(Number(value))} - /> -
-
- {t("pages.actors.index.relationship.type", "Relationship type")} - setActorInput(event.target.value)} - placeholder={t("pages.actors.index.enter.actor.id", "Enter actor ID")} - style={{ - fontFamily: '"IBM Plex Mono", "SF Mono", monospace', - fontSize: 12, - }} - value={actorInput} - /> -
- {!detailOnly ? ( -
- - {t("pages.actors.index.filter.actors", "Filter Actors")} - setActorKeyword(event.target.value)} - placeholder={t("pages.actors.index.filter.actors.2", "Filter Actors")} - value={actorKeyword} - /> -
- ) : null} -
- -
- - - {detailOnly ? ( - - ) : ( - - )} - -
-
- - {liveError ? ( - - ) : null} - -
- {detailOnly ? ( - <> - - - ) : ( - "n/a" - ) - } - /> - } - /> - - - ) : ( - <> - - - - - - )} -
- - {!detailOnly ? ( - {t("pages.actors.index.real.time", "real time")}} - > - {actorsQuery.isLoading ? ( - - ) : displayActors.length > 0 ? ( -
- - className="topology-actor-table" - columns={actorListColumns} - dataSource={displayActors} - locale={{ emptyText: t("pages.actors.index.there.are.currently.no.2", "There are currently no targets to trace.") }} - pagination={false} - rowKey={(record) => record.id} - scroll={{ x: actorTableMinWidth, y: actorTableBodyMaxHeight }} - size="middle" - style={actorTableStyle} - tableLayout="fixed" - /> -
- ) : ( - - )} -
- ) : null} - - {!detailOnly ? ( - setPreviewOpen(false)} - open={previewOpen} - size="large" - title={t("pages.actors.index.quick.overview.of.objects", "Quick overview of objects")} - > - {!previewActorId ? ( - - ) : loadingPreviewTopology ? ( - - ) : previewActorUnavailable ? ( - actorUnavailableNotice(previewActorId, { - compact: true, - contextLabel: previewContextLabel, - title: t("pages.actors.index.this.actor.is.currently", "This actor is currently not available for preview"), - }) - ) : previewError ? ( - - ) : !previewSnapshot ? ( - - ) : ( -
-
- - {t("pages.actors.index.real.time.object", "real time object")} - - - - -
- -
- - - - -
- -
- {t("pages.actors.index.entry.context.6", "Entry context")} - - - {previewSnapshot.lastOutput || t("pages.actors.index.there.are.currently.no.4", "There are currently no recent outputs.")} - -
- -
- {t("pages.actors.index.recent.events", "recent events")} - {previewTimeline.length > 0 ? ( - previewTimeline.map((event) => ( -
- - - - {formatDateTime(event.timestamp)} - - - - -
- )) - ) : ( - {t("pages.actors.index.there.are.currently.no.5", "There are currently no recent events.")} - )} -
- - - - - -
- )} -
- ) : null} - - {detailOnly ? ( -
- - {t("pages.actors.index.current.focus", "current focus")} - - - ) : undefined - } - > - {!selectedActorId ? ( - - ) : loadingLiveTopology ? ( - - ) : selectedActorUnavailable ? ( - actorUnavailableNotice(selectedActorId, { - action: , - contextLabel: currentContextLabel, - }) - ) : !selectedSnapshot ? ( - - ) : ( -
-
- - - - -
- - - - {t("pages.actors.index.node", "node")}{selectedSubgraph.nodes.length} - {t("pages.actors.index.side", "side")}{selectedSubgraph.edges.length} - - - } - > - {selectedSubgraph.nodes.length > 0 ? ( -
- {graphControls} - - {t("pages.actors.index.the.focus.actor.will", "The focus actor will be fixed as the root node, and the current relationship between workflow run, step and child actor is also shown in the figure.")} -
- setSelectedEdgeId("")} - onEdgeSelect={(edgeId) => { - setSelectedEdgeId(edgeId); - }} - onNodeSelect={(nodeId) => { - setSelectedNodeId(nodeId); - setSelectedEdgeId(""); - }} - selectedEdgeId={selectedEdgeId} - selectedNodeId={selectedNode?.nodeId} - /> -
-
- ) : ( - - )} -
- - {selectionInspector} -
- ), - }, - { - key: "timeline", - label: t("pages.actors.index.recent.events.2", "recent events"), - children: ( - - - columns={actorTableColumns} - dataSource={selectedTimeline} - locale={{ emptyText: t("pages.actors.index.there.are.currently.no.6", "There are currently no timeline events.") }} - pagination={false} - rowKey={(record) => - `${record.timestamp}-${record.stage}-${record.stepId}-${record.eventType}` - } - size="small" - /> - - ), - }, - { - key: "edges", - label: t("pages.actors.index.side.relationship", "side relationship"), - children: ( - - - columns={edgeTableColumns} - dataSource={selectedSubgraph.edges} - locale={{ emptyText: t("pages.actors.index.there.are.currently.no.7", "There are currently no edge relationships.") }} - pagination={false} - rowKey={(record) => record.edgeId} - size="small" - /> - - ), - }, - { - key: "snapshot", - label: t("pages.actors.index.snapshot", "Snapshot"), - children: ( - -
- - - } - /> - - - -
-
-
- - {t("pages.actors.index.recent.output", "recent output")} - - {selectedSnapshot.lastOutput || t("pages.actors.index.no.output.yet", "No output yet.")} - -
-
- - {t("pages.actors.index.recent.errors", "recent errors")} - - {selectedSnapshot.lastError || t("pages.actors.index.there.are.currently.no.8", "There are currently no errors.")} - -
-
-
- ), - }, - ]} - onChange={(key) => setActiveTab(key as TopologyTabKey)} - /> -
- )} - - - ) : null} - - {detailOnly ? ( - setGraphFullscreenOpen(false)} - open={graphFullscreenOpen} - style={{ top: 24 }} - title={t("pages.actors.index.full.screen.relationship.diagram", "Full screen relationship diagram")} - width="calc(100vw - 48px)" - > -
-
- {graphControls} -
- setSelectedEdgeId("")} - onEdgeSelect={(edgeId) => { - setSelectedEdgeId(edgeId); - }} - onNodeSelect={(nodeId) => { - setSelectedNodeId(nodeId); - setSelectedEdgeId(""); - }} - selectedEdgeId={selectedEdgeId} - selectedNodeId={selectedNode?.nodeId} - /> -
-
-
- -
- - - - -
-
- {selectionInspector} -
-
-
- ) : null} - - - ); -}; - -const ActorsPage: React.FC = () => ; - -export default ActorsPage; diff --git a/apps/aevatar-console-web/src/pages/auth/callback/index.test.tsx b/apps/aevatar-console-web/src/pages/auth/callback/index.test.tsx index 4465693fa8..654e303e6b 100644 --- a/apps/aevatar-console-web/src/pages/auth/callback/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/auth/callback/index.test.tsx @@ -8,8 +8,7 @@ import CallbackPage from './index'; const replaceLocation = jest.fn(); const handleRedirectCallback = jest.fn(); const loginWithRedirect = jest.fn(); -const reviewReturnTo = - '/scopes/scope-alpha/workflow-activity-vnext/settings?section=account'; +const reviewReturnTo = '/scopes/scope-alpha/settings?section=account'; jest.mock('@/shared/auth/client', () => ({ NyxIDAuthClient: jest.fn(), @@ -85,7 +84,7 @@ describe('NyxID callback page', () => { }, }); handleRedirectCallback.mockResolvedValue({ - returnTo: '/runtime/runs', + returnTo: '/scopes/scope-1/activity', session: { tokens: { accessToken: 'new-access-token', @@ -104,7 +103,7 @@ describe('NyxID callback page', () => { await waitFor(() => { expect(handleRedirectCallback).toHaveBeenCalledTimes(1); }); - expect(replaceLocation).toHaveBeenCalledWith('/runtime/runs'); + expect(replaceLocation).toHaveBeenCalledWith('/scopes/scope-1/activity'); }); it('returns to Account settings after service access review succeeds', async () => { @@ -197,7 +196,7 @@ describe('NyxID callback page', () => { Object.assign(new Error('required_service_access_missing'), { flow: 'signIn', reason: 'requiredServiceAccessMissing', - returnTo: '/scopes/scope-1/workflow-activity-vnext/workflows', + returnTo: '/scopes/scope-1/workflows', }), ); @@ -211,7 +210,7 @@ describe('NyxID callback page', () => { expect(loginWithRedirect).toHaveBeenCalledWith({ flow: 'signIn', prompt: 'consent', - returnTo: '/scopes/scope-1/workflow-activity-vnext/workflows', + returnTo: '/scopes/scope-1/workflows', }); }); diff --git a/apps/aevatar-console-web/src/pages/chat/ChatActorControls.test.tsx b/apps/aevatar-console-web/src/pages/chat/ChatActorControls.test.tsx deleted file mode 100644 index 2fc1b51321..0000000000 --- a/apps/aevatar-console-web/src/pages/chat/ChatActorControls.test.tsx +++ /dev/null @@ -1,596 +0,0 @@ -import { act, fireEvent, render, screen, within } from '@testing-library/react'; -import React from 'react'; -import { ChatActorControls } from './ChatActorControls'; -import { - type ChatActorProjection, - chatActionIdentityKey, - createChatActorProjection, -} from './chatActorState'; -import type { ChatActorStep, ChatTaskPlan } from './chatTaskPlan'; - -function stepFixture(overrides: Partial = {}): ChatActorStep { - return { - stepId: 'step-read', - order: 1, - kind: 'tool', - status: 'running', - required: true, - description: 'Inspect the connected repository', - source: { - kind: 'tool', - label: 'repository_read', - serviceSlug: 'github-api', - serviceId: 'svc-alpha', - }, - mayChangeExternalState: false, - externalEffect: 'not_started', - availableActions: { retry: false, skip: false, stop: true }, - updatedAt: '2026-08-08T00:00:00Z', - addedBy: 'initial', - addedInPlanRevision: 3, - dependsOn: [], - substeps: [ - { substepId: 'substep-access', title: 'Check access', status: 'done' }, - { - substepId: 'substep-read', - title: 'Read repository', - status: 'running', - }, - ], - operation: { - conversationActorId: 'conversation-alpha', - turnId: 'turn-alpha', - taskId: 'task-alpha', - stepId: 'step-read', - operationId: 'operation-alpha', - operationGeneration: 2, - kind: 'tool', - phase: 'running', - lastProgressAt: '2026-08-08T00:00:00Z', - stalledAt: '2026-08-08T00:02:00Z', - }, - ...overrides, - }; -} - -function planFixture(steps: readonly ChatActorStep[]): ChatTaskPlan { - return { - schemaVersion: 4, - actorId: 'conversation-alpha', - taskId: 'task-alpha', - turnId: 'turn-alpha', - planId: 'plan-alpha', - planRevision: 3, - planRevisionHistoryStart: 1, - planRevisions: [ - { - planRevision: 3, - revisionCause: 'steering', - committedAt: '2026-08-08T00:00:00Z', - addedStepIds: ['step-read'], - cancelledStepIds: [], - }, - ], - title: 'Inspect and verify the repository', - status: 'active', - activeStepId: steps[0]?.stepId, - steps, - }; -} - -function projectionFixture( - steps: readonly ChatActorStep[] = [stepFixture()], -): ChatActorProjection { - const projection = createChatActorProjection('conversation-alpha'); - projection.stateVersion = 17; - projection.activeTurn = { - turnId: 'turn-alpha', - taskId: 'task-alpha', - status: 'active', - }; - projection.task = planFixture(steps); - projection.steps = new Map(steps.map((step) => [step.stepId, step])); - return projection; -} - -function callbacks() { - return { - onActionOpen: jest.fn(), - onActionConnectCredential: jest.fn(), - onActionRefresh: jest.fn(), - onActionReport: jest.fn(), - onInputResolve: jest.fn(), - onRetry: jest.fn(), - onSkip: jest.fn(), - onSteer: jest.fn(), - onStop: jest.fn(), - }; -} - -describe('ChatActorControls', () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-08-08T00:02:00Z')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('renders the complete actor-owned plan ledger', () => { - const verify = stepFixture({ - stepId: 'step-verify', - order: 2, - kind: 'postcondition', - status: 'done', - description: 'Verify repository access', - source: { kind: 'postcondition', label: 'service.connected' }, - externalEffect: 'confirmed', - availableActions: { retry: false, skip: false, stop: false }, - substeps: [], - operation: null, - updatedAt: '2026-08-08T00:00:05Z', - }); - const projection = projectionFixture([stepFixture(), verify]); - const handlers = callbacks(); - render(); - - expect(screen.getByRole('region', { name: 'Task plan' })).toHaveTextContent( - 'Inspect and verify the repository', - ); - expect(screen.getByText('repository_read')).toBeInTheDocument(); - expect(screen.getByText('not_started')).toBeInTheDocument(); - expect(screen.getByText('Check access · done')).toBeInTheDocument(); - expect(screen.getByText('Stalled')).toBeInTheDocument(); - expect( - screen.getByText('Verified against service.connected'), - ).toBeInTheDocument(); - }); - - it('renders a committed NyxID approval observation from actor facts', () => { - const step = stepFixture({ - approvalObservation: { - approvalRequestId: 'nyxid-decision-alpha', - decisionMode: 'per_request', - receiptStatus: 'denied', - observedAt: '2026-08-08T00:03:00Z', - }, - }); - const projection = projectionFixture([step]); - render(); - - expect( - screen.getByRole('region', { name: 'NyxID approval observation' }), - ).toBeInTheDocument(); - expect(screen.getByText('nyxid-decision-alpha')).toBeInTheDocument(); - expect(screen.getAllByText('denied')).toHaveLength(2); - }); - - it('submits option identities and directs free text to the shared composer', () => { - const projection = projectionFixture(); - projection.pendingInput = { - requestId: 'input-alpha', - prompt: 'Choose a region or override the threshold', - options: [ - { optionId: 'option-sg', label: 'Singapore' }, - { optionId: 'option-fra', label: 'Frankfurt' }, - ], - allowFreeText: true, - multiSelect: false, - }; - const handlers = callbacks(); - render(); - - expect( - screen.getByText('Type the answer in the composer below.'), - ).toBeInTheDocument(); - expect(screen.queryByLabelText('Free text answer')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('radio', { name: 'Singapore' })); - fireEvent.click(screen.getByRole('button', { name: 'Submit answer' })); - expect(handlers.onInputResolve).toHaveBeenCalledWith( - { selectedOptionIds: ['option-sg'] }, - expect.objectContaining({ requestId: 'input-alpha' }), - ); - }); - - it('submits a typed numeric threshold from the actor control', () => { - const projection = projectionFixture(); - projection.pendingInput = { - requestId: 'input-threshold', - prompt: 'Choose the numeric threshold', - options: [], - allowFreeText: true, - multiSelect: false, - numericThreshold: { - suggestedValue: 70, - minimumValue: 0, - maximumValue: 100, - }, - }; - const handlers = callbacks(); - render(); - - fireEvent.change( - screen.getByRole('spinbutton', { name: 'Numeric threshold' }), - { - target: { value: '75' }, - }, - ); - fireEvent.click(screen.getByRole('button', { name: 'Submit answer' })); - - expect(handlers.onInputResolve).toHaveBeenCalledWith( - { freeText: '75' }, - expect.objectContaining({ requestId: 'input-threshold' }), - ); - expect(screen.getByText('Suggested 70')).toBeInTheDocument(); - expect( - screen.queryByText('Type the answer in the composer below.'), - ).not.toBeInTheDocument(); - }); - - it('renders committed condition and guarded-tool facts after reload', () => { - const condition = stepFixture({ - stepId: 'step-condition', - order: 1, - kind: 'condition', - status: 'done', - description: 'Evaluate the observed value', - source: { - kind: 'condition', - label: '80 >= 75', - condition: { - conditionId: 'condition-alpha', - sourceInputRequestId: 'input-threshold', - suggestedThreshold: 70, - effectiveThreshold: 75, - thresholdOrigin: 'user_override', - observedValue: 80, - comparison: 'gte', - outcome: 'true', - guardedToolName: 'external_record_create', - }, - }, - mayChangeExternalState: false, - externalEffect: 'not_applied', - availableActions: { retry: false, skip: false, stop: false }, - dependsOn: ['step-input'], - substeps: [], - operation: null, - }); - const guarded = stepFixture({ - stepId: 'step-write', - order: 2, - status: 'planned', - description: 'Create the verified record', - source: { kind: 'tool', label: 'external_record_create' }, - guard: { - conditionStepId: 'step-condition', - requiredOutcome: 'true', - }, - dependsOn: ['step-condition'], - operation: null, - }); - - render( - , - ); - - const facts = screen.getByRole('region', { - name: 'Committed condition facts', - }); - expect(facts).toHaveTextContent('80 >= 75'); - expect(facts).toHaveTextContent('true'); - expect(facts).toHaveTextContent('user_override'); - expect(facts).toHaveTextContent('external_record_create'); - expect( - screen.getByText('Guard step-condition requires true'), - ).toBeInTheDocument(); - }); - - it('shows only actor-authored recovery controls and moves steering to the composer', () => { - const retry = stepFixture({ - status: 'failed', - externalEffect: 'not_applied', - availableActions: { retry: true, skip: true, stop: true }, - }); - const handlers = callbacks(); - render( - , - ); - - fireEvent.click( - screen.getByRole('button', { - name: 'Retry Inspect the connected repository', - }), - ); - fireEvent.click( - screen.getByRole('button', { - name: 'Skip Inspect the connected repository', - }), - ); - fireEvent.click(screen.getByRole('button', { name: 'Stop task' })); - expect(handlers.onRetry).toHaveBeenCalledWith(retry); - expect(handlers.onSkip).toHaveBeenCalledWith(retry); - expect(handlers.onStop).toHaveBeenCalledTimes(1); - expect( - screen.getByText('Type a steering instruction in the composer.'), - ).toBeInTheDocument(); - expect( - screen.queryByLabelText('Steering instruction'), - ).not.toBeInTheDocument(); - }); - - it('shows a Tier-B receipt only inside its step after the observation exists', () => { - const handlers = callbacks(); - const { rerender } = render( - , - ); - expect( - screen.queryByRole('region', { name: 'NyxID approval observation' }), - ).not.toBeInTheDocument(); - - const observedStep = stepFixture({ - approvalObservation: { - approvalRequestId: 'nyxid-approval-alpha', - decisionMode: 'per_request', - receiptStatus: 'approval_required', - observedAt: '2026-08-08T00:10:00Z', - }, - }); - rerender( - , - ); - const step = screen - .getByText('Inspect the connected repository') - .closest('li'); - if (!step) throw new Error('Missing observed task step.'); - const observation = within(step).getByRole('region', { - name: 'NyxID approval observation', - }); - expect(observation).toHaveTextContent('NyxID request observed'); - expect(observation).toHaveTextContent('nyxid-approval-alpha'); - expect(observation).toHaveTextContent('per_request'); - expect(observation).toHaveTextContent('approval_required'); - expect(observation).toHaveTextContent('2026-08-08T00:10:00Z'); - expect( - screen.queryByRole('button', { name: 'Approve' }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'Reject' }), - ).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Approval reason')).not.toBeInTheDocument(); - }); - - it('keeps action completion pending until exact committed postcondition proof arrives', () => { - const projection = projectionFixture([]); - const request = { - schemaVersion: 4 as const, - actorId: 'conversation-alpha', - originTurnId: 'turn-alpha', - taskId: 'task-alpha', - stepId: 'step-connect', - actionRequestId: 'action-alpha', - action: 'service.connect' as const, - params: { - catalogService: { - serviceSlug: 'api-github', - requestedScopes: ['repo:read'], - }, - }, - }; - projection.actions.set('action-alpha', { - ...request, - request, - reports: [ - { - actionRequestId: 'action-alpha', - originTurnId: 'turn-alpha', - disposition: 'completed', - resource: { userService: { userServiceId: 'user-service-alpha' } }, - }, - ], - postconditionResult: null, - }); - const handlers = callbacks(); - const { rerender } = render( - , - ); - expect( - screen.getByText(/Reported; waiting for actor verification/), - ).toBeInTheDocument(); - expect(screen.getByText('repo:read')).toBeInTheDocument(); - - const action = projection.actions.get('action-alpha'); - if (!action) throw new Error('Missing action fixture.'); - projection.actions.set('action-alpha', { - ...action, - postconditionResult: { - actionRequestId: 'action-alpha', - disposition: 'completed', - verified: true, - resource: { userService: { userServiceId: 'user-service-alpha' } }, - }, - }); - rerender(); - expect(screen.getByText('Actor verified')).toBeInTheDocument(); - }); - - it.each([ - 'declined', - 'failed', - 'cancelled', - 'expired', - ] as const)('renders %s as terminal instead of waiting for verification', (disposition) => { - const projection = projectionFixture([]); - const request = { - schemaVersion: 4 as const, - actorId: 'conversation-alpha', - originTurnId: 'turn-alpha', - taskId: 'task-alpha', - stepId: 'step-connect', - actionRequestId: 'action-alpha', - action: 'service.connect' as const, - params: { catalogService: { serviceSlug: 'api-github' } }, - }; - projection.actions.set('action-alpha', { - ...request, - request, - reports: [ - { - actionRequestId: 'action-alpha', - originTurnId: 'turn-alpha', - disposition, - }, - ], - postconditionResult: null, - }); - - render(); - - expect(screen.getByText(disposition)).toBeInTheDocument(); - expect( - screen.queryByText(/waiting for actor verification/i), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'Refresh connection' }), - ).not.toBeInTheDocument(); - }); - - it('keeps the wire inspector gated and redacts secret keys and values', () => { - const projection = projectionFixture(); - const handlers = callbacks(); - const wire = { - custom: { - name: 'nyxid.task.snapshot', - payload: { - actorId: 'conversation-alpha', - authorization: 'Bearer secret-bearer', - nested: { apiKey: 'nyxid_secretvalue' }, - note: 'Authorization was Bearer another-secret', - }, - }, - }; - const view = render( - , - ); - expect( - screen.queryByRole('button', { name: 'Show wire' }), - ).not.toBeInTheDocument(); - - view.rerender( - , - ); - fireEvent.click(screen.getByRole('button', { name: 'Show wire' })); - const inspector = screen.getByRole('region', { name: 'Wire inspector' }); - expect(inspector).toHaveTextContent('conversation-alpha'); - expect(inspector).toHaveTextContent('Bearer [REDACTED]'); - expect(inspector).toHaveTextContent('[REDACTED]'); - expect(inspector).not.toHaveTextContent('secret-bearer'); - expect(inspector).not.toHaveTextContent('nyxid_secretvalue'); - }); - - it('presents typed idempotent and stale control outcomes without enabling controls', () => { - const projection = projectionFixture([]); - projection.activeTurn = null; - projection.task = null; - projection.latestTurn = { - turnId: 'turn-alpha', - taskId: 'task-alpha', - status: 'stopped', - }; - projection.latestControlResult = { - outcome: 'rejected', - reasonCode: 'NYXID_CHAT_STATE_VERSION_MISMATCH', - }; - projection.latestStepControlResult = { - outcome: 'idempotent', - reasonCode: 'NYXID_CHAT_OPERATION_ALREADY_RECONCILED', - }; - render(); - - const committed = screen.getByRole('region', { - name: 'Committed results', - }); - expect(committed).toHaveTextContent('rejected'); - expect(committed).toHaveTextContent('NYXID_CHAT_STATE_VERSION_MISMATCH'); - expect(committed).toHaveTextContent('idempotent'); - expect(committed).toHaveTextContent( - 'NYXID_CHAT_OPERATION_ALREADY_RECONCILED', - ); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); - - it('renders a committed reload summary without browser-cached action parameters', () => { - const projection = projectionFixture([]); - projection.actions.set('action-alpha', { - schemaVersion: 4, - actorId: 'conversation-alpha', - originTurnId: 'turn-alpha', - taskId: 'task-alpha', - stepId: 'step-connect', - actionRequestId: 'action-alpha', - action: 'service.connect', - reports: [], - postconditionResult: null, - request: null, - }); - render(); - - expect( - screen.getByText('Waiting for the connection decision'), - ).toBeInTheDocument(); - expect( - screen.getByText(/current-state contract does not expose/), - ).toBeInTheDocument(); - expect( - screen.queryByRole('button', { name: 'Open NyxID connection' }), - ).not.toBeInTheDocument(); - }); - - it('does not infer a stall from browser time without an actor-owned stalled fact', () => { - const baseStep = stepFixture(); - if (!baseStep.operation) throw new Error('Missing operation fixture.'); - const runningStep = stepFixture({ - operation: { - ...baseStep.operation, - lastProgressAt: new Date(Date.now()).toISOString(), - stalledAt: undefined, - }, - }); - render( - , - ); - - act(() => jest.advanceTimersByTime(10 * 60_000)); - expect(screen.queryByText('Stalled')).not.toBeInTheDocument(); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/chat/ChatActorControls.tsx b/apps/aevatar-console-web/src/pages/chat/ChatActorControls.tsx deleted file mode 100644 index 970d05f4ce..0000000000 --- a/apps/aevatar-console-web/src/pages/chat/ChatActorControls.tsx +++ /dev/null @@ -1,1261 +0,0 @@ -import { - PauseCircleOutlined, - RedoOutlined, - StopOutlined, -} from '@ant-design/icons'; -import { Button, InputNumber, Tag } from 'antd'; -import React, { useEffect, useState } from 'react'; -import { t } from '@/shared/i18n/messages'; -import AevatarTooltip from '@/shared/ui/AevatarTooltip'; -import type { - ChatActionSummary, - ChatActorProjection, - ChatActorStep, - ChatNyxIdActionRequest, - ChatPendingInput, -} from './chatActorState'; -import { chatActionIdentityKey } from './chatActorState'; -import type { ChatInputAnswer } from './chatApi'; -import type { - ChatApprovalObservation, - ChatExternalEffect, - ChatTaskStepSource, -} from './chatTaskPlan'; - -type ActionReport = { - actionRequestId: string; - originTurnId: string; - disposition: 'completed' | 'declined' | 'failed' | 'cancelled' | 'expired'; - resource?: { userService: { userServiceId: string } }; -}; - -export type ChatActionJourney = { - report?: ActionReport; - busy?: boolean; - error?: string; - baseline?: ReadonlySet; -}; - -type Props = { - projection: ChatActorProjection | null; - actionJourneys?: ReadonlyMap; - disabled?: boolean; - diagnosticWire?: unknown; - wireInspectorEnabled?: boolean; - onInputResolve: (answer: ChatInputAnswer, input: ChatPendingInput) => void; - onStop: () => void; - onSteer: (instruction: string) => void; - onRetry: (step: ChatActorStep) => void; - onSkip: (step: ChatActorStep) => void; - onActionOpen: (request: ChatNyxIdActionRequest) => void; - onActionRefresh: (request: ChatNyxIdActionRequest) => void; - onActionConnectCredential: ( - request: ChatNyxIdActionRequest, - credential: string, - ) => Promise; - onActionReport: ( - request: ChatNyxIdActionRequest, - disposition: ActionReport['disposition'], - ) => void; -}; - -const buttonStyle: React.CSSProperties = { - background: '#fff', - border: '1px solid #d8dee8', - borderRadius: 7, - cursor: 'pointer', - fontSize: 12, - minHeight: 30, - padding: '5px 10px', -}; - -export function ChatActorControls({ - projection, - actionJourneys = new Map(), - disabled = false, - diagnosticWire, - wireInspectorEnabled = false, - onInputResolve, - onStop, - onRetry, - onSkip, - onActionOpen, - onActionRefresh, - onActionConnectCredential, - onActionReport, -}: Props): React.ReactElement | null { - const [selectedOptionIds, setSelectedOptionIds] = useState([]); - const [numericAnswer, setNumericAnswer] = useState(null); - const pendingInput = projection?.pendingInput ?? null; - useEffect(() => { - setSelectedOptionIds([]); - setNumericAnswer(pendingInput?.numericThreshold?.suggestedValue ?? null); - }, [pendingInput?.requestId, pendingInput?.numericThreshold?.suggestedValue]); - const steps = [...(projection?.steps.values() ?? [])]; - const canStop = steps.some((step) => step.availableActions?.stop === true); - const active = projection?.activeTurn?.status === 'active'; - const actions = [...(projection?.actions.values() ?? [])].filter( - (action) => action.action === 'service.connect', - ); - const terminal = projection ? latestTerminalFact(projection) : null; - const hasControls = Boolean( - projection?.task || - projection?.pendingInput || - canStop || - active || - actions.length || - terminal || - steps.some( - (step) => step.availableActions?.retry || step.availableActions?.skip, - ), - ); - if (!projection || !hasControls) return null; - - return ( -
- {projection.task ? : null} - - - - {pendingInput ? ( - -
{pendingInput.prompt}
- {pendingInput.options.map((option) => ( - - ))} - {pendingInput.numericThreshold ? ( -
- setNumericAnswer(value)} - placeholder={String( - pendingInput.numericThreshold.suggestedValue, - )} - precision={0} - style={{ width: 180 }} - value={numericAnswer} - /> - - {t( - 'pages.chat.actorControls.suggestedThreshold', - 'Suggested {value}', - { value: pendingInput.numericThreshold.suggestedValue }, - )} - - -
- ) : pendingInput.allowFreeText ? ( -
- {t( - 'pages.chat.actorControls.answerInComposer', - 'Type the answer in the composer below.', - )} -
- ) : null} - {pendingInput.options.length ? ( - - ) : null} -
- ) : null} - - {steps.map((step) => - step.availableActions?.retry || step.availableActions?.skip ? ( - -
- {step.availableActions.retry ? ( - - ) : null} - {step.availableActions.skip ? ( - - ) : null} -
-
- ) : null, - )} - - {actions.map((action) => ( - - step.actionRequestId === action.actionRequestId && - step.kind === 'postcondition' && - step.status === 'done' && - step.externalEffect === 'confirmed', - )} - disabled={disabled} - journey={actionJourneys.get( - chatActionIdentityKey(action.actorId, action.actionRequestId), - )} - key={chatActionIdentityKey(action.actorId, action.actionRequestId)} - presentationTitle={projection.steps.get(action.stepId)?.description} - onOpen={onActionOpen} - onRefresh={onActionRefresh} - onConnectCredential={onActionConnectCredential} - onReport={onActionReport} - /> - ))} - - {terminal ? : null} - - {wireInspectorEnabled && diagnosticWire !== undefined ? ( - - ) : null} - - {active ? ( - -
- {t( - 'pages.chat.actorControls.steerInComposer', - 'Type a steering instruction in the composer.', - )} -
-
- {canStop ? ( - - ) : null} -
-
- ) : null} -
- ); -} - -function TaskPlanLedger({ - projection, -}: { - projection: ChatActorProjection; -}): React.ReactElement | null { - const plan = projection.task; - if (!plan) return null; - const statusCounts = plan.steps.reduce>( - (counts, step) => { - counts[step.status] = (counts[step.status] ?? 0) + 1; - return counts; - }, - {}, - ); - return ( -
-
-
-
- {plan.title} -
-
- {t( - 'pages.chat.actorControls.planRevision', - 'Plan revision {revision}', - { - revision: plan.planRevision, - }, - )} -
-
-
- - {Object.entries(statusCounts).map(([status, count]) => ( - {`${status} ${count}`} - ))} -
-
-
    - {plan.steps.map((step) => { - const stalled = isActorReportedStalled(step); - const verified = - step.kind === 'postcondition' && - step.status === 'done' && - step.externalEffect === 'confirmed'; - return ( -
  1. -
    - - {step.order} - -
    -
    - - {step.description} - - - {stalled ? ( - }> - {t('pages.chat.actorControls.stalled', 'Stalled')} - - ) : null} - -
    -
    - {formatStepSourceLabel(step.source)} - {step.source.kind === 'tool' && step.source.serviceSlug ? ( - {step.source.serviceSlug} - ) : null} - {step.addedBy ? ( - {`addedBy: ${step.addedBy}`} - ) : null} - {step.addedInPlanRevision ? ( - {`r${step.addedInPlanRevision}`} - ) : null} - {step.estimate ? ( - {`~${step.estimate.seconds}s`} - ) : null} -
    - {step.source.kind === 'condition' ? ( - - ) : null} - {step.guard ? ( -
    - {t( - 'pages.chat.actorControls.conditionGuard', - 'Guard {conditionId} requires {outcome}', - { - conditionId: step.guard.conditionStepId, - outcome: step.guard.requiredOutcome, - }, - )} -
    - ) : null} - {step.operation ? ( - <> -
    - {[ - step.operation.kind, - step.operation.phase, - step.operation.operationId, - step.operation.operationGeneration !== undefined - ? `generation ${step.operation.operationGeneration}` - : '', - ] - .filter(Boolean) - .join(' · ')} -
    - {step.operation.lastProgressAt ? ( -
    - {t( - 'pages.chat.actorControls.lastProgressAt', - 'Last progress {time}', - { time: step.operation.lastProgressAt }, - )} -
    - ) : null} - {stalled && step.operation.stalledAt ? ( -
    - {t( - 'pages.chat.actorControls.stalledAt', - 'Stalled since {time}', - { time: step.operation.stalledAt }, - )} -
    - ) : null} - - ) : null} - {step.approvalObservation ? ( - - ) : null} - {step.substeps.length ? ( -
      - {step.substeps.map((substep) => ( -
    • - - {substep.status === 'done' - ? '✓' - : substep.status === 'failed' - ? '×' - : '•'} - - {`${substep.title} · ${substep.status}`} -
    • - ))} -
    - ) : null} - {verified ? ( -
    - {t( - 'pages.chat.actorControls.verifiedAgainst', - 'Verified against {check}', - { check: step.source.label }, - )} -
    - ) : null} - {step.safeMessage ? ( -
    - {step.safeMessage} -
    - ) : null} -
    -
    -
  2. - ); - })} -
-
- ); -} - -function CommittedResults({ - projection, -}: { - projection: ChatActorProjection; -}): React.ReactElement | null { - const results = [ - ['control', projection.latestControlResult], - ['step-control', projection.latestStepControlResult], - ['input', projection.latestInputResolution], - ['approval', projection.latestApprovalResolution], - ].filter((entry): entry is [string, Record] => - Boolean(entry[1]), - ); - if (!results.length) return null; - return ( -
- - {t('pages.chat.actorControls.committedResults', 'Committed results')} - - {results.map(([kind, result]) => ( -
- {String(result.outcome || result.status || 'committed')} - {typeof result.approved === 'boolean' ? ( - - {result.approved ? 'approved' : 'denied'} - - ) : null} - {result.reasonCode ? {String(result.reasonCode)} : null} - {result.safeMessage ? ( - {String(result.safeMessage)} - ) : null} - {result.committedAt ? ( - {String(result.committedAt)} - ) : null} -
- ))} -
- ); -} - -function TerminalFact({ - terminal, -}: { - terminal: Record; -}): React.ReactElement { - const status = String(terminal.status || 'terminal'); - return ( - -
- - {terminal.safeMessage ? ( - - {String(terminal.safeMessage)} - - ) : null} - {terminal.terminalAt ? ( - - {String(terminal.terminalAt)} - - ) : null} -
-
- ); -} - -function latestTerminalFact( - projection: ChatActorProjection, -): Record | null { - const candidates = [projection.latestTurn, ...projection.recentTerminalTurns]; - return ( - candidates.find( - (candidate) => - candidate && - candidate.status !== 'active' && - candidate.status !== 'running', - ) ?? null - ); -} - -function StatusTag({ status }: { status: string }): React.ReactElement { - const color = - status === 'done' || status === 'succeeded' - ? 'success' - : status === 'failed' || status === 'uncertain' - ? 'error' - : status === 'running' || status === 'active' - ? 'processing' - : status === 'waiting' || status === 'blocked' - ? 'warning' - : 'default'; - return {status}; -} - -function EffectTag({ - effect, -}: { - effect: ChatExternalEffect; -}): React.ReactElement { - const color = - effect === 'confirmed' - ? 'success' - : effect === 'may_have_changed' - ? 'error' - : effect === 'not_applied' - ? 'blue' - : 'default'; - return ( - - {effect} - - ); -} - -function ConditionFacts({ - step, -}: { - step: ChatActorStep; -}): React.ReactElement | null { - if (step.source.kind !== 'condition') return null; - const condition = step.source.condition; - return ( -
- {`${condition.observedValue} >= ${condition.effectiveThreshold}`} - - {condition.outcome} - - {condition.thresholdOrigin} - {condition.guardedToolName} -
- ); -} - -function isActorReportedStalled(step: ChatActorStep): boolean { - return Boolean( - (step.status === 'running' || step.status === 'waiting') && - step.operation?.lastProgressAt && - step.operation.stalledAt && - (step.availableActions.retry || - step.availableActions.skip || - step.availableActions.stop), - ); -} - -function formatStepSourceLabel(source: ChatTaskStepSource): string { - if (source.kind === 'browserAction' && !source.label) { - return t( - 'pages.chat.actorControls.stepSourceBrowserAction', - 'Browser action', - ); - } - if (source.kind === 'postcondition' && !source.label) { - return t( - 'pages.chat.actorControls.stepSourcePostcondition', - 'Postcondition', - ); - } - if (source.kind === 'input') { - return t('pages.chat.actorControls.stepSourceUserInput', 'User input'); - } - if (source.kind === 'approval') { - return t('pages.chat.actorControls.stepSourceApproval', 'Approval'); - } - if (source.kind === 'web') { - return t('pages.chat.actorControls.stepSourceWeb', 'Web'); - } - - return source.label; -} - -function ApprovalObservation({ - observation, -}: { - observation: ChatApprovalObservation; -}): React.ReactElement { - const facts = [ - [ - t('pages.chat.actorControls.approvalRequestId', 'Request ID'), - observation.approvalRequestId, - ], - [ - t('pages.chat.actorControls.approvalDecisionMode', 'Decision mode'), - observation.decisionMode, - ], - [ - t('pages.chat.actorControls.approvalReceiptStatus', 'Receipt status'), - observation.receiptStatus, - ], - [ - t('pages.chat.actorControls.approvalObservedAt', 'Observed at'), - observation.observedAt, - ], - ] as const; - return ( -
-
- - {t( - 'pages.chat.actorControls.nyxIdRequestObserved', - 'NyxID request observed', - )} - - - {observation.receiptStatus} - -
-
- {facts.map(([label, value]) => ( - -
{label}
-
- {value} -
-
- ))} -
-
- ); -} - -function ControlCard({ - children, - title, -}: { - children: React.ReactNode; - title: string; -}): React.ReactElement { - return ( -
- {title} - {children} -
- ); -} - -function ActionCard({ - action, - actorConfirmed, - journey, - disabled, - presentationTitle, - onOpen, - onRefresh, - onConnectCredential, - onReport, -}: { - action: ChatActionSummary; - actorConfirmed: boolean; - journey?: ChatActionJourney; - disabled: boolean; - presentationTitle?: string; - onOpen: Props['onActionOpen']; - onRefresh: Props['onActionRefresh']; - onConnectCredential: Props['onActionConnectCredential']; - onReport: Props['onActionReport']; -}): React.ReactElement | null { - const [credential, setCredential] = useState(''); - const request = action.request; - if (action.conflicted) { - return ( - -
- {t( - 'pages.chat.actorControls.actionIdentityConflict', - 'Action identity conflict; this browser journey is disabled.', - )} -
-
- ); - } - if (!request) { - const report = action.reports?.at(-1); - const verified = action.postconditionResult?.verified === true; - const waitingForPostcondition = report?.disposition === 'completed'; - return ( - -
- {verified - ? t('pages.chat.actorControls.actorVerified', 'Actor verified') - : waitingForPostcondition - ? `${String(report.disposition)} · ${t('pages.chat.actorControls.postconditionPending', 'postcondition pending')}` - : report - ? String(report.disposition) - : t( - 'pages.chat.actorControls.waitingForAction', - 'Waiting for the connection decision', - )} -
-
- {t( - 'pages.chat.actorControls.reloadedActionDetailsUnavailable', - 'This committed action is visible, but the current-state contract does not expose its connection parameters.', - )} -
-
- ); - } - const actorReport = [...(action.reports ?? [])] - .reverse() - .find((candidate) => reportMatchesRequest(candidate, request)); - const localReport = journey?.report; - const report = - actorReport ?? - (localReport && reportMatchesRequest(localReport, request) - ? localReport - : null); - const expectedId = readUserServiceId(report?.resource); - const proof = action.postconditionResult; - const verified = Boolean( - report?.disposition === 'completed' && - (actorConfirmed || - (expectedId && - proof?.verified === true && - proof.actionRequestId === request.actionRequestId && - proof.disposition === report.disposition && - readUserServiceId(proof.resource) === expectedId)), - ); - const waitingForPostcondition = report?.disposition === 'completed'; - const terminalWithoutPostcondition = Boolean( - report && report.disposition !== 'completed', - ); - const isAccessReview = request.action === 'service.access_review'; - const serviceName = isAccessReview - ? request.params.serviceAccessReview.serviceSlug - : 'catalogService' in request.params - ? request.params.catalogService.serviceSlug - : request.params.customService.name; - return ( - - {isAccessReview && !verified && !report ? ( -
- {t( - 'pages.chat.actorControls.accessReviewExplainer', - '{service} is already connected to your NyxID. This chat session just needs a one-time authorization to use it.', - { service: serviceName }, - )} -
- ) : null} - {'catalogService' in request.params && - request.params.catalogService.requestedScopes?.length ? ( -
- {request.params.catalogService.requestedScopes.map((scope) => ( - {scope} - ))} -
- ) : null} - {verified ? ( -
- {t('pages.chat.actorControls.actorVerified', 'Actor verified')} -
- ) : waitingForPostcondition ? ( -
- {`${String(report.disposition)} · ${t( - 'pages.chat.actorControls.reportedWaitingProof', - 'Reported; waiting for actor verification', - )}`} -
- ) : report ? ( -
{String(report.disposition)}
- ) : ( -
- {t( - 'pages.chat.actorControls.waitingForAction', - 'Waiting for the connection decision', - )} -
- )} - {journey?.error ?
{journey.error}
: null} - {!verified && !terminalWithoutPostcondition ? ( -
- {'catalogService' in request.params && !report ? ( - <> - setCredential(event.target.value)} - type="password" - value={credential} - /> - - - ) : null} - {!report ? ( - - ) : null} - {!isAccessReview ? ( - - ) : null} - {!report ? ( - <> - - - - ) : null} -
- ) : null} -
- ); -} - -function WireInspector({ wire }: { wire: unknown }): React.ReactElement { - const [open, setOpen] = useState(false); - return ( -
- - {open ? ( -
-          {JSON.stringify(redactWire(wire), null, 2)}
-        
- ) : null} -
- ); -} - -const WIRE_SECRET_KEY = - /(?:^|[_-])(authorization|api[-_]?key|token|secret|password|credential|cookie|user[-_]?code|device[-_]?code)(?:$|[_-])/i; -const WIRE_SECRET_VALUE = - /(Bearer\s+)[A-Za-z0-9._~+/-]+|nyx(?:id)?_[A-Za-z0-9_-]{8,}/gi; - -function redactWire(input: unknown): unknown { - if (Array.isArray(input)) return input.map(redactWire); - if (input && typeof input === 'object') { - return Object.fromEntries( - Object.entries(input).map(([key, value]) => [ - key, - WIRE_SECRET_KEY.test(key) ? '[REDACTED]' : redactWire(value), - ]), - ); - } - return typeof input === 'string' - ? input.replace(WIRE_SECRET_VALUE, (_match, bearerPrefix: string) => - bearerPrefix ? `${bearerPrefix}[REDACTED]` : '[REDACTED]', - ) - : input; -} - -function reportMatchesRequest( - input: unknown, - request: ChatNyxIdActionRequest, -): input is Record { - if (!input || typeof input !== 'object' || Array.isArray(input)) return false; - const report = input as Record; - return ( - report.actionRequestId === request.actionRequestId && - report.originTurnId === request.originTurnId && - ['completed', 'declined', 'failed', 'cancelled', 'expired'].includes( - String(report.disposition), - ) - ); -} - -function readUserServiceId(input: unknown): string { - if (!input || typeof input !== 'object' || Array.isArray(input)) return ''; - const resource = input as Record; - const nested = resource.userService; - const nestedId = - nested && typeof nested === 'object' && !Array.isArray(nested) - ? (nested as Record).userServiceId - : undefined; - const value = nestedId ?? resource.userServiceId; - return typeof value === 'string' ? value.trim() : ''; -} diff --git a/apps/aevatar-console-web/src/pages/chat/chatActorState.test.ts b/apps/aevatar-console-web/src/pages/chat/chatActorState.test.ts deleted file mode 100644 index abe6a7b8fb..0000000000 --- a/apps/aevatar-console-web/src/pages/chat/chatActorState.test.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { - actorCan, - applyCurrentStateResult, - createChatActorProjection, - decodeActorFrame, - reduceActorFrame, - validateActionRequest, -} from './chatActorState'; - -function taskPlan(stepStatus: 'running' | 'failed' = 'running') { - return { - schemaVersion: 4, - actorId: 'conversation-alpha', - taskId: 'task-alpha', - turnId: 'turn-alpha', - planId: 'plan-alpha', - planRevision: 3, - planRevisionHistoryStart: 1, - planRevisions: [ - { - planRevision: 3, - revisionCause: 'steering', - committedAt: '2026-08-08T00:00:00Z', - addedStepIds: ['step-alpha'], - cancelledStepIds: [], - }, - ], - title: 'Inspect repository', - status: 'active', - activeStepId: 'step-alpha', - steps: [ - { - stepId: 'step-alpha', - order: 1, - kind: 'tool', - status: stepStatus, - required: true, - description: 'Inspect repository', - source: { - tool: { - toolName: 'repository_read', - serviceSlug: 'github-api', - serviceId: 'svc-alpha', - }, - }, - mayChangeExternalState: false, - externalEffect: stepStatus === 'failed' ? 'not_applied' : 'not_started', - availableActions: - stepStatus === 'failed' ? { retry: true } : { stop: true }, - updatedAt: '2026-08-08T00:00:00Z', - addedBy: 'steering', - addedInPlanRevision: 3, - dependsOn: [], - substeps: [ - { - substepId: 'substep-alpha', - title: 'Read', - status: stepStatus === 'failed' ? 'failed' : 'running', - }, - ], - operation: { - conversationActorId: 'conversation-alpha', - turnId: 'turn-alpha', - taskId: 'task-alpha', - stepId: 'step-alpha', - operationId: 'operation-alpha', - operationGeneration: 2, - phase: stepStatus, - lastProgressAt: '2026-08-08T00:00:30Z', - stalledAt: - stepStatus === 'failed' ? undefined : '2026-08-08T00:02:30Z', - }, - approvalObservation: { - approvalRequestId: 'nyxid-approval-alpha', - decisionMode: 'grant', - receiptStatus: 'denied', - observedAt: '2026-08-08T00:02:31Z', - terminalOutcome: 'expired', - subjectKind: 'nyxid.user-service', - }, - }, - ], - }; -} - -const actionRequest = { - schemaVersion: 4, - actorId: 'conversation-alpha', - originTurnId: 'turn-alpha', - taskId: 'task-alpha', - stepId: 'step-connect', - actionRequestId: 'action-alpha', - action: 'service.connect', - params: { - catalogService: { serviceSlug: 'api-github', requestedScopes: ['repo'] }, - }, -} as const; - -describe('chatActorState', () => { - it('decodes the same bounded numeric threshold from live frames and reloads', () => { - const pendingInput = { - requestId: 'input-threshold', - prompt: 'Choose the threshold', - options: [], - allowFreeText: true, - multiSelect: false, - numericThreshold: { - suggestedValue: 70, - minimumValue: 0, - maximumValue: 100, - }, - }; - const live = reduceActorFrame( - createChatActorProjection('conversation-alpha'), - decodeActorFrame({ - sequence: 1, - custom: { name: 'nyxid.input.request', payload: pendingInput }, - }), - ); - const reload = applyCurrentStateResult( - createChatActorProjection('conversation-alpha'), - { - status: 'current', - stateVersion: 5, - snapshot: { - actorId: 'conversation-alpha', - scopeId: 'scope-alpha', - stateVersion: 5, - progressSequence: 1, - activeTurn: null, - latestTurn: null, - recentTerminalTurns: [], - activeTask: null, - pendingInput, - pendingApproval: null, - pendingActions: [], - }, - }, - ).projection; - - expect(live.pendingInput).toEqual(pendingInput); - expect(reload.pendingInput).toEqual(pendingInput); - }); - - it.each([ - { suggestedValue: 70.5, minimumValue: 0, maximumValue: 100 }, - { - suggestedValue: Number.MAX_SAFE_INTEGER + 1, - minimumValue: 0, - maximumValue: Number.MAX_SAFE_INTEGER + 1, - }, - { suggestedValue: 70, minimumValue: 80, maximumValue: 100 }, - { suggestedValue: 101, minimumValue: 0, maximumValue: 100 }, - ])('rejects an invalid live numeric threshold %#', (numericThreshold) => { - expect(() => - decodeActorFrame({ - sequence: 1, - custom: { - name: 'nyxid.input.request', - payload: { - requestId: 'input-threshold', - numericThreshold, - }, - }, - }), - ).toThrow( - expect.objectContaining({ - code: 'NYXID_INPUT_NUMERIC_THRESHOLD_INVALID', - }), - ); - }); - - it('rejects an invalid reloaded numeric threshold', () => { - expect(() => - applyCurrentStateResult(createChatActorProjection('conversation-alpha'), { - status: 'current', - stateVersion: 5, - snapshot: { - actorId: 'conversation-alpha', - scopeId: 'scope-alpha', - stateVersion: 5, - progressSequence: 1, - activeTurn: null, - latestTurn: null, - recentTerminalTurns: [], - activeTask: null, - pendingInput: { - requestId: 'input-threshold', - numericThreshold: { - suggestedValue: 70, - minimumValue: 80, - maximumValue: 100, - }, - }, - pendingApproval: null, - pendingActions: [], - }, - }), - ).toThrow( - expect.objectContaining({ - code: 'NYXID_INPUT_NUMERIC_THRESHOLD_INVALID', - }), - ); - }); - - it('uses the same typed TaskPlan decoder for live frames and current-state reload', () => { - const live = reduceActorFrame( - createChatActorProjection('conversation-alpha'), - decodeActorFrame({ - type: 'CUSTOM', - sequence: 7, - custom: { name: 'nyxid.task.snapshot', payload: taskPlan() }, - }), - ); - const reloaded = applyCurrentStateResult( - createChatActorProjection('conversation-alpha'), - { - status: 'current', - stateVersion: 17, - snapshot: { - actorId: 'conversation-alpha', - scopeId: 'scope-alpha', - stateVersion: 17, - progressSequence: 7, - activeTurn: { - turnId: 'turn-alpha', - taskId: 'task-alpha', - status: 'active', - }, - latestTurn: null, - recentTerminalTurns: [], - activeTask: taskPlan(), - pendingInput: null, - pendingApproval: null, - pendingActions: [], - }, - }, - ).projection; - - expect(reloaded.task).toEqual(live.task); - expect([...reloaded.steps.values()]).toEqual([...live.steps.values()]); - expect(reloaded.steps.get('step-alpha')?.operation).toEqual( - expect.objectContaining({ - lastProgressAt: '2026-08-08T00:00:30Z', - stalledAt: '2026-08-08T00:02:30Z', - }), - ); - expect(reloaded.steps.get('step-alpha')?.approvalObservation).toEqual({ - approvalRequestId: 'nyxid-approval-alpha', - decisionMode: 'grant', - receiptStatus: 'denied', - observedAt: '2026-08-08T00:02:31Z', - terminalOutcome: 'expired', - subjectKind: 'nyxid.user-service', - }); - expect(actorCan(reloaded, 'stop')).toBe(true); - expect(reloaded.steps.get('step-alpha')?.availableActions).toEqual({ - retry: false, - skip: false, - stop: true, - }); - - const changed = reduceActorFrame( - reloaded, - decodeActorFrame({ - type: 'CUSTOM', - sequence: 8, - custom: { - name: 'nyxid.task.step.changed', - payload: { - taskId: 'task-alpha', - planRevision: 3, - step: { - ...taskPlan('failed').steps[0], - updatedAt: '2026-08-08T00:02:00Z', - }, - changeKind: 'status', - }, - }, - }), - ); - expect(changed.steps.get('step-alpha')?.status).toBe('failed'); - expect(changed.steps.get('step-alpha')?.updatedAt).toBe( - '2026-08-08T00:02:00Z', - ); - expect(actorCan(changed, 'retry', 'step-alpha')).toBe(true); - expect(changed.steps.get('step-alpha')?.availableActions).toEqual({ - retry: true, - skip: false, - stop: false, - }); - - const stale = reduceActorFrame( - changed, - decodeActorFrame({ - type: 'CUSTOM', - sequence: 7, - custom: { - name: 'nyxid.task.step.changed', - payload: { - taskId: 'task-alpha', - planRevision: 3, - step: taskPlan().steps[0], - changeKind: 'status', - }, - }, - }), - ); - const conflictingDuplicate = reduceActorFrame( - changed, - decodeActorFrame({ - type: 'CUSTOM', - sequence: 8, - custom: { - name: 'nyxid.task.step.changed', - payload: { - taskId: 'task-alpha', - planRevision: 3, - step: taskPlan().steps[0], - changeKind: 'status', - }, - }, - }), - ); - expect(stale).toBe(changed); - expect(conflictingDuplicate).toBe(changed); - expect(stale.steps.get('step-alpha')?.status).toBe('failed'); - }); - - it('keeps the committed control result across duplicate and stale current-state reads', () => { - const state = ( - stateVersion: number, - outcome: string, - stepOutcome: string, - recentStepOutcomes: readonly string[], - ) => ({ - status: 'current', - stateVersion, - snapshot: { - actorId: 'conversation-alpha', - scopeId: 'scope-alpha', - stateVersion, - progressSequence: stateVersion, - activeTurn: { - turnId: 'turn-alpha', - taskId: 'task-alpha', - status: 'active', - }, - latestTurn: null, - recentTerminalTurns: [], - activeTask: taskPlan(), - pendingInput: null, - pendingApproval: null, - pendingActions: [], - recentActions: [], - latestControlResult: { outcome }, - latestStepControlResult: { outcome: stepOutcome }, - recentStepControlResults: recentStepOutcomes.map((recentOutcome) => ({ - outcome: recentOutcome, - })), - }, - }); - const committed = applyCurrentStateResult( - createChatActorProjection('conversation-alpha'), - state(11, 'steered', 'retry_started', [ - 'retry_requested', - 'retry_started', - ]), - ).projection; - const duplicate = applyCurrentStateResult( - committed, - state(11, 'duplicate-regressed', 'duplicate-step-regressed', [ - 'duplicate-step-regressed', - ]), - ).projection; - const stale = applyCurrentStateResult( - duplicate, - state(10, 'stale-result', 'stale-step-result', ['stale-step-result']), - ).projection; - - expect(duplicate).toBe(committed); - expect(duplicate.latestControlResult).toEqual({ outcome: 'steered' }); - expect(duplicate.latestStepControlResult).toEqual({ - outcome: 'retry_started', - }); - expect(duplicate.recentStepControlResults).toEqual([ - { outcome: 'retry_requested' }, - { outcome: 'retry_started' }, - ]); - expect(stale).toBe(duplicate); - expect(stale.latestControlResult).toEqual({ outcome: 'steered' }); - expect(stale.latestStepControlResult).toEqual({ - outcome: 'retry_started', - }); - expect(stale.recentStepControlResults).toEqual([ - { outcome: 'retry_requested' }, - { outcome: 'retry_started' }, - ]); - expect(stale.stateVersion).toBe(11); - }); - - it('fails closed on invalid closed vocabulary instead of constructing browser state', () => { - expect(() => - decodeActorFrame({ - type: 'CUSTOM', - sequence: 7, - custom: { - name: 'nyxid.task.snapshot', - payload: { ...taskPlan(), status: 'almost_done' }, - }, - }), - ).not.toThrow(); - expect(() => - reduceActorFrame( - createChatActorProjection('conversation-alpha'), - decodeActorFrame({ - type: 'CUSTOM', - sequence: 7, - custom: { - name: 'nyxid.task.snapshot', - payload: { ...taskPlan(), status: 'almost_done' }, - }, - }), - ), - ).toThrow(expect.objectContaining({ code: 'NYXID_TASK_PLAN_INVALID' })); - }); - - it('rehydrates only secret-free exact pending and recent action requests', () => { - expect(validateActionRequest(actionRequest)).toEqual(actionRequest); - expect(() => - validateActionRequest({ ...actionRequest, apiKey: 'secret' }), - ).toThrow(expect.objectContaining({ code: 'NYXID_FIELD_UNDECLARED' })); - - const live = reduceActorFrame( - createChatActorProjection('conversation-alpha'), - decodeActorFrame({ - sequence: 1, - custom: { name: 'nyxid.action.request', payload: actionRequest }, - }), - ); - expect(live.actions.get('action-alpha')?.request).toEqual(actionRequest); - - const reload = applyCurrentStateResult( - createChatActorProjection('conversation-alpha'), - { - status: 'current', - stateVersion: 5, - snapshot: { - actorId: 'conversation-alpha', - scopeId: 'scope-alpha', - stateVersion: 5, - progressSequence: 2, - activeTurn: null, - latestTurn: null, - recentTerminalTurns: [], - activeTask: null, - pendingInput: null, - pendingApproval: null, - pendingActions: [ - { - schemaVersion: 4, - originTurnId: 'turn-alpha', - taskId: 'task-alpha', - stepId: 'step-connect', - actionRequestId: 'action-alpha', - action: 'service.connect', - reports: [], - postconditionResult: null, - request: actionRequest, - }, - ], - recentActions: [ - { - schemaVersion: 4, - originTurnId: 'turn-alpha', - taskId: 'task-alpha', - stepId: 'step-recent', - actionRequestId: 'action-recent', - action: 'service.connect', - reports: [], - postconditionResult: null, - request: { - ...actionRequest, - stepId: 'step-recent', - actionRequestId: 'action-recent', - }, - }, - ], - }, - }, - ).projection; - expect(reload.actions.get('action-alpha')?.request).toEqual(actionRequest); - expect(reload.actions.get('action-recent')?.request).toEqual({ - ...actionRequest, - stepId: 'step-recent', - actionRequestId: 'action-recent', - }); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/chat/chatActorState.ts b/apps/aevatar-console-web/src/pages/chat/chatActorState.ts deleted file mode 100644 index 1a2ae451dc..0000000000 --- a/apps/aevatar-console-web/src/pages/chat/chatActorState.ts +++ /dev/null @@ -1,1004 +0,0 @@ -import { - type ChatActorStep, - type ChatTaskPlan, - decodeChatTaskPlan, - decodeChatTaskStep, -} from './chatTaskPlan'; - -type JsonRecord = Record; - -const ACTOR_EVENT_NAMES = { - 'nyxid.task.snapshot': 'task_snapshot', - 'nyxid.task.step.changed': 'task_step_changed', - 'nyxid.control.changed': 'control_changed', - 'nyxid.continuation.changed': 'continuation_changed', - 'nyxid.step.control.changed': 'step_control_changed', - 'nyxid.input.request': 'input_request', - 'nyxid.input.changed': 'input_changed', - 'nyxid.approval.request': 'approval_request', - 'nyxid.approval.changed': 'approval_changed', - 'nyxid.action.request': 'action_request', -} as const; - -const ACTION_IDENTITY_KEYS = [ - 'actorId', - 'originTurnId', - 'taskId', - 'stepId', - 'actionRequestId', -] as const; -const ACTION_KEYS = [ - 'schemaVersion', - ...ACTION_IDENTITY_KEYS, - 'action', - 'params', -]; -const FORBIDDEN_ACTION_KEY = - /(?:^|[_-])(authorization|api[-_]?key|token|secret|password|credential|cookie|user[-_]?code|device[-_]?code)(?:$|[_-])/i; -const SECRET_VALUE = - /(Bearer\s+)[A-Za-z0-9._~+/-]+|nyx(?:id)?_[A-Za-z0-9_-]{8,}/gi; -const CUSTOM_SERVICE_AUTH_METHODS = [ - 'bearer', - 'header', - 'query', - 'path', - 'basic', - 'body', - 'none', -] as const; -type ChatCustomServiceAuthMethod = (typeof CUSTOM_SERVICE_AUTH_METHODS)[number]; - -export class ChatActorProtocolError extends Error { - readonly code: string; - - constructor(message: string, code: string) { - super(message); - this.name = 'ChatActorProtocolError'; - this.code = code; - } -} - -export type { ChatActorStep, ChatAvailableActions } from './chatTaskPlan'; - -export type ChatPendingInput = JsonRecord & { - requestId: string; - prompt: string; - options: readonly { optionId: string; label: string; description?: string }[]; - allowFreeText: boolean; - multiSelect: boolean; - numericThreshold?: { - suggestedValue: number; - minimumValue: number; - maximumValue: number; - } | null; -}; - -export type ChatPendingApproval = JsonRecord & { - approvalRequestId: string; - toolName: string; - action?: string; - target?: string; - reversibility?: 'reversible' | 'irreversible' | 'unknown'; - grantBoundary?: 'within_grant' | 'nyxid_step_up'; -}; - -export type ChatServiceConnectActionRequest = { - readonly schemaVersion: 4; - readonly actorId: string; - readonly originTurnId: string; - readonly taskId: string; - readonly stepId: string; - readonly actionRequestId: string; - readonly action: 'service.connect'; - readonly params: - | { - readonly catalogService: { - readonly serviceSlug: string; - readonly requestedScopes?: readonly string[]; - readonly viaNodeId?: string; - readonly targetOrgId?: string; - }; - } - | { - readonly customService: { - readonly name: string; - readonly endpointUrl: string; - readonly authMethod: ChatCustomServiceAuthMethod; - readonly authKeyName?: string; - readonly viaNodeId?: string; - readonly targetOrgId?: string; - }; - }; -}; - -export type ChatServiceAccessReviewActionRequest = { - readonly schemaVersion: 4; - readonly actorId: string; - readonly originTurnId: string; - readonly taskId: string; - readonly stepId: string; - readonly actionRequestId: string; - readonly action: 'service.access_review'; - readonly params: { - readonly serviceAccessReview: { - readonly userServiceId: string; - readonly serviceSlug: string; - readonly resourceUri: string; - }; - }; -}; - -export type ChatNyxIdActionRequest = - | ChatServiceConnectActionRequest - | ChatServiceAccessReviewActionRequest; - -export type ChatActionSummary = { - schemaVersion: number; - actorId: string; - originTurnId: string; - taskId: string; - stepId: string; - actionRequestId: string; - action: string; - reports?: readonly JsonRecord[]; - postconditionResult?: JsonRecord | null; - request?: ChatNyxIdActionRequest | null; - conflicted?: boolean; -}; - -export type ChatActorProjection = { - actorId: string | null; - scopeId: string | null; - stateVersion: number; - progressSequence: number; - activeTurn: JsonRecord | null; - latestTurn: JsonRecord | null; - recentTerminalTurns: JsonRecord[]; - task: ChatTaskPlan | null; - steps: Map; - pendingInput: ChatPendingInput | null; - pendingApproval: ChatPendingApproval | null; - actions: Map; - controlFence: JsonRecord | null; - latestControlResult: JsonRecord | null; - continuation: JsonRecord | null; - latestStepControlResult: JsonRecord | null; - recentStepControlResults: JsonRecord[]; - latestInputResolution: JsonRecord | null; - latestApprovalResolution: JsonRecord | null; - conflicts: readonly { code: string }[]; -}; - -export type ChatActorFrame = - | { type: 'ignored' } - | { - type: - | 'task_snapshot' - | 'task_step_changed' - | 'control_changed' - | 'continuation_changed' - | 'step_control_changed' - | 'input_request' - | 'input_changed' - | 'approval_request' - | 'approval_changed'; - sequence: number; - payload: JsonRecord; - } - | { - type: 'action_request'; - sequence: number; - request: ChatNyxIdActionRequest; - }; - -export function createChatActorProjection( - actorId: string | null = null, -): ChatActorProjection { - return { - actorId, - scopeId: null, - stateVersion: 0, - progressSequence: 0, - activeTurn: null, - latestTurn: null, - recentTerminalTurns: [], - task: null, - steps: new Map(), - pendingInput: null, - pendingApproval: null, - actions: new Map(), - controlFence: null, - latestControlResult: null, - continuation: null, - latestStepControlResult: null, - recentStepControlResults: [], - latestInputResolution: null, - latestApprovalResolution: null, - conflicts: [], - }; -} - -export function decodeActorFrame(raw: unknown): ChatActorFrame { - const frame = optionalRecord(raw); - const custom = optionalRecord(frame?.custom); - const name = typeof custom?.name === 'string' ? custom.name : ''; - const type = ACTOR_EVENT_NAMES[name as keyof typeof ACTOR_EVENT_NAMES]; - if (!type) return { type: 'ignored' }; - const sequence = frame?.sequence; - if (!validVersion(sequence)) { - throw new ChatActorProtocolError( - 'Actor progress sequence is invalid.', - 'NYXID_SEQUENCE_INVALID', - ); - } - const payload = unpackAny(custom?.payload); - if (type === 'input_request') { - const pendingInput = decodePendingInput(payload); - if (!pendingInput) throw invalidNumericThreshold(); - return { type, sequence, payload: pendingInput }; - } - return type === 'action_request' - ? { type, sequence, request: validateActionRequest(payload) } - : { type, sequence, payload }; -} - -export function reduceActorFrame( - projection: ChatActorProjection, - frame: ChatActorFrame, -): ChatActorProjection { - if ( - frame.type === 'ignored' || - frame.sequence <= projection.progressSequence - ) { - return projection; - } - const next = cloneProjection(projection); - next.progressSequence = frame.sequence; - switch (frame.type) { - case 'task_snapshot': - applyTask(next, frame.payload); - break; - case 'task_step_changed': - applyStep(next, optionalRecord(frame.payload.step)); - break; - case 'control_changed': - next.latestControlResult = cloneRecord(frame.payload); - break; - case 'continuation_changed': - next.continuation = cloneRecord(frame.payload); - break; - case 'step_control_changed': - next.latestStepControlResult = cloneRecord(frame.payload); - next.recentStepControlResults = appendDistinctRecord( - next.recentStepControlResults, - frame.payload, - ); - break; - case 'input_request': - next.pendingInput = decodePendingInput(frame.payload); - break; - case 'input_changed': - next.latestInputResolution = cloneRecord(frame.payload); - if (next.pendingInput?.requestId === frame.payload.requestId) { - next.pendingInput = null; - } - break; - case 'approval_request': - next.pendingApproval = normalizePendingApproval(frame.payload); - break; - case 'approval_changed': - next.latestApprovalResolution = cloneRecord(frame.payload); - if ( - next.pendingApproval?.approvalRequestId === - (frame.payload.approvalRequestId ?? frame.payload.requestId) - ) { - next.pendingApproval = null; - } - break; - case 'action_request': - applyActionRequest(next, frame.request); - break; - } - return next; -} - -export function applyCurrentStateResult( - projection: ChatActorProjection, - input: unknown, -): { projection: ChatActorProjection; reloadWithoutCursor: boolean } { - const envelope = optionalRecord(input); - if (!envelope) { - return { - projection: withConflict(projection, 'NYXID_STATE_STATUS_INVALID'), - reloadWithoutCursor: false, - }; - } - const status = envelope.status; - if (status === 'reload_required') { - return { projection, reloadWithoutCursor: true }; - } - if (status === 'not_found') { - return { - projection: createChatActorProjection(projection.actorId), - reloadWithoutCursor: false, - }; - } - if (status === 'not_modified') { - return validVersion(envelope.stateVersion) && - envelope.stateVersion === projection.stateVersion - ? { projection, reloadWithoutCursor: false } - : { - projection: withConflict(projection, 'NYXID_STATE_VERSION_CONFLICT'), - reloadWithoutCursor: false, - }; - } - if (status !== 'current') { - return { - projection: withConflict(projection, 'NYXID_STATE_STATUS_INVALID'), - reloadWithoutCursor: false, - }; - } - - const snapshot = optionalRecord(envelope.snapshot); - if ( - !snapshot || - !validVersion(envelope.stateVersion) || - envelope.stateVersion !== snapshot.stateVersion || - !validVersion(snapshot.progressSequence) - ) { - return { - projection: withConflict(projection, 'NYXID_STATE_SNAPSHOT_INVALID'), - reloadWithoutCursor: false, - }; - } - if ( - projection.stateVersion > envelope.stateVersion || - projection.progressSequence > snapshot.progressSequence - ) { - return { projection, reloadWithoutCursor: false }; - } - const actorId = readIdentity(snapshot.actorId); - const scopeId = readIdentity(snapshot.scopeId); - if ( - !actorId || - !scopeId || - (projection.actorId && projection.actorId !== actorId) || - (projection.scopeId && projection.scopeId !== scopeId) - ) { - return { - projection: withConflict(projection, 'NYXID_STATE_IDENTITY_CONFLICT'), - reloadWithoutCursor: false, - }; - } - if ( - projection.scopeId !== null && - projection.stateVersion === envelope.stateVersion && - projection.progressSequence === snapshot.progressSequence - ) { - return { projection, reloadWithoutCursor: false }; - } - - const next = createChatActorProjection(actorId); - next.scopeId = scopeId; - next.stateVersion = envelope.stateVersion; - next.progressSequence = snapshot.progressSequence; - next.activeTurn = cloneNullableRecord(snapshot.activeTurn); - next.latestTurn = cloneNullableRecord(snapshot.latestTurn); - next.recentTerminalTurns = Array.isArray(snapshot.recentTerminalTurns) - ? snapshot.recentTerminalTurns - .map(optionalRecord) - .filter((value): value is JsonRecord => Boolean(value)) - .map(cloneRecord) - : []; - next.pendingInput = decodePendingInput(snapshot.pendingInput); - next.pendingApproval = normalizePendingApproval(snapshot.pendingApproval); - next.controlFence = cloneNullableRecord(snapshot.controlFence); - next.latestControlResult = cloneNullableRecord(snapshot.latestControlResult); - next.latestStepControlResult = cloneNullableRecord( - snapshot.latestStepControlResult, - ); - next.recentStepControlResults = Array.isArray( - snapshot.recentStepControlResults, - ) - ? snapshot.recentStepControlResults - .map(optionalRecord) - .filter((value): value is JsonRecord => Boolean(value)) - .map(cloneRecord) - .slice(-32) - : []; - next.continuation = cloneNullableRecord(snapshot.continuationAdmission); - next.latestInputResolution = cloneNullableRecord( - snapshot.latestInputResolution, - ); - next.latestApprovalResolution = cloneNullableRecord( - snapshot.latestApprovalResolution, - ); - next.conflicts = [...projection.conflicts]; - const activeTask = optionalRecord(snapshot.activeTask); - if (activeTask) applyTask(next, activeTask); - applyActionSummaries( - next, - [ - ...(Array.isArray(snapshot.pendingActions) - ? snapshot.pendingActions - : []), - ...(Array.isArray(snapshot.recentActions) ? snapshot.recentActions : []), - ], - projection.actions, - ); - return { projection: next, reloadWithoutCursor: false }; -} - -export function actorCan( - projection: ChatActorProjection | null | undefined, - action: 'retry' | 'skip' | 'stop', - stepId?: string, -): boolean { - if (!projection) return false; - if (action === 'stop' && !stepId) { - return [...projection.steps.values()].some( - (step) => step.availableActions?.stop === true, - ); - } - if (!stepId) return false; - return projection.steps.get(stepId)?.availableActions?.[action] === true; -} - -export function validateActionRequest( - input: unknown, -): ChatNyxIdActionRequest { - const value = unpackAny(input); - assertAllowedKeys(value, ACTION_KEYS); - if ( - value.schemaVersion !== 4 || - (value.action !== 'service.connect' && - value.action !== 'service.access_review') - ) { - throw new ChatActorProtocolError( - 'Unsupported NyxID action request.', - 'NYXID_ACTION_UNSUPPORTED', - ); - } - const identity = Object.fromEntries( - ACTION_IDENTITY_KEYS.map((key) => [key, requireIdentity(value[key])]), - ) as Record<(typeof ACTION_IDENTITY_KEYS)[number], string>; - if (value.action === 'service.access_review') { - const params = validateServiceAccessReviewParams(value.params); - rejectSecretBearingInput({ ...identity, params }); - return { - schemaVersion: 4, - ...identity, - action: 'service.access_review', - params, - }; - } - const params = validateServiceConnectParams(value.params); - rejectSecretBearingInput({ ...identity, params }); - return { - schemaVersion: 4, - ...identity, - action: 'service.connect', - params, - }; -} - -export function chatActionIdentityKey( - actorId: string, - actionRequestId: string, -): string { - return JSON.stringify([actorId, actionRequestId]); -} - -function validateServiceAccessReviewParams( - input: unknown, -): ChatServiceAccessReviewActionRequest['params'] { - const value = requireRecord(input, 'NYXID_ACTION_VARIANT_INVALID'); - assertAllowedKeys(value, ['serviceAccessReview']); - const review = requireRecord( - value.serviceAccessReview, - 'NYXID_ACTION_VARIANT_INVALID', - ); - assertAllowedKeys(review, ['userServiceId', 'serviceSlug', 'resourceUri']); - return { - serviceAccessReview: { - userServiceId: requireIdentity(review.userServiceId), - serviceSlug: requireIdentity(review.serviceSlug), - resourceUri: requireIdentity(review.resourceUri), - }, - }; -} - -function validateServiceConnectParams( - input: unknown, -): ChatServiceConnectActionRequest['params'] { - const value = requireRecord(input, 'NYXID_ACTION_VARIANT_INVALID'); - assertAllowedKeys(value, ['catalogService', 'customService']); - const hasCatalog = 'catalogService' in value; - const hasCustom = 'customService' in value; - if (hasCatalog === hasCustom) throw invalidVariant(); - if (hasCatalog) { - const catalog = requireRecord( - value.catalogService, - 'NYXID_ACTION_VARIANT_INVALID', - ); - assertAllowedKeys(catalog, [ - 'serviceSlug', - 'requestedScopes', - 'viaNodeId', - 'targetOrgId', - ]); - const serviceSlug = requireBoundedString(catalog.serviceSlug, 128); - if (!/^[A-Za-z0-9._-]+$/.test(serviceSlug)) throw invalidVariant(); - const requestedScopes = catalog.requestedScopes; - if ( - requestedScopes !== undefined && - (!Array.isArray(requestedScopes) || requestedScopes.length > 64) - ) { - throw invalidVariant(); - } - return { - catalogService: { - serviceSlug, - ...(requestedScopes !== undefined - ? { - requestedScopes: requestedScopes.map((scope) => - requireBoundedString(scope, 256), - ), - } - : {}), - ...(catalog.viaNodeId !== undefined - ? { viaNodeId: requireIdentity(catalog.viaNodeId) } - : {}), - ...(catalog.targetOrgId !== undefined - ? { targetOrgId: requireIdentity(catalog.targetOrgId) } - : {}), - }, - }; - } - - const custom = requireRecord( - value.customService, - 'NYXID_ACTION_VARIANT_INVALID', - ); - assertAllowedKeys(custom, [ - 'name', - 'endpointUrl', - 'authMethod', - 'authKeyName', - 'viaNodeId', - 'targetOrgId', - ]); - const requestedAuthMethod = requireBoundedString(custom.authMethod, 32); - const authMethod = CUSTOM_SERVICE_AUTH_METHODS.find( - (method) => method === requestedAuthMethod, - ); - if (!authMethod) throw invalidVariant(); - const endpointUrl = requireBoundedString(custom.endpointUrl, 2048); - const authKeyName = - custom.authKeyName === undefined - ? undefined - : requireBoundedString(custom.authKeyName, 256); - if ( - authKeyName !== undefined && - !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(authKeyName) - ) { - throw invalidVariant(); - } - let url: URL; - try { - url = new URL(endpointUrl); - } catch { - throw unsafeUrl(); - } - if ( - url.protocol !== 'https:' || - !url.hostname || - url.username || - url.password || - url.search || - url.hash - ) { - throw unsafeUrl(); - } - return { - customService: { - name: requireBoundedString(custom.name, 256), - endpointUrl, - authMethod, - ...(authKeyName !== undefined ? { authKeyName } : {}), - ...(custom.viaNodeId !== undefined - ? { viaNodeId: requireIdentity(custom.viaNodeId) } - : {}), - ...(custom.targetOrgId !== undefined - ? { targetOrgId: requireIdentity(custom.targetOrgId) } - : {}), - }, - }; -} - -function applyTask(projection: ChatActorProjection, task: JsonRecord): void { - const decoded = decodeChatTaskPlan(task); - projection.task = decoded; - const steps = decoded.steps; - projection.steps = new Map(steps.map((step) => [step.stepId, step])); -} - -function applyStep( - projection: ChatActorProjection, - input: JsonRecord | null, -): void { - if (!input) return; - const stepId = readIdentity(input.stepId); - if (!stepId) return; - const step = decodeChatTaskStep(input); - projection.steps.set(stepId, step); - if (projection.task) { - projection.task = { - ...projection.task, - steps: [...projection.steps.values()].sort( - (left, right) => - left.order - right.order || left.stepId.localeCompare(right.stepId), - ), - }; - } -} - -function applyActionRequest( - projection: ChatActorProjection, - request: ChatNyxIdActionRequest, -): void { - if (projection.actorId && projection.actorId !== request.actorId) { - projection.conflicts = [ - ...projection.conflicts, - { code: 'NYXID_STATE_IDENTITY_CONFLICT' }, - ]; - return; - } - const existing = projection.actions.get(request.actionRequestId); - if ( - existing && - (!actionIdentityMatches(existing, request) || - (existing.request && - JSON.stringify(existing.request) !== JSON.stringify(request))) - ) { - projection.actions.set(request.actionRequestId, { - ...existing, - conflicted: true, - }); - return; - } - projection.actorId ||= request.actorId; - projection.actions.set(request.actionRequestId, { - schemaVersion: request.schemaVersion, - ...Object.fromEntries( - ACTION_IDENTITY_KEYS.map((key) => [key, request[key]]), - ), - action: request.action, - reports: existing?.reports ?? [], - postconditionResult: existing?.postconditionResult ?? null, - request, - } as ChatActionSummary); -} - -function applyActionSummaries( - projection: ChatActorProjection, - input: unknown, - observedActions: ReadonlyMap = new Map(), -): void { - projection.actions = new Map(); - if (!Array.isArray(input)) return; - for (const raw of input) { - const summary = optionalRecord(raw); - if (!summary) continue; - const actionRequestId = readIdentity(summary.actionRequestId); - const originTurnId = readIdentity(summary.originTurnId); - const taskId = readIdentity(summary.taskId); - const stepId = readIdentity(summary.stepId); - if (!actionRequestId || !originTurnId || !taskId || !stepId) continue; - const existing = projection.actions.get(actionRequestId); - if (existing) { - projection.actions.set(actionRequestId, { - ...existing, - conflicted: true, - request: null, - }); - projection.conflicts = [ - ...projection.conflicts, - { code: 'NYXID_ACTION_ID_CONFLICT' }, - ]; - continue; - } - const item: ChatActionSummary = { - schemaVersion: - typeof summary.schemaVersion === 'number' ? summary.schemaVersion : 0, - actorId: projection.actorId ?? '', - originTurnId, - taskId, - stepId, - actionRequestId, - action: typeof summary.action === 'string' ? summary.action : '', - reports: Array.isArray(summary.reports) - ? (summary.reports.filter(optionalRecord) as JsonRecord[]) - : [], - postconditionResult: cloneNullableRecord(summary.postconditionResult), - }; - // One unknown or malformed action must degrade on its own instead of - // voiding the whole projection (issue #3532): the summary stays visible - // and the conflict badge reports the unsupported request. - let reloadedRequest: ChatNyxIdActionRequest | null = null; - if (optionalRecord(summary.request)) { - try { - reloadedRequest = validateActionRequest(summary.request); - } catch (error) { - projection.conflicts = [ - ...projection.conflicts, - { - code: - error instanceof ChatActorProtocolError - ? error.code - : 'NYXID_ACTION_UNSUPPORTED', - }, - ]; - } - } - if (reloadedRequest) { - if (actionIdentityMatches(item, reloadedRequest)) { - item.request = reloadedRequest; - } else { - item.conflicted = true; - projection.conflicts = [ - ...projection.conflicts, - { code: 'NYXID_ACTION_ID_CONFLICT' }, - ]; - } - } - const observed = observedActions.get(actionRequestId); - if ( - !item.request && - !item.conflicted && - observed?.request && - !observed.conflicted && - actionIdentityMatches(item, observed.request) - ) { - item.request = observed.request; - } - projection.actions.set(actionRequestId, item); - } -} - -function normalizePendingApproval(input: unknown): ChatPendingApproval | null { - const value = optionalRecord(input); - if (!value) return null; - const presentation = optionalRecord(value.presentation); - const normalized = { ...cloneRecord(value), ...(presentation ?? {}) }; - const approvalRequestId = readIdentity( - value.approvalRequestId ?? value.requestId, - ); - if (!approvalRequestId) return null; - return { - ...normalized, - approvalRequestId, - toolName: - typeof normalized.toolName === 'string' ? normalized.toolName : '', - }; -} - -function decodePendingInput(input: unknown): ChatPendingInput | null { - const value = optionalRecord(input); - if (!value) return null; - const numericThreshold = value.numericThreshold; - if (numericThreshold === undefined || numericThreshold === null) { - return cloneRecord(value) as ChatPendingInput; - } - const threshold = optionalRecord(numericThreshold); - const suggestedValue = threshold?.suggestedValue; - const minimumValue = threshold?.minimumValue; - const maximumValue = threshold?.maximumValue; - if ( - !validSafeInteger(suggestedValue) || - !validSafeInteger(minimumValue) || - !validSafeInteger(maximumValue) || - minimumValue > maximumValue || - suggestedValue < minimumValue || - suggestedValue > maximumValue - ) { - throw invalidNumericThreshold(); - } - return { - ...cloneRecord(value), - numericThreshold: { - suggestedValue, - minimumValue, - maximumValue, - }, - } as ChatPendingInput; -} - -function actionIdentityMatches( - summary: ChatActionSummary, - request: ChatNyxIdActionRequest, -): boolean { - return ( - summary.schemaVersion === request.schemaVersion && - summary.action === request.action && - ACTION_IDENTITY_KEYS.every((key) => summary[key] === request[key]) - ); -} - -function unpackAny(input: unknown): JsonRecord { - const value = requireRecord(input, 'NYXID_ACTION_VARIANT_INVALID'); - const nested = optionalRecord(value.value); - if (nested) return nested; - const result = { ...value }; - delete result['@type']; - return result; -} - -function assertAllowedKeys( - value: JsonRecord, - allowed: readonly string[], -): void { - const set = new Set(allowed); - if (Object.keys(value).some((key) => !set.has(key))) { - throw new ChatActorProtocolError( - 'NyxID action contains an undeclared field.', - 'NYXID_FIELD_UNDECLARED', - ); - } -} - -function rejectSecretBearingInput(value: unknown): void { - if (Array.isArray(value)) { - value.forEach(rejectSecretBearingInput); - } else if (value && typeof value === 'object') { - for (const [key, child] of Object.entries(value)) { - if (FORBIDDEN_ACTION_KEY.test(key)) throw secretForbidden(); - rejectSecretBearingInput(child); - } - } else if (typeof value === 'string') { - SECRET_VALUE.lastIndex = 0; - if (SECRET_VALUE.test(value)) throw secretForbidden(); - } -} - -function requireRecord(input: unknown, code: string): JsonRecord { - const value = optionalRecord(input); - if (!value) throw new ChatActorProtocolError('Invalid object.', code); - return value; -} - -function optionalRecord(input: unknown): JsonRecord | null { - return input && typeof input === 'object' && !Array.isArray(input) - ? (input as JsonRecord) - : null; -} - -function validVersion(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; -} - -function validSafeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value); -} - -function readIdentity(value: unknown): string | null { - if (typeof value !== 'string' || value.length < 1 || value.length > 256) { - return null; - } - const invalid = [...value].some((character) => { - const code = character.charCodeAt(0); - return code <= 31 || code === 127 || /[\s/\\?#]/u.test(character); - }); - return invalid ? null : value; -} - -function requireIdentity(value: unknown): string { - const identity = readIdentity(value); - if (!identity) { - throw new ChatActorProtocolError( - 'NyxID action identity is invalid.', - 'NYXID_IDENTITY_INVALID', - ); - } - return identity; -} - -function requireBoundedString(value: unknown, maximum: number): string { - if ( - typeof value !== 'string' || - value.length < 1 || - value.length > maximum || - value.trim() !== value - ) { - throw invalidVariant(); - } - return value; -} - -function invalidVariant(): ChatActorProtocolError { - return new ChatActorProtocolError( - 'NyxID action params are invalid.', - 'NYXID_ACTION_VARIANT_INVALID', - ); -} - -function unsafeUrl(): ChatActorProtocolError { - return new ChatActorProtocolError( - 'NyxID action URL is unsafe.', - 'NYXID_URL_UNSAFE', - ); -} - -function invalidNumericThreshold(): ChatActorProtocolError { - return new ChatActorProtocolError( - 'NyxID numeric threshold is invalid.', - 'NYXID_INPUT_NUMERIC_THRESHOLD_INVALID', - ); -} - -function secretForbidden(): ChatActorProtocolError { - return new ChatActorProtocolError( - 'NyxID action input must not contain secrets.', - 'NYXID_SECRET_FORBIDDEN', - ); -} - -function cloneProjection(projection: ChatActorProjection): ChatActorProjection { - return { - ...projection, - recentTerminalTurns: projection.recentTerminalTurns.map(cloneRecord), - recentStepControlResults: - projection.recentStepControlResults.map(cloneRecord), - task: projection.task - ? (JSON.parse(JSON.stringify(projection.task)) as ChatTaskPlan) - : null, - steps: new Map( - [...projection.steps].map(([key, value]) => [key, cloneStep(value)]), - ), - pendingInput: projection.pendingInput - ? ({ ...projection.pendingInput } as ChatPendingInput) - : null, - pendingApproval: projection.pendingApproval - ? ({ ...projection.pendingApproval } as ChatPendingApproval) - : null, - actions: new Map( - [...projection.actions].map(([key, value]) => [key, { ...value }]), - ), - conflicts: [...projection.conflicts], - }; -} - -function cloneStep(step: ChatActorStep): ChatActorStep { - return JSON.parse(JSON.stringify(step)) as ChatActorStep; -} - -function cloneRecord(value: JsonRecord): JsonRecord { - return JSON.parse(JSON.stringify(value)) as JsonRecord; -} - -function cloneNullableRecord(value: unknown): JsonRecord | null { - const record = optionalRecord(value); - return record ? cloneRecord(record) : null; -} - -function appendDistinctRecord( - records: readonly JsonRecord[], - value: JsonRecord, -): JsonRecord[] { - const serialized = JSON.stringify(value); - const next = records.some((record) => JSON.stringify(record) === serialized) - ? records.map(cloneRecord) - : [...records.map(cloneRecord), cloneRecord(value)]; - return next.slice(-32); -} - -function withConflict( - projection: ChatActorProjection, - code: string, -): ChatActorProjection { - const next = cloneProjection(projection); - if (!next.conflicts.some((conflict) => conflict.code === code)) { - next.conflicts = [...next.conflicts, { code }]; - } - return next; -} diff --git a/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx b/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx deleted file mode 100644 index 1817082172..0000000000 --- a/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx +++ /dev/null @@ -1,2801 +0,0 @@ -import { AGUIEventType, CustomEventName } from "@aevatar-react-sdk/types"; -import { useIntl } from "@umijs/max"; -import { Alert, Empty, Space, Typography } from "antd"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { parseBackendSSEStream } from "@/shared/agui/sseFrameNormalizer"; -import { authFetch } from "@/shared/auth/fetch"; -import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; -import { runtimeRunsApi } from "@/shared/api/runtimeRunsApi"; -import { scopesApi } from "@/shared/api/scopesApi"; -import { scopeRuntimeApi } from "@/shared/api/scopeRuntimeApi"; -import { formatDateTime } from "@/shared/datetime/dateTime"; -import type { ServiceCatalogSnapshot } from "@/shared/models/services"; -import type { - WorkflowActorGraphEnrichedSnapshot, - WorkflowActorSnapshot, -} from "@/shared/models/runtime/actors"; -import type { ScopeServiceRunAuditSnapshot } from "@/shared/models/runtime/scopeServices"; -import { history } from "@/shared/navigation/history"; -import { - buildRuntimeExplorerHref, - buildRuntimeRunsHref, -} from "@/shared/navigation/runtimeRoutes"; -import { saveObservedRunSessionPayload } from "@/shared/runs/draftRunSession"; -import { - buildScopeConsoleServiceOptions, - extractRuntimeInvokeReceipt, - scopeServiceAppId, -} from "@/shared/runs/scopeConsole"; -import { studioApi } from "@/shared/studio/api"; -import { AevatarContextDrawer } from "@/shared/ui/aevatarPageShells"; -import { useConsoleToast } from "@/shared/ui/ConsoleToast"; -import { - AEVATAR_INTERACTIVE_BUTTON_CLASS, - AEVATAR_INTERACTIVE_CHIP_CLASS, - AEVATAR_PRESSABLE_CARD_CLASS, -} from "@/shared/ui/interactionStandards"; -import { - applyRuntimeEvent, - createRuntimeEventAccumulator, - isRawObserved, -} from "./chatEventAdapter"; -import { DebugPanel } from "./chatPresentation"; -import type { RuntimeEvent } from "./chatTypes"; -import { - buildTimelineRows, - filterTimelineRows, -} from "../actors/actorPresentation"; -import { - buildTimelineBlockingSummary, - describeActorCompletionStatus, -} from "./runtimeInspector"; -import { - formatConsoleMessage, - t, - type ConsoleMessageDescriptor, -} from "@/shared/i18n/messages"; - -type ConsoleTab = "query" | "execute" | "timeline" | "raw"; -type QueryTarget = "binding" | "services" | "workflows" | "actor"; - -type ConsoleFlow = { - badge?: ConsoleMessageDescriptor; - description: ConsoleMessageDescriptor; - group: "developer" | "operate" | "understand"; - id: ConsoleTab; - label: ConsoleMessageDescriptor; - priority: "primary" | "secondary"; -}; - -type ChatAdvancedConsoleProps = { - defaultServiceId?: string; - onClose: () => void; - onEnsureNyxIdBound?: () => Promise; - onTimelineActionResult?: (input: { - action: "resume" | "approve" | "reject" | "signal"; - actorId: string; - commandId?: string; - content: string; - error?: string; - kind: "human_input" | "human_approval" | "wait_signal"; - runId: string; - serviceId: string; - signalName?: string; - stepId: string; - success: boolean; - }) => void; - open: boolean; - scopeId: string; - services: readonly ServiceCatalogSnapshot[]; - sessionActorId?: string; -}; - -type ExecuteLaunchContext = { - endpointId: string; - endpointKind: string; - payloadBase64: string; - payloadTypeUrl: string; - prompt: string; - serviceId: string; -}; - -const monoFontFamily = - "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace"; - -const queryTargets: { - description: ConsoleMessageDescriptor; - id: QueryTarget; - label: ConsoleMessageDescriptor; -}[] = [ - { - description: { - id: "pages.chat.chatadvancedconsole.current.default.binding.for.this", - defaultMessage: "Current default binding for this workspace.", - }, - id: "binding", - label: { - id: "pages.chat.chatadvancedconsole.workspace.binding", - defaultMessage: "Workspace Binding", - }, - }, - { - description: { - id: "pages.chat.chatadvancedconsole.all.published.services.currently.visible", - defaultMessage: "All published services currently visible to this workspace.", - }, - id: "services", - label: { - id: "pages.chat.chatadvancedconsole.services", - defaultMessage: "Services", - }, - }, - { - description: { - id: "pages.chat.chatadvancedconsole.workflow.assets.currently.deployed.into", - defaultMessage: "Workflow assets currently deployed into this workspace.", - }, - id: "workflows", - label: { - id: "pages.chat.chatadvancedconsole.workflows", - defaultMessage: "Workflows", - }, - }, - { - description: { - id: "pages.chat.chatadvancedconsole.inspect.a.specific.actor.by", - defaultMessage: "Inspect a specific actor by its runtime ID.", - }, - id: "actor", - label: { - id: "pages.chat.chatadvancedconsole.actor.snapshot", - defaultMessage: "Actor Snapshot", - }, - }, -]; - -const consoleFlows: readonly ConsoleFlow[] = [ - { - badge: { - id: "pages.chat.chatadvancedconsole.recommended.first", - defaultMessage: "Recommended first", - }, - description: { - id: "pages.chat.chatadvancedconsole.check.the.default.route.target", - defaultMessage: - "Check the default route target, published services, deployed workflows, or inspect an actor directly.", - }, - group: "understand", - id: "query", - label: { - id: "pages.chat.chatadvancedconsole.query", - defaultMessage: "Query", - }, - priority: "primary", - }, - { - description: { - id: "pages.chat.chatadvancedconsole.inspect.actor.state.timeline.evidence", - defaultMessage: - "Inspect actor state, timeline evidence, graph topology, and any blocking gate that needs operator action.", - }, - group: "understand", - id: "timeline", - label: { - id: "pages.chat.chatadvancedconsole.timeline", - defaultMessage: "Timeline", - }, - priority: "secondary", - }, - { - badge: { - id: "pages.chat.chatadvancedconsole.common.next.step", - defaultMessage: "Common next step", - }, - description: { - id: "pages.chat.chatadvancedconsole.launch.a.service.endpoint.capture", - defaultMessage: - "Launch a service endpoint, capture the run receipt, and continue into Runs or Explorer when needed.", - }, - group: "operate", - id: "execute", - label: { - id: "pages.chat.chatadvancedconsole.execute", - defaultMessage: "Execute", - }, - priority: "primary", - }, - { - badge: { - id: "pages.chat.chatadvancedconsole.expert", - defaultMessage: "Expert", - }, - description: { - id: "pages.chat.chatadvancedconsole.send.direct.api.requests.only", - defaultMessage: - "Send direct API requests only when you need low-level integration or protocol debugging.", - }, - group: "developer", - id: "raw", - label: { - id: "pages.chat.chatadvancedconsole.raw.api.2", - defaultMessage: "Raw API", - }, - priority: "secondary", - }, -]; - -const drawerSectionStyle: React.CSSProperties = { - background: "#ffffff", - border: "1px solid #e7e5e4", - borderRadius: 16, - display: "flex", - flexDirection: "column", - gap: 12, - padding: 16, -}; - -const fieldLabelStyle: React.CSSProperties = { - color: "#6b7280", - fontSize: 12, - fontWeight: 600, -}; - -const monoBlockStyle: React.CSSProperties = { - background: "#fafaf8", - border: "1px solid #e7e5e4", - borderRadius: 12, - fontFamily: monoFontFamily, - fontSize: 12, - margin: 0, - maxHeight: 320, - overflow: "auto", - padding: 14, - whiteSpace: "pre-wrap", -}; - -const inputStyle: React.CSSProperties = { - background: "#ffffff", - border: "1px solid #d6d3d1", - borderRadius: 10, - color: "#111827", - fontSize: 13, - minHeight: 40, - outline: "none", - padding: "10px 12px", - width: "100%", -}; - -const textareaStyle: React.CSSProperties = { - ...inputStyle, - fontFamily: monoFontFamily, - minHeight: 120, - resize: "vertical", -}; - -const selectStyle: React.CSSProperties = { - ...inputStyle, - fontFamily: monoFontFamily, -}; - -const actionButtonStyle = ( - tone: "primary" | "secondary", - disabled = false -): React.CSSProperties => ({ - background: tone === "primary" ? "#111827" : "#ffffff", - border: `1px solid ${tone === "primary" ? "#111827" : "#d6d3d1"}`, - borderRadius: 10, - color: tone === "primary" ? "#ffffff" : "#4b5563", - cursor: disabled ? "not-allowed" : "pointer", - fontSize: 13, - fontWeight: 600, - opacity: disabled ? 0.45 : 1, - padding: "9px 14px", -}); - -function timelineStatusTone( - status: "processing" | "success" | "error" | "default" -): { background: string; color: string } { - switch (status) { - case "processing": - return { - background: "#eff6ff", - color: "#1d4ed8", - }; - case "success": - return { - background: "#ecfdf5", - color: "#047857", - }; - case "error": - return { - background: "#fef2f2", - color: "#dc2626", - }; - default: - return { - background: "#f5f5f4", - color: "#57534e", - }; - } -} - -function safeJson(value: unknown): string { - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -function createResultPanel( - label: string, - value: string, - onCopy?: () => void -): React.ReactElement { - return ( -
-
- {label} - {onCopy ? ( - - ) : null} -
-
{value}
-
- ); -} - -function renderAuditPreviewCard( - title: string, - description: string, - stamp?: string | null, - keySuffix?: string -): React.ReactElement { - return ( -
- {title} - - {description || t("pages.chat.chatadvancedconsole.no.detail", "No detail")} - - {stamp ? ( - - {formatDateTime(stamp)} - - ) : null} -
- ); -} - -function createObservedExecutionEvents(context: { - actorId?: string; - commandId?: string; - correlationId?: string; - runId?: string; -}): RuntimeEvent[] { - const events: RuntimeEvent[] = []; - - if (context.runId?.trim()) { - events.push({ - runId: context.runId.trim(), - threadId: - context.correlationId?.trim() || - context.commandId?.trim() || - context.runId.trim(), - timestamp: Date.now(), - type: AGUIEventType.RUN_STARTED, - } as RuntimeEvent); - } - - if (context.actorId?.trim() || context.commandId?.trim()) { - events.push({ - name: CustomEventName.RunContext, - timestamp: Date.now(), - type: AGUIEventType.CUSTOM, - value: { - actorId: context.actorId?.trim() || undefined, - commandId: context.commandId?.trim() || undefined, - }, - } as RuntimeEvent); - } - - return events; -} - -export function ChatAdvancedConsole({ - defaultServiceId, - onClose, - onEnsureNyxIdBound, - onTimelineActionResult, - open, - scopeId, - services, - sessionActorId, -}: ChatAdvancedConsoleProps): React.ReactElement { - const intl = useIntl(); - const executeAbortRef = useRef(null); - - const consoleServices = useMemo( - () => - buildScopeConsoleServiceOptions(services, defaultServiceId, { - sortBy: "displayName", - }), - [defaultServiceId, services] - ); - const [activeTab, setActiveTab] = useState("query"); - const [queryTarget, setQueryTarget] = useState("binding"); - const [queryActorId, setQueryActorId] = useState(""); - const [queryLoading, setQueryLoading] = useState(false); - const [queryResult, setQueryResult] = useState(null); - const [timelineActorInput, setTimelineActorInput] = useState(""); - const [timelineLoading, setTimelineLoading] = useState(false); - const [timelineError, setTimelineError] = useState(""); - const [timelineSnapshot, setTimelineSnapshot] = - useState(null); - const [timelineGraph, setTimelineGraph] = - useState(null); - const [timelineSearch, setTimelineSearch] = useState(""); - const [timelineOnlyErrors, setTimelineOnlyErrors] = useState(false); - const [timelineSelectedStage, setTimelineSelectedStage] = useState(""); - const [timelineItems, setTimelineItems] = useState< - ReturnType - >([]); - const [timelineRefreshTick, setTimelineRefreshTick] = useState(0); - const [timelineSelectedKey, setTimelineSelectedKey] = useState( - null - ); - const [timelineActionInput, setTimelineActionInput] = useState(""); - const [timelineActionLoading, setTimelineActionLoading] = useState(false); - const [timelineActionNotice, setTimelineActionNotice] = useState(""); - const toast = useConsoleToast(); - - const [executeServiceId, setExecuteServiceId] = useState(defaultServiceId || ""); - const [executeEndpointId, setExecuteEndpointId] = useState("chat"); - const [executePrompt, setExecutePrompt] = useState(""); - const [executePayloadTypeUrl, setExecutePayloadTypeUrl] = useState(""); - const [executePayloadBase64, setExecutePayloadBase64] = useState(""); - const [executeEvents, setExecuteEvents] = useState([]); - const [executeAssistantText, setExecuteAssistantText] = useState(""); - const [executeResponseText, setExecuteResponseText] = useState(""); - const [executeActorId, setExecuteActorId] = useState(""); - const [executeCommandId, setExecuteCommandId] = useState(""); - const [executeCorrelationId, setExecuteCorrelationId] = useState(""); - const [executeRunId, setExecuteRunId] = useState(""); - const [executeAuditSnapshot, setExecuteAuditSnapshot] = - useState(null); - const [executeAuditLoading, setExecuteAuditLoading] = useState(false); - const [executeAuditError, setExecuteAuditError] = useState(""); - const [executeLaunchContext, setExecuteLaunchContext] = - useState(null); - const [executeStatus, setExecuteStatus] = useState< - "idle" | "running" | "success" | "error" - >("idle"); - const [executeError, setExecuteError] = useState(""); - - const [rawMethod, setRawMethod] = useState("GET"); - const [rawPath, setRawPath] = useState(""); - const [rawBody, setRawBody] = useState(""); - const [rawLoading, setRawLoading] = useState(false); - const [rawResult, setRawResult] = useState<{ - body: string; - status: number; - statusText: string; - } | null>(null); - - const activeExecuteService = - consoleServices.find((service) => service.serviceId === executeServiceId) ?? - consoleServices[0] ?? - null; - const activeExecuteEndpoint = - activeExecuteService?.endpoints.find( - (endpoint) => endpoint.endpointId === executeEndpointId - ) ?? - activeExecuteService?.endpoints[0] ?? - null; - const effectiveTimelineServiceId = - executeLaunchContext?.serviceId || defaultServiceId || executeServiceId || ""; - const effectiveTimelineActorId = ( - timelineActorInput.trim() || - executeActorId.trim() || - sessionActorId?.trim() || - queryActorId.trim() - ).trim(); - const timelineRows = useMemo( - () => - filterTimelineRows(timelineItems, { - errorsOnly: timelineOnlyErrors, - eventTypes: [], - query: timelineSearch, - stages: timelineSelectedStage ? [timelineSelectedStage] : [], - stepTypes: [], - }), - [timelineItems, timelineOnlyErrors, timelineSearch, timelineSelectedStage] - ); - const timelineStageOptions = useMemo( - () => - [...new Set(timelineItems.map((item) => item.stage).filter(Boolean))].sort( - (left, right) => left.localeCompare(right) - ), - [timelineItems] - ); - const selectedTimelineRow = useMemo(() => { - if (!timelineRows.length) { - return null; - } - - return ( - timelineRows.find((item) => item.key === timelineSelectedKey) || - timelineRows[0] - ); - }, [timelineRows, timelineSelectedKey]); - const timelineBlockingSummary = useMemo( - () => buildTimelineBlockingSummary(timelineItems), - [timelineItems] - ); - const consoleFlowGroups = useMemo( - () => [ - { - description: intl.formatMessage({ - id: "pages.chat.chatadvancedconsole.inspect.the.current.workspace.and", - defaultMessage: "Inspect the current workspace and understand runtime state.", - }), - flows: consoleFlows.filter((flow) => flow.group === "understand"), - id: "understand", - label: intl.formatMessage({ - id: "pages.chat.chatadvancedconsole.understand", - defaultMessage: "Understand", - }), - }, - { - description: intl.formatMessage({ - id: "pages.chat.chatadvancedconsole.run.work.inspect.the.receipt", - defaultMessage: "Run work, inspect the receipt, and act on runtime gates.", - }), - flows: consoleFlows.filter((flow) => flow.group === "operate"), - id: "operate", - label: intl.formatMessage({ - id: "pages.chat.chatadvancedconsole.operate", - defaultMessage: "Operate", - }), - }, - { - description: intl.formatMessage({ - id: "pages.chat.chatadvancedconsole.drop.to.direct.api.calls", - defaultMessage: "Drop to direct API calls when you need low-level debugging.", - }), - flows: consoleFlows.filter((flow) => flow.group === "developer"), - id: "developer", - label: intl.formatMessage({ - id: "pages.chat.chatadvancedconsole.developer", - defaultMessage: "Developer", - }), - }, - ], - [intl] - ); - const activeConsoleFlow = useMemo( - () => consoleFlows.find((flow) => flow.id === activeTab) || null, - [activeTab] - ); - - const rawShortcuts = useMemo( - () => [ - { - label: t("pages.chat.chatadvancedconsole.binding", "Binding"), - method: "GET", - path: `/scopes/${scopeId}/binding`, - }, - { - label: t("pages.chat.chatadvancedconsole.services.2", "Services"), - method: "GET", - path: `/scopes/${scopeId}/services?appId=${scopeServiceAppId}&take=20`, - }, - { - label: t("pages.chat.chatadvancedconsole.workflows.2", "Workflows"), - method: "GET", - path: `/scopes/${scopeId}/workflows`, - }, - activeExecuteService - ? { - label: t("pages.chat.chatadvancedconsole.runs", "Runs"), - method: "GET", - path: `/scopes/${scopeId}/services/${activeExecuteService.serviceId}/runs?take=10`, - } - : null, - { - label: t("pages.chat.chatadvancedconsole.auth.session", "Auth Session"), - method: "GET", - path: "/auth/me", - }, - ].filter(Boolean) as Array<{ label: string; method: string; path: string }>, - [activeExecuteService, scopeId] - ); - - useEffect(() => { - if (!open) { - return; - } - - if (!queryActorId && sessionActorId) { - setQueryActorId(sessionActorId); - } - }, [open, queryActorId, sessionActorId]); - - useEffect(() => { - if (!consoleServices.length) { - setExecuteServiceId(""); - return; - } - - const preferredServiceId = - (defaultServiceId && - consoleServices.some((service) => service.serviceId === defaultServiceId) - ? defaultServiceId - : "") || - consoleServices[0].serviceId; - - if ( - !executeServiceId || - !consoleServices.some((service) => service.serviceId === executeServiceId) - ) { - setExecuteServiceId(preferredServiceId); - } - }, [consoleServices, defaultServiceId, executeServiceId]); - - useEffect(() => { - const defaultPath = scopeId ? `/scopes/${scopeId}/binding` : "/auth/me"; - setRawPath((current) => (current.trim() ? current : defaultPath)); - }, [scopeId]); - - useEffect(() => { - if (!activeExecuteService) { - setExecuteEndpointId(""); - return; - } - - if ( - !executeEndpointId || - !activeExecuteService.endpoints.some( - (endpoint) => endpoint.endpointId === executeEndpointId - ) - ) { - setExecuteEndpointId(activeExecuteService.endpoints[0]?.endpointId || ""); - } - }, [activeExecuteService, executeEndpointId]); - - useEffect(() => { - setExecutePayloadTypeUrl(activeExecuteEndpoint?.requestTypeUrl || ""); - }, [activeExecuteEndpoint?.endpointId, activeExecuteEndpoint?.requestTypeUrl]); - - useEffect(() => { - if (!open || activeTab !== "timeline") { - return; - } - - if (!effectiveTimelineActorId) { - setTimelineError(""); - setTimelineSnapshot(null); - setTimelineGraph(null); - setTimelineItems([]); - setTimelineSelectedKey(null); - return; - } - - let cancelled = false; - setTimelineLoading(true); - setTimelineError(""); - - void Promise.all([ - runtimeActorsApi.getActorSnapshot(effectiveTimelineActorId), - runtimeActorsApi.getActorTimeline(effectiveTimelineActorId, { take: 40 }), - runtimeActorsApi.getActorGraphEnriched(effectiveTimelineActorId, { - depth: 2, - take: 40, - }), - ]) - .then(([snapshot, timeline, graph]) => { - if (cancelled) { - return; - } - - setTimelineSnapshot(snapshot); - setTimelineGraph(graph); - setTimelineItems(buildTimelineRows(timeline)); - }) - .catch((error) => { - if (cancelled) { - return; - } - - setTimelineSnapshot(null); - setTimelineGraph(null); - setTimelineItems([]); - setTimelineError(error instanceof Error ? error.message : String(error)); - }) - .finally(() => { - if (!cancelled) { - setTimelineLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [activeTab, effectiveTimelineActorId, open, timelineRefreshTick]); - - useEffect(() => { - if (!timelineRows.length) { - setTimelineSelectedKey(null); - return; - } - - setTimelineSelectedKey((current) => - current && timelineRows.some((item) => item.key === current) - ? current - : timelineRows[0].key - ); - }, [timelineRows]); - - useEffect(() => { - setTimelineActionInput(""); - setTimelineActionNotice(""); - }, [timelineBlockingSummary?.kind, timelineBlockingSummary?.stepId]); - - useEffect( - () => () => { - executeAbortRef.current?.abort(); - }, - [] - ); - - const handleCopy = useCallback((value: string) => { - void navigator.clipboard?.writeText(value); - }, []); - - const handleQuerySubmit = useCallback(async () => { - if (!scopeId) { - return; - } - - setQueryLoading(true); - setQueryResult(null); - try { - let result: unknown; - switch (queryTarget) { - case "binding": - result = await studioApi.getDefaultRouteTarget(scopeId); - break; - case "services": - result = await scopeRuntimeApi.listServices(scopeId, { - appId: scopeServiceAppId, - take: 100, - }); - break; - case "workflows": - result = await scopesApi.listWorkflows(scopeId); - break; - case "actor": - if (!queryActorId.trim()) { - setQueryResult(safeJson({ error: "Actor ID is required." })); - setQueryLoading(false); - return; - } - result = await runtimeActorsApi.getActorSnapshot(queryActorId.trim()); - break; - } - - setQueryResult(safeJson(result)); - } catch (error) { - setQueryResult( - safeJson({ - error: error instanceof Error ? error.message : String(error), - }) - ); - } finally { - setQueryLoading(false); - } - }, [queryActorId, queryTarget, scopeId]); - - const handleExecuteSubmit = useCallback(async () => { - if (!scopeId || !activeExecuteService || !activeExecuteEndpoint) { - return; - } - - executeAbortRef.current?.abort(); - const controller = new AbortController(); - executeAbortRef.current = controller; - - setExecuteAssistantText(""); - setExecuteActorId(""); - setExecuteAuditError(""); - setExecuteAuditLoading(false); - setExecuteAuditSnapshot(null); - setExecuteCommandId(""); - setExecuteCorrelationId(""); - setExecuteError(""); - setExecuteEvents([]); - const launchContext: ExecuteLaunchContext = { - endpointId: activeExecuteEndpoint.endpointId, - endpointKind: activeExecuteEndpoint.kind, - payloadBase64: executePayloadBase64.trim(), - payloadTypeUrl: executePayloadTypeUrl.trim(), - prompt: executePrompt.trim(), - serviceId: activeExecuteService.serviceId, - }; - setExecuteLaunchContext(launchContext); - setExecuteResponseText(""); - setExecuteRunId(""); - setExecuteStatus("running"); - - try { - if (activeExecuteService.kind === "nyxid-chat") { - await onEnsureNyxIdBound?.(); - } - - const isStreamingEndpoint = - activeExecuteEndpoint.kind === "chat" || - activeExecuteEndpoint.endpointId.trim() === "chat"; - - if (isStreamingEndpoint) { - const accumulator = createRuntimeEventAccumulator(); - const response = await runtimeRunsApi.streamEndpoint( - scopeId, - { - endpointId: activeExecuteEndpoint.endpointId, - prompt: executePrompt, - }, - controller.signal, - { - serviceId: activeExecuteService.serviceId, - } - ); - - for await (const event of parseBackendSSEStream(response, { - signal: controller.signal, - })) { - applyRuntimeEvent(accumulator, event); - setExecuteEvents([...accumulator.events]); - setExecuteAssistantText(accumulator.assistantText); - setExecuteActorId(accumulator.actorId); - setExecuteCommandId(accumulator.commandId); - setExecuteRunId(accumulator.runId); - setExecuteError(accumulator.errorText); - } - - setExecuteStatus(accumulator.errorText ? "error" : "success"); - return; - } - - const response = await runtimeRunsApi.invokeEndpoint( - scopeId, - { - endpointId: activeExecuteEndpoint.endpointId, - payloadBase64: executePayloadBase64.trim() || undefined, - payloadTypeUrl: executePayloadTypeUrl.trim() || undefined, - prompt: executePrompt, - }, - { - serviceId: activeExecuteService.serviceId, - } - ); - const { - actorId: responseActorId, - commandId: responseCommandId, - correlationId: responseCorrelationId, - runId: responseRunId, - } = extractRuntimeInvokeReceipt(response); - - setExecuteActorId(responseActorId); - setExecuteCommandId(responseCommandId); - setExecuteCorrelationId(responseCorrelationId); - setExecuteRunId(responseRunId); - setExecuteResponseText(safeJson(response)); - setExecuteStatus("success"); - } catch (error) { - if (controller.signal.aborted) { - setExecuteError("Execution stopped by operator."); - } else { - setExecuteError(error instanceof Error ? error.message : String(error)); - } - setExecuteStatus("error"); - } finally { - if (executeAbortRef.current === controller) { - executeAbortRef.current = null; - } - } - }, [ - activeExecuteEndpoint, - activeExecuteService, - executePayloadBase64, - executePayloadTypeUrl, - executePrompt, - onEnsureNyxIdBound, - scopeId, - ]); - - const handleOpenRuns = useCallback(() => { - if (!scopeId || !executeLaunchContext) { - return; - } - - const observedEvents = - executeEvents.length > 0 - ? executeEvents - : createObservedExecutionEvents({ - actorId: executeActorId, - commandId: executeCommandId, - correlationId: executeCorrelationId, - runId: executeRunId, - }); - const draftKey = - observedEvents.length > 0 - ? saveObservedRunSessionPayload({ - actorId: executeActorId || undefined, - commandId: executeCommandId || undefined, - endpointId: executeLaunchContext.endpointId, - endpointKind: executeLaunchContext.endpointKind as - | "chat" - | "command" - | undefined, - events: observedEvents, - payloadBase64: - executeLaunchContext.endpointKind !== "chat" - ? executeLaunchContext.payloadBase64 || undefined - : undefined, - payloadTypeUrl: - executeLaunchContext.endpointKind !== "chat" - ? executeLaunchContext.payloadTypeUrl || undefined - : undefined, - prompt: executeLaunchContext.prompt, - runId: executeRunId || undefined, - scopeId, - serviceOverrideId: executeLaunchContext.serviceId, - }) - : ""; - - history.push( - buildRuntimeRunsHref({ - actorId: executeActorId || undefined, - draftKey: draftKey || undefined, - endpointId: executeLaunchContext.endpointId, - endpointKind: executeLaunchContext.endpointKind, - payloadBase64: - executeLaunchContext.endpointKind !== "chat" - ? executeLaunchContext.payloadBase64 || undefined - : undefined, - payloadTypeUrl: - executeLaunchContext.endpointKind !== "chat" - ? executeLaunchContext.payloadTypeUrl || undefined - : undefined, - prompt: executeLaunchContext.prompt || undefined, - scopeId, - serviceId: executeLaunchContext.serviceId, - }) - ); - }, [ - executeActorId, - executeCommandId, - executeCorrelationId, - executeEvents, - executeLaunchContext, - executeRunId, - scopeId, - ]); - - const handleOpenExplorer = useCallback(() => { - if (!scopeId) { - return; - } - - history.push( - buildRuntimeExplorerHref({ - actorId: effectiveTimelineActorId || undefined, - runId: executeRunId || undefined, - scopeId, - serviceId: executeLaunchContext?.serviceId, - }) - ); - }, [effectiveTimelineActorId, executeLaunchContext?.serviceId, executeRunId, scopeId]); - - const handleLoadAudit = useCallback(async () => { - if (!scopeId || !executeLaunchContext?.serviceId || !executeRunId) { - return; - } - - setExecuteAuditLoading(true); - setExecuteAuditError(""); - try { - const snapshot = await scopeRuntimeApi.getServiceRunAudit( - scopeId, - executeLaunchContext.serviceId, - executeRunId, - { - actorId: effectiveTimelineActorId || undefined, - } - ); - setExecuteAuditSnapshot(snapshot); - } catch (error) { - setExecuteAuditSnapshot(null); - setExecuteAuditError(error instanceof Error ? error.message : String(error)); - } finally { - setExecuteAuditLoading(false); - } - }, [ - effectiveTimelineActorId, - executeLaunchContext?.serviceId, - executeRunId, - scopeId, - ]); - - const executeAuditTimeline = executeAuditSnapshot?.audit.timeline ?? []; - const executeAuditSteps = executeAuditSnapshot?.audit.steps ?? []; - const executeAuditReplies = executeAuditSnapshot?.audit.roleReplies ?? []; - const executeAuditSummary = executeAuditSnapshot?.audit.summary; - const relatedAuditStep = useMemo(() => { - const stepId = - selectedTimelineRow?.stepId || timelineBlockingSummary?.stepId || ""; - if (!stepId) { - return null; - } - - return ( - executeAuditSteps.find((step) => step.stepId === stepId) || null - ); - }, [executeAuditSteps, selectedTimelineRow?.stepId, timelineBlockingSummary?.stepId]); - - const handleTimelineAction = useCallback( - async (action: "resume" | "approve" | "reject" | "signal") => { - if ( - !scopeId || - !timelineBlockingSummary || - !effectiveTimelineActorId || - !executeRunId || - !effectiveTimelineServiceId - ) { - return; - } - - setTimelineActionLoading(true); - setTimelineActionNotice(""); - - try { - if (action === "signal") { - const result = await runtimeRunsApi.signal( - scopeId, - { - actorId: effectiveTimelineActorId, - payload: timelineActionInput.trim() || undefined, - runId: executeRunId, - signalName: timelineBlockingSummary.signalName || "continue", - stepId: timelineBlockingSummary.stepId, - }, - { - serviceId: effectiveTimelineServiceId, - } - ); - - const content = `Signal ${ - timelineBlockingSummary.signalName || "continue" - } submitted.`; - setTimelineActionNotice(content); - onTimelineActionResult?.({ - action, - actorId: result.actorId || effectiveTimelineActorId, - commandId: result.commandId, - content, - kind: timelineBlockingSummary.kind, - runId: result.runId || executeRunId, - serviceId: effectiveTimelineServiceId, - signalName: timelineBlockingSummary.signalName, - stepId: timelineBlockingSummary.stepId, - success: true, - }); - } else { - const result = await runtimeRunsApi.resume( - scopeId, - { - actorId: effectiveTimelineActorId, - approved: action !== "reject", - runId: executeRunId, - stepId: timelineBlockingSummary.stepId, - userInput: timelineActionInput.trim() || undefined, - }, - { - serviceId: effectiveTimelineServiceId, - } - ); - - const content = - action === "reject" - ? `Rejection submitted for ${timelineBlockingSummary.stepId}.` - : timelineBlockingSummary.kind === "human_approval" - ? `Approval submitted for ${timelineBlockingSummary.stepId}.` - : `Input submitted for ${timelineBlockingSummary.stepId}.`; - setTimelineActionNotice(content); - onTimelineActionResult?.({ - action, - actorId: result.actorId || effectiveTimelineActorId, - commandId: result.commandId, - content, - kind: timelineBlockingSummary.kind, - runId: result.runId || executeRunId, - serviceId: effectiveTimelineServiceId, - signalName: timelineBlockingSummary.signalName, - stepId: timelineBlockingSummary.stepId, - success: true, - }); - } - - setTimelineActionInput(""); - setTimelineRefreshTick((current) => current + 1); - if (executeAuditSnapshot) { - void handleLoadAudit(); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - toast.error( - t( - "pages.chat.chatadvancedconsole.timelineActionFailed", - "Run action could not be completed. Try again.", - ), - ); - onTimelineActionResult?.({ - action, - actorId: effectiveTimelineActorId, - content: errorMessage, - error: errorMessage, - kind: timelineBlockingSummary.kind, - runId: executeRunId, - serviceId: effectiveTimelineServiceId, - signalName: timelineBlockingSummary.signalName, - stepId: timelineBlockingSummary.stepId, - success: false, - }); - } finally { - setTimelineActionLoading(false); - } - }, - [ - effectiveTimelineActorId, - effectiveTimelineServiceId, - executeAuditSnapshot, - executeRunId, - handleLoadAudit, - onTimelineActionResult, - scopeId, - timelineActionInput, - timelineBlockingSummary, - ] - ); - - const handleRawSubmit = useCallback(async () => { - const normalizedPath = rawPath.trim(); - if (!normalizedPath) { - return; - } - - setRawLoading(true); - setRawResult(null); - - try { - const response = await authFetch( - `/api${normalizedPath.startsWith("/") ? "" : "/"}${normalizedPath}`, - { - body: - rawMethod !== "GET" && rawBody.trim().length > 0 - ? rawBody - : undefined, - headers: - rawMethod !== "GET" && rawBody.trim().length > 0 - ? { - "Content-Type": "application/json", - } - : undefined, - method: rawMethod, - } - ); - - const contentType = response.headers.get("content-type") || ""; - const body = contentType.includes("json") - ? safeJson(await response.json()) - : await response.text(); - - setRawResult({ - body, - status: response.status, - statusText: response.statusText, - }); - } catch (error) { - setRawResult({ - body: error instanceof Error ? error.message : String(error), - status: 0, - statusText: "Network Error", - }); - } finally { - setRawLoading(false); - } - }, [rawBody, rawMethod, rawPath]); - - return ( - - {!scopeId ? ( - - ) : ( -
-
- {t("pages.chat.chatadvancedconsole.choose.task", "Choose a task")} - - {t("pages.chat.chatadvancedconsole.advanced.console.keeps.runtime.inspection", "Advanced Console keeps runtime inspection, operator actions, and developer tooling in one drawer. Start from the task you are trying to complete.")} -
- {t("pages.chat.chatadvancedconsole.suggested.path.start.with", "Suggested path: start with")}{t("pages.chat.chatadvancedconsole.query", "Query")} {t("pages.chat.chatadvancedconsole.to.orient.the.workspace.move", "to orient the workspace, move to")}{t("pages.chat.chatadvancedconsole.execute", "Execute")} {t("pages.chat.chatadvancedconsole.when.you.are.ready.to", "when you are ready to act, then use")}{t("pages.chat.chatadvancedconsole.timeline", "Timeline")} {t("pages.chat.chatadvancedconsole.if.the.run.needs.evidence", "if the run needs evidence or operator input. Keep")}{t("pages.chat.chatadvancedconsole.raw.api", "Raw API")} {t("pages.chat.chatadvancedconsole.for.protocol.level.debugging", "for protocol-level debugging.")}
- -
- {consoleFlowGroups.map((group) => ( -
-
-
- {group.label} -
-
- {group.description} -
-
-
- {group.flows.map((flow) => { - const active = activeTab === flow.id; - const flowLabel = formatConsoleMessage(flow.label); - const flowDescription = formatConsoleMessage(flow.description); - const flowBadge = flow.badge - ? formatConsoleMessage(flow.badge) - : ""; - return ( - - ); - })} -
-
- ))} -
-
- - {activeConsoleFlow ? ( - - ) : null} - - {activeTab === "query" ? ( -
-
- {t("pages.chat.chatadvancedconsole.query.workspace.state", "Query Workspace State")} -
- {queryTargets.map((target) => { - const targetLabel = formatConsoleMessage(target.label); - const targetDescription = formatConsoleMessage(target.description); - - return ( - - ); - })} -
- - {queryTarget === "actor" ? ( -
- {t("pages.chat.chatadvancedconsole.actor.id", "Actor ID")} - setQueryActorId(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - void handleQuerySubmit(); - } - }} - placeholder="actor://..." - style={{ ...inputStyle, fontFamily: monoFontFamily }} - value={queryActorId} - /> -
- ) : null} - -
- -
-
- - {queryResult - ? createResultPanel( - t("pages.chat.chatadvancedconsole.query.result", "Query Result"), - queryResult, - () => handleCopy(queryResult), - ) - : null} -
- ) : null} - - {activeTab === "execute" ? ( -
-
- {t("pages.chat.chatadvancedconsole.execute.service.endpoint", "Execute Service Endpoint")} -
- - - - -