From 22b947d17bf061a453932f25dad69c68c5e2c30e Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:29:02 +0800 Subject: [PATCH 01/35] feat(ai): expose bounded project public materials without runtime payload reads --- backend/app/api/v1/endpoints/ai.py | 37 ++++ backend/app/repositories/ai_sources.py | 266 ++++++++++++++++++++++ backend/app/schemas/ai_sources.py | 44 ++++ backend/app/services/ai_sources.py | 62 ++++++ backend/tests/test_ai_public_sources.py | 282 ++++++++++++++++++++++++ 5 files changed, 691 insertions(+) create mode 100644 backend/app/repositories/ai_sources.py create mode 100644 backend/app/schemas/ai_sources.py create mode 100644 backend/app/services/ai_sources.py create mode 100644 backend/tests/test_ai_public_sources.py diff --git a/backend/app/api/v1/endpoints/ai.py b/backend/app/api/v1/endpoints/ai.py index ac63c100..7e4f9932 100644 --- a/backend/app/api/v1/endpoints/ai.py +++ b/backend/app/api/v1/endpoints/ai.py @@ -13,12 +13,49 @@ AISuggestionResponse, AISuggestionReviewRequest, ) +from app.schemas.ai_sources import AIPublicSource, AISourceKind, AISourceSummary from app.schemas.common import Page from app.services.ai import AIJobService +from app.services.ai_sources import AISourceService router = APIRouter(prefix="/ai") +@router.get("/projects/{project_id}/sources", response_model=Page[AISourceSummary]) +async def list_public_ai_sources( + project_id: UUID, + kind: AISourceKind, + session: SessionDependency, + current_user: CurrentUser, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + search: str = Query(default="", max_length=200), +) -> Page[AISourceSummary]: + items, total = await AISourceService(session).list_sources( + actor=current_user, + project_id=project_id, + kind=kind, + page=page, + page_size=page_size, + search=search, + ) + return Page(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/projects/{project_id}/sources/{kind}/{source_id}", response_model=AIPublicSource) +async def read_public_ai_source( + project_id: UUID, + kind: AISourceKind, + source_id: UUID, + session: SessionDependency, + current_user: CurrentUser, + version: int | None = Query(default=None, ge=1), +) -> AIPublicSource: + return await AISourceService(session).read_source( + actor=current_user, project_id=project_id, kind=kind, source_id=source_id, version=version + ) + + @router.get("/status", response_model=AIStatusResponse) async def get_ai_status( project_id: UUID, diff --git a/backend/app/repositories/ai_sources.py b/backend/app/repositories/ai_sources.py new file mode 100644 index 00000000..28b7473e --- /dev/null +++ b/backend/app/repositories/ai_sources.py @@ -0,0 +1,266 @@ +from typing import cast +from uuid import UUID + +from pydantic import JsonValue +from sqlalchemy import String, func, or_, select +from sqlalchemy import cast as sql_cast +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.api_assets import APIDefinition, APIVersion +from app.models.protocols import SchemaArtifact +from app.models.workflows import WorkflowExecution +from app.schemas.ai_sources import ( + AIPublicSource, + AIPublicSourceMetadata, + AISourceKind, + AISourceSummary, +) + + +class AISourceRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_sources( + self, project_id: UUID, kind: AISourceKind, page: int, page_size: int, search: str + ) -> tuple[list[AISourceSummary], int]: + if kind == "api": + return await self._list_apis(project_id, page, page_size, search) + if kind == "schema": + return await self._list_schemas(project_id, page, page_size, search) + return await self._list_executions(project_id, page, page_size, search) + + async def read_source( + self, project_id: UUID, kind: AISourceKind, source_id: UUID, version: int | None + ) -> AIPublicSource | None: + if kind == "api": + return await self._api(project_id, source_id, version) + if kind == "schema": + return await self._schema(project_id, source_id) + return await self._execution(project_id, source_id) + + async def _list_apis( + self, project_id: UUID, page: int, size: int, search: str + ) -> tuple[list[AISourceSummary], int]: + filters = [APIDefinition.project_id == project_id] + if search: + filters.append(APIDefinition.name.icontains(search, autoescape=True)) + rows = await self._session.execute( + select( + APIDefinition.id, + APIDefinition.name, + APIDefinition.current_version, + APIDefinition.created_at, + ) + .where(*filters) + .order_by(APIDefinition.created_at.desc(), APIDefinition.id.desc()) + .offset((page - 1) * size) + .limit(size) + ) + total = await self._session.scalar( + select(func.count()).select_from(APIDefinition).where(*filters) + ) + return [ + AISourceSummary( + id=row.id, + kind="api", + name=row.name, + version=row.current_version, + created_at=row.created_at, + ) + for row in rows + ], int(total or 0) + + async def _list_schemas( + self, project_id: UUID, page: int, size: int, search: str + ) -> tuple[list[AISourceSummary], int]: + filters = [SchemaArtifact.project_id == project_id] + if search: + filters.append(SchemaArtifact.name.icontains(search, autoescape=True)) + rows = await self._session.execute( + select( + SchemaArtifact.id, + SchemaArtifact.name, + SchemaArtifact.version, + SchemaArtifact.protocol, + SchemaArtifact.created_at, + ) + .where(*filters) + .order_by(SchemaArtifact.created_at.desc(), SchemaArtifact.id.desc()) + .offset((page - 1) * size) + .limit(size) + ) + total = await self._session.scalar( + select(func.count()).select_from(SchemaArtifact).where(*filters) + ) + return [ + AISourceSummary( + id=row.id, + kind="schema", + name=row.name, + version=row.version, + protocol=row.protocol, + created_at=row.created_at, + ) + for row in rows + ], int(total or 0) + + async def _list_executions( + self, project_id: UUID, page: int, size: int, search: str + ) -> tuple[list[AISourceSummary], int]: + filters = [WorkflowExecution.project_id == project_id] + if search: + normalized = search.replace("-", "") or search + filters.append( + or_( + WorkflowExecution.status.icontains(search, autoescape=True), + func.replace(sql_cast(WorkflowExecution.id, String), "-", "").icontains( + normalized, autoescape=True + ), + ) + ) + rows = await self._session.execute( + select(WorkflowExecution.id, WorkflowExecution.status, WorkflowExecution.created_at) + .where(*filters) + .order_by(WorkflowExecution.created_at.desc(), WorkflowExecution.id.desc()) + .offset((page - 1) * size) + .limit(size) + ) + total = await self._session.scalar( + select(func.count()).select_from(WorkflowExecution).where(*filters) + ) + return [ + AISourceSummary( + id=row.id, + kind="execution", + name=f"执行 {row.id}", + status=row.status, + created_at=row.created_at, + ) + for row in rows + ], int(total or 0) + + async def _api( + self, project_id: UUID, source_id: UUID, version: int | None + ) -> AIPublicSource | None: + requested = APIVersion.version == ( + version if version is not None else APIDefinition.current_version + ) + row = ( + await self._session.execute( + select( + APIDefinition.name, + APIVersion.version, + APIVersion.method, + APIVersion.path, + APIVersion.canonical_contract, + APIVersion.contract_fingerprint, + ) + .join(APIVersion, APIVersion.api_definition_id == APIDefinition.id) + .where( + APIDefinition.project_id == project_id, APIDefinition.id == source_id, requested + ) + ) + ).one_or_none() + if row is None: + return None + name, revision, method, path, contract, fingerprint = row + return AIPublicSource( + project_id=project_id, + metadata=AIPublicSourceMetadata( + source_kind="api", + source_id=source_id, + name=name, + version=revision, + protocol="http", + method=method, + path=path, + content_fingerprint=fingerprint, + ), + schema_document=cast(dict[str, JsonValue], contract) if contract else None, + notices=[] if contract else ["该固定版本未提供完整规范契约。可补充手动 Schema。"], + ) + + async def _schema(self, project_id: UUID, source_id: UUID) -> AIPublicSource | None: + row = ( + await self._session.execute( + select( + SchemaArtifact.name, + SchemaArtifact.version, + SchemaArtifact.protocol, + SchemaArtifact.source_format, + SchemaArtifact.source_content, + SchemaArtifact.summary, + SchemaArtifact.content_sha256, + ).where(SchemaArtifact.project_id == project_id, SchemaArtifact.id == source_id) + ) + ).one_or_none() + if row is None: + return None + name, version, protocol, source_format, content, summary, fingerprint = row + document: dict[str, JsonValue] = {"protocol": protocol, "source_format": source_format} + notices: list[str] = [] + if source_format in { + "graphql_sdl", + "graphql_introspection", + "proto_source", + "event_json_schema", + "event_avro", + }: + try: + document["schema_text"] = content.decode("utf-8") + except UnicodeDecodeError: + document["summary"] = cast(JsonValue, summary) + notices.append("原版本文本无法按 UTF-8 读取。仅提供既有摘要。") + else: + document["summary"] = cast(JsonValue, summary) + notices.append("该版本为二进制或反射产物。仅提供既有摘要。不发起反射或仓库扫描。") + return AIPublicSource( + project_id=project_id, + metadata=AIPublicSourceMetadata( + source_kind="schema", + source_id=source_id, + name=name, + version=version, + protocol=protocol, + content_fingerprint=fingerprint, + ), + schema_document=document, + notices=notices, + ) + + async def _execution(self, project_id: UUID, source_id: UUID) -> AIPublicSource | None: + row = ( + await self._session.execute( + select( + WorkflowExecution.workflow_id, + WorkflowExecution.workflow_version_id, + WorkflowExecution.status, + WorkflowExecution.error_code, + WorkflowExecution.error_message, + WorkflowExecution.started_at, + WorkflowExecution.completed_at, + ).where( + WorkflowExecution.project_id == project_id, WorkflowExecution.id == source_id + ) + ) + ).one_or_none() + if row is None: + return None + workflow_id, revision_id, status, code, message, started_at, completed_at = row + return AIPublicSource( + project_id=project_id, + metadata=AIPublicSourceMetadata( + source_kind="execution", + source_id=source_id, + name=f"执行 {source_id}", + workflow_id=workflow_id, + workflow_version_id=revision_id, + status=status, + error_code=code, + error_message=message, + started_at=started_at, + completed_at=completed_at, + ), + notices=["仅提供冻结执行身份、状态、时间和错误摘要。未读取运行变量、请求或响应正文。"], + ) diff --git a/backend/app/schemas/ai_sources.py b/backend/app/schemas/ai_sources.py new file mode 100644 index 00000000..47ab96b7 --- /dev/null +++ b/backend/app/schemas/ai_sources.py @@ -0,0 +1,44 @@ +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, JsonValue + +AISourceKind = Literal["api", "schema", "execution"] + + +class AISourceSummary(BaseModel): + id: UUID + kind: AISourceKind + name: str + version: int | None = None + protocol: str | None = None + status: str | None = None + created_at: datetime + + +class AIPublicSourceMetadata(BaseModel): + model_config = ConfigDict(extra="forbid") + + source_kind: AISourceKind + source_id: UUID + name: str + version: int | None = None + protocol: str | None = None + method: str | None = None + path: str | None = None + status: str | None = None + workflow_id: UUID | None = None + workflow_version_id: UUID | None = None + error_code: str | None = None + error_message: str | None = None + content_fingerprint: str | None = None + started_at: datetime | None = None + completed_at: datetime | None = None + + +class AIPublicSource(BaseModel): + project_id: UUID + metadata: AIPublicSourceMetadata + schema_document: dict[str, JsonValue] | None = None + notices: list[str] diff --git a/backend/app/services/ai_sources.py b/backend/app/services/ai_sources.py new file mode 100644 index 00000000..95623604 --- /dev/null +++ b/backend/app/services/ai_sources.py @@ -0,0 +1,62 @@ +from typing import Protocol +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.errors import AppError +from app.models.access import User +from app.repositories.ai_sources import AISourceRepository +from app.schemas.ai_sources import AIPublicSource, AISourceKind, AISourceSummary +from app.services.projects import ProjectService + + +class AISourceReader(Protocol): + async def list_sources( + self, project_id: UUID, kind: AISourceKind, page: int, page_size: int, search: str + ) -> tuple[list[AISourceSummary], int]: ... + + async def read_source( + self, project_id: UUID, kind: AISourceKind, source_id: UUID, version: int | None + ) -> AIPublicSource | None: ... + + +class AISourceService: + def __init__(self, session: AsyncSession, reader: AISourceReader | None = None) -> None: + self._projects = ProjectService(session) + self._reader = reader if reader is not None else AISourceRepository(session) + + async def list_sources( + self, + *, + actor: User, + project_id: UUID, + kind: AISourceKind, + page: int, + page_size: int, + search: str, + ) -> tuple[list[AISourceSummary], int]: + await self._projects.authorize(actor=actor, project_id=project_id, editing=False) + return await self._reader.list_sources(project_id, kind, page, page_size, search.strip()) + + async def read_source( + self, + *, + actor: User, + project_id: UUID, + kind: AISourceKind, + source_id: UUID, + version: int | None, + ) -> AIPublicSource: + await self._projects.authorize(actor=actor, project_id=project_id, editing=False) + source = await self._reader.read_source(project_id, kind, source_id, version) + if source is None or (version is not None and source.metadata.version != version): + raise AppError( + code="AI_SOURCE_NOT_FOUND", message="公开材料来源或固定版本不存在", status_code=404 + ) + if len(source.model_dump_json().encode("utf-8")) > 1024 * 1024: + raise AppError( + code="AI_SOURCE_TOO_LARGE", + message="公开材料超过 1 MB。请缩小来源或手动选择材料", + status_code=413, + ) + return source diff --git a/backend/tests/test_ai_public_sources.py b/backend/tests/test_ai_public_sources.py new file mode 100644 index 00000000..0e83726e --- /dev/null +++ b/backend/tests/test_ai_public_sources.py @@ -0,0 +1,282 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from hashlib import sha256 +from uuid import UUID + +import pytest +from sqlalchemy import select +from test_ai_assistant import ( + ADMIN_EMAIL, + AIEnvironment, + _create_project, + _create_user, + _login_headers, + _login_user_headers, + _workflow_definition, +) +from test_ai_assistant import ai_environment as ai_environment + +from app.core.config import settings +from app.models.access import User +from app.models.protocols import SchemaArtifact +from app.models.workflows import WorkflowExecution + + +@dataclass(frozen=True) +class PublicSources: + project_id: str + foreign_project_id: str + headers: dict[str, str] + api_id: str + schema_id: str + execution_id: str + + def source_id(self, kind: str) -> str: + if kind == "api": + return self.api_id + if kind == "schema": + return self.schema_id + return self.execution_id + + +@pytest.fixture +async def public_sources(ai_environment: AIEnvironment) -> PublicSources: + context = ai_environment + headers = await _login_headers(context.client) + project_id = await _create_project(context.client, headers) + foreign_id = await _create_project(context.client, headers, name="Foreign source owner") + api = await context.client.post( + f"/api/v1/projects/{project_id}/apis", + headers=headers, + json={ + "name": "Public material API", + "request": { + "method": "GET", + "path": "/public-v1", + "headers": {"X-Private-Fixture": "private-header-not-shared"}, + "body_kind": "json", + "body": {"fixture": "private-body-not-shared"}, + "auth": {"kind": "none", "values": {}}, + }, + }, + ) + assert api.status_code == 201, api.text + environment = await context.client.post( + f"/api/v1/projects/{project_id}/environments", + headers=headers, + json={"name": "Source fixture", "base_url": "https://example.test"}, + ) + assert environment.status_code == 201 + workflow = await context.client.post( + f"/api/v1/projects/{project_id}/workflows", + headers=headers, + json={"name": "Source fixture", "definition": _workflow_definition()}, + ) + assert workflow.status_code == 201 + version = await context.client.post( + f"/api/v1/projects/{project_id}/workflows/{workflow.json()['id']}/versions", headers=headers + ) + assert version.status_code == 200 + async with context.sessions() as session: + actor = await session.scalar(select(User).where(User.email == ADMIN_EMAIL)) + assert actor is not None + content = b"type Query { fixture: String }" + schema = SchemaArtifact( + project_id=UUID(project_id), + protocol="graphql", + name="Public schema", + version=1, + source_format="graphql_sdl", + content_sha256=sha256(content).hexdigest(), + canonical_content=content, + source_content=content, + summary={"type_count": 1}, + created_by_id=actor.id, + ) + execution = WorkflowExecution( + project_id=UUID(project_id), + workflow_id=UUID(workflow.json()["id"]), + workflow_version_id=UUID(version.json()["id"]), + environment_id=UUID(environment.json()["id"]), + triggered_by_id=actor.id, + status="failed", + error_code="FIXTURE_FAILURE", + error_message="公开诊断原文", + snapshot={"private": "private-snapshot-not-shared"}, + context={"private": "private-context-not-shared"}, + started_at=datetime.now(UTC), + completed_at=datetime.now(UTC), + ) + session.add_all([schema, execution]) + await session.commit() + return PublicSources( + project_id, + foreign_id, + headers, + api.json()["definition"]["id"], + str(schema.id), + str(execution.id), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["api", "schema", "execution"]) +async def test_public_sources_only_return_declared_material_without_ai_dispatch( + ai_environment: AIEnvironment, + public_sources: PublicSources, + kind: str, +) -> None: + source = public_sources + response = await ai_environment.client.get( + f"/api/v1/ai/projects/{source.project_id}/sources/{kind}/{source.source_id(kind)}", + headers=source.headers, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["project_id"] == source.project_id + assert payload["metadata"]["source_id"] == source.source_id(kind) + assert "private-header-not-shared" not in response.text + assert "private-body-not-shared" not in response.text + assert "private-snapshot-not-shared" not in response.text + assert "private-context-not-shared" not in response.text + assert ai_environment.queue.job_ids == [] + if kind == "api": + assert payload["metadata"]["version"] == 1 + assert payload["metadata"]["path"] == "/public-v1" + assert payload["schema_document"]["path"] == "/public-v1" + elif kind == "schema": + assert payload["schema_document"]["schema_text"] == "type Query { fixture: String }" + else: + assert payload["metadata"]["error_code"] == "FIXTURE_FAILURE" + assert payload["metadata"]["error_message"] == "公开诊断原文" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["api", "schema", "execution"]) +async def test_public_source_scope_does_not_follow_foreign_ids( + ai_environment: AIEnvironment, + public_sources: PublicSources, + kind: str, +) -> None: + source = public_sources + response = await ai_environment.client.get( + f"/api/v1/ai/projects/{source.foreign_project_id}/sources/{kind}/{source.source_id(kind)}", + headers=source.headers, + ) + assert response.status_code == 404 + assert response.json()["error"]["trace_id"] + + +@pytest.mark.asyncio +async def test_public_source_catalog_is_paginated_literal_and_available_without_generation( + ai_environment: AIEnvironment, + public_sources: PublicSources, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = public_sources + monkeypatch.setattr(settings, "feature_ai_enabled", False) + for kind in ("api", "schema", "execution"): + route = f"/api/v1/ai/projects/{source.project_id}/sources" + response = await ai_environment.client.get( + route, headers=source.headers, params={"kind": kind, "page_size": 1} + ) + assert response.status_code == 200, response.text + assert response.json()["total"] == 1 + assert response.json()["items"][0]["id"] == source.source_id(kind) + empty = await ai_environment.client.get( + route, headers=source.headers, params={"kind": kind, "page": 2, "page_size": 1} + ) + assert empty.json()["items"] == [] + literal = await ai_environment.client.get( + route, headers=source.headers, params={"kind": kind, "search": "%_"} + ) + assert literal.json()["total"] == 0 + + +@pytest.mark.asyncio +async def test_public_source_rejects_oversized_material_and_unknown_api_version( + ai_environment: AIEnvironment, + public_sources: PublicSources, +) -> None: + source = public_sources + async with ai_environment.sessions() as session: + schema = await session.get(SchemaArtifact, UUID(source.schema_id)) + assert schema is not None + schema.source_content = b"x" * (1024 * 1024) + await session.commit() + root = f"/api/v1/ai/projects/{source.project_id}/sources" + oversized = await ai_environment.client.get( + f"{root}/schema/{source.schema_id}", headers=source.headers + ) + assert oversized.status_code == 413, oversized.text + assert oversized.json()["error"]["trace_id"] + missing = await ai_environment.client.get( + f"{root}/api/{source.api_id}", headers=source.headers, params={"version": 999} + ) + assert missing.status_code == 404 + + +@pytest.mark.asyncio +async def test_public_material_read_is_allowed_to_viewer_and_rejects_nonmember( + ai_environment: AIEnvironment, + public_sources: PublicSources, +) -> None: + source = public_sources + client = ai_environment.client + actor = await _create_user(client, source.headers, "source-viewer@example.com") + member = await client.put( + f"/api/v1/projects/{source.project_id}/members/{actor['id']}", + headers=source.headers, + json={"user_id": actor["id"], "role": "viewer"}, + ) + assert member.status_code == 200 + headers = await _login_user_headers( + client, "source-viewer@example.com", "initial-password-123!" + ) + root = f"/api/v1/ai/projects/{source.project_id}/sources" + assert (await client.get(root, headers=headers, params={"kind": "api"})).status_code == 200 + assert (await client.get(f"{root}/api/{source.api_id}", headers=headers)).status_code == 200 + denied = await client.get( + f"/api/v1/ai/projects/{source.foreign_project_id}/sources", + headers=headers, + params={"kind": "api"}, + ) + assert denied.status_code in (403, 404) + assert denied.json()["error"]["trace_id"] + assert ai_environment.queue.job_ids == [] + + +@pytest.mark.asyncio +async def test_fixed_public_api_material_does_not_follow_new_api_revision( + ai_environment: AIEnvironment, public_sources: PublicSources +) -> None: + source = public_sources + client = ai_environment.client + route = f"/api/v1/ai/projects/{source.project_id}/sources/api/{source.api_id}" + before = await client.get(route, headers=source.headers, params={"version": 1}) + assert before.status_code == 200 + newer = await client.post( + f"/api/v1/projects/{source.project_id}/apis/{source.api_id}/versions", + headers=source.headers, + json={"method": "GET", "path": "/public-v2", "body_kind": "none"}, + ) + assert newer.status_code == 201, newer.text + fixed = await client.get(route, headers=source.headers, params={"version": 1}) + current = await client.get(route, headers=source.headers) + assert fixed.json() == before.json() + assert current.json()["metadata"]["version"] == 2 + assert current.json()["metadata"]["path"] == "/public-v2" + assert ai_environment.queue.job_ids == [] + + +@pytest.mark.asyncio +async def test_public_schema_version_must_match_the_exact_artifact( + ai_environment: AIEnvironment, public_sources: PublicSources +) -> None: + source = public_sources + route = f"/api/v1/ai/projects/{source.project_id}/sources/schema/{source.schema_id}" + found = await ai_environment.client.get(route, headers=source.headers, params={"version": 1}) + missing = await ai_environment.client.get(route, headers=source.headers, params={"version": 2}) + assert found.status_code == 200 + assert missing.status_code == 404 + assert missing.json()["error"]["trace_id"] From e296a0280d5d7f6b197b222530fd90bc833498e0 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:07:23 +0800 Subject: [PATCH 02/35] feat(ai): guide explicit task materials and recover human review outcomes --- backend/app/schemas/ai.py | 2 +- backend/app/services/ai.py | 6 +- frontend/e2e/s21-ai-review.spec.ts | 20 +- .../src/features/ai/AIDraftProtection.tsx | 41 ++ frontend/src/features/ai/AISourcePicker.tsx | 163 +++++ .../features/ai/AISuggestionReviewDialog.tsx | 520 ++++++++++++++++ .../src/features/ai/CreateAIJobDialog.tsx | 510 ++++++++++++++++ frontend/src/features/ai/ai-service.test.ts | 78 +++ frontend/src/features/ai/ai-service.ts | 99 +++- .../src/features/ai/use-ai-review.test.tsx | 68 ++- frontend/src/features/ai/use-ai-review.ts | 184 ++++-- frontend/src/pages/AIAssistantPage.test.tsx | 79 ++- frontend/src/pages/AIAssistantPage.tsx | 559 +++++++----------- .../src/pages/AIAssistantRemediation.test.tsx | 520 ++++++++++++++++ 14 files changed, 2404 insertions(+), 445 deletions(-) create mode 100644 frontend/src/features/ai/AIDraftProtection.tsx create mode 100644 frontend/src/features/ai/AISourcePicker.tsx create mode 100644 frontend/src/features/ai/AISuggestionReviewDialog.tsx create mode 100644 frontend/src/features/ai/CreateAIJobDialog.tsx create mode 100644 frontend/src/features/ai/ai-service.test.ts create mode 100644 frontend/src/pages/AIAssistantRemediation.test.tsx diff --git a/backend/app/schemas/ai.py b/backend/app/schemas/ai.py index 3ae525ba..8374795b 100644 --- a/backend/app/schemas/ai.py +++ b/backend/app/schemas/ai.py @@ -40,7 +40,7 @@ class AIJobCreateRequest(BaseModel): @model_validator(mode="after") def require_context(self) -> "AIJobCreateRequest": if self.schema_document is None and not self.metadata: - raise ValueError("AI 任务必须提供 Schema 或脱敏元数据") + raise ValueError("AI 任务必须提供 Schema 或明确提交的元数据") return self diff --git a/backend/app/services/ai.py b/backend/app/services/ai.py index dd0b483e..d3fd7f29 100644 --- a/backend/app/services/ai.py +++ b/backend/app/services/ai.py @@ -331,7 +331,7 @@ async def _run_pending(self, job: AIJob) -> AIJob: except (SchemaError, ValidationError, AIInputError, ValueError, TypeError): logger.info("AI response validation failed", extra={"ai_job_id": str(job.id)}) return await self._fail( - job, code="AI_RESPONSE_INVALID", message="AI 建议未通过结构或脱敏校验" + job, code="AI_RESPONSE_INVALID", message="AI 建议未通过结构或当前处理策略校验" ) if job.job_type != "change_set": self._repository.add_suggestions(suggestions) @@ -391,13 +391,13 @@ def _authorize_sample( if not sample_sharing_enabled: raise AppError( code="AI_SAMPLE_SHARING_DISABLED", - message="项目未开启脱敏样本共享", + message="项目未允许向 AI 提交样本", status_code=403, ) if role not in {None, ProjectRole.OWNER}: raise AppError( code="AI_SAMPLE_OWNER_REQUIRED", - message="仅项目 Owner 可以提交脱敏样本", + message="仅项目 Owner 可以提交样本", status_code=403, ) diff --git a/frontend/e2e/s21-ai-review.spec.ts b/frontend/e2e/s21-ai-review.spec.ts index 847a726b..62220a37 100644 --- a/frontend/e2e/s21-ai-review.spec.ts +++ b/frontend/e2e/s21-ai-review.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test' import { authenticate } from './support/auth' -test('S21 AI 脱敏任务与人工接受主路径', async ({ page }) => { +test('S21 AI 明确提交与人工接受主路径', async ({ page }) => { test.setTimeout(60_000) const acceptedWorkflowName = `S21 AI 人工审核工作流 ${Date.now()}` @@ -12,7 +12,7 @@ test('S21 AI 脱敏任务与人工接受主路径', async ({ page }) => { await navigateMenu(page, 'AI 助手') await expect(page).toHaveURL(/\/ai$/) await expect(page.getByRole('heading', { name: 'AI 助手' })).toBeVisible() - await expect(page.getByText(/AI 不会读取 Secret、自动发布或自动执行/)).toBeVisible() + await expect(page.getByText(/任务不会自动发布或执行/)).toBeVisible() await expect(page.getByText('模型:flowtest-compose-model')).toBeVisible() await page.getByRole('button', { name: /新建 AI 任务/ }).click() @@ -23,17 +23,23 @@ test('S21 AI 脱敏任务与人工接受主路径', async ({ page }) => { const options = page.locator('.ant-select-dropdown:visible') await expect(options).not.toHaveClass(/ant-slide.*-(appear|enter|leave)/) await options.getByText('Workflow 草稿', { exact: true }).click() + await createDialog.getByLabel('流程目标').fill('生成开始和结束节点的可审核流程草稿') + await createDialog.getByRole('button', { name: '预览提交材料' }).click() + await expect(createDialog.getByLabel('最终提交负载')).toContainText( + '生成开始和结束节点的可审核流程草稿', + ) const created = page.waitForResponse( (response) => response.url().endsWith('/api/v1/ai/jobs') && response.request().method() === 'POST', ) - await createDialog.getByRole('button', { name: '确 定' }).click() + await createDialog.getByRole('button', { name: '确认提交任务' }).click() expect((await created).status()).toBe(202) const suggestionRow = page.getByRole('row').filter({ hasText: 'S21 AI 人工审核工作流' }) await expect(suggestionRow).toContainText('pending', { timeout: 30_000 }) await suggestionRow.getByRole('button', { name: /接受/ }).click() - const reviewDialog = page.getByRole('dialog', { name: '接受并生成草稿' }) + const reviewDialog = page.getByRole('dialog', { name: '接受建议' }) + await reviewDialog.getByRole('tab', { name: '高级 JSON' }).click() const contentEditor = reviewDialog.getByLabel('建议内容') const content = JSON.parse(await contentEditor.inputValue()) as Record content.name = acceptedWorkflowName @@ -42,8 +48,12 @@ test('S21 AI 脱敏任务与人工接受主路径', async ({ page }) => { const accepted = page.waitForResponse( (response) => response.url().endsWith('/accept') && response.request().method() === 'POST', ) - await reviewDialog.getByRole('button', { name: '确 定' }).click() + await reviewDialog.getByRole('button', { name: '确认接受' }).click() expect((await accepted).ok()).toBeTruthy() + const reviewed = page.getByRole('dialog', { name: '已审核建议' }) + await expect(reviewed.getByRole('link', { name: '打开流程草稿' })).toBeVisible() + await reviewed.getByRole('button', { name: /关\s*闭/ }).click() + await expect(reviewed).toBeHidden() await expect(suggestionRow).toContainText('accepted') await expect(suggestionRow.getByRole('button', { name: /接受/ })).toBeDisabled() }) diff --git a/frontend/src/features/ai/AIDraftProtection.tsx b/frontend/src/features/ai/AIDraftProtection.tsx new file mode 100644 index 00000000..e993ab8d --- /dev/null +++ b/frontend/src/features/ai/AIDraftProtection.tsx @@ -0,0 +1,41 @@ +import { Modal } from 'antd' +import { useContext, useEffect, type RefObject } from 'react' +import { UNSAFE_DataRouterContext, useBlocker } from 'react-router-dom' + +type Props = { + dirty: boolean + allowLeaveRef: RefObject + title: string + description: string +} +export default function AIDraftProtection(props: Props) { + const dataRouter = useContext(UNSAFE_DataRouterContext) + const { dirty, allowLeaveRef } = props + useEffect(() => { + if (!dirty) return + const block = (event: BeforeUnloadEvent) => { + if (!allowLeaveRef.current) event.preventDefault() + } + window.addEventListener('beforeunload', block) + return () => window.removeEventListener('beforeunload', block) + }, [dirty, allowLeaveRef]) + return dataRouter ? : null +} +function AINavigationGuard({ dirty, allowLeaveRef, title, description }: Props) { + const blocker = useBlocker(() => dirty && !allowLeaveRef.current) + return ( + { + allowLeaveRef.current = true + blocker.proceed?.() + }} + onCancel={() => blocker.reset?.()} + > + {description} + + ) +} diff --git a/frontend/src/features/ai/AISourcePicker.tsx b/frontend/src/features/ai/AISourcePicker.tsx new file mode 100644 index 00000000..ce75efd4 --- /dev/null +++ b/frontend/src/features/ai/AISourcePicker.tsx @@ -0,0 +1,163 @@ +import { useQuery } from '@tanstack/react-query' +import { Alert, Button, Card, Input, Pagination, Select, Space, Table, Typography } from 'antd' +import { useState } from 'react' +import { apiErrorWithTrace } from '../../lib/api' +import { + getAISource, + listAISources, + type AIPublicSource, + type AISourceKind, + type AISourceSummary, +} from './ai-service' + +export default function AISourcePicker({ + projectId, + onUse, + initialKind, +}: { + projectId: string + initialKind: AISourceKind + onUse: (source: AIPublicSource) => void +}) { + const [kind, setKind] = useState(initialKind) + const [page, setPage] = useState(1), + [search, setSearch] = useState(''), + [exactId, setExactId] = useState(''), + [searchDraft, setSearchDraft] = useState('') + const [selection, setSelection] = useState<{ id: string; version?: number } | null>(null) + const sources = useQuery({ + queryKey: ['ai-public-sources', projectId, kind, page, search], + queryFn: () => listAISources(projectId, kind, page, search), + }) + const detail = useQuery({ + queryKey: ['ai-public-source', projectId, kind, selection], + queryFn: () => getAISource(projectId, kind, selection!.id, selection?.version), + enabled: Boolean(selection), + }) + const error = sources.error ?? detail.error + return ( + + + 只读取公开契约或冻结执行的身份、状态和错误摘要。不会读取认证、请求头、变量或响应正文。 + + + setExactId(e.target.value)} + placeholder="确切来源ID" + /> + + + {detail.isFetching ? 正在读取固定来源… : null} + {detail.isSuccess && !detail.isFetching ? ( +
+ + {detail.data.metadata.name} · {detail.data.metadata.source_id} ·{' '} + {detail.data.metadata.version + ? `v${detail.data.metadata.version}` + : detail.data.metadata.status} + + {detail.data.notices.map((notice) => ( + + ))} +
+            {JSON.stringify(detail.data, null, 2)}
+          
+ +
+ ) : null} +
+ ) +} diff --git a/frontend/src/features/ai/AISuggestionReviewDialog.tsx b/frontend/src/features/ai/AISuggestionReviewDialog.tsx new file mode 100644 index 00000000..40377991 --- /dev/null +++ b/frontend/src/features/ai/AISuggestionReviewDialog.tsx @@ -0,0 +1,520 @@ +import { + Alert, + Button, + Descriptions, + Form, + Input, + InputNumber, + Modal, + Space, + Table, + Tabs, + Typography, +} from 'antd' +import { useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import AIDraftProtection from './AIDraftProtection' +import ResponseCodeReader from '../api-console/ResponseCodeReader' +import { apiErrorWithTrace } from '../../lib/api' +import { useRouteScopedState } from '../../lib/use-route-scoped-state' +import { projectPath } from '../projects/project-routing' +import type { AISuggestion } from './ai-service' + +type Props = { + projectId: string + suggestion: AISuggestion + decision: 'accept' | 'reject' + canEdit: boolean + submitting: boolean + onClose: () => void + onReload: () => Promise + onSubmit: (input: { + id: string + decision: 'accept' | 'reject' + content?: Record + note: string + }) => Promise +} +export default function AISuggestionReviewDialog(props: Props) { + const { suggestion, decision, canEdit, submitting, onClose, onSubmit } = props + const original = JSON.stringify(suggestion.content, null, 2) + const [edited, setEdited] = useState(original), + [note, setNote] = useState(suggestion.review_note ?? '') + const [error, setError] = useState(''), + [fieldError, setFieldError] = useState(''), + [discard, setDiscard] = useState(false), + [refreshing, setRefreshing] = useState(false) + const allowLeaveRef = useRef(false) + const editable = canEdit && suggestion.review_status === 'pending' + const dirty = + suggestion.review_status === 'pending' && + reviewDirty(suggestion, original, edited, note, fieldError) + const parsed = parseSuggestion(edited) + async function submit() { + if (!editable || submitting) return + if (decision === 'accept' && (!parsed || fieldError)) { + setError(fieldError || '建议内容必须是 JSON 对象') + return + } + const result = await onSubmit({ + id: suggestion.id, + decision, + content: decision === 'accept' ? parsed! : undefined, + note, + }) + if (result) { + allowLeaveRef.current = true + setEdited(JSON.stringify(result.content, null, 2)) + setNote(result.review_note) + setError('') + } else setError('审核结果未确认,输入已保留。可刷新审核状态核对已保存结果,再决定是否重试。') + } + async function reload() { + setRefreshing(true) + try { + await props.onReload() + setError('') + } catch (error) { + setError(apiErrorWithTrace(error)) + } finally { + setRefreshing(false) + } + } + function close() { + if (dirty) setDiscard(true) + else onClose() + } + return ( + <> + + + + + {editable ? ( + + ) : null} + + } + > + {suggestion.title} + + + {error ? : null} + {fieldError ? : null} + + + { + allowLeaveRef.current = true + setDiscard(false) + onClose() + }} + onCancel={() => setDiscard(false)} + > + 关闭将放弃当前内容与备注。 + + + ) +} +function reviewOutcome(suggestion: AISuggestion): string { + if (suggestion.review_status === 'rejected') return '已拒绝,可回看内容和备注' + if (suggestion.review_status === 'accepted') return '已接受,请继续检查审核结果' + return '待人工审核,请检查建议内容后明确接受或拒绝' +} +export function AcceptedAIResource({ + projectId, + suggestion, +}: { + projectId: string + suggestion: AISuggestion +}) { + if (suggestion.review_status !== 'accepted') return null + const type = suggestion.accepted_resource_type, + id = suggestion.accepted_resource_id + if (type === 'workflow' && id) + return ( + + + 打开流程草稿 + {' '} + · {id} + + ) + if (type === 'test_case' && id) + return ( + + + 打开用例草稿 + {' '} + · {id} + + ) + return ( + + 已保存审核记录; + {type || id + ? `资产类型 ${type ?? '未提供'},ID ${id ?? '未提供'},没有可确认的跳转入口。` + : '未提供独立资产 ID。'} + + ) +} +function parseSuggestion(text: string): Record | null { + try { + const value: unknown = JSON.parse(text) + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null + } catch { + return null + } +} +function SuggestionFields({ + suggestion, + value, + editable, + onChange, + onError, +}: { + suggestion: AISuggestion + value: Record | null + editable: boolean + onChange: (value: Record) => void + onError: (error: string) => void +}) { + if (!value) return + const change = (key: string, next: unknown) => onChange({ ...value, [key]: next }) + return ( +
+ {suggestion.suggestion_type === 'assertion' ? ( + + ) : null} + {['workflow', 'test_case'].includes(suggestion.suggestion_type) ? ( + <> + + change('name', e.target.value)} + /> + + + change('description', e.target.value)} + /> + + + + ) : null} + {suggestion.suggestion_type === 'failure_analysis' ? ( + <> + + change('category', e.target.value)} + /> + + + change('confidence', next)} + /> + + + change('reason', e.target.value)} + /> + + + ) : null} + + + ) +} +function AssertionFields({ + value, + editable, + onChange, + onError, +}: { + value: Record + editable: boolean + onChange: (key: string, value: unknown) => void + onError: (error: string) => void +}) { + return ( + <> + + onChange('expression', e.target.value)} + /> + + + onChange('operator', e.target.value)} + /> + + onChange('expected', next)} + onError={onError} + /> + {typeof value.status_code === 'number' ? ( + + onChange('status_code', next)} + /> + + ) : null} + + ) +} +function DefinitionSummary({ value }: { value: unknown }) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + return 未提供可识别的定义,请检查原文。 + const definition = value as Record + if (Array.isArray(definition.nodes)) + return ( + (typeof node.id === 'string' ? node.id : JSON.stringify(node))} + dataSource={definition.nodes.filter(isRecord)} + columns={[ + { title: '节点', dataIndex: 'name' }, + { title: '类型', dataIndex: 'type' }, + { title: '节点ID', dataIndex: 'id' }, + ]} + /> + ) + return ( + + ) +} +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} +function text(value: unknown): string { + return typeof value === 'string' ? value : '' +} +function reviewDirty( + suggestion: AISuggestion, + original: string, + edited: string, + note: string, + error: string, +): boolean { + return edited !== original || note !== (suggestion.review_note ?? '') || Boolean(error) +} +function reviewTitle( + editable: boolean, + decision: 'accept' | 'reject', + status: AISuggestion['review_status'], +): string { + if (status !== 'pending') return '已审核建议' + if (!editable) return '只读建议' + return decision === 'accept' ? '接受建议' : '拒绝建议' +} + +type ReviewContentModel = { + edited: string + note: string + parsed: Record | null + setEdited: (text: string) => void + setNote: (text: string) => void + setFieldError: (text: string) => void + setError: (text: string) => void +} +function ReviewContent({ + suggestion, + decision, + editable, + model, +}: { + suggestion: AISuggestion + decision: 'accept' | 'reject' + editable: boolean + model: ReviewContentModel +}) { + const { edited, note, parsed, setEdited, setNote, setFieldError, setError } = model + return ( + <> + {' '} + { + setEdited(JSON.stringify(value, null, 2)) + setError('') + }} + onError={setFieldError} + /> + ), + }, + { + key: 'json', + label: '高级 JSON', + forceRender: true, + children: ( +
+ + { + setEdited(e.target.value) + setFieldError('') + setError('') + }} + /> + + + ), + }, + { + key: 'original', + label: '原始建议', + children: , + }, + ]} + /> +
+ + setNote(e.target.value)} + /> + + + {!editable ? ( + + ) : null} + + ) +} + +function ExpectedValueInput({ + value, + editable, + onChange, + onError, +}: { + value: unknown + editable: boolean + onChange: (value: unknown) => void + onError: (message: string) => void +}) { + const serialized = JSON.stringify(value) ?? '' + const [draft, setDraft] = useRouteScopedState(null, serialized, serialized) + return ( + + { + setDraft(event.target.value) + try { + const parsed: unknown = JSON.parse(event.target.value) + onError('') + onChange(parsed) + } catch { + onError('预期值必须是有效 JSON') + } + }} + /> + + ) +} diff --git a/frontend/src/features/ai/CreateAIJobDialog.tsx b/frontend/src/features/ai/CreateAIJobDialog.tsx new file mode 100644 index 00000000..5b4f666d --- /dev/null +++ b/frontend/src/features/ai/CreateAIJobDialog.tsx @@ -0,0 +1,510 @@ +import { useQuery } from '@tanstack/react-query' +import { + Alert, + Button, + Checkbox, + Collapse, + Descriptions, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Typography, +} from 'antd' +import { useRef, useState } from 'react' +import AIDraftProtection from './AIDraftProtection' +import { apiErrorWithTrace } from '../../lib/api' +import { getProjectRedactionPolicy } from '../projects/project-service' +import { type AIJobInput, type AIJobType, type AIPublicSource } from './ai-service' +import AISourcePicker from './AISourcePicker' + +type JobForm = { + job_type: AIJobType + goal?: string + schema?: string + metadata?: string + sample?: string + includeSample?: boolean + coverage_scope?: string + response_path?: string + expected_status?: number + workflow_steps?: string + completion_condition?: string + expected_behavior?: string + observed_behavior?: string +} +const goals: Record = { + schema_cases: { + label: '用例覆盖目标', + key: 'coverage_goal', + help: '选择接口或协议契约,并描述边界、异常或正常路径。', + }, + assertion_suggestions: { + label: '断言目标', + key: 'assertion_goal', + help: '描述需要验证的字段、条件或状态,可附接口契约。', + }, + workflow_draft: { + label: '流程目标', + key: 'workflow_goal', + help: '描述步骤、依赖及结束条件;接受建议后生成未发布流程草稿。', + }, + failure_analysis: { + label: '失败现象', + key: 'failure_summary', + help: '选择冻结执行或填写已授权的失败现象。无需填写 Schema。', + }, +} +export default function CreateAIJobDialog(props: { + projectId: string + open: boolean + submitting: boolean + sampleEnabled: boolean + onClose: () => void + onCreate: (input: Omit) => Promise +}) { + if (!props.open) return null + return +} +function CreateAIJobForm({ + projectId, + submitting, + sampleEnabled, + onClose, + onCreate, +}: { + projectId: string + submitting: boolean + sampleEnabled: boolean + onClose: () => void + onCreate: (input: Omit) => Promise +}) { + const [form] = Form.useForm() + const allowLeaveRef = useRef(false) + const type = Form.useWatch('job_type', form) ?? 'schema_cases' + const includeSample = Form.useWatch('includeSample', form) ?? false + const [source, setSource] = useState(null) + const [preview, setPreview] = useState(null) + const [error, setError] = useState(''), + [dirty, setDirty] = useState(false), + [discard, setDiscard] = useState(false) + const policy = useQuery({ + queryKey: ['project-redaction-policy', projectId], + queryFn: () => getProjectRedactionPolicy(projectId), + }) + function prepare(value: JobForm) { + try { + setPreview(buildInput(projectId, value, source, sampleEnabled)) + setError('') + } catch (cause) { + setError(cause instanceof Error ? cause.message : '请检查提交材料') + } + } + async function submit() { + if (!preview || !policy.isSuccess || (preview.sample !== undefined && !sampleEnabled)) return + const { project_id: project, ...input } = preview + if (project !== projectId) return + allowLeaveRef.current = true + if (await onCreate(input)) { + setDirty(false) + onClose() + } else { + allowLeaveRef.current = false + setError('提交结果未确认,已保留材料。请先核对任务列表和错误信息,避免重复提交。') + } + } + return ( + <> + + (dirty ? setDiscard(true) : onClose())} + footer={ + + + {preview ? : null} + + + } + > + + {error ? : null} + {preview ? ( +
+ + 以下为发送到 FlowTest 的完整请求;确认后才进入 AI + 队列。服务端按届时有效策略处理,不额外读取材料。 + + +
+              {JSON.stringify(preview, null, 2)}
+            
+
+ ) : ( +
setDirty(true)} + onFinish={prepare} + > + + + + ) + if (type === 'assertion_suggestions') + return ( + <> + + + + + + + + ) + if (type === 'workflow_draft') + return ( + <> + + + + + + + + ) + return ( + <> + + + + + + + + ) +} +function addTaskMetadata(metadata: Record, value: JobForm): void { + const fields: Record = { + schema_cases: ['coverage_scope'], + assertion_suggestions: ['response_path', 'expected_status'], + workflow_draft: ['workflow_steps', 'completion_condition'], + failure_analysis: ['expected_behavior', 'observed_behavior'], + } + for (const field of fields[value.job_type]) { + const content = value[field] + if (content === undefined || content === '') continue + metadata[field] = normalizeTaskMaterial(field, content) + } +} +function normalizeTaskMaterial(field: keyof JobForm, content: string | number | boolean): unknown { + if (field === 'workflow_steps' && typeof content === 'string') + return content + .split('\n') + .map((step) => step.trim()) + .filter(Boolean) + return typeof content === 'string' ? content.trim() : content +} + +function AIInputPreviewSummary({ + payload, + source, +}: { + payload: AIJobInput + source: AIPublicSource | null +}) { + const goal = payload.metadata[goals[payload.job_type].key] + return ( + + ) +} diff --git a/frontend/src/features/ai/ai-service.test.ts b/frontend/src/features/ai/ai-service.test.ts new file mode 100644 index 00000000..17a0c5b1 --- /dev/null +++ b/frontend/src/features/ai/ai-service.test.ts @@ -0,0 +1,78 @@ +import { http, HttpResponse } from 'msw' +import { expect, it } from 'vitest' +import { server } from '../../test/server' +import { + createAIJob, + getAIJob, + getAISource, + listAISuggestions, + listAISources, + reviewAISuggestion, +} from './ai-service' + +it('rejects mismatched job, suggestion and review response identities', async () => { + server.use( + http.get('/api/v1/ai/jobs/:id', () => + HttpResponse.json({ id: 'other', project_id: 'foreign' }), + ), + http.post('/api/v1/ai/jobs', () => HttpResponse.json({ id: 'job', project_id: 'foreign' })), + http.get('/api/v1/ai/jobs/:id/suggestions', () => + HttpResponse.json([{ id: 'suggestion', job_id: 'foreign' }]), + ), + http.post('/api/v1/ai/suggestions/:id/accept', () => + HttpResponse.json({ id: 'wrong', job_id: 'job' }), + ), + ) + await expect(getAIJob('project', 'job')).rejects.toThrow('身份') + await expect( + createAIJob({ + project_id: 'project', + job_type: 'failure_analysis', + metadata: { failure_summary: 'failed' }, + }), + ).rejects.toThrow('身份') + await expect(listAISuggestions('job')).rejects.toThrow('身份') + await expect(reviewAISuggestion('suggestion', 'accept', { note: '' }, 'job')).rejects.toThrow( + '身份', + ) +}) +it('keeps project, search, pagination and fixed-version reads in the public-source contract', async () => { + let catalog: URL | null = null, + detail: URL | null = null + server.use( + http.get('/api/v1/ai/projects/project/sources', ({ request }) => { + catalog = new URL(request.url) + return HttpResponse.json({ items: [], total: 0, page: 2, page_size: 20 }) + }), + http.get('/api/v1/ai/projects/project/sources/api/source', ({ request }) => { + detail = new URL(request.url) + return HttpResponse.json({ + project_id: 'project', + metadata: { source_kind: 'api', source_id: 'source', version: 3 }, + schema_document: { path: '/v3' }, + notices: [], + }) + }), + ) + await listAISources('project', 'api', 2, '%_') + expect(catalog!.searchParams.get('kind')).toBe('api') + expect(catalog!.searchParams.get('page')).toBe('2') + expect(catalog!.searchParams.get('search')).toBe('%_') + const source = await getAISource('project', 'api', 'source', 3) + expect(detail!.searchParams.get('version')).toBe('3') + expect(source.schema_document).toEqual({ path: '/v3' }) +}) +it.each([ + { project_id: 'foreign', metadata: { source_kind: 'api', source_id: 'source', version: 3 } }, + { + project_id: 'project', + metadata: { source_kind: 'execution', source_id: 'source', version: 3 }, + }, + { project_id: 'project', metadata: { source_kind: 'api', source_id: 'other', version: 3 } }, + { project_id: 'project', metadata: { source_kind: 'api', source_id: 'source', version: 4 } }, +])('rejects an unproven public-source response %#', async (payload) => { + server.use( + http.get('/api/v1/ai/projects/project/sources/api/source', () => HttpResponse.json(payload)), + ) + await expect(getAISource('project', 'api', 'source', 3)).rejects.toThrow('不匹配') +}) diff --git a/frontend/src/features/ai/ai-service.ts b/frontend/src/features/ai/ai-service.ts index e1d97eaa..9977bd46 100644 --- a/frontend/src/features/ai/ai-service.ts +++ b/frontend/src/features/ai/ai-service.ts @@ -12,7 +12,7 @@ export type AIStatus = { export type AIJob = { id: string project_id: string - job_type: AIJobType + job_type: AIJobType | 'change_set' status: 'pending' | 'running' | 'completed' | 'failed' input_sha256: string prompt_template_version: string @@ -24,6 +24,10 @@ export type AIJob = { created_by_id: string created_at: string updated_at: string + started_at?: string | null + completed_at?: string | null + redaction_mode?: 'on' | 'off' | null + redaction_policy_version?: number | null } export type AISuggestion = { @@ -39,6 +43,8 @@ export type AISuggestion = { accepted_resource_id: string | null created_at: string updated_at: string + reviewed_by_id?: string | null + reviewed_at?: string | null } export type AIJobInput = { @@ -65,7 +71,9 @@ export async function updateAISettings( } export async function createAIJob(input: AIJobInput): Promise { - return (await apiClient.post('/ai/jobs', input)).data + const job = (await apiClient.post('/ai/jobs', input)).data + if (job.project_id !== input.project_id) throw new Error('AI 任务项目身份不匹配') + return job } export async function listAIJobs(projectId: string, page = 1, pageSize = 50): Promise> { @@ -76,19 +84,96 @@ export async function listAIJobs(projectId: string, page = 1, pageSize = 50): Pr ).data } -export async function getAIJob(jobId: string): Promise { - return (await apiClient.get(`/ai/jobs/${jobId}`)).data +export async function getAIJob(projectId: string, jobId: string): Promise { + const job = (await apiClient.get(`/ai/jobs/${jobId}`)).data + if (job.id !== jobId || job.project_id !== projectId) throw new Error('AI 任务项目或ID身份不匹配') + return job } export async function listAISuggestions(jobId: string): Promise { - return (await apiClient.get(`/ai/jobs/${jobId}/suggestions`)).data + const suggestions = (await apiClient.get(`/ai/jobs/${jobId}/suggestions`)).data + if (suggestions.some((item) => item.job_id !== jobId)) throw new Error('AI 建议任务身份不匹配') + return suggestions } export async function reviewAISuggestion( suggestionId: string, decision: 'accept' | 'reject', input: { content?: Record; note: string }, + jobId: string, ): Promise { - return (await apiClient.post(`/ai/suggestions/${suggestionId}/${decision}`, input)) - .data + const result = ( + await apiClient.post(`/ai/suggestions/${suggestionId}/${decision}`, input) + ).data + if (result.id !== suggestionId || result.job_id !== jobId) + throw new Error('AI 审核响应身份不匹配') + return result +} + +export type AISourceKind = 'api' | 'schema' | 'execution' +export type AISourceSummary = { + id: string + kind: AISourceKind + name: string + version: number | null + protocol: string | null + status: string | null + created_at: string +} +export type AIPublicSourceMetadata = { + source_kind: AISourceKind + source_id: string + name: string + version: number | null + protocol: string | null + method: string | null + path: string | null + status: string | null + workflow_id: string | null + workflow_version_id: string | null + error_code: string | null + error_message: string | null + content_fingerprint: string | null + started_at: string | null + completed_at: string | null +} +export type AIPublicSource = { + project_id: string + metadata: AIPublicSourceMetadata + schema_document: Record | null + notices: string[] +} +export async function listAISources( + projectId: string, + kind: AISourceKind, + page: number, + search: string, +): Promise> { + return ( + await apiClient.get>(`/ai/projects/${projectId}/sources`, { + params: { kind, page, page_size: 20, search }, + }) + ).data +} +export async function getAISource( + projectId: string, + kind: AISourceKind, + id: string, + version?: number, +): Promise { + const source = ( + await apiClient.get( + `/ai/projects/${projectId}/sources/${kind}/${encodeURIComponent(id)}`, + { params: { version } }, + ) + ).data + if ( + source.project_id !== projectId || + source.metadata.source_kind !== kind || + source.metadata.source_id !== id + ) + throw new Error('AI 来源项目或身份不匹配') + if (version !== undefined && source.metadata.version !== version) + throw new Error('AI 来源版本不匹配') + return source } diff --git a/frontend/src/features/ai/use-ai-review.test.tsx b/frontend/src/features/ai/use-ai-review.test.tsx index deb359f2..7ea3b89e 100644 --- a/frontend/src/features/ai/use-ai-review.test.tsx +++ b/frontend/src/features/ai/use-ai-review.test.tsx @@ -58,7 +58,7 @@ it.each(['create', 'select'] as const)( if (params.id === fresh.id) suggestionsReads += 1 return HttpResponse.json( params.id === fresh.id && completed - ? [{ id: 'fresh-suggestion', title: 'Finished suggestion' }] + ? [{ id: 'fresh-suggestion', job_id: fresh.id, title: 'Finished suggestion' }] : [], ) }), @@ -77,10 +77,74 @@ it.each(['create', 'select'] as const)( result.current.createJob({ job_type: 'assertion_suggestions', metadata: {} }), ) await waitFor(() => expect(result.current.selectedJobId).toBe('fresh-job')) - await waitFor(() => expect(suggestionsReads).toBeGreaterThan(0)) + expect(suggestionsReads).toBe(0) completed = true await waitFor(() => expect(result.current.suggestions.data?.[0]?.id).toBe('fresh-suggestion'), { timeout: 3000, }) + expect(suggestionsReads).toBeGreaterThan(0) + }, +) + +it.each(['create', 'review'] as const)( + 'ignores late %s completion after selecting another job', + async (command) => { + let started = false + let respond!: (response: Response) => void + const response = new Promise((resolve) => { + respond = resolve + }) + const first = { id: 'first', project_id: project.id, status: 'completed' } + const suggestion = { id: 'pending-suggestion', job_id: first.id, review_status: 'pending' } + server.use( + http.get('/api/v1/ai/status', () => HttpResponse.json({ enabled: true })), + http.get('/api/v1/ai/jobs', () => + HttpResponse.json({ items: [first], total: 1, page: 1, page_size: 20 }), + ), + http.get('/api/v1/ai/jobs/:id', ({ params }) => + HttpResponse.json({ ...first, id: params.id }), + ), + http.get('/api/v1/ai/jobs/:id/suggestions', ({ params }) => + HttpResponse.json(params.id === first.id ? [suggestion] : []), + ), + http.post('/api/v1/ai/jobs', () => { + started = true + return response + }), + http.post('/api/v1/ai/suggestions/:id/accept', () => { + started = true + return response + }), + ) + const { wrapper } = harness('ai') + const { result } = renderHook(() => useAIReview(), { wrapper }) + await waitFor(() => expect(result.current.suggestions.isSuccess).toBe(true)) + await waitFor(() => expect(result.current.canEdit).toBe(true)) + let completion!: Promise + act(() => { + completion = + command === 'create' + ? result.current.createJob({ + job_type: 'failure_analysis', + metadata: { failure_summary: 'failed' }, + }) + : result.current.review({ id: suggestion.id, decision: 'accept', note: '' }) + }) + await waitFor(() => expect(started).toBe(true)) + act(() => result.current.selectJob('other')) + await waitFor(() => expect(result.current.selectedJob.data?.id).toBe('other')) + let outcome: unknown + await act(async () => { + respond( + HttpResponse.json( + command === 'create' + ? { ...first, id: 'new-job' } + : { ...suggestion, content: {}, review_status: 'accepted', review_note: '' }, + ), + ) + outcome = await completion + }) + expect(outcome).toBe(command === 'create' ? false : null) + expect(result.current.selectedJobId).toBe('other') }, ) diff --git a/frontend/src/features/ai/use-ai-review.ts b/frontend/src/features/ai/use-ai-review.ts index dc12e77b..62c23335 100644 --- a/frontend/src/features/ai/use-ai-review.ts +++ b/frontend/src/features/ai/use-ai-review.ts @@ -1,9 +1,9 @@ import { useCatalogQuery } from '../../lib/catalog-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { App } from 'antd' - -import { apiErrorMessage } from '../../lib/api' -import { useRouteScopedState } from '../../lib/use-route-scoped-state' +import { useContext, useLayoutEffect, useMemo, useRef } from 'react' +import { UNSAFE_DataRouterContext, useLocation, useSearchParams } from 'react-router-dom' +import { apiErrorWithTrace } from '../../lib/api' import { useProjectCapabilities } from '../projects/use-project-capabilities' import { useProjectContext } from '../projects/use-project-context' import { @@ -15,6 +15,7 @@ import { reviewAISuggestion, updateAISettings, type AIJobInput, + type AISuggestion, } from './ai-service' type SuggestionReviewInput = { @@ -23,17 +24,15 @@ type SuggestionReviewInput = { content?: Record note: string } - export function useAIReview() { const { message } = App.useApp() const queryClient = useQueryClient() const { projectId } = useProjectContext() - const { canEdit, canManageSecurity, permissions } = useProjectCapabilities() - const [selectedJobId, setSelectedJobId] = useRouteScopedState( - projectId, - null, - null, - ) + const access = useProjectCapabilities() + const canEdit = access.canEdit && access.permissions.isSuccess + const canManageSecurity = access.canManageSecurity && access.permissions.isSuccess + const [params, setParams] = useSearchParams() + const commandCurrent = useAICommandContext(projectId) const status = useQuery({ queryKey: ['ai-status', projectId], queryFn: () => getAIStatus(projectId!), @@ -52,68 +51,131 @@ export function useAIReview() { : false, }, ) - const activeJobId = selectedJobId ?? jobs.data?.items[0]?.id ?? null + const activeJobId = selectedJobIdentity(params, jobs.data?.items) const selectedJob = useQuery({ queryKey: ['ai-job', projectId, activeJobId], - queryFn: () => getAIJob(activeJobId!), + queryFn: () => getAIJob(projectId!, activeJobId!), enabled: Boolean(projectId && activeJobId), refetchInterval: (query) => query.state.data && ['pending', 'running'].includes(query.state.data.status) ? 2000 : false, }) - const activeJobStatus = selectedJob.data?.status ?? null const suggestions = useQuery({ - queryKey: ['ai-suggestions', projectId, activeJobId, activeJobStatus], + queryKey: ['ai-suggestions', projectId, activeJobId, selectedJob.data?.status], queryFn: () => listAISuggestions(activeJobId!), - enabled: Boolean(projectId && activeJobId), - }) - const createMutation = useMutation({ - mutationFn: (input: AIJobInput) => createAIJob(input), - onError: (error) => message.error(apiErrorMessage(error)), + enabled: readyForSuggestions( + projectId, + activeJobId, + selectedJob.isSuccess, + selectedJob.data?.status, + ), }) + const createMutation = useMutation({ mutationFn: createAIJob }) const reviewMutation = useMutation({ - mutationFn: ({ id, decision, content, note }: SuggestionReviewInput) => - reviewAISuggestion(id, decision, { content, note }), - onError: (error) => message.error(apiErrorMessage(error)), + mutationFn: ({ input, job }: { input: SuggestionReviewInput; job: string }) => + reviewAISuggestion( + input.id, + input.decision, + { content: input.content, note: input.note }, + job, + ), }) const settingsMutation = useMutation({ mutationFn: ({ project, enabled }: { project: string; enabled: boolean }) => updateAISettings(project, enabled), - onSuccess: async (_, input) => { - await queryClient.invalidateQueries({ queryKey: ['ai-status', input.project] }) - message.success('AI 样本策略已更新') - }, - onError: (error) => message.error(apiErrorMessage(error)), }) - - async function createJob(input: Omit) { - if (!projectId || !canEdit) throw new Error('当前项目只读') - const job = await createMutation.mutateAsync({ ...input, project_id: projectId }) - setSelectedJobId(job.id) - await queryClient.invalidateQueries({ queryKey: ['ai-jobs', projectId] }) - message.success('AI 任务已进入独立队列') + function selectJob(id: string) { + setParams((current) => { + const next = new URLSearchParams(current) + next.set('job', id) + next.delete('suggestion') + next.delete('decision') + return next + }) } - - async function review(input: SuggestionReviewInput) { - if (!projectId || !canEdit) throw new Error('当前项目只读') - await reviewMutation.mutateAsync(input) - await queryClient.invalidateQueries({ queryKey: ['ai-suggestions', projectId, activeJobId] }) - message.success('审核结果已保存') + function selectSuggestion(id: string | null, decision: 'accept' | 'reject' = 'accept') { + setParams((current) => { + const next = new URLSearchParams(current) + if (activeJobId) next.set('job', activeJobId) + if (id) { + next.set('suggestion', id) + next.set('decision', decision) + } else { + next.delete('suggestion') + next.delete('decision') + } + return next + }) } - - function updateSampleSharing(enabled: boolean) { - if (projectId && canManageSecurity) settingsMutation.mutate({ project: projectId, enabled }) + async function createJob(input: Omit): Promise { + if (!projectId || !canEdit || !status.isSuccess || !status.data.enabled || !commandCurrent()) + return false + const project = projectId + try { + const job = await createMutation.mutateAsync({ ...input, project_id: project }) + void queryClient.invalidateQueries({ queryKey: ['ai-jobs', project] }) + if (!commandCurrent()) return false + selectJob(job.id) + void message.success('AI 任务已进入独立队列') + return true + } catch (error) { + void queryClient.invalidateQueries({ queryKey: ['ai-jobs', project] }) + if (commandCurrent()) void message.error(apiErrorWithTrace(error)) + return false + } + } + async function review(input: SuggestionReviewInput): Promise { + if ( + !projectId || + !activeJobId || + !canEdit || + !commandCurrent() || + !suggestions.data?.some((item) => item.id === input.id && item.review_status === 'pending') + ) + return null + const project = projectId, + job = activeJobId + try { + const result = await reviewMutation.mutateAsync({ input, job }) + queryClient.setQueryData( + ['ai-suggestions', project, job, 'completed'], + (current) => current?.map((item) => (item.id === result.id ? result : item)), + ) + if (!commandCurrent()) return null + void message.success('审核结果已保存') + return result + } catch (error) { + if (commandCurrent()) void message.error(apiErrorWithTrace(error)) + return null + } + } + async function updateSampleSharing(enabled: boolean) { + if ( + !projectId || + !canManageSecurity || + !status.isSuccess || + !status.data.enabled || + !commandCurrent() + ) + return + const project = projectId + try { + await settingsMutation.mutateAsync({ project, enabled }) + await queryClient.invalidateQueries({ queryKey: ['ai-status', project] }) + if (commandCurrent()) void message.success('AI 样本策略已更新') + } catch (error) { + if (commandCurrent()) void message.error(apiErrorWithTrace(error)) + } } - return { projectId, canEdit, canManageSecurity, - permissions, + permissions: access.permissions, status, jobs, selectedJob, selectedJobId: activeJobId, - selectJob: setSelectedJobId, + selectJob, suggestions, createJob, creating: createMutation.isPending, @@ -121,5 +183,35 @@ export function useAIReview() { reviewing: reviewMutation.isPending, updateSampleSharing, updatingSettings: settingsMutation.isPending, + suggestionId: params.get('suggestion'), + reviewDecision: params.get('decision') === 'reject' ? ('reject' as const) : ('accept' as const), + selectSuggestion, } } +function useAICommandContext(projectId: string | null) { + const location = useLocation(), + router = useContext(UNSAFE_DataRouterContext) + const context = useMemo(() => ({ projectId, key: location.key }), [projectId, location.key]) + const active = useRef(context) + useLayoutEffect(() => { + active.current = context + return () => { + active.current = null + } + }, [context]) + return () => + active.current === context && (!router || router.router.state.location.key === location.key) +} + +function readyForSuggestions( + projectId: string | null, + jobId: string | null, + success: boolean, + status?: string, +): boolean { + return Boolean(projectId && jobId && success && status === 'completed') +} + +function selectedJobIdentity(params: URLSearchParams, jobs?: { id: string }[]): string | null { + return params.get('job') ?? jobs?.[0]?.id ?? null +} diff --git a/frontend/src/pages/AIAssistantPage.test.tsx b/frontend/src/pages/AIAssistantPage.test.tsx index 43674580..3368304d 100644 --- a/frontend/src/pages/AIAssistantPage.test.tsx +++ b/frontend/src/pages/AIAssistantPage.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { App as AntdApp } from 'antd' +import { App as AntdApp, ConfigProvider } from 'antd' import { http, HttpResponse } from 'msw' import { beforeEach, describe, expect, it } from 'vitest' @@ -70,7 +70,7 @@ describe('AIAssistantPage', () => { renderPage('viewer') expect(await screen.findByText(suggestion.title)).toBeVisible() expect(screen.getByRole('button', { name: /新建 AI 任务/ })).toBeDisabled() - expect(screen.getByRole('switch', { name: '允许提交脱敏样本' })).toBeDisabled() + expect(screen.getByRole('switch', { name: '允许 Owner 提交样本' })).toBeDisabled() for (const name of [/接受/, /拒绝/]) { const button = screen.getByRole('button', { name }) expect(button).toBeDisabled() @@ -78,6 +78,12 @@ describe('AIAssistantPage', () => { } expect(writes).toBe(0) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: '查看' })) + const dialog = await screen.findByRole('dialog', { name: '只读建议' }) + await waitFor(() => expect(dialog).toBeVisible()) + expect(within(dialog).getByLabelText('审核备注')).toHaveAttribute('readonly') + expect(within(dialog).queryByRole('button', { name: '确认接受' })).not.toBeInTheDocument() + expect(writes).toBe(0) }) it('reports selected-job status errors and refreshes suggestions after retry', async () => { @@ -148,7 +154,7 @@ describe('AIAssistantPage', () => { expect(await screen.findByText('AI 助手当前关闭')).toBeVisible() expect(screen.getByText(/不会影响接口、Workflow 或计划运行/)).toBeVisible() expect(screen.getByRole('button', { name: /新建 AI 任务/ })).toBeDisabled() - expect(screen.getByRole('switch', { name: '允许提交脱敏样本' })).toBeDisabled() + expect(screen.getByRole('switch', { name: '允许 Owner 提交样本' })).toBeDisabled() }) it('requires explicit human review and sends edited content only after acceptance', async () => { @@ -189,11 +195,12 @@ describe('AIAssistantPage', () => { expect(await screen.findByText('校验成功状态码')).toBeVisible() expect(screen.getByText('模型:flowtest-eval-model')).toBeVisible() - await browser.click(screen.getByRole('switch', { name: '允许提交脱敏样本' })) + await browser.click(screen.getByRole('switch', { name: '允许 Owner 提交样本' })) await waitFor(() => expect(sharingEnabled).toBe(true)) await browser.click(screen.getByRole('button', { name: /接受/ })) - expect(screen.getByRole('dialog', { name: '接受并生成草稿' })).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: '接受建议' })).toBeInTheDocument() + await browser.click(screen.getByRole('tab', { name: '高级 JSON' })) const content = screen.getByLabelText('建议内容') fireEvent.change(content, { target: { @@ -201,7 +208,7 @@ describe('AIAssistantPage', () => { }, }) await browser.type(screen.getByLabelText('审核备注'), '人工确认') - await browser.click(screen.getByRole('button', { name: 'OK' })) + await browser.click(screen.getByRole('button', { name: '确认接受' })) await waitFor(() => expect(reviewed).toEqual({ content: { expression: 'status_code', operator: 'equals', expected: 201 }, @@ -213,7 +220,7 @@ describe('AIAssistantPage', () => { it('supports explicit rejection and blocks malformed acceptance edits', async () => { let rejected: unknown let acceptRequests = 0 - const failedJob = { ...job, id: `${job.id.slice(0, -1)}3`, status: 'failed' as const } + const second = { ...suggestion, id: 'second-suggestion', title: '另一条状态码建议' } server.use( http.get('/api/v1/ai/status', () => HttpResponse.json({ @@ -223,16 +230,16 @@ describe('AIAssistantPage', () => { }), ), http.get('/api/v1/ai/jobs', () => - HttpResponse.json({ items: [failedJob], total: 1, page: 1, page_size: 50 }), + HttpResponse.json({ items: [job], total: 1, page: 1, page_size: 50 }), ), - http.get(`/api/v1/ai/jobs/${failedJob.id}/suggestions`, () => - HttpResponse.json([{ ...suggestion, job_id: failedJob.id }]), + http.get(`/api/v1/ai/jobs/${job.id}/suggestions`, () => + HttpResponse.json([suggestion, second]), ), http.post(`/api/v1/ai/suggestions/${suggestion.id}/reject`, async ({ request }) => { rejected = await request.json() return HttpResponse.json({ ...suggestion, review_status: 'rejected' }) }), - http.post(`/api/v1/ai/suggestions/${suggestion.id}/accept`, () => { + http.post(`/api/v1/ai/suggestions/${second.id}/accept`, () => { acceptRequests += 1 return HttpResponse.json({ ...suggestion, review_status: 'accepted' }) }), @@ -241,16 +248,27 @@ describe('AIAssistantPage', () => { const browser = userEvent.setup() expect(await screen.findByText('校验成功状态码')).toBeVisible() - expect(screen.getByText('failed')).toBeVisible() - await browser.click(screen.getByRole('button', { name: /拒绝/ })) + expect(screen.getByText('completed')).toBeVisible() + await browser.click( + within(screen.getByRole('row', { name: /校验成功状态码/ })).getByRole('button', { + name: /拒绝/, + }), + ) expect(screen.getByRole('dialog', { name: '拒绝建议' })).toBeInTheDocument() await browser.type(screen.getByLabelText('审核备注'), '人工拒绝') - await browser.click(screen.getByRole('button', { name: 'OK' })) + await browser.click(screen.getByRole('button', { name: '确认拒绝' })) await waitFor(() => expect(rejected).toEqual({ note: '人工拒绝' })) - await browser.click(screen.getByRole('button', { name: /接受/ })) + await browser.click(await screen.findByRole('button', { name: /关\s*闭/ })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await browser.click( + within(screen.getByRole('row', { name: /另一条状态码建议/ })).getByRole('button', { + name: /接受/, + }), + ) + await browser.click(screen.getByRole('tab', { name: '高级 JSON' })) fireEvent.change(screen.getByLabelText('建议内容'), { target: { value: '[]' } }) - await browser.click(screen.getByRole('button', { name: 'OK' })) + await browser.click(screen.getByRole('button', { name: '确认接受' })) expect(await screen.findByText('建议内容必须是 JSON 对象')).toBeInTheDocument() expect(acceptRequests).toBe(0) }) @@ -282,10 +300,17 @@ describe('AIAssistantPage', () => { expect(screen.getByText('pending')).toBeVisible() await browser.click(screen.getByRole('button', { name: /新建 AI 任务/ })) expect(screen.getByRole('dialog', { name: '新建 AI 建议任务' })).toBeInTheDocument() - fireEvent.change(screen.getByLabelText('脱敏样本(Owner 显式开启)'), { + await browser.click(screen.getByRole('button', { name: /高级 JSON 材料/ })) + fireEvent.change(screen.getByLabelText('契约 Schema JSON(可选)'), { + target: { value: '{"openapi":"3.1.0","paths":{}}' }, + }) + await browser.click(screen.getByRole('checkbox', { name: '本次提交样本(Owner 显式确认)' })) + fireEvent.change(screen.getByLabelText('本次提交样本 JSON'), { target: { value: '{"status":500}' }, }) - await browser.click(screen.getByRole('button', { name: 'OK' })) + await browser.click(screen.getByRole('button', { name: '预览提交材料' })) + expect(created).toBeUndefined() + await browser.click(screen.getByRole('button', { name: '确认提交任务' })) await waitFor(() => expect(created).toEqual({ project_id: project.id, @@ -315,12 +340,14 @@ function renderPage(role: 'owner' | 'viewer' = 'owner') { defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) return render( - - - - - - - , + + + + + + + + + , ) } diff --git a/frontend/src/pages/AIAssistantPage.tsx b/frontend/src/pages/AIAssistantPage.tsx index f6bb675e..9d78e7d7 100644 --- a/frontend/src/pages/AIAssistantPage.tsx +++ b/frontend/src/pages/AIAssistantPage.tsx @@ -2,81 +2,107 @@ import CatalogPagination from '../components/CatalogPagination' import { CheckOutlined, CloseOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons' import { Alert, - App, Button, Card, Col, - Form, - Input, - Modal, + Descriptions, Row, - Select, Space, Switch, Table, Tag, Typography, } from 'antd' -import type { AIJobInput, AISuggestion } from '../features/ai/ai-service' +import { useState } from 'react' +import type { AIJob, AIJobInput, AISuggestion } from '../features/ai/ai-service' import { useAIReview } from '../features/ai/use-ai-review' +import CreateAIJobDialog from '../features/ai/CreateAIJobDialog' +import AISuggestionReviewDialog from '../features/ai/AISuggestionReviewDialog' import { useProjectContext } from '../features/projects/use-project-context' -import { apiErrorMessage } from '../lib/api' -import { useRouteScopedState } from '../lib/use-route-scoped-state' - -type JobForm = { - job_type: AIJobInput['job_type'] - schema: string - metadata: string - sample: string -} - -type ReviewDraft = { - suggestion: AISuggestion - decision: 'accept' | 'reject' -} +import { apiErrorWithTrace } from '../lib/api' export default function AIAssistantPage() { const { projectId } = useProjectContext() return } - +type AIReviewState = ReturnType function AIReviewWorkspace() { const state = useAIReview() - const [createOpen, setCreateOpen] = useRouteScopedState(state.projectId, null, false) - const review = useSuggestionReview(state) - const aiStatus = state.status.data + const [createOpen, setCreateOpen] = useState(false) + const suggestion = state.suggestions.isSuccess + ? state.suggestions.data.find((item) => item.id === state.suggestionId) + : undefined return ( <> setCreateOpen(true)} /> - {!state.status.isError ? ( - + {state.status.isSuccess && !state.status.data.enabled ? ( + ) : null}
- - setCreateOpen(false)} - onCreate={async (input) => { - await state.createJob(input) - setCreateOpen(false) - }} - /> - + +
+ + + + + + + + setCreateOpen(false)} /> + {missingSuggestion(state, Boolean(suggestion)) ? ( + state.selectSuggestion(null)}>返回当前任务} + /> + ) : null} + {suggestion && state.projectId ? ( + state.selectSuggestion(null)} + onSubmit={state.review} + onReload={async () => { + const result = await state.suggestions.refetch() + if (result.isError) throw result.error + }} + /> + ) : null} ) } - -type AIReviewState = ReturnType - +function AIPageHeader({ canCreate, onCreate }: { canCreate: boolean; onCreate: () => void }) { + return ( +
+
+ AI 助手 + + 选择公开契约或失败摘要生成建议,人工审核后继续处理。任务不会自动发布或执行。 + +
+ +
+ ) +} function AIErrorAlert({ state }: { state: AIReviewState }) { const queries = [ state.status, @@ -92,12 +118,12 @@ function AIErrorAlert({ state }: { state: AIReviewState }) { type="error" showIcon title="AI 工作区加载失败" - description={apiErrorMessage(error)} + description={apiErrorWithTrace(error)} action={ @@ -105,89 +131,47 @@ function AIErrorAlert({ state }: { state: AIReviewState }) { /> ) } - -function AIPageHeader({ canCreate, onCreate }: { canCreate: boolean; onCreate: () => void }) { - return ( -
-
- AI 助手 - - 基于 Schema 和项目元数据生成可审核建议。AI 不会读取 Secret、自动发布或自动执行。 - -
- -
- ) -} - -function AIDisabledAlert({ loading, enabled }: { loading: boolean; enabled: boolean }) { - if (loading || enabled) return null - return ( - - ) -} - function AIPolicyCard({ state }: { state: AIReviewState }) { - const aiStatus = state.status.data - const enabled = aiStatus?.enabled ?? false + const status = state.status.data, + enabled = state.status.isSuccess && state.status.data.enabled return ( - + } color={enabled ? 'processing' : 'default'}> - {enabled ? `模型:${aiStatus?.model}` : '未启用'} + {enabled ? `模型:${state.status.data?.model}` : '未启用'} - 允许 Owner 提交脱敏样本 + 允许 Owner 显式提交样本 state.updateSampleSharing(checked)} + onChange={(checked) => void state.updateSampleSharing(checked)} /> ) } - -function AIWorkspace({ - state, - onReview, -}: { - state: AIReviewState - onReview: (suggestion: AISuggestion, decision: 'accept' | 'reject') => void -}) { - return ( - -
- - - - - - - ) -} - function AIJobsCard({ state }: { state: AIReviewState }) { return ( -
rowKey="id" size="small" pagination={false} - dataSource={state.jobs.data?.items ?? []} + scroll={{ x: 460 }} + dataSource={state.jobs.isSuccess ? state.jobs.data.items : []} rowClassName={(job) => (job.id === state.selectedJobId ? 'selected-row' : '')} - onRow={(job) => ({ onClick: () => state.selectJob(job.id) })} columns={[ - { title: '类型', dataIndex: 'job_type', render: jobTypeLabel }, + { + title: '任务', + dataIndex: 'job_type', + render: (value: string, job) => ( + + ), + }, { title: '状态', dataIndex: 'status', @@ -197,47 +181,103 @@ function AIJobsCard({ state }: { state: AIReviewState }) { { title: '输入摘要', dataIndex: 'input_sha256', - render: (value: string) => value.slice(0, 10), + render: (value: string) => value?.slice(0, 10) ?? '未提供', }, ]} /> ) } - -function AISuggestionsCard({ - state, - onReview, -}: { - state: AIReviewState - onReview: (suggestion: AISuggestion, decision: 'accept' | 'reject') => void -}) { +function AIJobDetails({ job }: { job?: AIJob }) { + if (!job) return null + return ( + + {job.status === 'failed' ? ( + + ) : null} + + {nextStep(job.status)} + + ) +} +function AISuggestionsCard({ state }: { state: AIReviewState }) { return ( - -
+ rowKey="id" size="small" pagination={false} - dataSource={state.suggestions.data ?? []} - locale={{ emptyText: '任务完成后在此审核建议' }} + scroll={{ x: 530 }} + dataSource={ + state.suggestions.isSuccess && + state.selectedJob.isSuccess && + state.selectedJob.data.status === 'completed' + ? state.suggestions.data + : [] + } + locale={{ emptyText: nextStep(state.selectedJob.data?.status) }} columns={[ { title: '建议', dataIndex: 'title', ellipsis: true }, - { title: '类型', dataIndex: 'suggestion_type', width: 120 }, + { title: '类型', dataIndex: 'suggestion_type' }, { title: '状态', dataIndex: 'review_status', - width: 100, render: (value: string) => {value}, }, { title: '操作', - width: 150, - render: (_, suggestion: AISuggestion) => ( - + width: 240, + render: (_, suggestion) => ( + + + + + ), }, ]} @@ -245,245 +285,54 @@ function AISuggestionsCard({ ) } - -function ReviewActions({ - suggestion, - readOnly, - onReview, -}: { - suggestion: AISuggestion - readOnly: boolean - onReview: (suggestion: AISuggestion, decision: 'accept' | 'reject') => void -}) { - const disabled = readOnly || suggestion.review_status !== 'pending' - return ( - - - - - ) -} - -function ReviewDialog({ - state, - review, -}: { - state: AIReviewState - review: ReturnType -}) { - return ( - void review.submit()} - > - {review.draft?.decision === 'accept' ? ( - review.setEditedContent(event.target.value)} - /> - ) : null} - review.setNote(event.target.value)} - /> - - ) -} - -function useSuggestionReview(state: AIReviewState) { - const { message } = App.useApp() - const [draft, setDraft] = useRouteScopedState( - state.projectId, - state.selectedJobId, - null, - ) - const [editedContent, setEditedContent] = useRouteScopedState( - state.projectId, - state.selectedJobId, - '', - ) - const [note, setNote] = useRouteScopedState(state.projectId, state.selectedJobId, '') - - function open(suggestion: AISuggestion, decision: 'accept' | 'reject') { - setDraft({ suggestion, decision }) - setEditedContent(JSON.stringify(suggestion.content, null, 2)) - setNote('') - } - - function close() { - setDraft(null) - } - - async function submit() { - if (!draft) return - const content = acceptedContent(draft, editedContent, message.error) - if (draft.decision === 'accept' && !content) return - await state.review({ - id: draft.suggestion.id, - decision: draft.decision, - content, - note, - }) - close() +function jobTypeLabel(value: AIJobInput['job_type'] | string): string { + const names: Record = { + schema_cases: 'Schema 用例', + assertion_suggestions: '断言建议', + workflow_draft: 'Workflow', + failure_analysis: '失败归因', + change_set: 'AI 变更集', } - - return { draft, editedContent, note, setEditedContent, setNote, open, close, submit } + return names[value] ?? value } - -function acceptedContent( - draft: ReviewDraft, - editedContent: string, - showError: (message: string) => void, -) { - if (draft.decision !== 'accept') return undefined - try { - return parseObject(editedContent, '建议内容必须是 JSON 对象') - } catch { - showError('建议内容必须是 JSON 对象') - return undefined - } +function statusColor(value: string): string { + if (value === 'completed') return 'success' + if (value === 'failed') return 'error' + return 'processing' +} +function nextStep(status?: string): string { + if (status === 'failed') return '任务失败。检查错误后可重新填写新任务,当前不会自动重试。' + if (status === 'pending' || status === 'running') return '任务仍在处理中,完成后可人工审核建议。' + if (status === 'completed') return '任务已完成;如没有建议,可补充明确的公开材料后新建任务。' + return '请选择任务,完成后在此审核建议。' } -function CreateAIJobDialog({ +function AICreateDialog({ + state, open, - submitting, - sampleEnabled, onClose, - onCreate, }: { + state: AIReviewState open: boolean - submitting: boolean - sampleEnabled: boolean onClose: () => void - onCreate: (input: Omit) => Promise }) { - const [form] = Form.useForm() + if (!state.projectId) return null return ( - form.submit()} - destroyOnHidden - > - - void onCreate({ - job_type: value.job_type, - schema_document: parseObject(value.schema, 'Schema 必须是 JSON 对象'), - metadata: parseObject(value.metadata, '元数据必须是 JSON 对象'), - sample: sampleEnabled && value.sample.trim() ? JSON.parse(value.sample) : undefined, - }) - } - > - - + onNavigate({ providerType: value === 'all' ? null : (value as EvidenceProviderType) }) + } + options={[ + { value: 'all', label: '全部来源类型' }, + ...Array.from(new Set(detail.evidence_items.map((item) => item.source_type))).map( + (type) => ({ value: type, label: providerTypeLabel(type) }), + ), + ]} + /> +
`${item.source_type}:${item.provider_name}:${item.provider_version}`} dataSource={detail.providers} + scroll={{ x: 580 }} columns={[ { title: 'Provider', @@ -226,118 +325,20 @@ function ProviderSummary({ detail }: { detail: ContextDetail }) { { title: 'Finding', dataIndex: 'finding_count' }, { title: '确定性', dataIndex: 'deterministic_count' }, { title: '冲突', dataIndex: 'conflict_count' }, + { + title: '定位', + render: (_, item) => ( + + ), + }, ]} /> ) } -function EvidenceFindings({ items }: { items: ContextEvidenceItem[] }) { - return ( - - {items.length === 0 ? ( - - ) : ( - ( - - - {item.finding.statement} - {item.finding.kind} - - {item.deterministic ? '确定性' : '需复核'} - - - } - description={ - - - {item.provider_name} {item.provider_version} · {item.finding.source_path} - - - {item.source_ref}@{item.source_revision} · confidence {item.confidence} - - {item.warnings.map((warning) => ( - - ))} - - } - /> - - )} - /> - )} - - ) -} - -function Knowledge({ detail }: { detail: ContextDetail }) { - const knowledge = detail.revision.knowledge_snapshot - const labels = new Map(knowledge.nodes.map((node) => [node.id, node.label])) - const states = knowledge.nodes.filter((node) => node.kind === 'state_candidate') - return ( - - State Candidate - - Knowledge Graph -
`${item.source}:${item.relation}:${item.target}`} - dataSource={knowledge.edges} - columns={[ - { title: '源', dataIndex: 'source', render: (value) => labels.get(value) ?? value }, - { title: '关系', dataIndex: 'relation', render: (value) => {value} }, - { title: '目标', dataIndex: 'target', render: (value) => labels.get(value) ?? value }, - ]} - /> - - ) -} - -function KnowledgeNodes({ nodes }: { nodes: KnowledgeNode[] }) { - if (nodes.length === 0) return - return ( -
- node.facts - .filter((fact) => fact.name === 'evidence_ref') - .map((fact) => fact.value) - .join('、') || '-', - }, - { - title: '复核', - render: (_, node) => - node.facts.some((fact) => fact.name === 'requires_review' && fact.value === 'true') ? ( - 需复核 - ) : ( - 已确定 - ), - }, - ]} - /> - ) -} - function Proposals({ items, projectId }: { items: ContextProposal[]; projectId: string | null }) { return ( @@ -349,6 +350,7 @@ function Proposals({ items, projectId }: { items: ContextProposal[]; projectId: pagination={false} rowKey="id" dataSource={items} + scroll={{ x: 520 }} columns={[ { title: '名称', dataIndex: 'title' }, { title: '审核', dataIndex: 'review_status', render: proposalReviewLabel }, @@ -395,22 +397,6 @@ function StatusTag({ status }: { status: ContextStatus }) { return {labels[status]} } -function providerLabel(value: EvidenceProviderType): string { - const labels: Record = { - repository: '仓库', - contract: '契约', - data_profile: '数据画像', - service_topology: '服务拓扑', - existing_test: '已有测试', - workflow: '工作流', - runtime: '运行时', - change: '变更', - user_confirmed_rule: '人工规则', - database: '数据库', - } - return labels[value] -} - function proposalReviewLabel(value: ContextProposal['review_status']) { const label = value === 'accepted' ? '已接受' : value === 'rejected' ? '已拒绝' : '待审核' const color = value === 'accepted' ? 'success' : value === 'rejected' ? 'default' : 'warning' From f50eda984ee1a61afeca93feaa68cf60c68a10ec Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:32:52 +0800 Subject: [PATCH 06/35] feat(ai): freeze review baselines and allow authorized history reads --- .../app/api/v1/endpoints/ai_change_sets.py | 1 + backend/app/models/ai.py | 3 + backend/app/schemas/ai_change_sets.py | 24 +++ backend/app/services/ai_change_sets.py | 68 ++++++- .../20261021_0065_ai_review_baselines.py | 22 +++ .../test_ai_review_baseline_migration.py | 65 +++++++ backend/tests/test_quality_intelligence.py | 177 ++++++++++++++++-- 7 files changed, 343 insertions(+), 17 deletions(-) create mode 100644 backend/migrations/versions/20261021_0065_ai_review_baselines.py create mode 100644 backend/tests/test_ai_review_baseline_migration.py diff --git a/backend/app/api/v1/endpoints/ai_change_sets.py b/backend/app/api/v1/endpoints/ai_change_sets.py index 80d6869a..76342395 100644 --- a/backend/app/api/v1/endpoints/ai_change_sets.py +++ b/backend/app/api/v1/endpoints/ai_change_sets.py @@ -94,5 +94,6 @@ def _detail(change_set: AIChangeSet, items: list[AIChangeItem]) -> AIChangeSetDe return AIChangeSetDetailResponse( **summary.model_dump(), source_snapshot=change_set.source_snapshot, + review_baselines=change_set.review_baselines, items=[AIChangeItemResponse.model_validate(item) for item in items], ) diff --git a/backend/app/models/ai.py b/backend/app/models/ai.py index 482d714f..f3c05d35 100644 --- a/backend/app/models/ai.py +++ b/backend/app/models/ai.py @@ -130,6 +130,9 @@ class AIChangeSet(UuidPrimaryKeyMixin, TimestampMixin, Base): String(24), default="generating", server_default="generating", index=True ) source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON) + review_baselines: Mapped[list[dict[str, Any]]] = mapped_column( + JSON, default=list, server_default="[]", nullable=False + ) source_fingerprint: Mapped[str] = mapped_column(String(64), index=True) source_type: Mapped[str] = mapped_column( String(24), default="ai", server_default="ai", index=True diff --git a/backend/app/schemas/ai_change_sets.py b/backend/app/schemas/ai_change_sets.py index 1f889dc0..9f921fe2 100644 --- a/backend/app/schemas/ai_change_sets.py +++ b/backend/app/schemas/ai_change_sets.py @@ -96,8 +96,32 @@ class AIChangeSetSummaryResponse(BaseModel): updated_at: datetime +class AIReviewBaselineContent(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + description: str + definition: dict[str, JsonValue] + tags: list[str] | None = None + + +class AIReviewBaseline(BaseModel): + model_config = ConfigDict(extra="forbid") + + target_type: Literal["test_case", "workflow"] + target_id: UUID + snapshot_sha256: str = Field(pattern=r"^[a-f0-9]{64}$") + content: AIReviewBaselineContent + current_version: int | None + draft_revision: int | None + updated_at: datetime + redaction_mode: Literal["off", "on"] + redaction_policy_version: int + + class AIChangeSetDetailResponse(AIChangeSetSummaryResponse): source_snapshot: dict[str, JsonValue] + review_baselines: list[AIReviewBaseline] = Field(default_factory=list) items: list[AIChangeItemResponse] diff --git a/backend/app/services/ai_change_sets.py b/backend/app/services/ai_change_sets.py index d9dad497..7cd1938d 100644 --- a/backend/app/services/ai_change_sets.py +++ b/backend/app/services/ai_change_sets.py @@ -3,6 +3,7 @@ import hashlib import json import logging +from copy import deepcopy from dataclasses import dataclass from datetime import UTC, datetime from typing import Any, Literal, cast @@ -13,7 +14,7 @@ from app.core.config import settings from app.core.errors import AppError -from app.core.redaction import get_redaction_policy +from app.core.redaction import RedactionMode, get_redaction_policy, redaction_enabled from app.domain.ai import REDACTED, AIInputError, sanitize_ai_input from app.domain.governance import QuotaDimension from app.engine.contracts import AssertNodeConfig, NodeType, WorkflowDefinition @@ -26,6 +27,8 @@ from app.repositories.impact import ImpactRepository from app.schemas.ai_change_sets import ( AIChangeSetCreate, + AIReviewBaseline, + AIReviewBaselineContent, AITestCaseDraftCreate, AITestCaseDraftUpdate, AIWorkflowDraftCreate, @@ -93,6 +96,7 @@ async def create( code="RELEASE_RISK_NOT_FOUND", message="发布风险快照不存在", status_code=404 ) metadata = await self._source_metadata(risk, impact.run.changes) + baselines = await self._capture_review_baselines(metadata, payload.project_id) try: sanitized = sanitize_ai_input(schema_document=None, metadata=metadata, sample=None) except AIInputError as error: @@ -122,6 +126,7 @@ async def create( title=payload.title.strip(), status="generating", source_snapshot=sanitized.payload, + review_baselines=[item.model_dump(mode="json") for item in baselines], source_fingerprint=sanitized.sha256, source_type="ai", source_ref=f"ai-job://{job.id}", @@ -165,7 +170,6 @@ async def create( async def list_change_sets( self, *, actor: User, project_id: UUID, page: int, page_size: int ) -> tuple[list[AIChangeSet], int]: - _require_enabled() await self._projects.authorize(actor=actor, project_id=project_id, editing=False) return await self._repository.list_change_sets( project_id=project_id, offset=(page - 1) * page_size, limit=page_size @@ -174,7 +178,6 @@ async def list_change_sets( async def get( self, *, actor: User, change_set_id: UUID ) -> tuple[AIChangeSet, list[AIChangeItem]]: - _require_enabled() change_set = await self._get_change_set(change_set_id) await self._projects.authorize(actor=actor, project_id=change_set.project_id, editing=False) return change_set, await self._repository.list_items(change_set.id) @@ -368,6 +371,33 @@ async def _target_snapshot( "snapshot_sha256": _target_hash(workflow), } + async def _capture_review_baselines( + self, metadata: dict[str, JsonValue], project_id: UUID + ) -> list[AIReviewBaseline]: + raw_targets = metadata.get("allowed_targets") + if not isinstance(raw_targets, list): + return [] + baselines: list[AIReviewBaseline] = [] + for raw in raw_targets: + if not isinstance(raw, dict): + continue + target_id = _change_target_id(raw.get("target_id")) + target_type = raw.get("target_type") + if target_id is None or target_type not in {"workflow", "test_case"}: + continue + target = await self._review_baseline_target(str(target_type), target_id) + expected_hash = raw.get("snapshot_sha256") + _ensure_target(target, project_id, str(expected_hash)) + baselines.append(_review_baseline(cast(TestCase | Workflow, target))) + return baselines + + async def _review_baseline_target( + self, target_type: str, target_id: UUID + ) -> TestCase | Workflow | None: + if target_type == "test_case": + return await self._repository.get_test_case(target_id) + return await self._repository.get_workflow(target_id) + async def _materialize( self, *, @@ -542,6 +572,8 @@ def _target_hash(target: TestCase | Workflow) -> str: def _target_definition_for_ai( target_type: Literal["test_case", "workflow"], definition: dict[str, Any] ) -> dict[str, JsonValue]: + if not redaction_enabled(): + return cast(dict[str, JsonValue], deepcopy(definition)) safe_definition = cast(dict[str, JsonValue], dict(definition)) if target_type == "test_case": for field in ("runtime_variables", "runtime_headers"): @@ -1050,3 +1082,33 @@ def _review_status(items: list[AIChangeItem]) -> str: if pending: return "partially_reviewed" if accepted or rejected else "draft" return "accepted" if accepted else "rejected" + + +def _review_baseline(target: TestCase | Workflow) -> AIReviewBaseline: + policy = get_redaction_policy() + target_type: Literal["test_case", "workflow"] = ( + "test_case" if isinstance(target, TestCase) else "workflow" + ) + definition = deepcopy(target.draft_definition) + if policy.mode is RedactionMode.ON: + definition = _target_definition_for_ai(target_type, definition) + content = AIReviewBaselineContent( + name=target.name, + description=target.description, + definition=definition, + tags=target.tags if isinstance(target, TestCase) else None, + ) + sanitized = sanitize_ai_input( + schema_document=None, metadata=content.model_dump(mode="json"), sample=None + ) + return AIReviewBaseline( + target_type=target_type, + target_id=target.id, + snapshot_sha256=_target_hash(target), + content=AIReviewBaselineContent.model_validate(sanitized.payload["metadata"]), + current_version=target.current_version, + draft_revision=target.draft_revision if isinstance(target, Workflow) else None, + updated_at=target.updated_at, + redaction_mode=policy.mode.value, + redaction_policy_version=policy.policy_version, + ) diff --git a/backend/migrations/versions/20261021_0065_ai_review_baselines.py b/backend/migrations/versions/20261021_0065_ai_review_baselines.py new file mode 100644 index 00000000..0494f759 --- /dev/null +++ b/backend/migrations/versions/20261021_0065_ai_review_baselines.py @@ -0,0 +1,22 @@ +"""Freeze authorized asset baselines separately from AI input material.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261021_0065" +down_revision: str | None = "20261021_0064" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "ai_change_sets", + sa.Column("review_baselines", sa.JSON(), nullable=False, server_default="[]"), + ) + + +def downgrade() -> None: + op.drop_column("ai_change_sets", "review_baselines") diff --git a/backend/tests/test_ai_review_baseline_migration.py b/backend/tests/test_ai_review_baseline_migration.py new file mode 100644 index 00000000..c39c0e58 --- /dev/null +++ b/backend/tests/test_ai_review_baseline_migration.py @@ -0,0 +1,65 @@ +import json +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import create_engine, inspect + + +def test_ai_review_baseline_upgrade_preserves_legacy_rows_and_reverses( + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = Path(__file__).parent.parent / "migrations/versions/20261021_0065_ai_review_baselines.py" + spec = spec_from_file_location("ai_review_baseline_migration", path) + assert spec is not None and spec.loader is not None + migration = module_from_spec(spec) + spec.loader.exec_module(migration) + engine = create_engine("sqlite://") + try: + with engine.begin() as connection: + connection.exec_driver_sql("PRAGMA foreign_keys=ON") + connection.exec_driver_sql( + "CREATE TABLE ai_change_sets " + "(id TEXT PRIMARY KEY, fingerprint TEXT, source_snapshot JSON)" + ) + connection.exec_driver_sql( + "INSERT INTO ai_change_sets VALUES ('owned', " + "'original-frozen-fingerprint', '{\"fixed\":true}')" + ) + monkeypatch.setattr(migration, "op", Operations(MigrationContext.configure(connection))) + migration.upgrade() + assert ( + connection.exec_driver_sql("SELECT review_baselines FROM ai_change_sets").scalar() + == "[]" + ) + connection.exec_driver_sql( + "UPDATE ai_change_sets SET review_baselines=?", + ( + json.dumps( + [ + { + "scenario_id": "scenario-1", + "workflow_id": "original-workflow", + "test_case_id": "original-case", + } + ] + ), + ), + ) + migration.downgrade() + assert "review_baselines" not in { + item["name"] for item in inspect(connection).get_columns("ai_change_sets") + } + original = connection.exec_driver_sql( + "SELECT fingerprint, source_snapshot FROM ai_change_sets" + ).one() + assert tuple(original) == ("original-frozen-fingerprint", '{"fixed":true}') + migration.upgrade() + assert ( + connection.exec_driver_sql("SELECT review_baselines FROM ai_change_sets").scalar() + == "[]" + ) + finally: + engine.dispose() diff --git a/backend/tests/test_quality_intelligence.py b/backend/tests/test_quality_intelligence.py index 50f7f7ff..4c513396 100644 --- a/backend/tests/test_quality_intelligence.py +++ b/backend/tests/test_quality_intelligence.py @@ -1169,9 +1169,13 @@ async def test_ai_change_set_can_reject_large_persisted_proposal( @pytest.mark.asyncio -async def test_ai_change_set_redacts_runtime_values_from_target_snapshot( +@pytest.mark.parametrize("mode", ["on", "off"]) +async def test_ai_change_set_honors_target_and_review_baseline_redaction_policy( quality_context: QualityContext, + monkeypatch: pytest.MonkeyPatch, + mode: str, ) -> None: + monkeypatch.setattr(settings, "redaction_mode", mode) headers = await _login(quality_context.client) project_id = await _project(quality_context.client, headers, "AI 目标脱敏项目") workflow_definition = _workflow_definition() @@ -1226,19 +1230,30 @@ async def test_ai_change_set_redacts_runtime_values_from_target_snapshot( assert len(recommended_tests) == len(allowed_targets) == 1 assert recommended_tests[0]["target_id"] == workflow_id target = allowed_targets[0] - assert target["draft_definition"]["variables"] == { - "session_id": REDACTED, - "region": REDACTED, - } - assert target["draft_definition"]["nodes"][0]["config"] == { - "headers": {"X-Session": REDACTED}, - "body": {"customer": {"reference": REDACTED}}, - "attempt": REDACTED, - } - assert "opaque-session-material" not in str(job.sanitized_input) - assert "opaque-node-secret" not in str(job.sanitized_input) - assert "opaque-reference" not in str(job.sanitized_input) - assert "cn-north-1" not in str(change_set.source_snapshot) + baseline = change_set.review_baselines[0] + assert baseline["snapshot_sha256"] == target["snapshot_sha256"] + assert baseline["redaction_mode"] == mode + assert baseline["draft_revision"] == 1 + if mode == "off": + assert target["draft_definition"]["variables"]["region"] == "cn-north-1" + assert baseline["content"]["definition"]["variables"]["region"] == "cn-north-1" + assert "opaque-node-secret" in str(job.sanitized_input) + else: + assert target["draft_definition"]["variables"] == { + "session_id": REDACTED, + "region": REDACTED, + } + assert target["draft_definition"]["nodes"][0]["config"] == { + "headers": {"X-Session": REDACTED}, + "body": {"customer": {"reference": REDACTED}}, + "attempt": REDACTED, + } + assert "opaque-session-material" not in str(job.sanitized_input) + assert "opaque-node-secret" not in str(job.sanitized_input) + assert "opaque-reference" not in str(job.sanitized_input) + assert "cn-north-1" not in str(change_set.source_snapshot) + assert "opaque-session-material" not in str(baseline) + assert "opaque-node-secret" not in str(baseline) def test_ai_target_snapshot_redacts_test_case_runtime_maps_without_mutating_source() -> None: @@ -1400,6 +1415,130 @@ async def test_regular_test_case_update_takes_same_row_lock_as_ai_acceptance() - assert updated.description == "人工更新" +@pytest.mark.asyncio +@pytest.mark.parametrize("flag", ["feature_ai_enabled", "feature_quality_intelligence_enabled"]) +async def test_ai_change_set_history_remains_read_only_when_generation_disabled( + quality_context: QualityContext, monkeypatch: pytest.MonkeyPatch, flag: str +) -> None: + headers = await _login(quality_context.client) + project_id = await _project(quality_context.client, headers, "关闭态历史项目") + impact_id = await _seed_impact(quality_context.sessions, project_id) + risk = await quality_context.client.post( + f"/api/v1/projects/{project_id}/release-risks", + headers=headers, + json={"impact_run_id": impact_id, "title": "固定风险", "window_days": 7}, + ) + assert risk.status_code == 201 + change_id, item_id = await _generate_change_set( + quality_context, + headers=headers, + project_id=project_id, + impact_run_id=impact_id, + risk_id=risk.json()["id"], + provider=ChangeSetProvider(), + ) + await _grant_viewer(quality_context.sessions, project_id) + reader = await _login(quality_context.client, email=VIEWER_EMAIL, password=VIEWER_PASSWORD) + foreign_project = await _project(quality_context.client, headers, "无权历史项目") + foreign_impact = await _seed_impact(quality_context.sessions, foreign_project) + foreign_risk = await quality_context.client.post( + f"/api/v1/projects/{foreign_project}/release-risks", + headers=headers, + json={"impact_run_id": foreign_impact, "title": "其他项目风险", "window_days": 7}, + ) + assert foreign_risk.status_code == 201 + foreign_change, _ = await _generate_change_set( + quality_context, + headers=headers, + project_id=foreign_project, + impact_run_id=foreign_impact, + risk_id=foreign_risk.json()["id"], + provider=ChangeSetProvider(), + ) + monkeypatch.setattr(settings, flag, False) + detail = await quality_context.client.get(f"/api/v1/ai/change-sets/{change_id}", headers=reader) + assert detail.status_code == 200, detail.text + listed = await quality_context.client.get( + "/api/v1/ai/change-sets", headers=reader, params={"project_id": project_id} + ) + assert listed.status_code == 200, listed.text + assert listed.json()["total"] == 1 + for path in [ + f"/api/v1/ai/change-sets/{foreign_change}", + f"/api/v1/ai/change-sets?project_id={foreign_project}", + ]: + forbidden = await quality_context.client.get(path, headers=reader) + assert forbidden.status_code == 404, forbidden.text + assert forbidden.json()["error"]["code"] == "PROJECT_NOT_FOUND" + assert forbidden.json()["error"]["trace_id"] + assert foreign_change not in forbidden.text + for decision in ["accept", "reject"]: + denied = await quality_context.client.post( + f"/api/v1/ai/change-sets/{change_id}/items/{item_id}/{decision}", + headers=headers, + json={"note": "关闭时不写入"}, + ) + assert denied.status_code == 503 + assert denied.json()["error"]["trace_id"] + async with quality_context.sessions() as session: + item = await session.get(AIChangeItem, item_id) + assert item is not None and item.review_status == "pending" + assert ( + await session.scalar(select(Workflow).where(Workflow.project_id == UUID(project_id))) + is None + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("decision", ["accept", "reject"]) +async def test_change_set_suggestions_require_dedicated_review_entry( + quality_context: QualityContext, decision: str +) -> None: + headers = await _login(quality_context.client) + project_id = await _project(quality_context.client, headers, "专用审核边界") + impact_id = await _seed_impact(quality_context.sessions, project_id) + risk = await quality_context.client.post( + f"/api/v1/projects/{project_id}/release-risks", + headers=headers, + json={"impact_run_id": impact_id, "title": "边界风险", "window_days": 7}, + ) + assert risk.status_code == 201 + change_id, item_id = await _generate_change_set( + quality_context, + headers=headers, + project_id=project_id, + impact_run_id=impact_id, + risk_id=risk.json()["id"], + provider=ChangeSetProvider(), + ) + async with quality_context.sessions() as session: + item = await session.get(AIChangeItem, item_id) + assert item is not None + suggestion_id = item.suggestion_id + payload = {"note": "不能绕过变更集守卫"} + if decision == "accept": + payload["content"] = { + "name": "不应生成的草稿", + "description": "隔离反例", + "definition": _workflow_definition(), + } + response = await quality_context.client.post( + f"/api/v1/ai/suggestions/{suggestion_id}/{decision}", headers=headers, json=payload + ) + assert response.status_code == 409, response.text + assert response.json()["error"]["code"] == "AI_CHANGE_SET_REVIEW_REQUIRED" + assert response.json()["error"]["trace_id"] + detail = await quality_context.client.get( + f"/api/v1/ai/change-sets/{change_id}", headers=headers + ) + assert detail.json()["items"][0]["review_status"] == "pending" + async with quality_context.sessions() as session: + assert ( + await session.scalar(select(Workflow).where(Workflow.project_id == UUID(project_id))) + is None + ) + + @pytest.mark.asyncio async def test_ai_change_set_viewer_can_read_but_cannot_create_or_review( quality_context: QualityContext, @@ -1671,6 +1810,16 @@ async def test_ai_change_set_preserves_target_snapshot_captured_before_generatio ) assert item is not None + detail = await quality_context.client.get( + f"/api/v1/ai/change-sets/{created.json()['id']}", headers=headers + ) + assert detail.status_code == 200, detail.text + baseline = detail.json()["review_baselines"][0] + assert baseline["target_id"] == workflow_id + assert baseline["draft_revision"] == 1 + assert baseline["content"]["name"] == "生成期人工维护流程" + assert baseline["snapshot_sha256"] == detail.json()["items"][0]["target_snapshot_sha256"] + conflicted = await quality_context.client.post( f"/api/v1/ai/change-sets/{created.json()['id']}/items/{item.id}/accept", headers=headers, From 8e8dfa6faaa69f2bf63b34242f2b097d7fea9b96 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:35:48 +0800 Subject: [PATCH 07/35] docs(audit): record context evidence acceptance and delivery state --- .../2026-10-20/batch-23-validation.md | 9 ++++ .../ui-v5/remediation/2026-10-20/batch-23.md | 42 +++++++++++++++++++ .../ui-v5/remediation/2026-10-20/progress.md | 8 +++- .../remediation/2026-10-20/validation.md | 2 + 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 docs/design/ui-v5/remediation/2026-10-20/batch-23-validation.md create mode 100644 docs/design/ui-v5/remediation/2026-10-20/batch-23.md diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-23-validation.md b/docs/design/ui-v5/remediation/2026-10-20/batch-23-validation.md new file mode 100644 index 00000000..cfc83657 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-23-validation.md @@ -0,0 +1,9 @@ +# 第二十三批验证补充 + +详见[完整模块回执](batch-23.md)。M21一项本地完成;当前61/73、余12、GitHub已合并58。原审计未修改。 + +- 精确前端树ebc047dac9e91ee5853692e13e81d80df7ab69af:177文件1259项通过,覆盖率86.10/82.40/85.87/88.64%,格式、lint/类型与构建通过。 +- 针对性5文件20项和既有后端合同7项通过;本模块未改后端,未重复完整后端检查。后端继承M20树及其1911项完整通过的记录,不将它写作本批重新执行。 +- 真实固定修订、冲突/证据/知识联动、实际Proposal、刷新和390px通过;检查阶段业务写入0、页面异常0、执行0。资料准备的8次写入单独记账,受限账号已撤销。 +- 原始失败、取消与最终完整日志均保留;没有抬高超时、增加重试或降低覆盖率。 +- S57完整框架验收、当前PR门禁、主分支独立回归与正式部署另行确认。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-23.md b/docs/design/ui-v5/remediation/2026-10-20/batch-23.md new file mode 100644 index 00000000..9a4f4979 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-23.md @@ -0,0 +1,42 @@ +# 第二十三批:M21 上下文检查器联动 + +对应冻结审计 [F-CTX-01](../../audits/2026-10-19/complete-audit.md#f-ctx-01) 与[实施方案](../../audits/2026-10-19/remediation-plan.md#f-ctx-01)。原审计、原方案保持不变。 + +## 实施结果 + +- 将详情分为概要、完整性与冲突、证据、知识关系、关联提案五个可切换区域;保留既有状态、Provider统计、完整性、知识和提案能力。 +- 冲突按当前固定修订的完整证据指纹定位,显示匹配数和原始指纹;来源类型、确定性、警告及同一来源修订可以组合筛选,显示当前/总量。未知引用明确提示,不按相似内容猜测。 +- 证据详情提供上下文修订、证据ID、来源ref/revision、路径、主题、置信度、指纹、警告及完整Finding原文;复制不额外读取凭据或Secret。 +- 知识节点、关系两端和evidence_ref联动到唯一匹配的证据;未知或重复引用不生成外部跳转。关系显示相关数/总数。 +- focus、revision、当前分区、筛选与证据/知识选择进入URL,刷新恢复;目录分页可直接定位,目录限高,证据/知识列表各自分页。 +- 当前详情接口仅返回当前修订。旧revision链接会阻止将新修订冒充旧修订,明确显示两者身份,并要求主动选择“查看当前修订”;不夸大为所有历史接口均不可用。 +- 过期只读、缺失引用、服务错误和目录/详情重试明确区分;响应的项目与对象身份不符时停止展示,直接Data Router切换期间的迟到结果不能覆盖新选择。 +- 保留实际Flow Proposal深链;页面不增加扫描、Review、Apply、执行等写入能力。 + +## 代码与检查 + +实施提交 `986ced4b45539bc84e18f90e84e9f71dfbd194c1`;前端树 `ebc047dac9e91ee5853692e13e81d80df7ab69af`。变更位于ContextInspectorPage、context-inspector服务/Hook及新证据、知识、导航组件;同步S57既有验收步骤以切换到对应分区,保留原可见性与Proposal断言。 + +针对性前端5文件20项通过,覆盖共享Context分页的失败修复和回归维护消费者;后端既有S49/S57合同7项通过。全量前端177文件1259项通过,无未处理异常,覆盖率语句86.10%、分支82.40%、函数85.87%、行88.64%;格式、lint/类型、构建通过。详见[验证补充](batch-23-validation.md)。 + +先补反例再实现:联动/旧修订3项失败、原6项通过;增加未知/外项目/迟到响应后最终20项通过。中途JSDOM深层Card/Table样式计算导致取消的检查均保留为未完成;压平布局与改为紧凑分页列表后通过,没有放宽超时、重试、覆盖率或原可见性断言。 + +## 真实浏览器验证 + +在自有隔离Compose `flowtest-product-completion` / `127.0.0.1:13078`,使用已拥有的隔离项目 `fc3f1f13-763a-43c0-88ae-8d9f26925ca7`。原3000端口环境未改动。 + +通过受限临时服务账号创建2个明确标注的合成Context、3次合成证据摄入和1个未应用Flow Proposal;账号在finally中撤销。凭据只在进程内使用,不写日志或文件。合成资料不代表被测系统结果。 + +- 冲突Context `9212559b-09d1-466f-b099-281dfb1771bb`,旧修订 `8da25da7-691d-41d3-a0c0-b73a3a36b138`,当前 `ad9bfbb9-a34b-410b-8ec8-feea7b06cbb2`:旧修订阻止替换、主动切换当前、冲突→契约Finding、1/2计数、URL刷新恢复通过。 +- 知识节点 `state.m21` → 仓库证据,筛选恢复2/2,source_ref/revision/path一致。 +- 就绪Context `89968cce-4e3b-49f3-a3ab-709f5c308e0c` → 实际Proposal `d9af57c2-5a8a-4372-a893-411ef2812a15`:打开真实“Flow Proposal 可视化审核”,未审核或应用。 +- 1440px与390px截图已检查;390px文档宽度390,无横向溢出。 +- 资料准备8次业务HTTP写入(含账号创建/撤销);随后实际页面联动验证业务写入0、页面异常0、发布/执行0、AI队列0。首次准备时遇到并发认证刷新409,未重复资料写入;其后资料成功一次,受限账号已撤销。 + +原始日志、截图在主工作树 `output/playwright/audit-20261019/remediation-context-*`。关键日志:`remediation-context-links-browser.log`、`remediation-context-final-targeted.log`、`remediation-context-backend-contracts.log`、`remediation-context-full-frontend.log`。 + +## 交付与剩余边界 + +F-CTX-01本地完成,本地61/73、待完成12。R3 PR145在精确HEAD `4599b91904c93cfaba84742d75c431d68ca23e50` Required Gate成功、两条评审均解决且完整分页无遗漏后,合并为main `cbd6410840b99f5fa7c2c55910a58456927fef23`;已合并58/73。其独立主分支Full Regression为运行39625538263,记录时仍运行,不能写成已通过。 + +本批与M20的远端最新HEAD门禁、完整Compose S21/S57及正式部署仍单独验收。M22及之后12项继续实施。通用AI审核入口已有服务端change_set守卫,M22反例已确认接受/拒绝均409;不把它记为已确认的绕过漏洞。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/progress.md b/docs/design/ui-v5/remediation/2026-10-20/progress.md index 509444d1..815af873 100644 --- a/docs/design/ui-v5/remediation/2026-10-20/progress.md +++ b/docs/design/ui-v5/remediation/2026-10-20/progress.md @@ -2,7 +2,7 @@ 依据:[完整审计](../../audits/2026-10-19/complete-audit.md)、[详细方案](../../audits/2026-10-19/remediation-plan.md)。审计基线 `420d2e0cd30ff3f5c0519df8fcfd26b3bc2704e4`。 -2026-10-20 至 21 日整改:**60 项已实现并完成本地验证,13 项待完成**(此前58项加AI助手2项)。按73项等权计数,本地完成约82%,已合并48项约66%;不代表工时消耗比例。R0、R1、R2、R3全部完成本地验证,R4其余十三项继续实施。**不是73项全部完成,也不是全站设计签收。** +2026-10-20 至 21 日整改:**61 项已实现并完成本地验证,12 项待完成**(此前60项加上下文检查器1项)。按73项等权计数,本地完成约84%,已合并58项约79%;不代表工时消耗比例。R0、R1、R2、R3全部完成本地验证,R4其余十二项继续实施。**不是73项全部完成,也不是全站设计签收。** 状态指代码和本地验证;浏览器专项、GitHub交付与正式环境验收分开记录。详情见[首批实施与验证](batch-01.md)、[冻结断言来源补充整改](batch-02.md)与[完整本地验证记录](validation.md)。 @@ -65,7 +65,7 @@ | F-REL-03 | M19 | R3 | 已实现,本地验证通过 | | F-AI-01 | M20 | R4 | 已实现,本地验证通过 | | F-AI-02 | M20 | R4 | 已实现,本地验证通过 | -| F-CTX-01 | M21 | R4 | 待实施 | +| F-CTX-01 | M21 | R4 | 已实现,本地验证通过 | | F-AIC-01 | M22 | R4 | 待实施 | | F-AIC-02 | M22 | R4 | 待实施 | | F-AIC-03 | M22 | R4 | 待实施 | @@ -344,3 +344,7 @@ F-REL-02、F-REL-03已实现并完成完整本地检查及实际浏览器验收 ## M20 AI助手 F-AI-01、F-AI-02完成本地整改及完整检查,详见[第二十二批](batch-22.md)与[验证补充](batch-22-validation.md)。四类任务公开材料、明确预览/提交、人工审核、失败原因、实际草稿、URL恢复与离开保护通过。本地60/73、待完成13、已合并48;R3最新门禁及R4远端交付单独核验。 + +## M21 上下文检查器 + +F-CTX-01完成固定证据、冲突和知识联动,URL修订保护、未知引用及实际提案入口通过;全量前端177文件1259项通过。详见[第二十三批](batch-23.md)与[验证补充](batch-23-validation.md)。本地61/73、余12,PR145已合并后已交付58;独立主分支回归及R4新门禁仍另行核验。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/validation.md b/docs/design/ui-v5/remediation/2026-10-20/validation.md index 70793846..e7c25d1f 100644 --- a/docs/design/ui-v5/remediation/2026-10-20/validation.md +++ b/docs/design/ui-v5/remediation/2026-10-20/validation.md @@ -208,3 +208,5 @@ PR #142 的旧HEAD `b30027ef96424b6bfdefc97d5e8555fa6b6b16cf` 对应[正式运 ## M20 AI助手完整检查 [第二十二批](batch-22.md)及[验证补充](batch-22-validation.md)记录最终源码、28项专项和实际浏览器。后端最终树47228bd19aecf80f9d1c66cf43e7cf8941da5cbe完整1911项、91.14%;前端最终树2559df2206210f18a2445720ae924b620274018e完整176文件1253项、无未处理异常,覆盖率86.10/82.40/85.91/88.62%,格式、类型及构建通过。本地60/73、已合并48;没有将模拟网关或本地完整结果等同于真实模型质量、最新远端门禁或正式部署。 + +第二十三批上下文检查器:精确前端树ebc047dac9e91ee5853692e13e81d80df7ab69af,完整177文件1259项,覆盖率86.10/82.40/85.87/88.64%,格式、lint/类型与构建通过;针对性20项、后端既有合同7项、真实页面与390px通过,详见[验证补充](batch-23-validation.md)。本批未改后端、未重复完整后端;正式PR门禁、主分支与部署独立记账。 From edb06bbc5259f0390b85bb45f731e72fd2fc8242 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:43:24 +0800 Subject: [PATCH 08/35] feat(ai): present frozen change evidence and protect manual reviews --- .../features/ai/AIChangeReviewEvidence.tsx | 258 ++++++++++++++++++ .../src/features/ai/ai-change-diff.test.ts | 21 ++ frontend/src/features/ai/ai-change-diff.ts | 22 ++ .../features/ai/ai-change-set-service.test.ts | 53 ++++ .../src/features/ai/ai-change-set-service.ts | 40 ++- .../features/ai/use-ai-change-sets.test.tsx | 77 ++++++ .../src/features/ai/use-ai-change-sets.ts | 139 +++++++--- frontend/src/pages/AIChangeSetsPage.test.tsx | 192 ++++++++++++- frontend/src/pages/AIChangeSetsPage.tsx | 257 ++++++++++++----- 9 files changed, 950 insertions(+), 109 deletions(-) create mode 100644 frontend/src/features/ai/AIChangeReviewEvidence.tsx create mode 100644 frontend/src/features/ai/ai-change-diff.test.ts create mode 100644 frontend/src/features/ai/ai-change-diff.ts create mode 100644 frontend/src/features/ai/ai-change-set-service.test.ts diff --git a/frontend/src/features/ai/AIChangeReviewEvidence.tsx b/frontend/src/features/ai/AIChangeReviewEvidence.tsx new file mode 100644 index 00000000..a33e6b40 --- /dev/null +++ b/frontend/src/features/ai/AIChangeReviewEvidence.tsx @@ -0,0 +1,258 @@ +import { Alert, Card, Collapse, Descriptions, Flex, List, Space, Typography } from 'antd' +import { Link } from 'react-router-dom' +import ResponseCodeReader from '../api-console/ResponseCodeReader' +import type { AIChangeItem, AIChangeSetDetail, AIReviewBaseline } from './ai-change-set-service' +import { changeFields, proposedDraft } from './ai-change-diff' + +export function AIChangeSource({ + detail, + projectId, +}: { + detail: AIChangeSetDetail + projectId: string +}) { + return ( + + + {detail.source_fingerprint} + + ), + }, + { + key: 'impact', + label: '影响分析', + children: ( + + {detail.impact_run_id} + + ), + }, + { + key: 'risk', + label: '风险快照', + children: ( + + {detail.release_risk_id} + + ), + }, + { + key: 'job', + label: 'AI 任务', + children: ( + + {detail.ai_job_id} + + ), + }, + ]} + /> + + 以下材料冻结于生成时;打开来源页面不会替换这份审核证据。 + + , + }, + ]} + /> + + ) +} +export function AIChangeBaseline({ + detail, + item, + proposed = item.proposed_content, +}: { + detail: AIChangeSetDetail + item: AIChangeItem + proposed?: Record +}) { + if (item.action === 'create') return + const expectedType = item.item_type === 'assertion' ? 'workflow' : item.item_type + const matches = (detail.review_baselines ?? []).filter( + (baseline) => + baseline.target_id === item.target_resource_id && + baseline.target_type === expectedType && + baseline.snapshot_sha256 === item.target_snapshot_sha256, + ) + const baseline = matches.length === 1 ? matches[0] : null + return ( + + {item.target_resource_id ?? '未提供'} + ), + }, + { + key: 'hash', + label: '冻结目标哈希', + children: ( + + {item.target_snapshot_sha256 ?? '未提供'} + + ), + }, + ]} + /> + {baseline ? ( + + ) : ( + <> + + + + )} + + ) +} +function FrozenBaseline({ + baseline, + proposed, +}: { + baseline: AIReviewBaseline + proposed: Record +}) { + const after = proposedDraft(baseline.content, proposed) + const fields = changeFields(baseline.content, after) + return ( + + + {baseline.content.name} + + 草稿修订 {baseline.draft_revision ?? '未提供'} · 版本{' '} + {baseline.current_version ?? '未发布'} · {baseline.updated_at} + + + + + ( + + + + {field.path} + +
+                  原值:{JSON.stringify(field.before) ?? '(字段不存在)'}
+                  {'\n'}新值:{JSON.stringify(field.after) ?? '(字段不存在)'}
+                
+
+
+ )} + /> + {fields.length > 300 ? ( + + ) : null} +
+ , + }, + { + key: 'after', + label: '拟写入草稿完整内容', + children: , + }, + ]} + /> +
+ ) +} +export function AIChangeReviewReceipt({ + item, + projectId, +}: { + item: AIChangeItem + projectId: string +}) { + if (item.review_status === 'pending') return null + return ( + + + {item.review_status === 'accepted' ? ( + + ) : null} + + ) +} +function MaterializedDraft({ item, projectId }: { item: AIChangeItem; projectId: string }) { + const id = item.materialized_resource_id, + type = item.materialized_resource_type + if (!id) return + const routes = { + workflow: { section: 'workflows', label: '打开流程草稿', query: { focus: id } }, + test_case: { + section: 'assets', + label: '打开用例草稿', + query: { type: 'case', focus: id, version: 'draft' }, + }, + } + const target = type === 'workflow' || type === 'test_case' ? routes[type] : null + return ( + + {target ? ( + + {target.label} + + ) : ( + 未识别的资产类型:{type ?? '未提供'} + )} + + {id} + + 仅更新草稿;未发布、未执行。 + + ) +} diff --git a/frontend/src/features/ai/ai-change-diff.test.ts b/frontend/src/features/ai/ai-change-diff.test.ts new file mode 100644 index 00000000..9ce26a71 --- /dev/null +++ b/frontend/src/features/ai/ai-change-diff.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { changeFields, proposedDraft } from './ai-change-diff' + +describe('frozen draft review', () => { + it('preserves omitted and null update fields while replacing a provided definition', () => { + const before = { + name: '原流程', + description: '保持说明', + definition: { nodes: [{ id: 'old' }], edges: ['old'] }, + } + const patch = { name: '修改流程', description: null, definition: { nodes: [{ id: 'new' }] } } + const after = proposedDraft(before, patch) + expect(after.description).toBe('保持说明') + expect(changeFields(before, after)).toEqual([ + { path: '$.name', before: '原流程', after: '修改流程' }, + { path: '$.definition.nodes', before: [{ id: 'old' }], after: [{ id: 'new' }] }, + { path: '$.definition.edges', before: ['old'], after: undefined }, + ]) + expect(before.definition.nodes[0].id).toBe('old') + }) +}) diff --git a/frontend/src/features/ai/ai-change-diff.ts b/frontend/src/features/ai/ai-change-diff.ts new file mode 100644 index 00000000..2ab1f173 --- /dev/null +++ b/frontend/src/features/ai/ai-change-diff.ts @@ -0,0 +1,22 @@ +export type ChangeField = { path: string; before: unknown; after: unknown } +export function proposedDraft( + before: Record, + patch: Record, +): Record { + return { + ...before, + ...Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== null && value !== undefined), + ), + } +} +export function changeFields(before: unknown, after: unknown, path = '$'): ChangeField[] { + if (JSON.stringify(before) === JSON.stringify(after)) return [] + if (!isObject(before) || !isObject(after)) return [{ path, before, after }] + return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => + changeFields(before[key], after[key], `${path}.${key}`), + ) +} +function isObject(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} diff --git a/frontend/src/features/ai/ai-change-set-service.test.ts b/frontend/src/features/ai/ai-change-set-service.test.ts new file mode 100644 index 00000000..aa0e485f --- /dev/null +++ b/frontend/src/features/ai/ai-change-set-service.test.ts @@ -0,0 +1,53 @@ +import { http, HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' +import { server } from '../../test/server' +import { + createAIChangeSet, + getAIChangeSet, + listAIChangeSets, + reviewAIChangeItem, +} from './ai-change-set-service' + +describe('AI change receipts', () => { + it('rejects a foreign catalog and a different detail identity', async () => { + server.use( + http.get('/api/v1/ai/change-sets', () => + HttpResponse.json({ items: [{ id: 'other', project_id: 'foreign' }], total: 1 }), + ), + http.get('/api/v1/ai/change-sets/requested', () => HttpResponse.json({ id: 'other' })), + ) + await expect(listAIChangeSets('owned')).rejects.toThrow('其他项目') + await expect(getAIChangeSet('requested')).rejects.toThrow('与请求不一致') + }) + it('rejects an unrelated create receipt', async () => { + server.use( + http.post('/api/v1/ai/change-sets', () => + HttpResponse.json({ id: 'new', project_id: 'foreign' }), + ), + ) + await expect( + createAIChangeSet({ + project_id: 'owned', + impact_run_id: 'impact', + release_risk_id: 'risk', + title: '隔离', + }), + ).rejects.toThrow('不属于当前项目') + }) + it.each([ + { id: 'other', review_status: 'accepted' }, + { id: 'item', review_status: 'pending' }, + ])('does not accept a mismatched review acknowledgment %#', async (receipt) => { + let writes = 0 + server.use( + http.post('/api/v1/ai/change-sets/set/items/item/accept', () => { + writes++ + return HttpResponse.json(receipt) + }), + ) + await expect(reviewAIChangeItem('set', 'item', 'accept', { note: '人工核验' })).rejects.toThrow( + '与请求不一致', + ) + expect(writes).toBe(1) + }) +}) diff --git a/frontend/src/features/ai/ai-change-set-service.ts b/frontend/src/features/ai/ai-change-set-service.ts index 45c2bdaf..3e7f0e4a 100644 --- a/frontend/src/features/ai/ai-change-set-service.ts +++ b/frontend/src/features/ai/ai-change-set-service.ts @@ -33,7 +33,24 @@ export type AIChangeSetSummary = { updated_at: string } +export type AIReviewBaseline = { + target_type: 'test_case' | 'workflow' + target_id: string + snapshot_sha256: string + content: { + name: string + description: string + definition: Record + tags?: string[] | null + } + current_version: number | null + draft_revision: number | null + updated_at: string + redaction_mode: 'on' | 'off' + redaction_policy_version: number +} export type AIChangeSetDetail = AIChangeSetSummary & { + review_baselines?: AIReviewBaseline[] source_snapshot: Record items: AIChangeItem[] } @@ -50,19 +67,28 @@ export async function listAIChangeSets( page = 1, pageSize = 100, ): Promise> { - return ( + const result = ( await apiClient.get>('/ai/change-sets', { params: { project_id: projectId, page, page_size: pageSize }, }) ).data + if (result.items.some((item) => item.project_id !== projectId)) + throw new Error('变更集目录含有其他项目记录,已停止展示') + return result } export async function getAIChangeSet(changeSetId: string): Promise { - return (await apiClient.get(`/ai/change-sets/${changeSetId}`)).data + const result = ( + await apiClient.get(`/ai/change-sets/${encodeURIComponent(changeSetId)}`) + ).data + if (result.id !== changeSetId) throw new Error('变更集回执与请求不一致') + return result } export async function createAIChangeSet(input: AIChangeSetInput): Promise { - return (await apiClient.post('/ai/change-sets', input)).data + const result = (await apiClient.post('/ai/change-sets', input)).data + if (result.project_id !== input.project_id) throw new Error('生成回执不属于当前项目') + return result } export async function reviewAIChangeItem( @@ -71,10 +97,16 @@ export async function reviewAIChangeItem( decision: 'accept' | 'reject', input: { content?: Record; note: string }, ): Promise { - return ( + const result = ( await apiClient.post( `/ai/change-sets/${changeSetId}/items/${itemId}/${decision}`, input, ) ).data + if ( + result.id !== itemId || + result.review_status !== (decision === 'accept' ? 'accepted' : 'rejected') + ) + throw new Error('变更项审核回执与请求不一致') + return result } diff --git a/frontend/src/features/ai/use-ai-change-sets.test.tsx b/frontend/src/features/ai/use-ai-change-sets.test.tsx index 31fb33cc..e64423d0 100644 --- a/frontend/src/features/ai/use-ai-change-sets.test.tsx +++ b/frontend/src/features/ai/use-ai-change-sets.test.tsx @@ -16,11 +16,13 @@ import { type AIChangeSetSummary, } from './ai-change-set-service' import { useAIChangeSets } from './use-ai-change-sets' +import { getAIStatus } from './ai-service' vi.mock('../impact/impact-service') vi.mock('../projects/use-project-context') vi.mock('../quality/quality-service') vi.mock('./ai-change-set-service') +vi.mock('./ai-service') let projectId = 'project-1' @@ -28,6 +30,7 @@ describe('useAIChangeSets', () => { beforeEach(() => { vi.resetAllMocks() projectId = 'project-1' + vi.mocked(getAIStatus).mockResolvedValue({ enabled: true } as never) vi.mocked(useProjectContext).mockImplementation(() => ({ projectId }) as never) vi.mocked(listAIChangeSets).mockImplementation(async (currentProjectId) => { const item = summary(`change-set-${currentProjectId}`, currentProjectId) @@ -52,6 +55,80 @@ describe('useAIChangeSets', () => { vi.mocked(reviewAIChangeItem).mockResolvedValue({} as never) }) + it('ignores a late create receipt after switching projects', async () => { + let release!: (value: AIChangeSetSummary) => void + vi.mocked(createAIChangeSet).mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }), + ) + const rendered = renderChangeSetsHook() + await waitFor(() => expect(rendered.result.current.canCreate).toBe(true)) + let pending!: Promise + act(() => { + pending = rendered.result.current.addChangeSet({ + title: '旧项目任务', + impact_run_id: 'impact', + release_risk_id: 'risk', + }) + }) + await waitFor(() => expect(createAIChangeSet).toHaveBeenCalledTimes(1)) + projectId = 'project-2' + rendered.rerender() + await waitFor(() => expect(rendered.result.current.activeId).toBe('change-set-project-2')) + await act(async () => { + release(summary('late-created', 'project-1')) + await pending + }) + expect(await pending).toBe(false) + expect(rendered.result.current.activeId).toBe('change-set-project-2') + expect(getAIChangeSet).not.toHaveBeenCalledWith('late-created') + }) + + it('submits one command when generation is triggered twice before rerender', async () => { + let release!: (value: AIChangeSetSummary) => void + vi.mocked(createAIChangeSet).mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }), + ) + const rendered = renderChangeSetsHook() + await waitFor(() => expect(rendered.result.current.canCreate).toBe(true)) + let first!: Promise, second!: Promise + act(() => { + const input = { title: '一次生成', impact_run_id: 'impact', release_risk_id: 'risk' } + first = rendered.result.current.addChangeSet(input) + second = rendered.result.current.addChangeSet(input) + }) + await waitFor(() => expect(createAIChangeSet).toHaveBeenCalledTimes(1)) + await act(async () => { + release(summary('change-set-project-1', 'project-1')) + await first + }) + expect(await first).toBe(true) + expect(await second).toBe(false) + }) + + it('does not write when generation is disabled or an item is outside the current detail', async () => { + vi.mocked(getAIStatus).mockResolvedValue({ enabled: false } as never) + const rendered = renderChangeSetsHook() + await waitFor(() => expect(rendered.result.current.detail.isSuccess).toBe(true)) + expect( + await rendered.result.current.addChangeSet({ + title: '禁止写入', + impact_run_id: 'impact', + release_risk_id: 'risk', + }), + ).toBe(false) + expect(await rendered.result.current.reviewItem('foreign-item', 'accept', undefined, '')).toBe( + false, + ) + expect(createAIChangeSet).not.toHaveBeenCalled() + expect(reviewAIChangeItem).not.toHaveBeenCalled() + }) + it('does not retain or fetch the previous project selection after switching projects', async () => { const rendered = renderChangeSetsHook() await waitFor(() => expect(rendered.result.current.activeId).toBe('change-set-project-1')) diff --git a/frontend/src/features/ai/use-ai-change-sets.ts b/frontend/src/features/ai/use-ai-change-sets.ts index a38fed82..dd162eab 100644 --- a/frontend/src/features/ai/use-ai-change-sets.ts +++ b/frontend/src/features/ai/use-ai-change-sets.ts @@ -1,8 +1,9 @@ import { useCatalogQuery } from '../../lib/catalog-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { App } from 'antd' +import { useEffect, useLayoutEffect, useRef } from 'react' -import { apiErrorMessage } from '../../lib/api' +import { apiErrorWithTrace } from '../../lib/api' import { useRouteScopedSelection } from '../../lib/use-route-scoped-state' import { listImpactRuns } from '../impact/impact-service' import { useProjectContext } from '../projects/use-project-context' @@ -25,29 +26,23 @@ export function useAIChangeSets(initialChangeSetId: string | null = null) { const { canEdit, permissions } = useProjectCapabilities() const { flags, status, impacts, risks } = useAIChangeSources(projectId) const [selection, setSelection] = useRouteScopedSelection(projectId, initialChangeSetId) - const changeSets = useCatalogQuery( - 'ai-change-sets', - projectId, - (page) => listAIChangeSets(required(projectId), page, 20), - Boolean(projectId), - '', - { - refetchInterval: (query) => - query.state.data?.items.some((item) => item.status === 'generating') ? 1_000 : false, - }, - ) - const currentItems = changeSets.data?.items.filter((item) => item.project_id === projectId) ?? [] - const activeId = selection ?? currentItems.at(0)?.id ?? null - const detail = useQuery({ - queryKey: ['ai-change-set', projectId, activeId], - queryFn: async () => { - const result = await getAIChangeSet(required(activeId)) - if (result.project_id !== projectId) throw new Error('该变更集不属于当前项目') - return result - }, - enabled: Boolean(projectId && activeId), - refetchInterval: (query) => (query.state.data?.status === 'generating' ? 1_000 : false), - }) + const canReview = canWrite(canEdit, status.data?.enabled, flags.data?.quality_intelligence) + const polling = canWrite(true, status.data?.enabled, flags.data?.quality_intelligence) + const { changeSets, detail, activeId } = useAIChangeRecords(projectId, selection, polling) + const canCreate = canReview && flags.data?.impact_engine === true + const scope = `${projectId}:${activeId}:${initialChangeSetId}` + const current = useRef(scope) + useLayoutEffect(() => { + current.current = scope + }, [scope]) + const pending = useRef(false) + const alive = useRef(true) + useEffect(() => { + alive.current = true + return () => { + alive.current = false + } + }, []) const create = useMutation({ mutationFn: createAIChangeSet }) const review = useMutation({ mutationFn: ({ @@ -64,16 +59,21 @@ export function useAIChangeSets(initialChangeSetId: string | null = null) { }) async function addChangeSet(input: Omit) { - if (!canEdit || !status.data?.enabled) return false + if (!canCreate || pending.current) return false + pending.current = true try { const created = await create.mutateAsync({ ...input, project_id: required(projectId) }) - setSelection(created.id) await queryClient.invalidateQueries({ queryKey: ['ai-change-sets', projectId] }) + if (!alive.current || current.current !== scope) return false + setSelection(created.id) void message.success('AI Draft Change Set 已提交生成') return true } catch (error) { - void message.error(apiErrorMessage(error)) + void queryClient.invalidateQueries({ queryKey: ['ai-change-sets', projectId] }) + if (alive.current && current.current === scope) void message.error(apiErrorWithTrace(error)) return false + } finally { + pending.current = false } } @@ -83,25 +83,34 @@ export function useAIChangeSets(initialChangeSetId: string | null = null) { content: Record | undefined, note: string, ) { - if (!canEdit) return false + if (!mayReviewItem(canReview, pending.current, detail.data, itemId)) return false + pending.current = true try { - await review.mutateAsync({ itemId, decision, content, note }) + const result = await review.mutateAsync({ itemId, decision, content, note }) + if (decision === 'accept') + await invalidateDraft(queryClient, projectId, result.materialized_resource_type) await Promise.all([ queryClient.invalidateQueries({ queryKey: ['ai-change-set', projectId, activeId] }), queryClient.invalidateQueries({ queryKey: ['ai-change-sets', projectId] }), ]) + if (!alive.current || current.current !== scope) return false void message.success(decision === 'accept' ? '变更项已接受并生成草稿' : '变更项已拒绝') return true } catch (error) { - void message.error(apiErrorMessage(error)) + if (alive.current && current.current === scope) void message.error(apiErrorWithTrace(error)) return false + } finally { + pending.current = false } } return { projectId, canEdit, - canCreate: canEdit && status.data?.enabled === true, + canCreate, + canReview, + reviewError: review.error ? apiErrorWithTrace(review.error) : '', + clearReviewError: review.reset, permissions, flags, status, @@ -148,3 +157,71 @@ function required(value: string | null): string { if (!value) throw new Error('请选择项目') return value } + +function canWrite( + canEdit: boolean, + aiEnabled: boolean | undefined, + qualityEnabled: boolean | undefined, +): boolean { + return canEdit && aiEnabled === true && qualityEnabled === true +} +async function invalidateDraft( + client: ReturnType, + projectId: string | null, + type: string | null, +) { + const keys = + type === 'test_case' + ? [ + 'test-cases', + 'test-asset', + 'test-case-versions', + 'test-case-options', + 'asset-directory-counts', + ] + : ['workflows', 'workflow', 'workflow-versions'] + await Promise.all(keys.map((key) => client.invalidateQueries({ queryKey: [key, projectId] }))) +} + +function useAIChangeRecords(projectId: string | null, selection: string | null, polling: boolean) { + const changeSets = useCatalogQuery( + 'ai-change-sets', + projectId, + (page) => listAIChangeSets(required(projectId), page, 20), + Boolean(projectId), + '', + { + refetchInterval: (query) => + polling && query.state.data?.items.some((item) => item.status === 'generating') + ? 1_000 + : false, + }, + ) + const currentItems = changeSets.data?.items.filter((item) => item.project_id === projectId) ?? [] + const activeId = selection ?? currentItems.at(0)?.id ?? null + const detail = useQuery({ + queryKey: ['ai-change-set', projectId, activeId], + queryFn: async () => { + const result = await getAIChangeSet(required(activeId)) + if (result.project_id !== projectId) throw new Error('该变更集不属于当前项目') + return result + }, + enabled: Boolean(projectId && activeId), + refetchInterval: (query) => + polling && query.state.data?.status === 'generating' ? 1_000 : false, + }) + return { changeSets, detail, activeId } +} + +function mayReviewItem( + allowed: boolean, + pending: boolean, + detail: import('./ai-change-set-service').AIChangeSetDetail | undefined, + itemId: string, +): boolean { + return Boolean( + allowed && + !pending && + detail?.items.some((item) => item.id === itemId && item.review_status === 'pending'), + ) +} diff --git a/frontend/src/pages/AIChangeSetsPage.test.tsx b/frontend/src/pages/AIChangeSetsPage.test.tsx index 1c6e7a19..dd33ed18 100644 --- a/frontend/src/pages/AIChangeSetsPage.test.tsx +++ b/frontend/src/pages/AIChangeSetsPage.test.tsx @@ -51,6 +51,196 @@ const detail: AIChangeSetDetail = { } describe('AIChangeSetsPage', () => { + it('paginates risk choices inside the dialog while keeping the selected risk binding', async () => { + handlers(detail) + let submitted: Record | null = null + server.use( + http.get(`/api/v1/projects/${project.id}/release-risks`, ({ request }) => { + const page = Number(new URL(request.url).searchParams.get('page')) + return HttpResponse.json({ + items: [ + { + id: page === 1 ? riskId : '00000000-0000-4000-8000-000000000277', + impact_run_id: impactId, + title: page === 1 ? '首屏风险证据' : '第二页风险证据', + score: 42, + }, + ], + total: 21, + page, + page_size: 20, + }) + }), + http.post('/api/v1/ai/change-sets', async ({ request }) => { + submitted = (await request.json()) as Record + return HttpResponse.json(detail) + }), + ) + renderPage() + await waitFor(() => + expect(screen.getByRole('button', { name: '生成 Draft Change Set' })).toBeEnabled(), + ) + await userEvent.click(screen.getByRole('button', { name: '生成 Draft Change Set' })) + const title = await screen.findByText('生成 Draft Change Set', { selector: '.ant-modal-title' }) + const dialog = title.closest('.ant-modal') as HTMLElement + fireEvent.change(within(dialog).getByLabelText('变更集名称'), { + target: { value: '跨页绑定验证' }, + }) + await userEvent.click(within(dialog).getByLabelText('发布风险证据')) + await userEvent.click( + await screen.findByText('首屏风险证据 · 风险 42', { + selector: '.ant-select-item-option-content', + }), + ) + await userEvent.click(within(dialog).getByTitle('2')) + await waitFor(() => expect(within(dialog).getByText('2')).toBeInTheDocument()) + expect(within(dialog).getByText('首屏风险证据 · 风险 42')).toBeVisible() + await userEvent.click(within(dialog).getByRole('button', { name: 'OK' })) + await waitFor(() => + expect(submitted).toEqual({ + project_id: project.id, + title: '跨页绑定验证', + release_risk_id: riskId, + impact_run_id: impactId, + }), + ) + }) + + it('keeps failed review input visible with its trace and never retries a write', async () => { + handlers(detail) + let writes = 0 + server.use( + http.post(`/api/v1/ai/change-sets/${changeSetId}/items/${item.id}/accept`, () => { + writes++ + return HttpResponse.json( + { + error: { + code: 'AI_CHANGE_TARGET_CONFLICT', + message: '目标已变化', + trace_id: 'm22-trace', + }, + }, + { status: 409 }, + ) + }), + ) + renderPage() + await screen.findByText('新增异常流程') + await userEvent.click(screen.getByRole('button', { name: '审核并接受' })) + const title = await screen.findByText('编辑并接受变更项', { selector: '.ant-modal-title' }) + const dialog = title.closest('.ant-modal') as HTMLElement + fireEvent.change(within(dialog).getByLabelText('审核备注'), { + target: { value: '保留失败输入' }, + }) + await userEvent.click(within(dialog).getByRole('button', { name: 'OK' })) + expect(await within(dialog).findByText('目标已变化(Trace ID: m22-trace)')).toBeVisible() + expect(within(dialog).getByLabelText('审核备注')).toHaveValue('保留失败输入') + expect(writes).toBe(1) + }) + + it('shows the canonical draft result and review metadata without inventing unknown links', async () => { + handlers({ + ...detail, + items: [ + { + ...item, + review_status: 'accepted', + review_note: '已核验冻结证据', + reviewed_by_id: user.id, + reviewed_at: item.created_at, + materialized_resource_type: 'workflow', + materialized_resource_id: 'actual-draft', + }, + ], + }) + renderPage() + expect(await screen.findByRole('link', { name: '打开流程草稿' })).toHaveAttribute( + 'href', + `/projects/${project.id}/workflows?focus=actual-draft`, + ) + expect(screen.getByText('已核验冻结证据')).toBeVisible() + expect(screen.queryByRole('button', { name: '审核并接受' })).not.toBeInTheDocument() + }) + + it('blocks creation and review when quality intelligence is disabled while showing history', async () => { + handlers(detail) + server.use( + http.get('/api/v1/v3/features', () => + HttpResponse.json({ impact_engine: true, quality_intelligence: false }), + ), + ) + renderPage() + await screen.findByText('新增异常流程') + await waitFor(() => + expect(screen.getByRole('button', { name: '生成 Draft Change Set' })).toBeDisabled(), + ) + expect(screen.getByRole('button', { name: '审核并接受' })).toBeDisabled() + expect(screen.getByText('质量智能尚未启用,历史记录只读。')).toBeVisible() + }) + + it('shows frozen sources and target baseline without fetching the current asset', async () => { + const targetId = '00000000-0000-4000-8000-000000000270' + handlers({ + ...detail, + source_snapshot: { + metadata: { release_risk: { id: riskId, score: 42, frozen: '原始固定风险' } }, + }, + review_baselines: [ + { + target_type: 'workflow', + target_id: targetId, + snapshot_sha256: 'd'.repeat(64), + content: { name: '冻结基线流程', description: '原说明', definition: { nodes: [] } }, + current_version: 1, + draft_revision: 3, + updated_at: item.created_at, + redaction_mode: 'off', + redaction_policy_version: 1, + }, + ], + items: [ + { + ...item, + action: 'update', + target_resource_id: targetId, + target_snapshot_sha256: 'd'.repeat(64), + proposed_content: { name: '拟修改流程', description: null }, + }, + ], + } as AIChangeSetDetail) + let currentReads = 0 + server.use( + http.get(`/api/v1/projects/${project.id}/workflows/${targetId}`, () => { + currentReads++ + return HttpResponse.json({ name: '当前已漂移的流程' }) + }), + ) + renderPage() + expect(await screen.findByText('固定来源证据')).toBeVisible() + expect(await screen.findByText('冻结目标基线')).toBeVisible() + expect(screen.getByText('冻结基线流程', { exact: true })).toBeVisible() + expect(screen.getByText('$.name', { exact: true })).toBeVisible() + expect(screen.queryByText('当前已漂移的流程')).not.toBeInTheDocument() + expect(currentReads).toBe(0) + }) + + it('protects a dirty review and preserves its note when cancelling discard', async () => { + handlers(detail) + renderPage() + await screen.findByText('新增异常流程') + await userEvent.click(screen.getByRole('button', { name: '审核并接受' })) + const title = await screen.findByText('编辑并接受变更项', { selector: '.ant-modal-title' }) + const dialog = title.closest('.ant-modal') as HTMLElement + fireEvent.change(within(dialog).getByLabelText('审核备注'), { + target: { value: '不可静默丢弃' }, + }) + await userEvent.click(within(dialog).getByRole('button', { name: 'Close' })) + const guardTitle = await screen.findByText('审核修改尚未保存', { selector: '.ant-modal-title' }) + const guard = guardTitle.closest('.ant-modal') as HTMLElement + await userEvent.click(within(guard).getByRole('button', { name: '继续编辑' })) + expect(within(dialog).getByLabelText('审核备注')).toHaveValue('不可静默丢弃') + }) + it('shows suggestions to viewers without allowing generation or item review', async () => { handlers(detail) let writes = 0 @@ -105,7 +295,7 @@ describe('AIChangeSetsPage', () => { expect(await screen.findByText('AI 生成功能尚未启用,可查看已有审核记录。')).toBeInTheDocument() expect(await screen.findByText('新增异常流程')).toBeInTheDocument() expect(screen.getByRole('button', { name: '生成 Draft Change Set' })).toBeDisabled() - await waitFor(() => expect(screen.getByRole('button', { name: '审核并接受' })).toBeEnabled()) + await waitFor(() => expect(screen.getByRole('button', { name: '审核并接受' })).toBeDisabled()) expect(evidenceRequests).toBe(0) }) diff --git a/frontend/src/pages/AIChangeSetsPage.tsx b/frontend/src/pages/AIChangeSetsPage.tsx index feae34dd..939d0637 100644 --- a/frontend/src/pages/AIChangeSetsPage.tsx +++ b/frontend/src/pages/AIChangeSetsPage.tsx @@ -15,7 +15,7 @@ import { Tag, Typography, } from 'antd' -import { useState } from 'react' +import { useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import type { @@ -25,7 +25,13 @@ import type { } from '../features/ai/ai-change-set-service' import { useAIChangeSets } from '../features/ai/use-ai-change-sets' import { useProjectContext } from '../features/projects/use-project-context' -import { apiErrorMessage } from '../lib/api' +import { apiErrorWithTrace } from '../lib/api' +import AIDraftProtection from '../features/ai/AIDraftProtection' +import { + AIChangeSource, + AIChangeBaseline, + AIChangeReviewReceipt, +} from '../features/ai/AIChangeReviewEvidence' export default function AIChangeSetsPage() { const { projectId } = useProjectContext() @@ -110,7 +116,14 @@ function AIChangeSetWorkspace() { extra={}>接受后只更新草稿} loading={state.detail.isLoading} > - + { + state.clearReviewError() + setReview(value) + }} + />
@@ -119,6 +132,7 @@ function AIChangeSetWorkspace() { open={createOpen} submitting={state.creating} risks={state.risks.data?.items ?? []} + riskQuery={state.risks} impacts={state.impacts.data?.items ?? []} onClose={() => setCreateOpen(false)} onCreate={async (input) => { @@ -126,7 +140,9 @@ function AIChangeSetWorkspace() { }} /> setReview(null)} @@ -139,15 +155,7 @@ function AIChangeSetWorkspace() { } function AIChangeSetStatus({ state }: { state: ReturnType }) { - const queries = [ - state.status, - state.flags, - state.permissions, - state.changeSets, - state.detail, - state.impacts, - state.risks, - ] + const queries = [state.status, state.flags, state.permissions, state.detail] const failed = queries.filter((query) => query.isError) if (failed.length) return ( @@ -155,7 +163,7 @@ function AIChangeSetStatus({ state }: { state: ReturnType void Promise.all(failed.map((query) => query.refetch()))}> 重试 @@ -172,6 +180,19 @@ function AIChangeSetStatus({ state }: { state: ReturnType ) + if (state.flags.data?.quality_intelligence === false) + return ( + + ) + if (state.flags.data?.impact_engine === false) + return ( + + ) return null } @@ -187,13 +208,27 @@ function ChangeSetDetail({ if (!detail) { return 请选择或生成一个 Draft Change Set。 } - if (detail.status === 'generating') { + return ( + + + + + ) +} +function ChangeSetContent({ + detail, + readOnly, + onReview, +}: { + detail: AIChangeSetDetail + readOnly: boolean + onReview: (value: { item: AIChangeItem; decision: 'accept' | 'reject' }) => void +}) { + if (detail.status === 'generating') return - } - if (detail.status === 'failed') { + if (detail.status === 'failed') return - } - return + return } function CreateChangeSetDialog({ @@ -201,6 +236,7 @@ function CreateChangeSetDialog({ open, submitting, risks, + riskQuery, impacts, onClose, onCreate, @@ -209,13 +245,17 @@ function CreateChangeSetDialog({ open: boolean submitting: boolean risks: Array<{ id: string; impact_run_id: string; title: string; score: number }> + riskQuery: ReturnType['risks'] impacts: Array<{ id: string; title: string }> onClose: () => void onCreate: (input: Omit) => Promise }) { const [form] = Form.useForm>() const riskId = Form.useWatch('release_risk_id', form) - const risk = risks.find((item) => item.id === riskId) + const [selectedRisk, setSelectedRisk] = useState<(typeof risks)[number] | null>(null) + const risk = + risks.find((item) => item.id === riskId) ?? (selectedRisk?.id === riskId ? selectedRisk : null) + const options = risk && !risks.some((item) => item.id === risk.id) ? [risk, ...risks] : risks return ( { if ('release_risk_id' in changed) { const selected = risks.find((item) => item.id === changed.release_risk_id) + setSelectedRisk(selected ?? null) form.setFieldValue('impact_run_id', selected?.impact_run_id) } }} @@ -243,12 +284,16 @@ function CreateChangeSetDialog({ void }) { + const items = detail.items if (items.length === 0) { return AI 没有给出可审核的变更项 } @@ -313,7 +359,10 @@ function ChangeItems({ ) } > -
{JSON.stringify(item.proposed_content, null, 2)}
+ + + + ))} @@ -321,12 +370,16 @@ function ChangeItems({ } function ReviewChangeItemDialog({ + detail, + apiError, readOnly, value, submitting, onClose, onSubmit, }: { + detail: AIChangeSetDetail | undefined + apiError: string readOnly: boolean value: { item: AIChangeItem; decision: 'accept' | 'reject' } | null submitting: boolean @@ -341,6 +394,8 @@ function ReviewChangeItemDialog({ if (!value) return null return ( JSON.stringify(value.item.proposed_content, null, 2)) - const [note, setNote] = useState('') + const [note, setNote] = useState(value.item.review_note) const [error, setError] = useState('') + const [discardOpen, setDiscardOpen] = useState(false) + const allowLeaveRef = useRef(false) + const dirty = + content !== JSON.stringify(value.item.proposed_content, null, 2) || + note !== value.item.review_note + function requestClose() { + if (submitting) return + if (dirty) setDiscardOpen(true) + else onClose() + } return ( - { - let parsed: Record | undefined - if (value.decision === 'accept') { - try { - const candidate: unknown = JSON.parse(content) - if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { - throw new Error('内容必须是 JSON 对象') + <> + + setDiscardOpen(false)} + > + 关闭会丢弃本次修改,不会写入审核决定。 + + { + let parsed: Record | undefined + if (value.decision === 'accept') { + try { + const candidate: unknown = JSON.parse(content) + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { + throw new Error('内容必须是 JSON 对象') + } + parsed = + JSON.stringify(candidate) === originalContent + ? undefined + : (candidate as Record) + } catch (parseError) { + setError(parseError instanceof Error ? parseError.message : 'JSON 格式无效') + return } - parsed = - JSON.stringify(candidate) === originalContent - ? undefined - : (candidate as Record) - } catch (parseError) { - setError(parseError instanceof Error ? parseError.message : 'JSON 格式无效') - return } - } - void onSubmit(value.item, value.decision, parsed, note) - }} - > - {value.decision === 'accept' ? ( - <> - - 接受后只创建或更新草稿;不会发布或执行。 - - setContent(event.target.value)} - /> - {error ? {error} : null} - - ) : null} - setNote(event.target.value)} - style={{ marginTop: 12 }} - /> - + void onSubmit(value.item, value.decision, parsed, note) + }} + > + {detail ? ( + + + + + ) : null} + {apiError ? : null} + {value.decision === 'accept' ? ( + <> + + 接受后只创建或更新草稿;不会发布或执行。 + + setContent(event.target.value)} + /> + {error ? {error} : null} + + ) : null} + setNote(event.target.value)} + style={{ marginTop: 12 }} + /> + + ) } +function readDraft(content: string, original: Record): Record { + try { + const parsed: unknown = JSON.parse(content) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : original + } catch { + return original + } +} function statusLabel(value: string): string { return ( From b1440de522ac2677d722b8ebd806e906c46b3038 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:10:33 +0800 Subject: [PATCH 09/35] fix: restore organization governance states and audit navigation --- backend/app/api/v1/endpoints/organizations.py | 11 +- backend/app/repositories/organizations.py | 12 + backend/app/schemas/organizations.py | 2 + .../app/services/organization_governance.py | 8 +- backend/app/services/organizations.py | 17 +- backend/tests/test_organizations_api.py | 152 ++++++- .../2026-10-20/batch-26-organization.md | 47 ++ .../organizations/organization-service.ts | 16 +- frontend/src/pages/OrganizationAuditPanel.tsx | 242 ++++++++++ .../pages/OrganizationGovernancePage.test.tsx | 369 +++++++++++++++- .../src/pages/OrganizationGovernancePage.tsx | 416 ++++++++++-------- .../src/pages/OrganizationQueryBoundary.tsx | 54 +++ 12 files changed, 1145 insertions(+), 201 deletions(-) create mode 100644 docs/design/ui-v5/remediation/2026-10-20/batch-26-organization.md create mode 100644 frontend/src/pages/OrganizationAuditPanel.tsx create mode 100644 frontend/src/pages/OrganizationQueryBoundary.tsx diff --git a/backend/app/api/v1/endpoints/organizations.py b/backend/app/api/v1/endpoints/organizations.py index e81d3ab5..ce3232a4 100644 --- a/backend/app/api/v1/endpoints/organizations.py +++ b/backend/app/api/v1/endpoints/organizations.py @@ -96,7 +96,12 @@ async def list_organization_members( actor=current_user, organization_id=organization_id, ) - return [OrganizationMemberResponse.model_validate(item) for item in members] + return [ + OrganizationMemberResponse.model_validate(item.member).model_copy( + update={"display_name": item.display_name, "email": item.email} + ) + for item in members + ] @router.put( @@ -255,7 +260,9 @@ async def list_organization_audit_logs( session: SessionDependency, current_user: CurrentUser, action: str | None = Query(default=None, max_length=100), + actor_user_id: UUID | None = None, resource_type: str | None = Query(default=None, max_length=100), + resource_id: UUID | None = None, created_from: datetime | None = None, created_to: datetime | None = None, page: int = Query(default=1, ge=1), @@ -265,7 +272,9 @@ async def list_organization_audit_logs( actor=current_user, organization_id=organization_id, action=action, + actor_user_id=actor_user_id, resource_type=resource_type, + resource_id=resource_id, created_from=created_from, created_to=created_to, offset=(page - 1) * page_size, diff --git a/backend/app/repositories/organizations.py b/backend/app/repositories/organizations.py index 6e79ecd8..6ca96837 100644 --- a/backend/app/repositories/organizations.py +++ b/backend/app/repositories/organizations.py @@ -4,6 +4,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.models.access import User from app.models.organizations import Organization, OrganizationMember, ServiceAccount @@ -67,6 +68,17 @@ async def list_members(self, organization_id: UUID) -> list[OrganizationMember]: ).all() ) + async def list_member_identities( + self, organization_id: UUID + ) -> list[tuple[OrganizationMember, User]]: + rows = await self._session.execute( + select(OrganizationMember, User) + .join(User, User.id == OrganizationMember.user_id) + .where(OrganizationMember.organization_id == organization_id) + .order_by(OrganizationMember.created_at, OrganizationMember.id) + ) + return list(rows.tuples()) + async def count_members(self, organization_id: UUID) -> int: value = await self._session.scalar( select(func.count()) diff --git a/backend/app/schemas/organizations.py b/backend/app/schemas/organizations.py index b78b360f..4684ce0e 100644 --- a/backend/app/schemas/organizations.py +++ b/backend/app/schemas/organizations.py @@ -44,6 +44,8 @@ class OrganizationMemberResponse(BaseModel): id: UUID organization_id: UUID user_id: UUID + display_name: str | None = None + email: str | None = None role: OrganizationRole created_at: datetime updated_at: datetime diff --git a/backend/app/services/organization_governance.py b/backend/app/services/organization_governance.py index d3a2a510..6fc47799 100644 --- a/backend/app/services/organization_governance.py +++ b/backend/app/services/organization_governance.py @@ -110,7 +110,9 @@ async def list_audit_logs( actor: User, organization_id: UUID, action: str | None, + actor_user_id: UUID | None, resource_type: str | None, + resource_id: UUID | None, created_from: datetime | None, created_to: datetime | None, offset: int, @@ -124,6 +126,10 @@ async def list_audit_logs( filters.append(AuditLog.action == action) if resource_type: filters.append(AuditLog.resource_type == resource_type) + if actor_user_id: + filters.append(AuditLog.actor_user_id == actor_user_id) + if resource_id: + filters.append(AuditLog.resource_id == resource_id) if created_from: filters.append(AuditLog.created_at >= created_from) if created_to: @@ -133,7 +139,7 @@ async def list_audit_logs( await self._session.scalars( select(AuditLog) .where(*filters) - .order_by(AuditLog.created_at.desc()) + .order_by(AuditLog.created_at.desc(), AuditLog.id.desc()) .offset(offset) .limit(limit) ) diff --git a/backend/app/services/organizations.py b/backend/app/services/organizations.py index d9a62eae..7fbc3e9a 100644 --- a/backend/app/services/organizations.py +++ b/backend/app/services/organizations.py @@ -26,6 +26,13 @@ class OrganizationAccess: member_count: int | None = None +@dataclass(frozen=True, slots=True) +class OrganizationMemberIdentity: + member: OrganizationMember + display_name: str + email: str + + class OrganizationContextService: """Resolve and lazily bootstrap the organization boundary for a user.""" @@ -215,9 +222,15 @@ async def update( async def list_members( self, *, actor: User, organization_id: UUID - ) -> Sequence[OrganizationMember]: + ) -> Sequence[OrganizationMemberIdentity]: await self._authorize(actor, organization_id, "read") - return await self._organizations.list_members(organization_id) + identities = await self._organizations.list_member_identities(organization_id) + return [ + OrganizationMemberIdentity( + member=member, display_name=user.display_name, email=user.email + ) + for member, user in identities + ] async def upsert_member( self, diff --git a/backend/tests/test_organizations_api.py b/backend/tests/test_organizations_api.py index a00ec6bb..6ddeb462 100644 --- a/backend/tests/test_organizations_api.py +++ b/backend/tests/test_organizations_api.py @@ -1,7 +1,8 @@ from base64 import urlsafe_b64encode from collections.abc import AsyncIterator +from datetime import UTC, datetime from hashlib import sha256 -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from httpx import ASGITransport, AsyncClient @@ -731,6 +732,155 @@ async def test_organization_user_quota_only_blocks_new_members( assert users[2]["id"] not in {member["user_id"] for member in members_after_rejection.json()} +@pytest.mark.asyncio +async def test_organization_audit_filters_page_beyond_fifty_and_keep_tenant_scope( + client: AsyncClient, +) -> None: + headers = _authorization(await _login(client)) + organizations = (await client.get("/api/v1/organizations", headers=headers)).json() + organization_id = organizations[0]["id"] + resource_id = uuid4() + other_actor = uuid4() + async for session in app.dependency_overrides[get_session](): + actor = await session.scalar(select(User).where(User.email == ADMIN_EMAIL)) + assert actor is not None + actor_id = actor.id + session.add_all( + [ + AuditLog( + id=UUID(int=index + 1), + actor_user_id=actor_id, + organization_id=UUID(organization_id), + action="organization.audit_fixture", + resource_type="organization", + resource_id=resource_id, + details={"trace_id": f"existing-trace-{index}"}, + created_at=datetime(2026, 10, 20, tzinfo=UTC), + ) + for index in range(61) + ] + ) + session.add( + AuditLog( + actor_user_id=other_actor, + organization_id=UUID(organization_id), + action="organization.audit_fixture", + resource_type="organization", + resource_id=resource_id, + details={}, + created_at=datetime(2026, 10, 20, tzinfo=UTC), + ) + ) + session.add( + AuditLog( + actor_user_id=actor_id, + organization_id=UUID(organization_id), + action="organization.audit_fixture", + resource_type="organization", + resource_id=uuid4(), + details={}, + created_at=datetime(2026, 10, 20, tzinfo=UTC), + ) + ) + session.add( + AuditLog( + actor_user_id=actor_id, + organization_id=uuid4(), + action="organization.audit_fixture", + resource_type="organization", + resource_id=resource_id, + details={}, + created_at=datetime(2026, 10, 20, tzinfo=UTC), + ) + ) + await session.commit() + response = await client.get( + f"/api/v1/organizations/{organization_id}/audit-logs", + headers=headers, + params={ + "action": "organization.audit_fixture", + "actor_user_id": str(actor_id), + "resource_type": "organization", + "resource_id": str(resource_id), + "created_from": "2026-10-20T00:00:00Z", + "created_to": "2026-10-21T00:00:00Z", + "page": 6, + "page_size": 10, + }, + ) + assert response.status_code == 200, response.text + page = response.json() + assert page["total"] == 61 + assert len(page["items"]) == 10 + assert page["items"][0]["id"] == str(UUID(int=11)) + assert {entry["actor_user_id"] for entry in page["items"]} == {str(actor_id)} + assert {entry["resource_id"] for entry in page["items"]} == {str(resource_id)} + assert page["items"][0]["details"] == {"trace_id": "existing-trace-10"} + empty = await client.get( + f"/api/v1/organizations/{organization_id}/audit-logs", + headers=headers, + params={"resource_id": str(uuid4())}, + ) + assert empty.status_code == 200 + assert empty.json()["total"] == 0 + + +@pytest.mark.asyncio +async def test_organization_member_summaries_remain_scoped_and_directory_is_not_granted( + client: AsyncClient, +) -> None: + headers = _authorization(await _login(client)) + organizations = (await client.get("/api/v1/organizations", headers=headers)).json() + organization_id = organizations[0]["id"] + created = await client.post( + "/api/v1/users", + headers=headers, + json={ + "email": "scoped-owner@example.com", + "display_name": "同名成员", + "password": ADMIN_PASSWORD, + "is_system_admin": False, + }, + ) + assert created.status_code == 201 + member_id = created.json()["id"] + async for session in app.dependency_overrides[get_session](): + created_user = await session.get(User, UUID(member_id)) + assert created_user is not None + created_user.requires_password_change = False + await session.commit() + await client.put( + f"/api/v1/organizations/{organization_id}/members/{member_id}", + headers=headers, + json={"user_id": member_id, "role": "owner"}, + ) + owner_headers = { + **_authorization(await _login(client, "scoped-owner@example.com")), + "X-Organization-Id": organization_id, + } + members = await client.get( + f"/api/v1/organizations/{organization_id}/members", headers=owner_headers + ) + assert members.status_code == 200, members.text + summary = next(item for item in members.json() if item["user_id"] == member_id) + assert summary["display_name"] == "同名成员" + assert summary["email"] == "scoped-owner@example.com" + assert "password_hash" not in summary + directory = await client.post( + "/api/v1/users/search", + headers=owner_headers, + json={"query": "", "page": 1, "page_size": 20}, + ) + assert directory.status_code == 403 + other = await client.post( + "/api/v1/organizations", headers=headers, json={"name": "Other", "slug": "other-scoped"} + ) + outsider = await client.get( + f"/api/v1/organizations/{other.json()['id']}/members", headers=owner_headers + ) + assert outsider.status_code in {403, 404} + + async def _login( client: AsyncClient, email: str = ADMIN_EMAIL, diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-26-organization.md b/docs/design/ui-v5/remediation/2026-10-20/batch-26-organization.md new file mode 100644 index 00000000..865ef96e --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-26-organization.md @@ -0,0 +1,47 @@ +# 第二十六批:M24 组织治理 + +对应冻结审计 [F-ORG-01](../../audits/2026-10-19/complete-audit.md#f-org-01)、[F-ORG-02](../../audits/2026-10-19/complete-audit.md#f-org-02)、[F-ORG-03](../../audits/2026-10-19/complete-audit.md#f-org-03) 及[实施方案](../../audits/2026-10-19/remediation-plan.md#m24)。冻结文件未修改。 + +## 实施结果 + +- Runner、密钥安全、组织审计、支持包各自显示初次加载、失败、403 无权限及独立重试。错误呈现服务端标准消息和 Trace;失败或重新读取期间不显示旧数据或成功默认值。成员、服务账号、组织策略也使用各自读取边界,避免一个子域失败遮住其他子域。 +- 密钥区仅在当前安全读取成功、能力名称、模式、重加密标识和阻塞字段共同满足原合同后显示“能力已确认”,使用信息态呈现;数据不足时不提供 Prepare / Apply / Rollback。保留系统管理员或组织 Owner 的原 `canRotate` 规则,Admin / Member / Viewer 不获得轮换操作。Runner 未读完整时禁用治理策略保存并说明原因。 +- 组织审计按服务端 `page/page_size/total` 请求,支持时间、动作、操作者 UUID、资源类型和资源 UUID 精确筛选;筛选从第一页开始。保留原组织授权,新增两个过滤字段在服务层同时作用于列表与 total,并按时间、ID 稳定排序。 +- 审计表格显示操作者、资源和已有 Trace;只读详情展示组织、项目以及服务端已经授权返回的 details。Trace 只读取已有 `details.trace_id`,未增加采集、日志、诊断或数据库字段。保留策略清理导致页码越界时,明确说明并提供返回首页操作。 +- 切组织销毁旧组织工作区,重置审计页码、筛选、详情和成员草稿;旧组织一次性 token 也不会带到新组织。失败读取不混入前一组织的目录或详情。 +- 系统管理员复用现有 `/users/search` 的真实搜索、分页和账号选择,显示名称与邮箱以区分同名用户。组织 Owner / Admin 仅搜索已经有权读取的本组织成员;添加其他已知账号保留高级完整 UUID 输入并解释边界,不调用系统用户目录,不新增目录授权。 +- 本组织成员列表增加当前显示名、邮箱两个可选只读摘要字段,由原组织 `read` 授权后查询本组织成员关联用户;响应不返回密码字段。角色说明采用中文。成员提交等待服务端成功后才清空;404 / 500 时保留身份、所选角色和原列表角色,持续显示标准错误与 Trace;提交期间禁用表单和成员角色修改,阻止重复写入。 +- 服务账号签发、轮换、撤销及 token 一次显示流程保留。已确认并关闭的 token 不会因安全读取重试再次出现。密钥加密、重加密、校验、事务回滚及审计实现未修改;无数据库模型、迁移、依赖或 CI 治理变更。 + +## 反例与检查 + +开发前已阅读工作树规则及 M24 冻结章节。先增加行为反例:前端首次 11 项失败、2 项原有行为通过;后端新增反例确认身份/资源筛选未生效,total 为 62 而预期 61,成员摘要缺失。修复后补充读取挂起、页外恢复、角色边界、切组织和重复提交验证;未修改测试超时、全局 mock 或覆盖率阈值。 + +实际执行: + +- `make test-frontend-targeted TARGETS='["src/pages/OrganizationGovernancePage.test.tsx","src/pages/organization-governance-rotation.test.tsx","src/features/projects/DirectoryPicker.test.tsx"]'`:3 文件、29 项通过。 +- `make test-backend-targeted TARGETS='["tests/test_organizations_api.py","tests/test_organization_governance.py","tests/test_key_rotation.py","tests/test_access_domain.py","tests/test_access_api.py","tests/test_errors_and_trace.py"]'`:6 文件、31 项通过。保留既有 `TestPlan` 收集警告,未处理无关历史警告。 +- 变更后端文件 Ruff 格式检查、Ruff 检查通过;`uv run mypy app`:445 源文件通过。 +- 前端全量 `pnpm lint`(含 `tsc -b --pretty false`)及 `pnpm build` 通过;本批修改文件 Prettier 和 `git diff --check` 通过。 + +覆盖的关键行为包括四个子域逐一 500 / 403 / 挂起 / 重试成功、能力字段不足、安全数据不可读时禁止操作、Admin / Member / Viewer 不可轮换、第六页可达、total 与过滤条件一致、已有 Trace 与只读详情、目录越界恢复、切组织拒绝读取时无旧详情、同名账号区分、低权限不请求全目录、成员失败保留身份和角色、成功清空、挂起期间重复点击不重复写入、已关闭 token 不因重试恢复。后端还运行现有真实密文重加密与回滚、账号生命周期、访问授权及标准错误 Trace 合同测试。 + +原始日志保存在本批工作树 `output/m24-organization-20261022/`,包括 `frontend-red.log`、`backend-red.log`、`extra-red.log`、`frontend-final.log`、`backend-final.log`、`frontend-lint.log`、`frontend-build.log`、`backend-static.log` 和 `format-check.log`。日志只涉及测试合成账号与固定测试 token,没有真实凭据。 + +## 改动文件 + +- 前端:`OrganizationGovernancePage.tsx`、`OrganizationGovernancePage.test.tsx`、新增 `OrganizationAuditPanel.tsx` 与 `OrganizationQueryBoundary.tsx`、组织 `organization-service.ts`。 +- 后端:组织专属 endpoint、service、repository、schema,组织治理 service,以及 `test_organizations_api.py`;不涉及分布式执行面的共享文件。 + +## 真实浏览器验收交接 + +本批未运行真实浏览器,也未重配、重启或使用主代理共享 Compose / 浏览器会话。主代理整合代码后可在其隔离环境准备以下合成资料验证: + +1. 两个组织,其中一个至少 61 条同动作审计,包含不同操作者与不同资源 ID;第一组织查看第六页、组合筛选、只读详情,再切第二组织确认页码与筛选重置。 +2. 两个同名但不同邮箱账号,以及系统管理员、组织 Owner、Admin、Viewer 身份;验证系统搜索和本组织搜索边界、错误 ID 与失败重试、角色操作权限。 +3. 通过测试路由拦截分别挂起及返回四个读取端点的 500 / 403,再解除拦截重试;安全失败时没有轮换操作,Runner 失败时不能保存默认治理策略。 +4. 使用临时最小权限服务账号签发测试 token,确认后关闭,再重试失败的读取;确认 token 不重新显示。不得记录真实 token 或密钥材料,不执行真实密钥轮换作为页面失败态演示。 + +## 完成边界 + +F-ORG-01 / 02 / 03 达到本地开发、反例和针对性合同验证完成,可供主代理整合。未执行 `make check` 全量覆盖率、Compose 浏览器验收、Windows 包、真实 KMS / 生产账号轮换、远端最新提交门禁、合并或部署;这些不由本地 29 / 31 项通过推定完成。提交 SHA 由本批交付消息记录。 diff --git a/frontend/src/features/organizations/organization-service.ts b/frontend/src/features/organizations/organization-service.ts index 5e0d2650..a40f5f12 100644 --- a/frontend/src/features/organizations/organization-service.ts +++ b/frontend/src/features/organizations/organization-service.ts @@ -27,6 +27,8 @@ export type OrganizationMember = { id: string organization_id: string user_id: string + display_name?: string | null + email?: string | null role: OrganizationRole created_at: string updated_at: string @@ -251,14 +253,26 @@ export async function getRunnerGovernance( export async function listOrganizationAuditLogs( organizationId: string, + filters: OrganizationAuditFilters = { page: 1, page_size: 10 }, ): Promise> { return ( await apiClient.get>(`/organizations/${organizationId}/audit-logs`, { - params: { page: 1, page_size: 50 }, + params: filters, }) ).data } +export type OrganizationAuditFilters = { + page?: number + page_size?: number + action?: string + actor_user_id?: string + resource_type?: string + resource_id?: string + created_from?: string + created_to?: string +} + export async function getOrganizationSecurity( organizationId: string, ): Promise { diff --git a/frontend/src/pages/OrganizationAuditPanel.tsx b/frontend/src/pages/OrganizationAuditPanel.tsx new file mode 100644 index 00000000..61884e44 --- /dev/null +++ b/frontend/src/pages/OrganizationAuditPanel.tsx @@ -0,0 +1,242 @@ +import { AuditOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { + Alert, + Button, + Card, + Descriptions, + Form, + Input, + Modal, + Space, + Table, + Typography, +} from 'antd' +import { useState } from 'react' + +import { + listOrganizationAuditLogs, + type OrganizationAuditFilters, + type OrganizationAuditLog, +} from '../features/organizations/organization-service' +import OrganizationQueryBoundary from './OrganizationQueryBoundary' + +type AuditFormValues = Omit + +export default function OrganizationAuditPanel({ organizationId }: { organizationId: string }) { + const [filters, setFilters] = useState({ page: 1, page_size: 10 }) + const [selected, setSelected] = useState(null) + const audit = useQuery({ + queryKey: ['organization-audit', organizationId, filters], + queryFn: () => listOrganizationAuditLogs(organizationId, filters), + }) + return ( + + + 组织审计查询 + + } + > + { + setSelected(null) + setFilters({ ...values, page: 1, page_size: filters.page_size }) + }} + /> + + {(page) => ( + <> + {page.page > 1 && page.items.length === 0 && ( + setFilters({ ...filters, page: 1 })}>返回审计首页 + } + /> + )} +
`共 ${total} 条`, + onChange: (next) => setFilters({ ...filters, page: next }), + }} + columns={[ + { + title: '时间', + dataIndex: 'created_at', + render: (value: string) => + new Date(value).toLocaleString('zh-CN', { hour12: false }), + }, + { title: '动作', dataIndex: 'action' }, + { + title: '操作者', + dataIndex: 'actor_user_id', + render: (value: string | null) => value ?? '系统 / 已删除用户', + }, + { title: '资源类型', dataIndex: 'resource_type' }, + { + title: '资源 ID', + dataIndex: 'resource_id', + render: (value: string | null) => value ?? '未记录', + }, + { + title: 'Trace ID', + render: (_, entry) => + typeof entry.details.trace_id === 'string' ? entry.details.trace_id : '未记录', + }, + { + title: '详情', + render: (_, entry) => ( + + ), + }, + ]} + /> + + )} + + setSelected(null)} + /> + + ) +} + +function AuditFilters({ onSearch }: { onSearch: (values: AuditFormValues) => void }) { + const [form] = Form.useForm() + return ( + onSearch(normalizeFilters(values))} + style={{ marginBottom: 16, gap: 8 }} + > + + + + + + + + + + + + + + + + + validateEndTime(form.getFieldValue('created_from'), value), + }, + ]} + > + + + + + + ) +} + +function normalizeFilters(values: AuditFormValues): AuditFormValues { + return { + action: values.action?.trim() || undefined, + actor_user_id: values.actor_user_id?.trim() || undefined, + resource_type: values.resource_type?.trim() || undefined, + resource_id: values.resource_id?.trim() || undefined, + created_from: toUtc(values.created_from), + created_to: toUtc(values.created_to), + } +} + +function toUtc(value?: string): string | undefined { + return value ? new Date(value).toISOString() : undefined +} + +function validateEndTime(start?: string, end?: string): Promise { + if (start && end && new Date(end) < new Date(start)) + return Promise.reject(new Error('结束时间不能早于开始时间')) + return Promise.resolve() +} + +function AuditDetails({ + entry, + onClose, +}: { + entry: OrganizationAuditLog | null + onClose: () => void +}) { + const trace = entry?.details.trace_id + return ( + 关闭详情} + > + {entry && ( + <> + + + {entry.actor_user_id ?? '系统 / 已删除用户'} + + + {entry.resource_type} / {entry.resource_id ?? '未记录'} + + {entry.organization_id} + {entry.project_id ?? '组织范围'} + + {typeof trace === 'string' ? trace : '未记录'} + + + + 以下仅展示服务端已授权返回的审计详情。 + + ({ + key, + label: key, + children: typeof value === 'string' ? value : JSON.stringify(value), + }))} + /> + + )} + + ) +} diff --git a/frontend/src/pages/OrganizationGovernancePage.test.tsx b/frontend/src/pages/OrganizationGovernancePage.test.tsx index 3ab9f0a8..d3b96e50 100644 --- a/frontend/src/pages/OrganizationGovernancePage.test.tsx +++ b/frontend/src/pages/OrganizationGovernancePage.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { App as AntdApp } from 'antd' import { http, HttpResponse } from 'msw' @@ -31,7 +31,7 @@ describe('OrganizationGovernancePage', () => { await browser.click(screen.getByRole('tab', { name: /审计与安全/ })) expect(await screen.findByText('Organization Data Encryption Key Rotation')).toBeVisible() - expect(screen.getByText('真实 Key Rotation 可用')).toBeVisible() + expect(screen.getByText('密钥轮换能力已确认')).toBeVisible() expect(screen.getByText(/Apply 在同一事务内/)).toBeVisible() expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Rollback' })).not.toBeInTheDocument() @@ -41,7 +41,13 @@ describe('OrganizationGovernancePage', () => { it('issues a least-privilege service account and displays its token once', async () => { let submitted: Record | undefined + let securityFailed = true server.use( + http.get(`/api/v1/organizations/${organizationId}/security`, () => + securityFailed + ? HttpResponse.json({ error: { message: '密钥读取暂不可用' } }, { status: 500 }) + : HttpResponse.json(readResponse('security')), + ), http.post(`/api/v1/organizations/${organizationId}/service-accounts`, async ({ request }) => { submitted = (await request.json()) as Record return HttpResponse.json( @@ -84,6 +90,15 @@ describe('OrganizationGovernancePage', () => { account_key: 'governance-bot', scopes: ['mcp:evidence:write', 'mcp:flow:propose'], }) + await browser.click(screen.getByRole('button', { name: '我已安全保存' })) + await browser.click(screen.getByRole('tab', { name: /审计与安全/ })) + await screen.findByText('密钥安全加载失败') + securityFailed = false + await browser.click(screen.getByRole('button', { name: '重试密钥安全' })) + await screen.findByText('密钥轮换能力已确认') + await browser.click(screen.getByRole('tab', { name: 'Service Account' })) + expect(screen.queryByText('令牌只显示一次')).not.toBeInTheDocument() + expect(screen.queryByText('ftsa_one-time-governance-token')).not.toBeInTheDocument() }) it('prepares a key rotation plan from the security tab', async () => { @@ -127,8 +142,358 @@ describe('OrganizationGovernancePage', () => { }), ) }) + + it.each([ + ['runner-governance', 'Runner 治理', '配额与 Runner'], + ['security', '密钥安全', '审计与安全'], + ['audit-logs', '组织审计', '审计与安全'], + ['support-bundle/redaction', '支持包', '审计与安全'], + ])('reports %s failure and recovers only that read', async (path, label, tab) => { + let failed = true + server.use( + http.get(`/api/v1/organizations/${organizationId}/${path}`, () => + failed + ? HttpResponse.json( + { error: { message: '读取不可用', trace_id: 'org-read-trace' } }, + { status: 500 }, + ) + : HttpResponse.json(readResponse(path)), + ), + ) + renderPage() + await screen.findByText('组织与角色') + await userEvent.click(screen.getByRole('tab', { name: new RegExp(tab) })) + expect(await screen.findByText(`${label}加载失败`)).toBeVisible() + expect(screen.getByText(/org-read-trace/)).toBeVisible() + if (path === 'security') { + expect(screen.queryByText('密钥轮换能力已确认')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '创建轮换版本' })).not.toBeInTheDocument() + } + failed = false + await userEvent.click(screen.getByRole('button', { name: `重试${label}` })) + await waitFor(() => expect(screen.queryByText(`${label}加载失败`)).not.toBeInTheDocument()) + }) + + it.each([ + ['runner-governance', 'Runner 治理', '配额与 Runner'], + ['security', '密钥安全', '审计与安全'], + ['audit-logs', '组织审计', '审计与安全'], + ['support-bundle/redaction', '支持包', '审计与安全'], + ])('shows %s permission denial without success defaults', async (path, label, tab) => { + server.use( + http.get(`/api/v1/organizations/${organizationId}/${path}`, () => + HttpResponse.json( + { error: { message: '无权读取', trace_id: 'org-forbidden' } }, + { status: 403 }, + ), + ), + ) + renderPage() + await screen.findByText('组织与角色') + await userEvent.click(screen.getByRole('tab', { name: new RegExp(tab) })) + expect(await screen.findByText(`无权读取${label}`)).toBeVisible() + if (path === 'security') + expect(screen.queryByText('密钥轮换能力已确认')).not.toBeInTheDocument() + }) + + it.each([ + ['runner-governance', 'Runner 治理', '配额与 Runner'], + ['security', '密钥安全', '审计与安全'], + ['audit-logs', '组织审计', '审计与安全'], + ['support-bundle/redaction', '支持包', '审计与安全'], + ])('keeps %s pending distinct from successful empty data', async (path, label, tab) => { + let release: (() => void) | undefined + server.use( + http.get(`/api/v1/organizations/${organizationId}/${path}`, async () => { + await new Promise((resolve) => { + release = resolve + }) + return HttpResponse.json(readResponse(path)) + }), + ) + renderPage() + await screen.findByText('组织与角色') + await userEvent.click(screen.getByRole('tab', { name: new RegExp(tab) })) + expect(await screen.findByLabelText(`正在加载${label}`)).toBeInTheDocument() + if (path === 'security') + expect(screen.queryByText('密钥轮换能力已确认')).not.toBeInTheDocument() + await act(async () => release?.()) + await waitFor(() => expect(screen.queryByLabelText(`正在加载${label}`)).not.toBeInTheDocument()) + }) + + it('rejects a successful security read whose capability fields do not confirm availability', async () => { + server.use( + http.get(`/api/v1/organizations/${organizationId}/security`, () => + HttpResponse.json({ + ...readResponse('security'), + ciphertext_reencryption_available: false, + }), + ), + ) + renderPage() + await screen.findByText('组织与角色') + await userEvent.click(screen.getByRole('tab', { name: /审计与安全/ })) + expect(await screen.findByText('密钥轮换能力不可用')).toBeVisible() + expect(screen.queryByRole('button', { name: '创建轮换版本' })).not.toBeInTheDocument() + }) + + it.each(['admin', 'member', 'viewer'])( + 'preserves the %s role boundary even when key data is available', + async (role) => { + authenticateTestUser({ ...user, is_system_admin: false }) + server.use( + http.get('/api/v1/organizations', () => HttpResponse.json([{ ...organization, role }])), + ) + renderPage() + await screen.findByText('组织与角色') + await userEvent.click(screen.getByRole('tab', { name: /审计与安全/ })) + expect(await screen.findByText('密钥轮换能力已确认')).toBeVisible() + expect(screen.queryByRole('button', { name: '创建轮换版本' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Rollback' })).not.toBeInTheDocument() + }, + ) + + it.each([404, 500])( + 'keeps exact account ID, selected role and old row role after %s', + async (status) => { + authenticateTestUser({ ...user, is_system_admin: false }) + let directoryReads = 0 + server.use( + http.post('/api/v1/users/search', () => { + directoryReads += 1 + return HttpResponse.json({}) + }), + http.put(`/api/v1/organizations/${organizationId}/members/${user.id}`, () => + HttpResponse.json( + { error: { message: '用户更新失败', trace_id: 'exact-member-trace' } }, + { status }, + ), + ), + ) + renderPage() + await userEvent.click(await screen.findByRole('button', { name: '按 ID 输入' })) + await userEvent.type(screen.getByRole('textbox', { name: '用户 ID' }), user.id) + await userEvent.click(screen.getByRole('combobox', { name: '成员角色' })) + await userEvent.click( + screen.getByText('管理员 Admin', { selector: '.ant-select-item-option-content' }), + ) + await userEvent.click(screen.getByRole('button', { name: '添加 / 更新' })) + expect(await screen.findByText(/用户更新失败.*exact-member-trace/)).toBeVisible() + expect(screen.getByRole('textbox', { name: '用户 ID' })).toHaveValue(user.id) + expect(screen.getAllByText('管理员 Admin').length).toBeGreaterThan(0) + expect( + screen.getByRole('combobox', { name: `更新 ${user.id} 角色` }).closest('.ant-select'), + ).toHaveTextContent('所有者 Owner') + expect(directoryReads).toBe(0) + expect(screen.getByText(/仅搜索已有组织成员/)).toBeVisible() + }, + ) + + it('loads audit page 6 from server and applies identity, resource and time filters', async () => { + const requests: URL[] = [] + server.use( + http.get(`/api/v1/organizations/${organizationId}/audit-logs`, ({ request }) => { + const url = new URL(request.url) + requests.push(url) + const page = Number(url.searchParams.get('page')) + return HttpResponse.json({ items: [auditEntry(page)], page, page_size: 10, total: 61 }) + }), + ) + renderPage() + await screen.findByText('组织与角色') + await userEvent.click(screen.getByRole('tab', { name: /审计与安全/ })) + expect(await screen.findByText('共 61 条')).toBeVisible() + await userEvent.click(screen.getByTitle('6')) + expect(await screen.findByText('audit-resource-6')).toBeVisible() + expect(requests.at(-1)?.searchParams.get('page')).toBe('6') + await userEvent.click(screen.getByRole('button', { name: '查看审计详情' })) + const details = await screen.findByRole('dialog') + expect(details).toHaveTextContent('audit-trace-6') + expect(details).toHaveTextContent('仅服务端已授权详情') + await userEvent.click(screen.getByRole('button', { name: '关闭详情' })) + await userEvent.type(screen.getByLabelText('审计动作'), 'organization.updated') + await userEvent.type(screen.getByLabelText('操作者 ID'), user.id) + await userEvent.type(screen.getByLabelText('资源类型'), 'organization') + await userEvent.type(screen.getByLabelText('资源 ID'), organizationId) + fireEvent.change(screen.getByLabelText('开始时间'), { target: { value: '2026-10-01T12:00' } }) + fireEvent.change(screen.getByLabelText('结束时间'), { target: { value: '2026-10-21T12:00' } }) + await userEvent.click(screen.getByRole('button', { name: '查询审计' })) + await waitFor(() => expect(requests.at(-1)?.searchParams.get('actor_user_id')).toBe(user.id)) + expect(Object.fromEntries(requests.at(-1)!.searchParams)).toMatchObject({ + page: '1', + page_size: '10', + action: 'organization.updated', + resource_type: 'organization', + resource_id: organizationId, + }) + expect(requests.at(-1)?.searchParams.get('created_from')).toContain('2026-10-01') + expect(requests.at(-1)?.searchParams.get('created_to')).toContain('2026-10-21') + }) + + it('shows an out of range audit page after retention removes its entries', async () => { + server.use( + http.get(`/api/v1/organizations/${organizationId}/audit-logs`, ({ request }) => { + const page = Number(new URL(request.url).searchParams.get('page')) + return HttpResponse.json({ + items: page === 1 ? [auditEntry(1)] : [], + total: page === 1 ? 61 : 3, + page, + page_size: 10, + }) + }), + ) + renderPage() + await screen.findByText('组织与角色') + await userEvent.click(screen.getByRole('tab', { name: /审计与安全/ })) + await screen.findByText('共 61 条') + await userEvent.click(screen.getByTitle('6')) + expect(await screen.findByText('当前页已超出审计目录范围')).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: '返回审计首页' })) + expect(await screen.findByText('audit-resource-1')).toBeVisible() + }) + + it('resets audit scope and hides old details when switching to an organization denied by server', async () => { + const otherId = '00000000-0000-4000-8000-000000000799' + const reads: URL[] = [] + server.use( + http.get('/api/v1/organizations', () => + HttpResponse.json([ + organization, + { ...organization, id: otherId, name: '其他组织', slug: 'other-org' }, + ]), + ), + http.get(`/api/v1/organizations/${organizationId}/audit-logs`, ({ request }) => { + const url = new URL(request.url) + return HttpResponse.json({ + items: [auditEntry(Number(url.searchParams.get('page')))], + total: 61, + page: Number(url.searchParams.get('page')), + page_size: 10, + }) + }), + http.get(`/api/v1/organizations/${otherId}/:path*`, ({ request }) => { + reads.push(new URL(request.url)) + return HttpResponse.json( + { error: { message: '其他组织读取被拒绝', trace_id: 'other-org-trace' } }, + { status: 403 }, + ) + }), + ) + renderPage() + await screen.findByText('组织与角色') + await userEvent.click(screen.getByRole('tab', { name: /审计与安全/ })) + await screen.findByText('共 61 条') + await userEvent.click(screen.getByTitle('6')) + await userEvent.click(await screen.findByRole('button', { name: '查看审计详情' })) + expect(await screen.findByRole('dialog')).toHaveTextContent('audit-resource-6') + await userEvent.click(screen.getByRole('button', { name: '关闭详情' })) + await userEvent.click(screen.getByRole('combobox', { name: '治理组织' })) + await userEvent.click( + await screen.findByText('其他组织 · other-org', { + selector: '.ant-select-item-option-content', + }), + ) + await screen.findByText('无权读取组织成员') + await userEvent.click(screen.getByRole('tab', { name: /审计与安全/ })) + expect(await screen.findByText('无权读取组织审计')).toBeVisible() + expect(screen.queryByText('audit-resource-6')).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + const auditRead = reads.find((url) => url.pathname.endsWith('/audit-logs')) + expect(auditRead?.searchParams.get('page')).toBe('1') + expect(auditRead?.searchParams.has('action')).toBe(false) + }) + + it('keeps failed member selection and role, disables repeated writes and resets after success', async () => { + let failed = true + let writes = 0 + let release: (() => void) | undefined + server.use( + http.post('/api/v1/users/search', () => + HttpResponse.json({ + items: [ + { ...user, display_name: '同名用户' }, + { ...user, id: organizationId, email: 'other@example.com', display_name: '同名用户' }, + ], + total: 2, + page: 1, + page_size: 20, + }), + ), + http.put(`/api/v1/organizations/${organizationId}/members/${user.id}`, async () => { + writes += 1 + await new Promise((resolve) => { + release = resolve + }) + return failed + ? HttpResponse.json( + { error: { message: '成员更新不可用', trace_id: 'member-trace' } }, + { status: 500 }, + ) + : HttpResponse.json(member) + }), + ) + renderPage() + await screen.findByText('组织与角色') + await userEvent.click(await screen.findByRole('combobox', { name: '组织用户' })) + expect(await screen.findByText('同名用户 (other@example.com)')).toBeInTheDocument() + await userEvent.click(screen.getByText(`同名用户 (${user.email})`)) + await userEvent.click(screen.getByRole('button', { name: '添加 / 更新' })) + await waitFor(() => expect(writes).toBe(1)) + expect(screen.getByRole('button', { name: '添加 / 更新' })).toBeDisabled() + await userEvent.click(screen.getByRole('button', { name: '添加 / 更新' })) + expect(writes).toBe(1) + release?.() + expect(await screen.findByText(/成员更新不可用.*member-trace/)).toBeVisible() + expect( + screen.getByRole('combobox', { name: '组织用户' }).closest('.ant-select'), + ).toHaveTextContent(`同名用户 (${user.email})`) + failed = false + await userEvent.click(screen.getByRole('button', { name: '添加 / 更新' })) + await waitFor(() => expect(writes).toBe(2)) + release?.() + await waitFor(() => + expect(screen.queryByText(`同名用户 (${user.email})`)).not.toBeInTheDocument(), + ) + }) }) +function auditEntry(page: number) { + return { + id: `audit-${page}`, + actor_user_id: user.id, + organization_id: organizationId, + project_id: null, + action: 'organization.updated', + resource_type: 'organization', + resource_id: `audit-resource-${page}`, + details: { trace_id: `audit-trace-${page}`, reason: '仅服务端已授权详情' }, + created_at: '2026-10-21T12:00:00Z', + } +} + +function readResponse(path: string) { + if (path === 'audit-logs') return { items: [], total: 0, page: 1, page_size: 10 } + if (path === 'runner-governance') + return { pool_count: 0, runner_count: 0, current_load: 0, capacity: 0, pools: [] } + if (path === 'security') + return { + active_key_version: 1, + capability_name: 'Organization Data Encryption Key Rotation', + capability_mode: 'reencrypt_verify_activate_rollback', + ciphertext_reencryption_available: true, + ga_blocker: null, + key_versions: [], + } + return { + schema_version: 'support-v1', + data_classification: 'internal-redacted', + included_sections: [], + redacted_fields: [], + excluded_fields: [], + } +} + function installHandlers() { server.use( http.get('/api/v1/organizations', () => HttpResponse.json([organization])), diff --git a/frontend/src/pages/OrganizationGovernancePage.tsx b/frontend/src/pages/OrganizationGovernancePage.tsx index 8ff87b0c..2324a272 100644 --- a/frontend/src/pages/OrganizationGovernancePage.tsx +++ b/frontend/src/pages/OrganizationGovernancePage.tsx @@ -1,5 +1,4 @@ import { - AuditOutlined, KeyOutlined, LockOutlined, PlusOutlined, @@ -39,7 +38,6 @@ import { getOrganizationSecurity, getRunnerGovernance, getSupportBundleRedaction, - listOrganizationAuditLogs, listOrganizationMembers, listOrganizations, listServiceAccounts, @@ -57,7 +55,10 @@ import { type QuotaMode, type ServiceAccount, } from '../features/organizations/organization-service' -import { setOrganizationId } from '../lib/api' +import DirectoryPicker from '../features/projects/DirectoryPicker' +import { apiErrorWithTrace, setOrganizationId } from '../lib/api' +import OrganizationAuditPanel from './OrganizationAuditPanel' +import OrganizationQueryBoundary from './OrganizationQueryBoundary' import { rotationAction, type SecurityKeyVersion } from './organization-governance-rotation' const quotaDimensions: Array<{ key: QuotaDimension; label: string; unit?: string }> = [ @@ -164,7 +165,6 @@ function OrganizationGovernanceContent({ onSelect: (id: string) => void }) { const { message } = App.useApp() - const [issuedToken, setIssuedToken] = useState(null) const selected = organizations.find((organization) => organization.id === selectedId) ?? null if (isLoading) return
加载组织治理...
@@ -173,12 +173,11 @@ function OrganizationGovernanceContent({ if (!selected) return
正在选择组织...
return ( notify(message, type, text)} /> ) @@ -197,18 +196,15 @@ function OrganizationGovernanceSelected({ organizations, selected, onSelect, - issuedToken, - setIssuedToken, onMessage, }: { isSystemAdmin: boolean organizations: Organization[] selected: Organization onSelect: (id: string) => void - issuedToken: string | null - setIssuedToken: (token: string | null) => void onMessage: (type: 'success' | 'error', text: string) => void }) { + const [issuedToken, setIssuedToken] = useState(null) const canManage = isSystemAdmin || selected.role === 'owner' || selected.role === 'admin' const canRotate = isSystemAdmin || selected.role === 'owner' return ( @@ -238,6 +234,7 @@ function OrganizationGovernanceSelected({ void }) { const workspace = useOrganizationWorkspace({ organization, setIssuedToken, onMessage }) - const { governance, members, accounts } = workspace - const isLoading = [governance, members, accounts].some((query) => query.isLoading) - const isError = [governance, members, accounts].some((query) => query.isError) const accountPending = workspace.accountMutation.isPending || workspace.rotateMutation.isPending || @@ -314,11 +310,10 @@ function OrganizationWorkspace({ workspace.applyKeyMutation.isPending || workspace.rollbackKeyMutation.isPending - if (isLoading) return
正在加载组织治理...
- if (isError) return return ( ), children: ( - workspace.memberMutation.mutate({ userId, role })} - pending={workspace.memberMutation.isPending} - /> + + {(members) => ( + { + try { + await workspace.memberMutation.mutateAsync({ userId, role }) + return true + } catch { + return false + } + }} + pending={workspace.memberMutation.isPending} + /> + )} + ), }, { key: 'accounts', label: 'Service Account', children: ( - setIssuedToken(null)} - onCreate={(input) => workspace.accountMutation.mutate(input)} - onRotate={(id) => workspace.rotateMutation.mutate(id)} - onRevoke={(id) => workspace.revokeMutation.mutate(id)} - pending={accountPending} - /> + + {(accounts) => ( + setIssuedToken(null)} + onCreate={(input) => workspace.accountMutation.mutate(input)} + onRotate={(id) => workspace.rotateMutation.mutate(id)} + onRevoke={(id) => workspace.revokeMutation.mutate(id)} + pending={accountPending} + /> + )} + ), }, { key: 'governance', label: '配额与 Runner', children: ( - workspace.governanceMutation.mutate(input)} - /> + + {(governance) => ( + workspace.governanceMutation.mutate(input)} + /> + )} + ), }, { @@ -408,16 +428,30 @@ function OrganizationTabs({ ), children: ( - workspace.prepareMutation.mutate(input)} - onApply={(id) => workspace.applyKeyMutation.mutate(id)} - onRollback={(id) => workspace.rollbackKeyMutation.mutate(id)} - pending={securityPending} - /> + + +
+ + {(security) => ( + workspace.prepareMutation.mutate(input)} + onApply={(id) => workspace.applyKeyMutation.mutate(id)} + onRollback={(id) => workspace.rollbackKeyMutation.mutate(id)} + pending={securityPending} + /> + )} + + + + + {(supportBundle) => } + + + + + ), }, ]} @@ -447,10 +481,6 @@ function useOrganizationWorkspace({ queryKey: ['organization-service-accounts', organization.id], queryFn: () => listServiceAccounts(organization.id), }) - const audit = useQuery({ - queryKey: ['organization-audit', organization.id], - queryFn: () => listOrganizationAuditLogs(organization.id), - }) const runner = useQuery({ queryKey: ['organization-runner-governance', organization.id], queryFn: () => getRunnerGovernance(organization.id), @@ -475,7 +505,6 @@ function useOrganizationWorkspace({ await invalidate('organization-members') onMessage('success', '组织角色已更新') }, - onError: () => onMessage('error', '组织角色更新失败'), }) const accountMutation = useMutation({ mutationFn: (input: ServiceAccountFormValues) => createServiceAccount(organization.id, input), @@ -540,7 +569,6 @@ function useOrganizationWorkspace({ governance, members, accounts, - audit, runner, security, supportBundle, @@ -558,14 +586,18 @@ function useOrganizationWorkspace({ function OverviewTab({ organization, members, + canSearchUsers, + memberError, canManage, onMemberChange, pending, }: { organization: Organization members: OrganizationMember[] + canSearchUsers: boolean + memberError: Error | null canManage: boolean - onMemberChange: (userId: string, role: OrganizationRole) => void + onMemberChange: (userId: string, role: OrganizationRole) => Promise pending: boolean }) { const [form] = Form.useForm() @@ -590,42 +622,77 @@ function OverviewTab({ {members.length} 人}> + {memberError && ( + + )} {canManage && (
{ - onMemberChange(values.user_id.trim(), values.role) - form.resetFields() + disabled={pending} + onFinish={async (values) => { + if (pending) return + if (await onMemberChange(values.user_id.trim(), values.role)) form.resetFields() }} className="organization-inline-form" > - - + +
( + + {member.display_name ?? member.user_id} + + {member.email ?? '身份摘要未提供'} + + {member.user_id} + + ), + }, { title: '角色', render: (_, member) => ( onChange?.(event.target.value)} + disabled={disabled} + /> + ) : ( +
) => JSON.stringify(value), - }, - ]} - /> - - ) -} - function CreateOrganizationDialog({ open, submitting, @@ -1240,10 +1270,10 @@ function CreateOrganizationDialog({ } const roleOptions = [ - { value: 'owner' as const, label: 'Owner' }, - { value: 'admin' as const, label: 'Admin' }, - { value: 'member' as const, label: 'Member' }, - { value: 'viewer' as const, label: 'Viewer' }, + { value: 'owner' as const, label: '所有者 Owner' }, + { value: 'admin' as const, label: '管理员 Admin' }, + { value: 'member' as const, label: '成员 Member' }, + { value: 'viewer' as const, label: '只读成员 Viewer' }, ] function toGovernanceFormValues(value: OrganizationGovernance): GovernanceFormValues { diff --git a/frontend/src/pages/OrganizationQueryBoundary.tsx b/frontend/src/pages/OrganizationQueryBoundary.tsx new file mode 100644 index 00000000..7683a727 --- /dev/null +++ b/frontend/src/pages/OrganizationQueryBoundary.tsx @@ -0,0 +1,54 @@ +import { Alert, Button, Spin } from 'antd' +import axios from 'axios' +import type { ReactNode } from 'react' + +import { apiErrorWithTrace } from '../lib/api' + +type OrganizationRead = { + data: T | undefined + error: unknown + isError: boolean + isPending: boolean + isFetching: boolean + refetch: () => Promise +} + +export default function OrganizationQueryBoundary({ + label, + query, + children, +}: { + label: string + query: OrganizationRead + children: (data: T) => ReactNode +}) { + if (query.isError) { + const forbidden = axios.isAxiosError(query.error) && query.error.response?.status === 403 + return ( + void query.refetch()} + > + 重试 + + } + /> + ) + } + if (query.isPending || query.isFetching || query.data === undefined) { + return ( + +
+ + ) + } + return children(query.data) +} From 291ea986424e3ee80ff905c4f768abdad9092221 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:04:53 +0800 Subject: [PATCH 10/35] fix(execution-fabric): expose scoped queue and task evidence --- backend/app/api/v1/endpoints/runner_fabric.py | 68 +++- backend/app/repositories/runner_fabric.py | 98 ++++- backend/app/schemas/runner_fabric.py | 14 + backend/app/services/runner_fabric.py | 77 ++++ backend/tests/test_runner_fabric.py | 147 ++++++++ .../2026-10-20/batch-27-execution-fabric.md | 32 ++ .../execution-fabric/FabricInspection.tsx | 357 ++++++++++++++++++ .../execution-fabric-service.ts | 41 +- .../execution-fabric/use-fabric-polling.ts | 14 + .../src/pages/ExecutionFabricPage.test.tsx | 222 ++++++++++- frontend/src/pages/ExecutionFabricPage.tsx | 230 ++++++++--- 11 files changed, 1214 insertions(+), 86 deletions(-) create mode 100644 docs/design/ui-v5/remediation/2026-10-20/batch-27-execution-fabric.md create mode 100644 frontend/src/features/execution-fabric/FabricInspection.tsx create mode 100644 frontend/src/features/execution-fabric/use-fabric-polling.ts diff --git a/backend/app/api/v1/endpoints/runner_fabric.py b/backend/app/api/v1/endpoints/runner_fabric.py index e1236f51..76a2500f 100644 --- a/backend/app/api/v1/endpoints/runner_fabric.py +++ b/backend/app/api/v1/endpoints/runner_fabric.py @@ -27,6 +27,7 @@ RunnerPoolResponse, RunnerPoolUpdate, RunnerProgressRequest, + RunnerQueueSnapshotResponse, RunnerRegisterRequest, RunnerRegisterResponse, RunnerRegistrationTokenCreate, @@ -57,16 +58,48 @@ async def list_runner_fabric_pools( page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), ) -> Page[RunnerPoolResponse]: - views = await _service(session).list_pools(actor=current_user) - start = (page - 1) * page_size + views, total = await _service(session).pool_page(actor=current_user, page=page, limit=page_size) return Page( - items=[_pool_response(pool, runners) for pool, runners in views[start : start + page_size]], - total=len(views), + items=[_pool_response(pool, runners) for pool, runners in views], + total=total, page=page, page_size=page_size, ) +@admin_router.get("/queue", response_model=RunnerQueueSnapshotResponse) +async def get_runner_queue( + session: SessionDependency, current_user: CurrentUser +) -> RunnerQueueSnapshotResponse: + return await _service(session).queue_snapshot(actor=current_user) + + +@admin_router.get("/pools/{pool_id}", response_model=RunnerPoolResponse) +async def get_runner_pool( + pool_id: UUID, session: SessionDependency, current_user: CurrentUser +) -> RunnerPoolResponse: + pool, runners = await _service(session).inspect_pool(actor=current_user, pool_id=pool_id) + return _pool_response(pool, runners) + + +@admin_router.get("/runners/{runner_id}", response_model=RunnerResponse) +async def get_runner( + runner_id: UUID, session: SessionDependency, current_user: CurrentUser +) -> RunnerResponse: + return RunnerResponse.model_validate( + await _service(session).inspect_runner(actor=current_user, runner_id=runner_id) + ) + + +@admin_router.get("/tasks/{task_id}", response_model=RunnerTaskResponse) +async def get_runner_task( + task_id: UUID, session: SessionDependency, current_user: CurrentUser +) -> RunnerTaskResponse: + return RunnerTaskResponse.model_validate( + await _service(session).inspect_task(actor=current_user, task_id=task_id) + ) + + @admin_router.post("/pools", response_model=RunnerPoolResponse, status_code=status.HTTP_201_CREATED) async def create_runner_fabric_pool( payload: RunnerPoolCreate, @@ -133,12 +166,13 @@ async def list_runner_tasks( session: SessionDependency, current_user: CurrentUser, limit: int = Query(default=100, ge=1, le=100), + page: int = Query(default=1, ge=1), ) -> Page[RunnerTaskResponse]: - items = await _service(session).list_tasks(actor=current_user, limit=limit) + items, total = await _service(session).task_page(actor=current_user, page=page, limit=limit) return Page( items=[RunnerTaskResponse.model_validate(item) for item in items], - total=len(items), - page=1, + total=total, + page=page, page_size=limit, ) @@ -148,12 +182,16 @@ async def list_runner_leases( session: SessionDependency, current_user: CurrentUser, limit: int = Query(default=100, ge=1, le=100), + page: int = Query(default=1, ge=1), + task_id: UUID | None = None, ) -> Page[RunnerLeaseAdminResponse]: - items = await _service(session).list_leases(actor=current_user, limit=limit) + items, total = await _service(session).lease_page( + actor=current_user, page=page, limit=limit, task_id=task_id + ) return Page( items=[RunnerLeaseAdminResponse.model_validate(item) for item in items], - total=len(items), - page=1, + total=total, + page=page, page_size=limit, ) @@ -163,12 +201,16 @@ async def list_runner_events( session: SessionDependency, current_user: CurrentUser, limit: int = Query(default=100, ge=1, le=100), + page: int = Query(default=1, ge=1), + task_id: UUID | None = None, ) -> Page[RunnerEventResponse]: - items = await _service(session).list_events(actor=current_user, limit=limit) + items, total = await _service(session).event_page( + actor=current_user, page=page, limit=limit, task_id=task_id + ) return Page( items=[RunnerEventResponse.model_validate(item) for item in items], - total=len(items), - page=1, + total=total, + page=page, page_size=limit, ) diff --git a/backend/app/repositories/runner_fabric.py b/backend/app/repositories/runner_fabric.py index 9ce544c3..a1a754a5 100644 --- a/backend/app/repositories/runner_fabric.py +++ b/backend/app/repositories/runner_fabric.py @@ -2,7 +2,7 @@ from typing import Any, cast from uuid import UUID -from sqlalchemy import Select, func, or_, select, update +from sqlalchemy import Select, case, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import ColumnElement @@ -266,6 +266,102 @@ async def list_events( ).all() ) + def _inspection_scope(self, organization_id: UUID | None) -> ColumnElement[bool]: + return or_(Project.organization_id == organization_id, Project.organization_id.is_(None)) + + async def pool_page( + self, *, organization_id: UUID | None, page: int, limit: int + ) -> tuple[list[RunnerPool], int]: + statement = select(RunnerPool) + if organization_id is not None: + statement = statement.where( + or_( + RunnerPool.organization_id == organization_id, + RunnerPool.organization_id.is_(None), + ) + ) + return await self._inspection_page( + statement.order_by(RunnerPool.created_at.desc(), RunnerPool.id), page, limit + ) + + async def task_page( + self, *, organization_id: UUID | None, page: int, limit: int + ) -> tuple[list[RunnerTask], int]: + statement = select(RunnerTask).join(Project, Project.id == RunnerTask.project_id) + if organization_id is not None: + statement = statement.where(self._inspection_scope(organization_id)) + return await self._inspection_page( + statement.order_by(RunnerTask.created_at.desc(), RunnerTask.id), page, limit + ) + + async def lease_page( + self, *, organization_id: UUID | None, page: int, limit: int, task_id: UUID | None + ) -> tuple[list[RunnerLeaseRecord], int]: + statement = ( + select(RunnerLeaseRecord) + .join(RunnerTask, RunnerTask.id == RunnerLeaseRecord.task_id) + .join(Project, Project.id == RunnerTask.project_id) + ) + if organization_id is not None: + statement = statement.where(self._inspection_scope(organization_id)) + if task_id is not None: + statement = statement.where(RunnerLeaseRecord.task_id == task_id) + return await self._inspection_page( + statement.order_by(RunnerLeaseRecord.acquired_at.desc(), RunnerLeaseRecord.id), + page, + limit, + ) + + async def event_page( + self, *, organization_id: UUID | None, page: int, limit: int, task_id: UUID | None + ) -> tuple[list[RunnerEvent], int]: + statement = select(RunnerEvent).join(RunnerPool, RunnerPool.id == RunnerEvent.pool_id) + if organization_id is not None: + statement = statement.where( + or_( + RunnerPool.organization_id == organization_id, + RunnerPool.organization_id.is_(None), + ) + ) + # Shared pools can contain other organizations' tasks. Scope task events too. + statement = statement.outerjoin(RunnerTask, RunnerTask.id == RunnerEvent.task_id) + statement = statement.outerjoin(Project, Project.id == RunnerTask.project_id).where( + or_(RunnerEvent.task_id.is_(None), self._inspection_scope(organization_id)) + ) + if task_id is not None: + statement = statement.where(RunnerEvent.task_id == task_id) + return await self._inspection_page( + statement.order_by(RunnerEvent.created_at.desc(), RunnerEvent.id), page, limit + ) + + async def queue_groups( + self, *, organization_id: UUID | None + ) -> list[tuple[str, int, int, int]]: + statement = select( + RunnerTask.required_runner_type, + func.sum(case((RunnerTask.status == "queued", 1), else_=0)), + func.sum(case((RunnerTask.status == "leased", 1), else_=0)), + func.sum(case((RunnerTask.status == "failed", 1), else_=0)), + ).join(Project, Project.id == RunnerTask.project_id) + if organization_id is not None: + statement = statement.where(self._inspection_scope(organization_id)) + rows = await self._session.execute(statement.group_by(RunnerTask.required_runner_type)) + return [ + (kind, int(queued), int(leased), int(failed)) for kind, queued, leased, failed in rows + ] + + async def _inspection_page[ModelT]( + self, statement: Select[tuple[ModelT]], page: int, limit: int + ) -> tuple[list[ModelT], int]: + total = int( + await self._session.scalar( + select(func.count()).select_from(statement.order_by(None).subquery()) + ) + or 0 + ) + items = await self._session.scalars(statement.offset((page - 1) * limit).limit(limit)) + return list(items.all()), total + async def counts(self, *, organization_id: UUID | None = None) -> dict[str, int]: if organization_id is None: pools = await self._count(RunnerPool) diff --git a/backend/app/schemas/runner_fabric.py b/backend/app/schemas/runner_fabric.py index a6007042..a1f5d4ea 100644 --- a/backend/app/schemas/runner_fabric.py +++ b/backend/app/schemas/runner_fabric.py @@ -318,6 +318,20 @@ class RunnerFabricOverviewResponse(BaseModel): failed_tasks: int +class RunnerQueueGroupResponse(BaseModel): + runner_type: str + queued: int + leased: int + failed: int + + +class RunnerQueueSnapshotResponse(BaseModel): + generated_at: datetime + organization_id: UUID | None + scope: Literal["all_organizations", "organization_and_shared"] + groups: list[RunnerQueueGroupResponse] + + class RunnerActionRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/backend/app/services/runner_fabric.py b/backend/app/services/runner_fabric.py index 896a58cd..b08c710e 100644 --- a/backend/app/services/runner_fabric.py +++ b/backend/app/services/runner_fabric.py @@ -45,6 +45,8 @@ RunnerPoolCreate, RunnerPoolUpdate, RunnerProgressRequest, + RunnerQueueGroupResponse, + RunnerQueueSnapshotResponse, RunnerRegisterRequest, RunnerRegisterResponse, ) @@ -192,6 +194,81 @@ async def list_events(self, *, actor: User, limit: int) -> list[RunnerEvent]: self._require_admin(actor) return await self._repository.list_events(limit=limit, organization_id=_organization_id()) + async def queue_snapshot(self, *, actor: User) -> RunnerQueueSnapshotResponse: + self._require_admin(actor) + organization_id = _organization_id() + groups = await self._repository.queue_groups(organization_id=organization_id) + return RunnerQueueSnapshotResponse( + generated_at=datetime.now(UTC), + organization_id=organization_id, + scope="organization_and_shared" if organization_id else "all_organizations", + groups=[ + RunnerQueueGroupResponse( + runner_type=kind, queued=queued, leased=leased, failed=failed + ) + for kind, queued, leased, failed in groups + ], + ) + + async def pool_page( + self, *, actor: User, page: int, limit: int + ) -> tuple[list[tuple[RunnerPool, list[Runner]]], int]: + self._require_admin(actor) + pools, total = await self._repository.pool_page( + organization_id=_organization_id(), page=page, limit=limit + ) + return [(pool, await self._repository.list_runners(pool.id)) for pool in pools], total + + async def inspect_pool(self, *, actor: User, pool_id: UUID) -> tuple[RunnerPool, list[Runner]]: + self._require_admin(actor) + pool = await self._require_pool(pool_id) + return pool, await self._repository.list_runners(pool_id) + + async def inspect_runner(self, *, actor: User, runner_id: UUID) -> Runner: + self._require_admin(actor) + return await self._require_runner(runner_id) + + async def inspect_task(self, *, actor: User, task_id: UUID) -> RunnerTask: + self._require_admin(actor) + task = await self._require_task(task_id) + project = await self._repository.get_project(task.project_id) + organization_id = _organization_id() + if project is None or ( + organization_id is not None and project.organization_id not in (None, organization_id) + ): + raise AppError( + code="RUNNER_TASK_NOT_FOUND", message="Runner 任务不存在", status_code=404 + ) + return task + + async def task_page( + self, *, actor: User, page: int, limit: int + ) -> tuple[list[RunnerTask], int]: + self._require_admin(actor) + return await self._repository.task_page( + organization_id=_organization_id(), page=page, limit=limit + ) + + async def lease_page( + self, *, actor: User, page: int, limit: int, task_id: UUID | None = None + ) -> tuple[list[RunnerLeaseRecord], int]: + self._require_admin(actor) + if task_id is not None: + await self.inspect_task(actor=actor, task_id=task_id) + return await self._repository.lease_page( + organization_id=_organization_id(), page=page, limit=limit, task_id=task_id + ) + + async def event_page( + self, *, actor: User, page: int, limit: int, task_id: UUID | None = None + ) -> tuple[list[RunnerEvent], int]: + self._require_admin(actor) + if task_id is not None: + await self.inspect_task(actor=actor, task_id=task_id) + return await self._repository.event_page( + organization_id=_organization_id(), page=page, limit=limit, task_id=task_id + ) + async def create_registration_token( self, *, actor: User, pool_id: UUID, expires_in_seconds: int ) -> tuple[RunnerRegistrationToken, str]: diff --git a/backend/tests/test_runner_fabric.py b/backend/tests/test_runner_fabric.py index a114d61f..77ddb615 100644 --- a/backend/tests/test_runner_fabric.py +++ b/backend/tests/test_runner_fabric.py @@ -1283,6 +1283,153 @@ def test_runner_profile_and_production_transport_are_strict() -> None: ) +@pytest.mark.asyncio +async def test_admin_inspection_counts_all_tasks_and_pages_exact_evidence( + fabric_api_context: FabricApiContext, +) -> None: + context = fabric_api_context + async with context.sessions() as session: + actor = await session.get(User, context.actor.id) + assert actor is not None + project = await session.scalar(select(Project)) + assert project is not None + service = RunnerFabricService(session, enabled=True) + pool = await service.create_pool(actor=actor, payload=_pool_payload()) + token = await _register_runner(service, actor, pool.id, "inspection-runner") + plan, _execution = await _seed_execution_plan(session, actor, project) + task = await service.enqueue(plan) + lease = await service.claim(runner_token=token) + assert lease is not None + task_id = task.id + lease_id = lease.lease_id + for index in range(150): + execution = WorkflowExecution( + project_id=project.id, + workflow_id=uuid4(), + workflow_version_id=uuid4(), + environment_id=uuid4(), + triggered_by_id=actor.id, + status="running", + snapshot={}, + context={}, + started_at=datetime.now(UTC), + ) + session.add(execution) + await session.flush() + session.add( + RunnerTask( + execution_id=execution.id, + project_id=project.id, + required_runner_type="protocol" if index < 50 else "general", + required_labels=[], + required_capabilities=["flow.workflow"], + status="queued", + available_at=datetime.now(UTC), + ) + ) + await session.commit() + queue = await context.client.get("/api/v1/execution-fabric/queue") + assert queue.status_code == 200, queue.text + assert queue.json()["scope"] == "all_organizations" + assert sum(item["queued"] for item in queue.json()["groups"]) == 150 + first = await context.client.get("/api/v1/execution-fabric/tasks?limit=100") + second = await context.client.get("/api/v1/execution-fabric/tasks?limit=100&page=2") + assert first.json()["total"] == second.json()["total"] == 151 + assert len(second.json()["items"]) == 51 + assert not ( + {item["id"] for item in first.json()["items"]} + & {item["id"] for item in second.json()["items"]} + ) + exact = await context.client.get(f"/api/v1/execution-fabric/tasks/{task_id}") + assert exact.json()["last_lease_id"] == str(lease_id) + leases = await context.client.get(f"/api/v1/execution-fabric/leases?task_id={task_id}") + assert leases.json()["items"][0]["id"] == str(lease_id) + events = await context.client.get(f"/api/v1/execution-fabric/events?task_id={task_id}") + assert all(item["task_id"] == str(task_id) for item in events.json()["items"]) + missing = await context.client.get(f"/api/v1/execution-fabric/tasks/{uuid4()}") + assert missing.status_code == 404 and missing.json()["error"]["trace_id"] + context.actor.is_system_admin = False + denied = await context.client.get("/api/v1/execution-fabric/queue") + assert denied.status_code == 403 + + +@pytest.mark.asyncio +async def test_pool_inspection_pages_beyond_one_hundred_and_reads_exact_worker( + fabric_api_context: FabricApiContext, +) -> None: + from app.models.capabilities import RunnerPool + + context = fabric_api_context + async with context.sessions() as session: + for index in range(101): + session.add( + RunnerPool( + name=f"inspection-pool-{index}", + runner_type="general", + network_zone="default", + labels=[], + max_concurrency=1, + enabled=True, + created_by_id=context.actor.id, + ) + ) + await session.commit() + response = await context.client.get("/api/v1/execution-fabric/pools?page=6&page_size=20") + assert response.status_code == 200 + assert response.json()["total"] == 101 + assert len(response.json()["items"]) == 1 + selected = response.json()["items"][0] + detail = await context.client.get(f"/api/v1/execution-fabric/pools/{selected['id']}") + assert detail.status_code == 200 and detail.json()["id"] == selected["id"] + context.actor.is_system_admin = False + for route in ("queue", "tasks", "leases", "events", f"pools/{selected['id']}"): + denied = await context.client.get(f"/api/v1/execution-fabric/{route}") + assert denied.status_code == 403, (route, denied.text) + + +@pytest.mark.asyncio +async def test_inspection_scope_rejects_foreign_tasks_even_in_shared_pool( + fabric_sessions: async_sessionmaker[AsyncSession], +) -> None: + from app.core.context import reset_tenant_context, set_tenant_context + from app.domain.tenant import TenantContext + + async with fabric_sessions() as session: + actor, project = await _seed_actor_and_project(session) + plan, _execution = await _seed_execution_plan(session, actor, project) + service = RunnerFabricService(session, enabled=True) + pool = await service.create_pool(actor=actor, payload=_pool_payload()) + token = await _register_runner(service, actor, pool.id, "foreign-task") + task = await service.enqueue(plan) + lease = await service.claim(runner_token=token) + assert lease is not None + project.organization_id = uuid4() + await session.commit() + context_token = set_tenant_context( + TenantContext( + organization_id=uuid4(), + actor_id=actor.id, + role=None, + is_system_admin=True, + ) + ) + try: + snapshot = await service.queue_snapshot(actor=actor) + assert snapshot.scope == "organization_and_shared" and snapshot.groups == [] + items, total = await service.task_page(actor=actor, page=1, limit=20) + assert items == [] and total == 0 + items_events, count = await service.event_page(actor=actor, page=1, limit=20) + assert count > 0 # Shared pool registration remains visible. + assert all(event.task_id is None for event in items_events) + with pytest.raises(AppError) as error: + await service.inspect_task(actor=actor, task_id=task.id) + assert error.value.status_code == 404 + with pytest.raises(AppError): + await service.lease_page(actor=actor, page=1, limit=20, task_id=task.id) + finally: + reset_tenant_context(context_token) + + @pytest.mark.asyncio async def test_runner_fabric_admin_and_agent_api( fabric_api_context: FabricApiContext, diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-27-execution-fabric.md b/docs/design/ui-v5/remediation/2026-10-20/batch-27-execution-fabric.md new file mode 100644 index 00000000..ca2f2186 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-27-execution-fabric.md @@ -0,0 +1,32 @@ +# M25 分布式执行面整改回执 + +对应冻结审计:F-FAB-01、F-FAB-02。开发基线:`edb06bbc5259f0390b85bb45f731e72fd2fc8242`。 + +## 实现 + +- 队列按类型数字由服务端 SQL 对当前组织及共享项目或全部组织进行全量聚合,返回统计范围和生成时间。排队、租用是当前任务状态,失败是保留历史累计;分页任务目录不再充当统计分母。 +- Pool、任务、Lease、事件使用真实总数及有界分页。新增管理员 Pool、Runner、任务精确读取;任务 Lease、事件按任务筛选。共享 Pool 的任务事件同时应用项目组织边界。 +- 可见页每 15 秒刷新,后台暂停,提供手动刷新和最近成功读取时间。读取失败保留缓存并明确过期;任务或 Lease 刷新失败期间有效性显示待核对。 +- 任务 ID 打开只读证据,展示执行、Runner、Lease、Fence、重试、错误和时间;支持页外 URL 定位及工作流执行、报告来源入口。Lease 区分当前 Fence、过期记录和历史状态。 +- 保留原注册令牌、Drain/Resume 行为、权限、功能关闭边界、调度和 Lease/Fence 状态机;没有数据库变更。 + +## 涉及文件 + +后端:`runner_fabric` 的 router、schema、service、repository 及 `tests/test_runner_fabric.py`。 +前端:`ExecutionFabricPage.tsx`、对应页面测试、`execution-fabric-service.ts`、`FabricInspection.tsx`、`use-fabric-polling.ts`。 + +## 针对性验证 + +- `make test-backend-targeted TARGETS='["tests/test_runner_fabric.py"]'`:35 项通过。新增全量 150 排队任务、真实任务分页、Pool 101、精确详情、非管理员拒绝和共享 Pool 跨组织事件隔离。 +- `make test-frontend-targeted TARGETS='["src/pages/ExecutionFabricPage.test.tsx"]'`:9 项通过。覆盖服务端 150 聚合、Pool 第 6 页、页外精确任务、多 Lease/Fence、刷新失败保留数据、旧 Lease 有效性降级、跨组织迟到结果隔离、15 秒可见页刷新/隐藏暂停和关闭态。 +- 受影响文件 Ruff、ESLint、Prettier、TypeScript 检查与 `mypy app` 通过。 +- 完整原始日志保留在本工作树 `output/validation/m25-execution-fabric/`(未提交生成物)。 +- 独立只读子审指出的缓存 Lease 刷新失败仍称有效问题已整改;未改变任何重试、超时或覆盖率门槛。 + +## 条件环境与验收边界 + +真实动态 GUI、Runner 心跳/故障转移、完整 `make check`、Compose 端到端、正式最新提交门禁、合并后 CI 和发布验收尚未执行,由整合负责人分别记账。当前 targeted 通过不代表这些验收通过。 + +验收负责人可在隔离数据库启用 `runner_fabric`,使用 `/tmp/flowtest-m25-isolated-fixture.py --project-id <已有测试项目> --admin-id <管理员>` 准备夹具;该脚本仅已做语法检查,尚未运行。项目需有一条已有执行,以使用合法外键。脚本创建独有 `audit-m25-*` 标签、101 个停用 Pool、一个没有令牌的离线 Runner、150 个未来可用且要求不存在能力的合成任务,以及一个包含过期/当前 Fence 的只读合成任务。执行记录为取消态;不调用执行、令牌或 Runner 状态接口。禁止在共享验收或用户数据库中直接运行。 + +在同一组织管理员会话中核对全量聚合 150、Pool 翻至第 6 页、`/execution-fabric?task=<返回ID>` 的多 Lease 和来源入口、隐藏/显示页面轮询及只读刷新;再切其他组织确认精确任务拒绝、切普通角色确认不读取管理目录。若已有其他任务,聚合应按夹具增量核对,而非假定总数恰为 150。合成任务不会产生真实报告,来源入口缺证据应按真实状态反馈。 diff --git a/frontend/src/features/execution-fabric/FabricInspection.tsx b/frontend/src/features/execution-fabric/FabricInspection.tsx new file mode 100644 index 00000000..92e6f1ab --- /dev/null +++ b/frontend/src/features/execution-fabric/FabricInspection.tsx @@ -0,0 +1,357 @@ +import { useQuery } from '@tanstack/react-query' +import { Alert, Button, Descriptions, Drawer, Pagination, Space, Table, Typography } from 'antd' +import { useState } from 'react' +import type { UseQueryResult } from '@tanstack/react-query' +import type { Page } from '../../lib/api' +import { useFabricPolling } from './use-fabric-polling' + +import QueryFailureNotice from '../../components/QueryFailureNotice' +import { reportExecutionPath, workflowExecutionPath } from '../workflows/execution-navigation' +import { + getFabricPool, + getFabricRunner, + getFabricTask, + listFabricEvents, + listFabricLeases, + type FabricLease, + type FabricEvent, + type FabricTask, +} from './execution-fabric-service' + +export function FabricTaskTable({ + items, + onSelect, +}: { + items: FabricTask[] + onSelect: (id: string) => void +}) { + return ( +
( + + ), + }, + { title: '类型', dataIndex: 'required_runner_type' }, + { title: '状态', dataIndex: 'status' }, + { title: '尝试次数', render: (_, task) => `${task.attempts} / ${task.max_attempts}` }, + { title: '创建时间', dataIndex: 'created_at', render: time }, + ]} + /> + ) +} + +export default function FabricInspection({ + scope, + taskId, + poolId, + runnerId, + onClose, +}: { + scope: string + taskId: string | null + poolId: string | null + runnerId: string | null + onClose: () => void +}) { + if (taskId) + return ( + + ) + if (poolId || runnerId) + return ( + + ) + return null +} + +function TaskInspection({ + scope, + taskId, + onClose, +}: { + scope: string + taskId: string + onClose: () => void +}) { + const [leasePage, setLeasePage] = useState(1) + const [eventPage, setEventPage] = useState(1) + const polling = useFabricPolling() + const task = useQuery({ + queryKey: ['runner-fabric', scope, 'task', taskId], + queryFn: () => getFabricTask(taskId), + ...polling, + }) + const leases = useQuery({ + queryKey: ['runner-fabric', scope, 'task-leases', taskId, leasePage], + queryFn: () => listFabricLeases(leasePage, taskId), + enabled: task.isSuccess, + ...polling, + }) + const events = useQuery({ + queryKey: ['runner-fabric', scope, 'task-events', taskId, eventPage], + queryFn: () => listFabricEvents(eventPage, taskId), + enabled: task.isSuccess, + ...polling, + }) + return ( + + + + {task.isError && task.data && ( + + )} + {task.isPending && 正在读取任务} + {task.data && ( + <> + + + + + )} + + ) +} + +function TaskLeaseHistory({ + query, + task, + page, + onPage, + fresh, +}: { + query: UseQueryResult> + fresh: boolean + task: FabricTask + page: number + onPage: (page: number) => void +}) { + return ( + <> + {' '} + Lease 历史(当前任务) +
( + + {id} + + ), + }, + { title: 'Fence', dataIndex: 'fencing_token' }, + { + title: '有效性', + render: (_, lease) => leaseValidity(lease, task, fresh && !query.isError), + }, + { title: '记录状态', dataIndex: 'status' }, + { title: '取得时间', dataIndex: 'acquired_at', render: time }, + { title: '过期时间', dataIndex: 'expires_at', render: time }, + { title: '最近续租', dataIndex: 'last_renewed_at', render: time }, + ]} + /> + + + ) +} + +function TaskEventHistory({ + query, + page, + onPage, +}: { + query: UseQueryResult> + page: number + onPage: (page: number) => void +}) { + return ( + <> + {' '} + 关联事件(当前任务) +
String(event.details.fencing_token ?? '未提供'), + }, + ]} + /> + + + ) +} + +function TaskDetails({ task }: { task: FabricTask }) { + return ( + + + + + 查看工作流执行 + + + 查看执行报告 + + + + ) +} + +function WorkerInspection({ + scope, + poolId, + runnerId, + onClose, +}: { + scope: string + poolId: string | null + runnerId: string | null + onClose: () => void +}) { + const polling = useFabricPolling() + const runner = useQuery({ + queryKey: ['runner-fabric', scope, 'runner', runnerId], + queryFn: () => getFabricRunner(runnerId!), + enabled: Boolean(runnerId), + ...polling, + }) + const effectivePool = poolId ?? runner.data?.pool_id + const pool = useQuery({ + queryKey: ['runner-fabric', scope, 'pool', effectivePool], + queryFn: () => getFabricPool(effectivePool!), + enabled: Boolean(effectivePool), + ...polling, + }) + return ( + + + + {pool.data && ( + item.name).join('、') || '无', + }, + ]} + /> + )} + {runner.data && ( + + )} + + ) +} + +function leaseValidity(lease: FabricLease, task: FabricTask, fresh: boolean): string { + if (!fresh) return '有效性待刷新核对' + if (lease.status !== 'active') return '历史 Lease' + if (new Date(lease.expires_at).getTime() <= Date.now()) return '已过期,等待回收' + if ( + task.status !== 'leased' || + task.last_lease_id !== lease.id || + task.fencing_token !== lease.fencing_token + ) + return '已失效 Fence' + return '当前有效 Lease' +} + +function time(value: string | null): string { + return value ? new Date(value).toLocaleString() : '未提供' +} + +function StaleInspectionNotice({ reads }: { reads: { isError: boolean; data: unknown }[] }) { + if (!reads.some((query) => query.isError && query.data !== undefined)) return null + return +} diff --git a/frontend/src/features/execution-fabric/execution-fabric-service.ts b/frontend/src/features/execution-fabric/execution-fabric-service.ts index 88281ba4..efdbab3e 100644 --- a/frontend/src/features/execution-fabric/execution-fabric-service.ts +++ b/frontend/src/features/execution-fabric/execution-fabric-service.ts @@ -112,32 +112,34 @@ export async function getRunnerFabricOverview(): Promise { return (await apiClient.get('/execution-fabric/overview')).data } -export async function listFabricPools(): Promise> { +export async function listFabricPools(page = 1): Promise> { return ( await apiClient.get>('/execution-fabric/pools', { - params: { page: 1, page_size: 100 }, + params: { page, page_size: 20 }, }) ).data } -export async function listFabricTasks(): Promise> { +export async function listFabricTasks(page = 1): Promise> { return ( - await apiClient.get>('/execution-fabric/tasks', { params: { limit: 100 } }) + await apiClient.get>('/execution-fabric/tasks', { + params: { limit: 20, page }, + }) ).data } -export async function listFabricLeases(): Promise> { +export async function listFabricLeases(page = 1, taskId?: string): Promise> { return ( await apiClient.get>('/execution-fabric/leases', { - params: { limit: 100 }, + params: { limit: 20, page, task_id: taskId }, }) ).data } -export async function listFabricEvents(): Promise> { +export async function listFabricEvents(page = 1, taskId?: string): Promise> { return ( await apiClient.get>('/execution-fabric/events', { - params: { limit: 100 }, + params: { limit: 20, page, task_id: taskId }, }) ).data } @@ -165,3 +167,26 @@ export async function changeRunnerState( }) ).data } + +export type FabricQueueSnapshot = { + generated_at: string + organization_id: string | null + scope: 'all_organizations' | 'organization_and_shared' + groups: { runner_type: string; queued: number; leased: number; failed: number }[] +} + +export async function getFabricQueue(): Promise { + return (await apiClient.get('/execution-fabric/queue')).data +} + +export async function getFabricTask(id: string): Promise { + return (await apiClient.get(`/execution-fabric/tasks/${id}`)).data +} + +export async function getFabricPool(id: string): Promise { + return (await apiClient.get(`/execution-fabric/pools/${id}`)).data +} + +export async function getFabricRunner(id: string): Promise { + return (await apiClient.get(`/execution-fabric/runners/${id}`)).data +} diff --git a/frontend/src/features/execution-fabric/use-fabric-polling.ts b/frontend/src/features/execution-fabric/use-fabric-polling.ts new file mode 100644 index 00000000..b990b71f --- /dev/null +++ b/frontend/src/features/execution-fabric/use-fabric-polling.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react' + +export function useFabricPolling() { + const [visible, setVisible] = useState(document.visibilityState !== 'hidden') + useEffect(() => { + const update = () => setVisible(document.visibilityState !== 'hidden') + document.addEventListener('visibilitychange', update) + return () => document.removeEventListener('visibilitychange', update) + }, []) + return { + refetchInterval: visible ? 15_000 : (false as const), + refetchIntervalInBackground: false, + } +} diff --git a/frontend/src/pages/ExecutionFabricPage.test.tsx b/frontend/src/pages/ExecutionFabricPage.test.tsx index f8c05461..2c0daf75 100644 --- a/frontend/src/pages/ExecutionFabricPage.test.tsx +++ b/frontend/src/pages/ExecutionFabricPage.test.tsx @@ -1,9 +1,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, within } from '@testing-library/react' +import { render, screen, within, waitFor, act, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { App as AntdApp } from 'antd' import { http, HttpResponse } from 'msw' -import { beforeEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { useAuthStore } from '../features/auth/auth-store' import { authenticateTestUser } from '../test/auth' @@ -122,6 +123,203 @@ describe('ExecutionFabricPage', () => { expect(await screen.findByText('PostgreSQL 单调递增')).toBeVisible() }) + it('uses the server aggregate and reaches Pool 101 without counting a task sample', async () => { + installHandlers() + const pages: string[] = [] + server.use( + http.get('/api/v1/execution-fabric/queue', () => + HttpResponse.json({ + generated_at: '2026-10-21T01:00:00Z', + scope: 'all_organizations', + organization_id: null, + groups: [{ runner_type: 'general', queued: 150, leased: 12, failed: 9 }], + }), + ), + http.get('/api/v1/execution-fabric/pools', ({ request }) => { + const number = new URL(request.url).searchParams.get('page') ?? '1' + pages.push(number) + return HttpResponse.json({ + items: [ + { + ...pool, + id: `pool-${number}`, + name: number === '6' ? 'Pool 101' : pool.name, + runners: [], + }, + ], + total: 101, + page: Number(number), + page_size: 20, + }) + }), + ) + renderPage() + expect(await screen.findByText('排队 150 · 运行 12 · 失败 9')).toBeVisible() + expect(screen.getByText(/服务端全量聚合/)).toBeVisible() + await userEvent.click(screen.getByTitle('6')) + expect(await screen.findByRole('button', { name: 'Pool 101 · 签发注册令牌' })).toBeVisible() + expect(pages).toEqual(['1', '6']) + }) + + it('loads an exact task outside the directory and distinguishes old Lease fences', async () => { + installHandlers() + const current = { + ...tasks[1], + last_lease_id: 'lease-current', + fencing_token: 2, + selected_runner_id: pool.runners[0].id, + } + const lease = { + id: 'lease-current', + task_id: current.id, + runner_id: current.selected_runner_id, + fencing_token: 2, + status: 'active', + acquired_at: '2026-10-21T01:00:00Z', + expires_at: '2099-10-21T01:00:00Z', + last_renewed_at: '2026-10-21T01:01:00Z', + completed_at: null, + } + server.use( + http.get('/api/v1/execution-fabric/tasks', () => page([])), + http.get('/api/v1/execution-fabric/tasks/:id', () => HttpResponse.json(current)), + http.get('/api/v1/execution-fabric/leases', ({ request }) => { + expect(new URL(request.url).searchParams.get('task_id')).toBe(current.id) + return page([ + lease, + { ...lease, id: 'lease-old', fencing_token: 1, expires_at: '2026-01-01T00:00:00Z' }, + ]) + }), + ) + const view = renderPage(`/execution-fabric?task=${current.id}`) + expect(await screen.findByText('当前有效 Lease')).toBeVisible() + expect(screen.getByText('已过期,等待回收')).toBeVisible() + expect(screen.getByRole('link', { name: '查看执行报告' })).toHaveAttribute( + 'href', + expect.stringContaining(`execution=${current.execution_id}`), + ) + expect(screen.getByRole('link', { name: '查看工作流执行' })).toHaveAttribute( + 'href', + expect.stringContaining(current.project_id), + ) + await waitFor(() => expect(view.queryClient.isFetching()).toBe(0)) + server.use( + http.get('/api/v1/execution-fabric/leases', () => + HttpResponse.json( + { error: { code: 'OFFLINE', message: 'Lease 暂不可用' } }, + { status: 503 }, + ), + ), + ) + await userEvent.click(screen.getByRole('button', { name: '刷新执行面' })) + expect( + await screen.findByText('部分详情刷新失败,保留的 Lease、事件或 Worker 数据可能过期'), + ).toBeVisible() + expect(screen.queryByText('当前有效 Lease')).not.toBeInTheDocument() + expect(screen.getAllByText('有效性待刷新核对')).toHaveLength(2) + }) + + it('retains old data on refresh failure without performing a state action', async () => { + let writes = 0 + installHandlers({ + onAction: () => { + writes += 1 + }, + }) + const view = renderPage() + await screen.findByText('runner-a') + await waitFor(() => expect(view.queryClient.isFetching()).toBe(0)) + server.use( + http.get('/api/v1/execution-fabric/queue', () => + HttpResponse.json( + { error: { code: 'OFFLINE', message: '聚合暂不可用', trace_id: 'fabric-trace' } }, + { status: 503 }, + ), + ), + ) + await userEvent.click(screen.getByRole('button', { name: '刷新执行面' })) + expect(await screen.findByText('读取失败,已显示的数据可能过期;请刷新后核对')).toBeVisible() + expect(screen.getByText('排队 1 · 运行 1 · 失败 0')).toBeVisible() + expect(screen.getByText(/fabric-trace/)).toBeVisible() + await waitFor(() => expect(writes).toBe(0)) + }) + + it('does not show a late task from the previous organization', async () => { + installHandlers() + let finish: (value: Response) => void = () => undefined + let reads = 0 + server.use( + http.get('/api/v1/execution-fabric/tasks/:id', () => { + reads += 1 + if (reads === 1) + return new Promise((resolve) => { + finish = resolve + }) + return HttpResponse.json( + { error: { code: 'RUNNER_TASK_NOT_FOUND', message: '当前组织任务不存在' } }, + { status: 404 }, + ) + }), + ) + renderPage(`/execution-fabric?task=${tasks[0].id}`) + await waitFor(() => expect(reads).toBe(1)) + act(() => { + useAuthStore.setState({ organizationId: 'other-organization' }) + }) + expect(await screen.findByText('当前组织任务不存在')).toBeVisible() + await act(async () => { + finish(HttpResponse.json(tasks[0])) + }) + expect(screen.queryByText(tasks[0].execution_id)).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: '查看执行报告' })).not.toBeInTheDocument() + }) + + it('polls visible pages and pauses while the page is hidden', async () => { + let reads = 0 + installHandlers() + server.use( + http.get('/api/v1/execution-fabric/overview', () => { + reads += 1 + return HttpResponse.json({ + pools: 1, + runners_online: reads, + runners_offline: 0, + runners_draining: 0, + queued_tasks: 1, + active_leases: 1, + completed_tasks: 4, + failed_tasks: 1, + }) + }), + ) + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + const visibility = vi.spyOn(document, 'visibilityState', 'get') + try { + renderPage() + await screen.findByText('runner-a') + await waitFor(() => expect(reads).toBe(1)) + visibility.mockReturnValue('hidden') + act(() => { + fireEvent(document, new Event('visibilitychange')) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000) + }) + expect(reads).toBe(1) + visibility.mockReturnValue('visible') + act(() => { + fireEvent(document, new Event('visibilitychange')) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000) + }) + await waitFor(() => expect(reads).toBe(2)) + } finally { + visibility.mockRestore() + vi.useRealTimers() + } + }) + it('creates a constrained worker pool and reports query failure', async () => { let submitted: Record | undefined installHandlers({ @@ -177,6 +375,17 @@ function installHandlers({ failed_tasks: 1, }), ), + http.get('/api/v1/execution-fabric/queue', () => + HttpResponse.json({ + generated_at: '2026-10-21T01:00:00Z', + scope: 'all_organizations', + organization_id: null, + groups: [ + { runner_type: 'general', queued: 1, leased: 1, failed: 0 }, + { runner_type: 'protocol', queued: 0, leased: 0, failed: 1 }, + ], + }), + ), http.get('/api/v1/execution-fabric/pools', () => page([pool])), http.get('/api/v1/execution-fabric/tasks', () => page(tasks)), http.get('/api/v1/execution-fabric/leases', () => page([])), @@ -246,15 +455,18 @@ function page(items: T[]) { return HttpResponse.json({ items, total: items.length, page: 1, page_size: 100 }) } -function renderPage() { +function renderPage(entry = '/execution-fabric') { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) - return render( + const view = render( - + + + , ) + return { ...view, queryClient } } diff --git a/frontend/src/pages/ExecutionFabricPage.tsx b/frontend/src/pages/ExecutionFabricPage.tsx index dc24c349..755bd276 100644 --- a/frontend/src/pages/ExecutionFabricPage.tsx +++ b/frontend/src/pages/ExecutionFabricPage.tsx @@ -11,6 +11,7 @@ import { Input, InputNumber, Modal, + Pagination, Row, Select, Space, @@ -20,24 +21,29 @@ import { Typography, } from 'antd' import { useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { useFabricPolling } from '../features/execution-fabric/use-fabric-polling' +import type { UseQueryResult } from '@tanstack/react-query' +import type { Page } from '../lib/api' import { useAuthStore } from '../features/auth/auth-store' import { getV3FeatureFlags } from '../features/capabilities/capability-service' +import FabricInspection, { FabricTaskTable } from '../features/execution-fabric/FabricInspection' import QueryFailureNotice from '../components/QueryFailureNotice' import { changeRunnerState, createFabricPool, createRegistrationToken, getRunnerFabricOverview, + getFabricQueue, listFabricEvents, - listFabricLeases, listFabricPools, listFabricTasks, type FabricEvent, type FabricPool, type FabricPoolInput, type FabricRunner, - type FabricTask, + type FabricQueueSnapshot, type RegistrationToken, type RunnerFabricOverview, } from '../features/execution-fabric/execution-fabric-service' @@ -59,32 +65,52 @@ function ExecutionFabricFeatureBoundary() { if (flags.isError) return if (flags.isPending) return if (!flags.data?.runner_fabric) return - return + return } -function ExecutionFabricWorkspace() { +function ScopedExecutionFabricWorkspace() { + const scope = useAuthStore((state) => `${state.user?.id}:${state.organizationId}`) + return +} + +function ExecutionFabricWorkspace({ scope }: { scope: string }) { const { message } = App.useApp() const queryClient = useQueryClient() const [poolDialogOpen, setPoolDialogOpen] = useState(false) const [registration, setRegistration] = useState(null) + const [poolPage, setPoolPage] = useState(1) + const [taskPage, setTaskPage] = useState(1) + const [eventPage, setEventPage] = useState(1) + const [params, setParams] = useSearchParams() + const polling = useFabricPolling() const overview = useQuery({ - queryKey: ['runner-fabric-overview'], + queryKey: ['runner-fabric', scope, 'overview'], queryFn: getRunnerFabricOverview, + ...polling, }) - const pools = useQuery({ queryKey: ['runner-fabric-pools'], queryFn: listFabricPools }) - const tasks = useQuery({ queryKey: ['runner-fabric-tasks'], queryFn: listFabricTasks }) - const leases = useQuery({ queryKey: ['runner-fabric-leases'], queryFn: listFabricLeases }) - const events = useQuery({ queryKey: ['runner-fabric-events'], queryFn: listFabricEvents }) - const invalidate = async () => { - await queryClient.invalidateQueries({ queryKey: ['runner-fabric'] }) - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['runner-fabric-overview'] }), - queryClient.invalidateQueries({ queryKey: ['runner-fabric-pools'] }), - queryClient.invalidateQueries({ queryKey: ['runner-fabric-tasks'] }), - queryClient.invalidateQueries({ queryKey: ['runner-fabric-leases'] }), - queryClient.invalidateQueries({ queryKey: ['runner-fabric-events'] }), - ]) - } + const queue = useQuery({ + queryKey: ['runner-fabric', scope, 'queue'], + queryFn: getFabricQueue, + ...polling, + }) + const pools = useQuery({ + queryKey: ['runner-fabric', scope, 'pools', poolPage], + queryFn: () => listFabricPools(poolPage), + ...polling, + }) + const tasks = useQuery({ + queryKey: ['runner-fabric', scope, 'tasks', taskPage], + queryFn: () => listFabricTasks(taskPage), + ...polling, + }) + const events = useQuery({ + queryKey: ['runner-fabric', scope, 'events', eventPage], + queryFn: () => listFabricEvents(eventPage), + ...polling, + }) + const reads = [overview, queue, pools, tasks, events] + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['runner-fabric', scope] }) + const selectTask = (id: string) => setParams({ task: id }) const createPool = useMutation({ mutationFn: createFabricPool, onSuccess: async () => { @@ -108,10 +134,9 @@ function ExecutionFabricWorkspace() { }, onError: () => void message.error('Runner 状态更新失败'), }) - const poolItems = pools.data?.items ?? [] - const taskItems = tasks.data?.items ?? [] - const eventItems = events.data?.items ?? [] - const failed = [overview, pools, tasks, leases, events].some((query) => query.isError) + const poolItems = pageItems(pools.data) + const eventItems = pageItems(events.data) + const failed = reads.some((query) => query.isError) return ( <>
@@ -124,9 +149,18 @@ function ExecutionFabricWorkspace() { 管理 Worker Pool、队列、Lease 与 Fencing,故障转移时只允许当前 Fence 写入唯一终态。
- + + + + + + {failed && } + + 可见页面每 15 秒刷新;后台暂停。最近成功读取: + {overview.dataUpdatedAt + ? formatTime(new Date(overview.dataUpdatedAt).toISOString()) + : '尚未加载'} +
@@ -145,24 +187,54 @@ function ExecutionFabricWorkspace() { ))} + `共 ${total} 个 Pool`} + /> mutation.isPending)} onAction={(runnerId, value) => action.mutate({ runnerId, value })} onRegister={(poolId) => register.mutate(poolId)} /> - - - +
+ + + + - + + + setParams({})} + /> (page: Page | undefined): T[] { + return page?.items ?? [] +} +function pageTotal(page: Page | undefined): number { + return page?.total ?? 0 +} +function QueueCard({ query }: { query: UseQueryResult }) { + return ( + + {query.isPending ? ( + 正在读取队列聚合 + ) : query.data ? ( + + ) : ( + 队列统计未提供 + )} + + ) +} + function FabricOverview({ value }: { value?: RunnerFabricOverview }) { const overview = value const items = [ @@ -231,14 +323,23 @@ function RunnerTable({ title: 'Worker', render: (_, { runner }) => ( - {runner.name} + + {runner.name} + {runner.runtime} · {runner.architecture} ), }, - { title: '池', render: (_, { pool }) => pool.name }, + { + title: '池', + render: (_, { pool }) => ( + + {pool.name} + + ), + }, { title: '能力', render: (_, { runner }) => @@ -285,47 +386,58 @@ function RunnerTable({ ) } -function QueueSummary({ tasks }: { tasks: FabricTask[] }) { - const groups = useMemo(() => { - const counts = new Map() - for (const task of tasks) { - const row = counts.get(task.required_runner_type) ?? { queued: 0, leased: 0, failed: 0 } - if (task.status === 'queued') row.queued += 1 - if (task.status === 'leased') row.leased += 1 - if (task.status === 'failed') row.failed += 1 - counts.set(task.required_runner_type, row) - } - return [...counts.entries()] - }, [tasks]) - if (!groups.length) return 当前队列为空 +function QueueSummary({ snapshot }: { snapshot: FabricQueueSnapshot }) { return ( - - {groups.map(([type, counts]) => ( - - - {runnerTypeLabel(type)} - - 排队 {counts.queued} · 运行 {counts.leased} · 失败 {counts.failed} - - + + + 服务端全量聚合 ·{' '} + {snapshot.scope === 'all_organizations' + ? '全部组织' + : `组织 ${snapshot.organization_id} 与共享项目`}{' '} + · 更新于 {formatTime(snapshot.generated_at)}。排队/运行按当前任务状态;失败为保留历史累计。 + + {!snapshot.groups.length && 当前范围没有任务记录} + {snapshot.groups.map((counts) => ( + + {runnerTypeLabel(counts.runner_type)} +
+ 排队 {counts.queued} · 运行 {counts.leased} · 失败 {counts.failed} +
))}
) } -function EventTable({ events }: { events: FabricEvent[] }) { +function EventTable({ + events, + onSelectTask, +}: { + events: FabricEvent[] + onSelectTask: (id: string) => void +}) { return (
+ id ? ( + + ) : ( + '—' + ), + }, { title: '事件', dataIndex: 'message' }, { title: 'Fencing Token', From 1d499ef4ea333f50cc945f5451070b68805ea756 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:16:17 +0800 Subject: [PATCH 11/35] fix(ai): retain navigation protection through pending submission --- .../2026-10-20/pr146-review-followup.md | 13 ++ .../src/features/ai/AIDraftProtection.tsx | 4 +- .../src/features/ai/CreateAIJobDialog.tsx | 27 +++- frontend/src/features/ai/use-ai-review.ts | 6 +- .../src/pages/AIAssistantRemediation.test.tsx | 124 ++++++++++++++++++ 5 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 docs/design/ui-v5/remediation/2026-10-20/pr146-review-followup.md diff --git a/docs/design/ui-v5/remediation/2026-10-20/pr146-review-followup.md b/docs/design/ui-v5/remediation/2026-10-20/pr146-review-followup.md new file mode 100644 index 00000000..eb7e12e8 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/pr146-review-followup.md @@ -0,0 +1,13 @@ +# PR146 复审补修 + +[PR #146](https://github.com/a3384379/FlowTest/pull/146) 复审发现:确认提交 AI 任务后,原实现立即放开离开保护,请求尚未完成时材料便可能丢失。现在只有服务端成功回执且命令仍属于当前工作区时,才在定位新任务前放行程序导航;等待和失败阶段继续保护材料。 + +提交期间禁用返回编辑、取消、关闭按钮、遮罩关闭及 Esc,避免在途材料发生变化。失败保留完整预览和错误说明,不自动重复提交。成功仍可定位实际返回任务,并正常关闭输入窗口。 + +新增202成功和503失败的受控慢请求行为测试,验证 beforeunload、Data Router 导航、继续编辑、失败保留、成功自动定位及只提交一次。修复前两项新增反例失败;最终四文件30项通过。修改文件格式、全量 lint/类型及构建通过,未调整测试超时、重试或覆盖率。 + +真实隔离浏览器继续发现离开确认弹窗可能被原编辑弹窗遮挡。确认框现采用主题弹层基准上方的层级;实际前进导航、点击继续编辑、原材料保留与503后保护均通过。浏览器拦截一次 POST,在页面内延迟后返回合成503,没有发到后端;实际业务写入、AI 入队和执行均为0。这项验收不冒充真实创建任务。测试脚本另有读取渲染状态过早、清理阶段重复点击已关闭弹窗及错误祖先选择器的失败,均保留原日志;最终按真实可见状态等待,没有使用强制点击或提高超时。 + +最终浏览器结果为 `passed:true`,日志 `remediation-pr146-pending-browser-r4.log`;目标测试 `remediation-pr146-pending-stacking-targeted.log`,构建 `remediation-pr146-pending-stacking-build.log`,截图 `remediation-pr146-pending-navigation.png`,均在主验收工作树忽略目录 `output/playwright/audit-20261019/`。临时前端挂载已恢复。 + +本次仅改前端,后端完整结果继承本PR原精确后端树。旧前端177文件1259项完整结果不作为补修后全量结果;最新提交仍需正式完整前端、Compose与 Required Gate,再检查全部审查线程和精确 HEAD 后合并。没有以30项目标测试推定远端门禁成功。 diff --git a/frontend/src/features/ai/AIDraftProtection.tsx b/frontend/src/features/ai/AIDraftProtection.tsx index e993ab8d..ee3d34c7 100644 --- a/frontend/src/features/ai/AIDraftProtection.tsx +++ b/frontend/src/features/ai/AIDraftProtection.tsx @@ -1,4 +1,4 @@ -import { Modal } from 'antd' +import { Modal, theme } from 'antd' import { useContext, useEffect, type RefObject } from 'react' import { UNSAFE_DataRouterContext, useBlocker } from 'react-router-dom' @@ -23,9 +23,11 @@ export default function AIDraftProtection(props: Props) { } function AINavigationGuard({ dirty, allowLeaveRef, title, description }: Props) { const blocker = useBlocker(() => dirty && !allowLeaveRef.current) + const { token } = theme.useToken() return ( void - onCreate: (input: Omit) => Promise + onCreate: (input: Omit, onCreated?: () => void) => Promise }) { if (!props.open) return null return @@ -79,7 +79,7 @@ function CreateAIJobForm({ submitting: boolean sampleEnabled: boolean onClose: () => void - onCreate: (input: Omit) => Promise + onCreate: (input: Omit, onCreated?: () => void) => Promise }) { const [form] = Form.useForm() const allowLeaveRef = useRef(false) @@ -106,8 +106,11 @@ function CreateAIJobForm({ if (!preview || !policy.isSuccess || (preview.sample !== undefined && !sampleEnabled)) return const { project_id: project, ...input } = preview if (project !== projectId) return - allowLeaveRef.current = true - if (await onCreate(input)) { + if ( + await onCreate(input, () => { + allowLeaveRef.current = true + }) + ) { setDirty(false) onClose() } else { @@ -129,13 +132,25 @@ function CreateAIJobForm({ width={760} style={{ top: 24 }} styles={{ body: { maxHeight: 'calc(100dvh - 190px)', overflowY: 'auto' } }} - onCancel={() => (dirty ? setDiscard(true) : onClose())} + closable={!submitting} + maskClosable={!submitting} + keyboard={!submitting} + onCancel={() => { + if (!submitting) { + if (dirty) setDiscard(true) + else onClose() + } + }} footer={ - {preview ? : null} + {preview ? ( + + ) : null} - {preview ? : null} + {preview ? ( + + ) : null}
(value ? '是' : '否'), + }, + { title: '说明', dataIndex: 'description' }, + ]} + /> + + + ) +} + +function schemaProperties( + value: unknown, +): { name: string; type: string; required: boolean; description: string }[] { + if (!isRecord(value) || !isRecord(value.properties)) return [] + const required = Array.isArray(value.required) ? value.required : [] + return Object.entries(value.properties) + .slice(0, 200) + .map(([name, property]) => ({ + name, + required: required.includes(name), + type: schemaType(property), + description: isRecord(property) ? text(property.description) : '未提供', + })) +} + +function schemaType(value: unknown): string { + if (typeof value === 'boolean') return value ? '允许任意值' : '禁止值' + if (!isRecord(value)) return '未提供' + if (value.type !== undefined) return text(value.type) + return value.$ref ? `引用 ${text(value.$ref)}` : '组合或未声明类型' +} + +function PluginInspector({ plugin }: { plugin: Plugin }) { + return ( + <> + 插件详情 + }, + { label: '完整 Digest', children: }, + { label: '签名身份(声明)', children: }, + { label: '状态', children: plugin.status }, + { label: '创建时间', children: text(plugin.created_at) }, + { label: '更新时间', children: text(plugin.updated_at) }, + ]} + /> + + + ) +} + +function PoolInspector({ pool, runnerEnabled }: { pool: RunnerPool; runnerEnabled: boolean }) { + return ( + <> + Runner Pool 详情 + }, + { label: '类型', children: pool.runner_type }, + { label: '网络区', children: pool.network_zone }, + { label: '标签', children: list(pool.labels) }, + { label: '并发上限', children: pool.max_concurrency }, + { label: '状态', children: bool(pool.enabled) }, + ]} + /> + {runnerEnabled ? ( + + 在执行面查看此 Pool + + ) : ( + + )} +
+ + ) +} + +function CopyValue({ value }: { value: string | null | undefined }) { + return value ? ( + + {value} + + ) : ( + <>未提供 + ) +} +function seconds(value: number | undefined): string { + return value === undefined ? '未提供' : `${value} 秒` +} +function bool(value: boolean | undefined): string { + return value === undefined ? '未提供' : value ? '是' : '否' +} +function list(value: string[] | undefined): string { + return value === undefined ? '未提供' : value.join('、') || '无' +} +function text(value: unknown): string { + if (value === undefined || value === null || value === '') return '未提供' + return typeof value === 'string' ? value : JSON.stringify(value) +} +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +function networkLabel(value: string | undefined): string { + if (value === undefined) return '未提供' + if (value === 'denied') return '禁止网络' + if (value === 'project_allowlist') return '项目白名单' + if (value === 'broker_only') return '仅 Broker' + return `未知策略:${value}` +} diff --git a/frontend/src/features/capabilities/capability-service.ts b/frontend/src/features/capabilities/capability-service.ts index a05b2c95..09e43f89 100644 --- a/frontend/src/features/capabilities/capability-service.ts +++ b/frontend/src/features/capabilities/capability-service.ts @@ -103,26 +103,55 @@ export async function getV3FeatureFlags(): Promise { return (await apiClient.get('/v3/features')).data } -export async function listCapabilities(): Promise> { +export async function listCapabilities(page = 1, search = ''): Promise> { return ( await apiClient.get>('/capabilities', { - params: { page: 1, page_size: 100 }, + params: { page, page_size: 20, search }, }) ).data } -export async function listPlugins(): Promise> { +export async function listPlugins(page = 1, search = ''): Promise> { return ( await apiClient.get>('/plugins', { - params: { page: 1, page_size: 100 }, + params: { page, page_size: 20, search }, }) ).data } -export async function listRunnerPools(): Promise> { +export async function listRunnerPools(page = 1, search = ''): Promise> { return ( await apiClient.get>('/runner-pools', { - params: { page: 1, page_size: 100 }, + params: { page, page_size: 20, search }, }) ).data } + +export type PlatformSelection = + | { kind: 'capability'; id: string; version: string } + | { kind: 'plugin'; id: string } + | { kind: 'runnerPool'; id: string } + +export type PlatformObject = + | { kind: 'capability'; record: Capability } + | { kind: 'plugin'; record: Plugin } + | { kind: 'runnerPool'; record: RunnerPool } + +export async function getPlatformObject(selection: PlatformSelection): Promise { + const id = encodeURIComponent(selection.id) + if (selection.kind === 'capability') + return { + kind: selection.kind, + record: ( + await apiClient.get( + `/capabilities/${id}/versions/${encodeURIComponent(selection.version)}`, + ) + ).data, + } + if (selection.kind === 'plugin') + return { kind: selection.kind, record: (await apiClient.get(`/plugins/${id}`)).data } + return { + kind: selection.kind, + record: (await apiClient.get(`/runner-pools/${id}`)).data, + } +} diff --git a/frontend/src/pages/PlatformCapabilitiesPage.test.tsx b/frontend/src/pages/PlatformCapabilitiesPage.test.tsx index e3bec68a..85a4139a 100644 --- a/frontend/src/pages/PlatformCapabilitiesPage.test.tsx +++ b/frontend/src/pages/PlatformCapabilitiesPage.test.tsx @@ -1,9 +1,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { render, screen, within, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { App as AntdApp } from 'antd' import { http, HttpResponse } from 'msw' -import { beforeEach, describe, expect, it } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Capability, Plugin, RunnerPool } from '../features/capabilities/capability-service' import { useAuthStore } from '../features/auth/auth-store' @@ -112,9 +113,13 @@ describe('PlatformCapabilitiesPage', () => { ), ) renderPage() - expect(await screen.findByText(/能力目录读取失败/)).toBeInTheDocument() - expect(screen.getAllByText('未提供').length).toBeGreaterThanOrEqual(2) + expect(await screen.findByText('能力目录读取失败')).toBeInTheDocument() + expect(screen.getByText(/当前目录共 未提供/)).toBeVisible() expect(screen.getByRole('button', { name: '重试' })).toBeEnabled() + await userEvent.click(screen.getByTitle('插件')) + await screen.findByRole('button', { name: plugin.display_name }) + expect(screen.queryByText('能力目录读取失败')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument() }) beforeEach(() => { @@ -127,9 +132,10 @@ describe('PlatformCapabilitiesPage', () => { expect(await screen.findByText('HTTP 请求')).toBeVisible() expect(screen.getByText('Capability SDK 当前处于兼容预览模式')).toBeVisible() - expect(screen.getByText('0123456789abcdef…')).toBeVisible() - expect(screen.getByText('固定 Schema 哈希')).toBeVisible() - expect(screen.getByText(/插件不会获得 Secret 明文/)).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: 'HTTP 请求' })) + expect(await screen.findByText(capability.schema_hash)).toBeVisible() + expect(screen.getByText('最大超时')).toBeVisible() + expect(screen.getByText('300 秒')).toBeVisible() expect(screen.getByRole('button', { name: '安装签名插件' })).toBeDisabled() }) @@ -143,11 +149,250 @@ describe('PlatformCapabilitiesPage', () => { await browser.click(screen.getByText('插件')) expect(await screen.findByText('内部签名能力')).toBeVisible() - expect(screen.getByText(plugin.oci_digest)).toBeVisible() + await browser.click(screen.getByRole('button', { name: plugin.display_name })) + expect(await within(inspector()).findByText(plugin.oci_digest)).toBeVisible() + expect(within(inspector()).getByText(plugin.signature_identity)).toBeVisible() await browser.click(screen.getByTitle('Runner')) expect(await screen.findByText('General Pool')).toBeVisible() expect(screen.getByText('1 个')).toBeVisible() + expect(within(inspector()).queryByText('插件详情')).not.toBeInTheDocument() + await browser.click(screen.getByRole('button', { name: 'General Pool' })) + expect(await within(inspector()).findByText('Runner Pool 详情')).toBeVisible() + expect(within(inspector()).getByText('runner-1')).toBeVisible() + }) + + it('clears a capability inspector when switching to an empty plugin inventory', async () => { + installHandlers({ capabilitySdk: true }) + server.use( + http.get('/api/v1/plugins', () => + HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }), + ), + ) + renderPage() + await userEvent.click(await screen.findByRole('button', { name: 'HTTP 请求' })) + await within(inspector()).findByText(`${capability.id}@${capability.version}`) + await userEvent.click(screen.getByTitle('插件')) + expect( + within(inspector()).queryByText(`${capability.id}@${capability.version}`), + ).not.toBeInTheDocument() + expect(within(inspector()).getByText('选择当前库存对象查看详情')).toBeVisible() + expect(await screen.findByText(/尚未安装管理员签名插件/)).toBeVisible() + }) + + it('copies each complete schema and shows current policies without changing the manifest', async () => { + installHandlers({ capabilitySdk: true }) + const schemas = { + input_schema: { + type: 'object', + properties: { ticket: { type: 'string', description: '票据' } }, + required: ['ticket'], + }, + output_schema: { type: 'object', properties: { accepted: { type: 'boolean' } } }, + configuration_schema: { type: 'object', properties: { zone: { type: 'string' } } }, + } + const record = { ...capability, manifest: { ...capability.manifest, ...schemas } } + server.use( + http.get('/api/v1/capabilities/:id/versions/:version', () => HttpResponse.json(record)), + ) + const browser = userEvent.setup() + const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + renderPage() + await browser.click(await screen.findByRole('button', { name: 'HTTP 请求' })) + await within(inspector()).findByText(capability.schema_hash) + for (const [label, value] of [ + ['输入 Schema', schemas.input_schema], + ['输出 Schema', schemas.output_schema], + ['配置 Schema', schemas.configuration_schema], + ] as const) { + await browser.click(screen.getByRole('tab', { name: label })) + const reader = within(inspector()).getByRole('region', { name: label }) + await browser.click(within(reader).getByRole('button', { name: '复制完整内容' })) + expect(copy).toHaveBeenLastCalledWith(JSON.stringify(value, null, 2)) + await browser.type(within(reader).getByLabelText(`${label}搜索内容`), 'type') + expect(within(reader).getByText(/匹配/)).toBeVisible() + } + await browser.click(screen.getByRole('tab', { name: '策略与来源' })) + expect(within(inspector()).getByText('DNS 重新校验')).toBeVisible() + expect(within(inspector()).getByText('http、https')).toBeVisible() + expect(within(inspector()).getByText('encrypted_reference')).toBeVisible() + expect(capability.manifest.input_schema).toEqual({ type: 'object' }) + }) + + it('restores an exact version outside the current page and retains it while searching', async () => { + installHandlers({ capabilitySdk: true }) + let searched = '' + server.use( + http.get('/api/v1/capabilities', ({ request }) => { + searched = new URL(request.url).searchParams.get('search') ?? '' + return HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }) + }), + ) + renderPage(`/platform?mode=capability&id=${capability.id}&version=2.0.0`) + expect(await within(inspector()).findByText(capability.schema_hash)).toBeVisible() + await userEvent.type(screen.getByLabelText('搜索平台库存'), 'page-101{Enter}') + await waitFor(() => expect(searched).toBe('page-101')) + expect(within(inspector()).getByText(`${capability.id}@2.0.0`)).toBeVisible() + }) + + it('ignores a late capability response after switching to plugin details', async () => { + installHandlers({ capabilitySdk: true }) + let finish: (value: Response) => void = () => undefined + let requested = false + server.use( + http.get('/api/v1/capabilities/:id/versions/:version', () => { + requested = true + return new Promise((resolve) => { + finish = resolve + }) + }), + ) + renderPage(`/platform?id=${capability.id}&version=2.0.0`) + await waitFor(() => expect(requested).toBe(true)) + await userEvent.click(screen.getByTitle('插件')) + await userEvent.click(await screen.findByRole('button', { name: plugin.display_name })) + await within(inspector()).findByText('插件详情') + await act(async () => { + finish(HttpResponse.json(capability)) + }) + expect(within(inspector()).queryByText('能力契约检查器')).not.toBeInTheDocument() + expect(within(inspector()).getByText(plugin.signature_identity)).toBeVisible() + }) + + it.each(['future-policy', 'constructor', '__proto__'])( + 'degrades missing schema and unknown policy %s without claiming a safe result', + async (policy) => { + installHandlers({ capabilitySdk: true }) + server.use( + http.get('/api/v1/capabilities/:id/versions/:version', () => + HttpResponse.json({ ...capability, network_access: policy, manifest: {} }), + ), + ) + renderPage(`/platform?id=${capability.id}&version=2.0.0`) + expect(await within(inspector()).findByText(`未知策略:${policy}`)).toBeVisible() + expect(within(inspector()).getAllByText('未提供').length).toBeGreaterThan(2) + await userEvent.click(screen.getByRole('tab', { name: '输入 Schema' })) + expect(within(inspector()).getByText('Schema 未提供')).toBeVisible() + expect(within(inspector()).queryByText('安全边界')).not.toBeInTheDocument() + }, + ) + + it.each([ + { mode: 'plugin', id: plugin.id, title: '插件详情', name: plugin.signature_identity }, + { mode: 'runnerPool', id: runnerPool.id, title: 'Runner Pool 详情', name: 'runner-1' }, + ])('restores a $mode URL outside an empty catalog', async ({ mode, id, title, name }) => { + installHandlers({ capabilitySdk: true }) + server.use( + http.get('/api/v1/plugins', () => + HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }), + ), + http.get('/api/v1/runner-pools', () => + HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }), + ), + ) + renderPage(`/platform?mode=${mode}&id=${id}`) + expect(await within(inspector()).findByText(title)).toBeVisible() + expect(within(inspector()).getByText(name)).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: '刷新库存' })) + expect(within(inspector()).getByText(title)).toBeVisible() + }) + + it('keeps the selected plugin while reaching page 101 of its directory', async () => { + installHandlers({ capabilitySdk: true }) + const pages: string[] = [] + server.use( + http.get('/api/v1/plugins', ({ request }) => { + const page = new URL(request.url).searchParams.get('page') ?? '1' + pages.push(page) + return HttpResponse.json({ + items: [{ ...plugin, id: `plugin-${page}`, display_name: `插件目录第${page}页` }], + total: 101, + page: Number(page), + page_size: 20, + }) + }), + ) + renderPage(`/platform?mode=plugin&id=${plugin.id}`) + await within(inspector()).findByText('插件详情') + await userEvent.click(screen.getByTitle('6')) + expect(await screen.findByRole('button', { name: '插件目录第6页' })).toBeVisible() + expect(within(inspector()).getByText(plugin.signature_identity)).toBeVisible() + expect(pages).toEqual(['1', '6']) + }) + + it('discards a late old version after another version is selected', async () => { + installHandlers({ capabilitySdk: true }) + const newer = { + ...capability, + version: '3.0.0', + display_name: '新版 HTTP 请求', + schema_hash: 'version-three-hash', + } + let finish: (value: Response) => void = () => undefined + let requested = false + server.use( + http.get('/api/v1/capabilities', () => + HttpResponse.json({ items: [capability, newer], total: 2, page: 1, page_size: 20 }), + ), + http.get('/api/v1/capabilities/:id/versions/:version', ({ params }) => { + if (params.version === '3.0.0') return HttpResponse.json(newer) + requested = true + return new Promise((resolve) => { + finish = resolve + }) + }), + ) + renderPage(`/platform?id=${capability.id}&version=2.0.0`) + await waitFor(() => expect(requested).toBe(true)) + await userEvent.click(await screen.findByRole('button', { name: '新版 HTTP 请求' })) + expect(await within(inspector()).findByText('version-three-hash')).toBeVisible() + await act(async () => { + finish(HttpResponse.json(capability)) + }) + expect(within(inspector()).queryByText(capability.schema_hash)).not.toBeInTheDocument() + expect(within(inspector()).getByText(`${capability.id}@3.0.0`)).toBeVisible() + }) + + it('hides a late detail response from the previous organization', async () => { + installHandlers({ capabilitySdk: true }) + let finish: (value: Response) => void = () => undefined + let reads = 0 + server.use( + http.get('/api/v1/plugins/:id', () => { + reads += 1 + if (reads === 1) + return new Promise((resolve) => { + finish = resolve + }) + return HttpResponse.json( + { error: { code: 'PLUGIN_NOT_FOUND', message: '当前组织无此对象' } }, + { status: 404 }, + ) + }), + ) + renderPage(`/platform?mode=plugin&id=${plugin.id}`) + await waitFor(() => expect(reads).toBe(1)) + act(() => { + useAuthStore.setState({ organizationId: 'another-organization' }) + }) + expect(await within(inspector()).findByText('当前组织无此对象')).toBeVisible() + await act(async () => { + finish(HttpResponse.json(plugin)) + }) + expect(within(inspector()).queryByText(plugin.signature_identity)).not.toBeInTheDocument() + expect(within(inspector()).queryByText('插件详情')).not.toBeInTheDocument() + }) + + it('does not retain administrator details after the same user loses that role', async () => { + installHandlers({ capabilitySdk: true }) + renderPage(`/platform?mode=plugin&id=${plugin.id}`) + await within(inspector()).findByText('插件详情') + act(() => { + useAuthStore.setState({ user: { ...user, is_system_admin: false } }) + }) + expect(within(inspector()).queryByText(plugin.signature_identity)).not.toBeInTheDocument() + expect(within(inspector()).getByText('当前角色不能读取此类库存详情')).toBeVisible() + expect(screen.getByText(/当前目录共 未提供/)).toBeVisible() }) it('does not request or expose administrator inventory to regular users', async () => { @@ -186,6 +431,9 @@ function installHandlers({ http.get('/api/v1/capabilities', () => HttpResponse.json({ items: [capability], total: 1, page: 1, page_size: 100 }), ), + http.get('/api/v1/capabilities/:id/versions/:version', () => HttpResponse.json(capability)), + http.get('/api/v1/plugins/:id', () => HttpResponse.json(plugin)), + http.get('/api/v1/runner-pools/:id', () => HttpResponse.json(runnerPool)), http.get('/api/v1/plugins', () => { if (rejectAdminRequests) throw new Error('regular users must not request plugins') return HttpResponse.json({ items: [plugin], total: 1, page: 1, page_size: 100 }) @@ -197,14 +445,20 @@ function installHandlers({ ) } -function renderPage() { +function inspector() { + return screen.getByLabelText('平台对象检查器') +} + +function renderPage(entry = '/platform') { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) return render( - + + + , ) diff --git a/frontend/src/pages/PlatformCapabilitiesPage.tsx b/frontend/src/pages/PlatformCapabilitiesPage.tsx index 2406a60e..1e5d807f 100644 --- a/frontend/src/pages/PlatformCapabilitiesPage.tsx +++ b/frontend/src/pages/PlatformCapabilitiesPage.tsx @@ -1,427 +1,401 @@ -import { - ApiOutlined, - CloudServerOutlined, - LockOutlined, - SafetyCertificateOutlined, -} from '@ant-design/icons' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { Alert, Button, Card, - Col, - Descriptions, Empty, - Row, + Input, + Pagination, Segmented, Space, - Statistic, Table, - Tag, Typography, } from 'antd' -import { useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import QueryFailureNotice from '../components/QueryFailureNotice' +import { useAuthStore } from '../features/auth/auth-store' import { + getPlatformObject, getV3FeatureFlags, listCapabilities, listPlugins, listRunnerPools, type Capability, + type PlatformSelection, type Plugin, type RunnerPool, } from '../features/capabilities/capability-service' -import { useAuthStore } from '../features/auth/auth-store' -import QueryFailureNotice from '../components/QueryFailureNotice' +import PlatformInspector from '../features/capabilities/PlatformInspector' -type ViewMode = '能力' | '插件' | 'Runner' +type ViewMode = PlatformSelection['kind'] +const modes: { label: string; value: ViewMode }[] = [ + { label: '能力', value: 'capability' }, + { label: '插件', value: 'plugin' }, + { label: 'Runner', value: 'runnerPool' }, +] export default function PlatformCapabilitiesPage() { - const isSystemAdmin = useSystemAdminStatus() - const [mode, setMode] = useState('能力') - const [selectedKey, setSelectedKey] = useState(null) - const inventory = usePlatformInventory(isSystemAdmin) - const selected = useSelectedCapability(inventory.capabilities, selectedKey) + const scope = useAuthStore( + (state) => `${state.user?.id}:${state.organizationId}:${state.user?.is_system_admin}`, + ) + return +} +function PlatformWorkspace({ scope }: { scope: string }) { + const [params, setParams] = useSearchParams() + const isAdmin = useAuthStore((state) => Boolean(state.user?.is_system_admin)) + const queryClient = useQueryClient() + const mode = viewMode(params.get('mode')) + const selection = readSelection(params, mode) + const page = pageNumber(params.get('page')) + const search = params.get('search') ?? '' + const inventory = usePlatformInventory(scope, mode, page, search, isAdmin) + const permitted = mode === 'capability' || isAdmin + const detail = useQuery({ + queryKey: ['platform', scope, 'detail', selection], + queryFn: () => getPlatformObject(selection!), + enabled: permitted && selection !== null, + }) + const updatePage = (next: number) => { + const nextParams = new URLSearchParams(params) + nextParams.set('page', String(next)) + setParams(nextParams) + } + const select = (value: PlatformSelection) => { + const next = new URLSearchParams(params) + next.set('id', value.id) + if (value.kind === 'capability') next.set('version', value.version) + setParams(next) + } + const onSearch = (value: string) => { + const next = new URLSearchParams(params) + next.set('search', value) + next.delete('page') + setParams(next) + } return (
- +
+
+ 能力与插件中心 + + 按版本核对能力、插件和 Runner 库存及已有安全策略。 + +
+ + + + +
- - - setSelectedKey(capabilityKey(item))} - /> + + + + + + value={mode} + options={modes} + onChange={(value) => setParams({ mode: value })} + /> + +
+
+ + {permitted && ( + + )} +
+ +
+
) } -function useSystemAdminStatus(): boolean { - return useAuthStore((state) => Boolean(state.user?.is_system_admin)) -} - -function usePlatformInventory(isSystemAdmin: boolean) { +function usePlatformInventory( + scope: string, + mode: ViewMode, + page: number, + search: string, + admin: boolean, +) { const flags = useQuery({ queryKey: ['v3-feature-flags'], queryFn: getV3FeatureFlags }) - const capabilities = useQuery({ queryKey: ['capabilities'], queryFn: listCapabilities }) + const capabilities = useQuery({ + queryKey: ['platform', scope, 'capabilities', page, search], + queryFn: () => listCapabilities(page, search), + enabled: mode === 'capability', + }) const plugins = useQuery({ - queryKey: ['plugins'], - queryFn: listPlugins, - enabled: isSystemAdmin, + queryKey: ['platform', scope, 'plugins', page, search], + queryFn: () => listPlugins(page, search), + enabled: admin && mode === 'plugin', }) - const runnerPools = useQuery({ - queryKey: ['runner-pools'], - queryFn: listRunnerPools, - enabled: isSystemAdmin, + const pools = useQuery({ + queryKey: ['platform', scope, 'pools', page, search], + queryFn: () => listRunnerPools(page, search), + enabled: admin && mode === 'runnerPool', }) - const capabilityItems = pageItems(capabilities.data) - const pluginItems = pageItems(plugins.data) - const poolItems = pageItems(runnerPools.data) + const active = mode === 'capability' ? capabilities : mode === 'plugin' ? plugins : pools return { - queries: [flags, capabilities, plugins, runnerPools], - capabilitiesAvailable: capabilities.isSuccess, - flags: flags.data, - capabilities: capabilityItems, - capabilitiesLoading: capabilities.isLoading, - plugins: pluginItems, - pluginCount: plugins.isSuccess ? pluginItems.length : '未提供', - pluginsLoading: plugins.isLoading, - runnerPools: poolItems, - runnerCount: runnerPools.isSuccess - ? poolItems.flatMap((pool) => pool.runners).length - : '未提供', - runnerPoolsLoading: runnerPools.isLoading, + flags, + capabilities, + plugins, + pools, + active, + queries: mode === 'capability' || admin ? [flags, active] : [flags], } } -function pageItems(page: { items: T[] } | undefined): T[] { - return page ? page.items : [] -} - -function PlatformPageHeading() { - return ( -
-
- - 能力与插件中心 - V3 · S22 - - - 以版本化 Capability 扩展执行能力,同时固定 Snapshot、Schema 哈希和 Runner 安全边界。 - -
- -
- ) -} - -function PlatformWorkspace({ - mode, - onModeChange, - selected, - onSelect, - ...inventory -}: { - mode: ViewMode - onModeChange: (mode: ViewMode) => void - selected: Capability | null - onSelect: (item: Capability) => void - isSystemAdmin: boolean - capabilities: Capability[] - capabilitiesLoading: boolean - plugins: Plugin[] - pluginsLoading: boolean - runnerPools: RunnerPool[] - runnerPoolsLoading: boolean -}) { - return ( - - - value={mode} - options={['能力', '插件', 'Runner']} - onChange={onModeChange} - /> -
-
- -
- -
-
- ) -} - function InventoryTable({ mode, - isSystemAdmin, - capabilities, - capabilitiesLoading, - plugins, - pluginsLoading, - runnerPools, - runnerPoolsLoading, - selected, + permitted, + inventory, onSelect, }: { mode: ViewMode - isSystemAdmin: boolean - capabilities: Capability[] - capabilitiesLoading: boolean - plugins: Plugin[] - pluginsLoading: boolean - runnerPools: RunnerPool[] - runnerPoolsLoading: boolean - selected: Capability | null - onSelect: (item: Capability) => void + permitted: boolean + inventory: ReturnType + onSelect: (value: PlatformSelection) => void }) { - if (mode === '能力') { + if (!permitted) return ( - ) - } - if (!isSystemAdmin) { - const message = mode === '插件' ? '仅系统管理员可查看插件清单' : '仅系统管理员可查看执行面' - return - } - if (mode === '插件') return - return + if (mode === 'capability') + return + if (mode === 'plugin') return + return } -function PluginTable({ items, loading }: { items: Plugin[]; loading: boolean }) { +function CapabilityInventory({ + query, + onSelect, +}: { + query: ReturnType['capabilities'] + onSelect: (value: PlatformSelection) => void +}) { return ( -
+ rowKey={(item) => `${item.id}@${item.version}`} + loading={query.isPending} + dataSource={query.data?.items ?? []} + pagination={false} + locale={{ + emptyText: query.isError ? '能力目录读取失败,请重试' : '没有匹配的能力', + }} columns={[ - { title: '插件', dataIndex: 'display_name' }, - { title: '标识', dataIndex: 'plugin_key' }, + { + title: '能力', + render: (_, item) => ( + + ), + }, + { title: '标识', dataIndex: 'id' }, { title: '版本', dataIndex: 'version' }, - { title: 'OCI Digest', dataIndex: 'oci_digest', ellipsis: true }, - { title: '状态', dataIndex: 'status' }, + { title: 'Runner', dataIndex: 'runner_type' }, + { title: '状态', render: (_, item) => (item.enabled ? '启用' : '预览') }, ]} /> ) } -function RunnerPoolTable({ items, loading }: { items: RunnerPool[]; loading: boolean }) { +function PluginInventory({ + query, + onSelect, +}: { + query: ReturnType['plugins'] + onSelect: (value: PlatformSelection) => void +}) { return ( -
rowKey="id" - loading={loading} - dataSource={items} - locale={{ emptyText: 'Runner Fabric 尚未启用' }} + loading={query.isPending} + dataSource={query.data?.items ?? []} + pagination={false} + locale={{ + emptyText: query.isError + ? '插件目录读取失败,请重试' + : '尚未安装管理员签名插件或没有匹配结果', + }} columns={[ - { title: '池名称', dataIndex: 'name' }, - { title: 'Runner 类型', dataIndex: 'runner_type' }, - { title: '网络区', dataIndex: 'network_zone' }, - { title: '并发', dataIndex: 'max_concurrency' }, - { title: 'Runner', render: (_, pool) => `${pool.runners.length} 个` }, + { + title: '插件', + render: (_, item) => ( + + ), + }, + { title: '标识', dataIndex: 'plugin_key' }, + { title: '版本', dataIndex: 'version' }, + { title: 'OCI Digest', dataIndex: 'oci_digest', ellipsis: true }, + { title: '状态', dataIndex: 'status' }, ]} /> ) } -function FeatureBoundaryAlert({ - flags, +function PoolInventory({ + query, + onSelect, }: { - flags?: Awaited> + query: ReturnType['pools'] + onSelect: (value: PlatformSelection) => void }) { - if (flags?.capability_sdk !== false) return null return ( - + rowKey="id" + loading={query.isPending} + dataSource={query.data?.items ?? []} + pagination={false} + locale={{ + emptyText: query.isError ? 'Runner 目录读取失败,请重试' : '没有匹配的 Runner Pool', + }} + columns={[ + { + title: '池名称', + render: (_, item) => ( + + ), + }, + { title: '类型', dataIndex: 'runner_type' }, + { title: '网络区', dataIndex: 'network_zone' }, + { title: '并发上限', dataIndex: 'max_concurrency' }, + { title: 'Runner', render: (_, item) => `${item.runners.length} 个` }, + ]} /> ) } -function CapabilityStatistics({ - available, - capabilities, - pluginCount, - runnerCount, +function SelectedInspector({ + selection, + detail, + runnerEnabled, }: { - available: boolean - capabilities: Capability[] - pluginCount: number | string - runnerCount: number | string + selection: PlatformSelection | null + detail: ReturnType>>> + runnerEnabled: boolean }) { - const enabled = capabilities.filter((item) => item.enabled).length - const runnerTypes = new Set(capabilities.map((item) => item.runner_type)).size + if (!selection) return return ( - - - - } - /> - - - - - } - /> - - - - - } /> - - - - - } - /> - - - + <> + + {detail.isError && detail.data && ( + + )} + {detail.isPending && 正在读取所选对象} + {detail.data && ( + + )} + ) } -function CapabilityTable({ - items, - loading, - selected, - onSelect, +function FeatureNotice({ + mode, + flags, }: { - items: Capability[] - loading: boolean - selected: Capability | null - onSelect: (item: Capability) => void + mode: ViewMode + flags: Awaited> | undefined }) { - return ( -
- capabilityKey(item) === capabilityKey(selected) ? 'selected-row' : '' - } - onRow={(item) => ({ onClick: () => onSelect(item) })} - columns={[ - { - title: '能力', - render: (_, item) => ( - - {item.id} - {item.display_name} - - ), - }, - { - title: '类型', - dataIndex: 'source', - render: (value) => (value === 'builtin' ? '内置' : '插件'), - }, - { title: '版本', dataIndex: 'version' }, - { title: 'Runner', dataIndex: 'runner_type' }, - { title: '网络策略', dataIndex: 'network_access', render: networkLabel }, - { - title: '状态', - dataIndex: 'enabled', - render: (value) => ( - {value ? '启用' : '预览'} - ), - }, - ]} - /> - ) -} - -function CapabilityInspector({ capability }: { capability: Capability | null }) { - if (!capability) + if (!flags) return + if (mode === 'capability' && !flags.capability_sdk) return ( - - ) - const manifest = capability.manifest - return ( - - ) + ) + if (mode === 'plugin' && !flags.plugin_registry) + return + if (mode === 'runnerPool' && !flags.runner_fabric) + return + return null } -function useSelectedCapability(items: Capability[], selectedKey: string | null) { - return useMemo( - () => items.find((item) => capabilityKey(item) === selectedKey) ?? items[0] ?? null, - [items, selectedKey], - ) +function viewMode(value: string | null): ViewMode { + return value === 'plugin' || value === 'runnerPool' ? value : 'capability' } - -function capabilityKey(item: Capability | null): string { - return item ? `${item.id}@${item.version}` : '' +function pageNumber(value: string | null): number { + const page = Number(value) + return Number.isSafeInteger(page) && page > 0 ? page : 1 +} +function readSelection(params: URLSearchParams, mode: ViewMode): PlatformSelection | null { + const id = params.get('id') + if (!id) return null + if (mode !== 'capability') return { kind: mode, id } + const version = params.get('version') + return version ? { kind: mode, id, version } : null } -function networkLabel(value: string): string { +function InventorySummary({ page }: { page: { total: number; items: unknown[] } | undefined }) { return ( - { - denied: '禁止网络', - project_allowlist: '项目白名单', - broker_only: '仅 Broker', - }[value] ?? value + + 当前目录共 {page?.total ?? '未提供'} 项,已载入 {page?.items.length ?? '未提供'}{' '} + 项。搜索覆盖当前模式全部库存;选中详情独立于目录页。 + ) } +function inventoryTotal(page: { total: number } | undefined): number { + return page?.total ?? 0 +} + +function InventoryStaleNotice({ query }: { query: { isError: boolean; data: unknown } }) { + if (!query.isError || query.data === undefined) return null + return +} From 3752a7bd69b68d95d9883cc119664e34923de577 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:19:33 +0800 Subject: [PATCH 15/35] fix(execution-fabric): scope lease-linked event history --- backend/app/repositories/runner_fabric.py | 26 +++- backend/tests/test_runner_fabric.py | 124 +++++++++++++++++- .../2026-10-20/batch-27-execution-fabric.md | 10 ++ 3 files changed, 151 insertions(+), 9 deletions(-) diff --git a/backend/app/repositories/runner_fabric.py b/backend/app/repositories/runner_fabric.py index a1a754a5..e43d9a1a 100644 --- a/backend/app/repositories/runner_fabric.py +++ b/backend/app/repositories/runner_fabric.py @@ -2,7 +2,7 @@ from typing import Any, cast from uuid import UUID -from sqlalchemy import Select, case, func, or_, select, update +from sqlalchemy import Select, and_, case, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import ColumnElement @@ -315,7 +315,14 @@ async def lease_page( async def event_page( self, *, organization_id: UUID | None, page: int, limit: int, task_id: UUID | None ) -> tuple[list[RunnerEvent], int]: - statement = select(RunnerEvent).join(RunnerPool, RunnerPool.id == RunnerEvent.pool_id) + event_task_id = func.coalesce(RunnerEvent.task_id, RunnerLeaseRecord.task_id) + statement = ( + select(RunnerEvent) + .join(RunnerPool, RunnerPool.id == RunnerEvent.pool_id) + .outerjoin(RunnerLeaseRecord, RunnerLeaseRecord.id == RunnerEvent.lease_id) + .outerjoin(RunnerTask, RunnerTask.id == event_task_id) + .outerjoin(Project, Project.id == RunnerTask.project_id) + ) if organization_id is not None: statement = statement.where( or_( @@ -323,13 +330,18 @@ async def event_page( RunnerPool.organization_id.is_(None), ) ) - # Shared pools can contain other organizations' tasks. Scope task events too. - statement = statement.outerjoin(RunnerTask, RunnerTask.id == RunnerEvent.task_id) - statement = statement.outerjoin(Project, Project.id == RunnerTask.project_id).where( - or_(RunnerEvent.task_id.is_(None), self._inspection_scope(organization_id)) + statement = statement.where( + or_( + and_(RunnerEvent.task_id.is_(None), RunnerEvent.lease_id.is_(None)), + and_( + RunnerTask.id.is_not(None), + Project.id.is_not(None), + self._inspection_scope(organization_id), + ), + ) ) if task_id is not None: - statement = statement.where(RunnerEvent.task_id == task_id) + statement = statement.where(event_task_id == task_id) return await self._inspection_page( statement.order_by(RunnerEvent.created_at.desc(), RunnerEvent.id), page, limit ) diff --git a/backend/tests/test_runner_fabric.py b/backend/tests/test_runner_fabric.py index 77ddb615..0a0a9d48 100644 --- a/backend/tests/test_runner_fabric.py +++ b/backend/tests/test_runner_fabric.py @@ -42,10 +42,10 @@ from app.models.access import Project, User from app.models.artifacts import Artifact from app.models.durable_execution import ExecutionCommand -from app.models.runner_fabric import RunnerLeaseRecord, RunnerTask +from app.models.runner_fabric import RunnerEvent, RunnerLeaseRecord, RunnerTask from app.models.workflows import WorkflowExecution, WorkflowNodeExecution from app.repositories.durable_execution import DurableExecutionRepository -from app.repositories.runner_fabric import _advisory_lock_key +from app.repositories.runner_fabric import RunnerFabricRepository, _advisory_lock_key from app.runner.agent import ( RunnerAgent, _checkpoint_payload, @@ -1430,6 +1430,126 @@ async def test_inspection_scope_rejects_foreign_tasks_even_in_shared_pool( reset_tenant_context(context_token) +@pytest.mark.asyncio +async def test_progress_event_uses_existing_lease_for_scope_and_exact_task_history( + fabric_sessions: async_sessionmaker[AsyncSession], +) -> None: + from app.core.context import reset_tenant_context, set_tenant_context + from app.domain.tenant import TenantContext + from app.models.organizations import Organization + + async with fabric_sessions() as session: + actor, project = await _seed_actor_and_project(session) + organization_a = Organization( + name="Inspection A", slug="inspection-a", description="", created_by_id=actor.id + ) + organization_b = Organization( + name="Inspection B", slug="inspection-b", description="", created_by_id=actor.id + ) + session.add_all([organization_a, organization_b]) + await session.flush() + project.organization_id = organization_b.id + await session.commit() + service = RunnerFabricService(session, enabled=True) + pool = await service.create_pool(actor=actor, payload=_pool_payload()) + token = await _register_runner(service, actor, pool.id, "scoped-progress-runner") + plan, _execution = await _seed_execution_plan(session, actor, project) + task = await service.enqueue(plan) + lease = await service.claim(runner_token=token) + assert lease is not None + await service.progress( + runner_token=token, + lease_id=lease.lease_id, + payload=RunnerProgressRequest( + fencing_token=lease.task.fencing_token, + progress_percent=42, + message="组织 B 的任务进度", + ), + ) + progress = await session.scalar( + select(RunnerEvent).where(RunnerEvent.kind == "lease_renewed") + ) + assert progress is not None + assert progress.task_id is None and progress.lease_id == lease.lease_id + repository = RunnerFabricRepository(session) + foreign_events, foreign_total = await repository.event_page( + organization_id=organization_a.id, page=1, limit=20, task_id=None + ) + assert foreign_total == len(foreign_events) == 1 + assert foreign_events[0].kind == "registered" + own_events, own_total = await repository.event_page( + organization_id=organization_b.id, page=1, limit=20, task_id=None + ) + assert own_total == len(own_events) == 3 + assert progress.id in {event.id for event in own_events} + context_token = set_tenant_context( + TenantContext( + organization_id=organization_b.id, + actor_id=actor.id, + role=None, + is_system_admin=True, + ) + ) + try: + first, total = await service.event_page(actor=actor, page=1, limit=1, task_id=task.id) + second, second_total = await service.event_page( + actor=actor, page=2, limit=1, task_id=task.id + ) + assert total == second_total == 2 + assert len(first) == len(second) == 1 + assert first[0].id != second[0].id + assert progress.id in {first[0].id, second[0].id} + finally: + reset_tenant_context(context_token) + all_scope, all_total = await repository.event_page( + organization_id=None, page=1, limit=20, task_id=task.id + ) + assert all_total == len(all_scope) == 2 + assert progress.id in {event.id for event in all_scope} + project.organization_id = None + await session.commit() + shared_project_events, shared_total = await repository.event_page( + organization_id=organization_a.id, page=1, limit=20, task_id=task.id + ) + assert shared_total == len(shared_project_events) == 2 + assert progress.id in {event.id for event in shared_project_events} + + +@pytest.mark.asyncio +async def test_unresolved_task_or_lease_does_not_become_a_shared_pool_event( + fabric_sessions: async_sessionmaker[AsyncSession], +) -> None: + async with fabric_sessions() as session: + actor, _project = await _seed_actor_and_project(session) + service = RunnerFabricService(session, enabled=True) + pool = await service.create_pool(actor=actor, payload=_pool_payload()) + session.add_all( + [ + RunnerEvent( + pool_id=pool.id, + task_id=None, + lease_id=uuid4(), + kind="lease_renewed", + message="无法解析的历史 Lease", + details={}, + ), + RunnerEvent( + pool_id=pool.id, + task_id=uuid4(), + lease_id=None, + kind="lease_acquired", + message="无法解析的历史任务", + details={}, + ), + ] + ) + await session.commit() + events, total = await RunnerFabricRepository(session).event_page( + organization_id=uuid4(), page=1, limit=20, task_id=None + ) + assert events == [] and total == 0 + + @pytest.mark.asyncio async def test_runner_fabric_admin_and_agent_api( fabric_api_context: FabricApiContext, diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-27-execution-fabric.md b/docs/design/ui-v5/remediation/2026-10-20/batch-27-execution-fabric.md index ca2f2186..29ca6125 100644 --- a/docs/design/ui-v5/remediation/2026-10-20/batch-27-execution-fabric.md +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-27-execution-fabric.md @@ -23,6 +23,16 @@ - 完整原始日志保留在本工作树 `output/validation/m25-execution-fabric/`(未提交生成物)。 - 独立只读子审指出的缓存 Lease 刷新失败仍称有效问题已整改;未改变任何重试、超时或覆盖率门槛。 +## 独立复审修复(2026-10-22) + +复审 `761b87b` 时通过真实 `progress()` 写入复现:共享 Pool 中组织 B 的进度事件只关联 Lease、没有 `task_id`,组织 A 的事件目录错误读到该消息,组织 B 的精确任务事件目录却遗漏它。原隔离测试未生成进度事件,单独检查 `task_id is None` 无法识别该问题。 + +本次仅修复 repository 查询:使用事件直接任务 ID 或已有 Lease 的任务 ID 解析归属,同时用于组织过滤与精确任务定位;只有任务 ID、Lease ID 均为空的记录才按 Pool 事件处理。带有关联但无法解析任务及项目的记录在组织范围内不再被当作共享事件。列表与 total 继续使用同一查询,稳定分页不变。未修改 progress 写入路径,没有新增事件、日志、采集、数据库字段或迁移。 + +新增两个行为反例先得到 2 项失败、原有 35 项通过,修复后 `make test-backend-targeted TARGETS='["tests/test_runner_fabric.py"]'` 共 37 项通过。覆盖真实 progress 产生的既有记录格式、其他组织不可见、共享注册事件仍可见、本组织及全部组织按任务定位、共享项目兼容、两页无重复及 total 一致、无法解析 Lease / 任务的失败关闭。受影响文件 Ruff 格式和检查通过,`uv run mypy app` 的 445 个源文件通过,`git diff --check` 通过。 + +复审修复原始日志位于本工作树 `output/validation/m25-fabric-review/`:`backend-red.log`、`backend-green.log`、`backend-mypy.log`、`backend-static.log`、`receipt-format.log`。本修复未运行前端、全量覆盖率或真实浏览器验收,其余验收边界仍按下节单独记账。 + ## 条件环境与验收边界 真实动态 GUI、Runner 心跳/故障转移、完整 `make check`、Compose 端到端、正式最新提交门禁、合并后 CI 和发布验收尚未执行,由整合负责人分别记账。当前 targeted 通过不代表这些验收通过。 From 654b13ec658bc18dc5c3c05b5307831c5775023a Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:25:32 +0800 Subject: [PATCH 16/35] fix(mcp): read exact materialized design behind proposal authorization --- .../api/v1/endpoints/mcp_controlled_write.py | 16 ++ backend/app/schemas/test_design.py | 13 ++ backend/app/services/mcp_controlled_write.py | 66 ++++++++ backend/tests/test_mcp_materialized_design.py | 155 ++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 backend/tests/test_mcp_materialized_design.py diff --git a/backend/app/api/v1/endpoints/mcp_controlled_write.py b/backend/app/api/v1/endpoints/mcp_controlled_write.py index 1b18aacf..df906f09 100644 --- a/backend/app/api/v1/endpoints/mcp_controlled_write.py +++ b/backend/app/api/v1/endpoints/mcp_controlled_write.py @@ -15,6 +15,7 @@ MCPControlledWriteEnvelope, MCPControlledWriteReview, MCPManualApprovalCreate, + MCPMaterializedDesignResponse, ) from app.services.mcp_controlled_write import MCPControlledWriteService @@ -54,6 +55,21 @@ async def get_change_set( ) +@router.get( + "/change-sets/{change_set_id}/items/{item_id}/materialized-design", + response_model=MCPMaterializedDesignResponse, +) +async def get_materialized_design( + change_set_id: UUID, + item_id: UUID, + session: SessionDependency, + current_user: CurrentUser, +) -> MCPMaterializedDesignResponse: + return await MCPControlledWriteService(session).materialized_design( + actor=current_user, change_set_id=change_set_id, item_id=item_id + ) + + @router.get( "/change-sets/{change_set_id}/control-block-preview", response_model=MCPControlBlockPreviewResponse, diff --git a/backend/app/schemas/test_design.py b/backend/app/schemas/test_design.py index 362695bf..038f129f 100644 --- a/backend/app/schemas/test_design.py +++ b/backend/app/schemas/test_design.py @@ -1,5 +1,6 @@ """API contracts for S42 test design proposals and controlled writes.""" +from datetime import datetime from typing import Literal from uuid import UUID @@ -55,3 +56,15 @@ class MCPControlledWriteEnvelope(BaseModel): redactions: list[str] = Field(default_factory=list, max_length=100) trace_id: str = Field(min_length=1, max_length=128) warnings: list[str] = Field(default_factory=list, max_length=100) + + +class MCPMaterializedDesignResponse(BaseModel): + id: UUID + project_id: UUID + name: str + status: str + fingerprint: str + source_change_set_id: UUID + reviewed_by_id: UUID | None + reviewed_at: datetime | None + design: TestDesignDocument diff --git a/backend/app/services/mcp_controlled_write.py b/backend/app/services/mcp_controlled_write.py index b96852da..7547e74e 100644 --- a/backend/app/services/mcp_controlled_write.py +++ b/backend/app/services/mcp_controlled_write.py @@ -45,6 +45,7 @@ MCPControlledWriteEnvelope, MCPControlledWriteReview, MCPManualApprovalCreate, + MCPMaterializedDesignResponse, MCPTestCaseDraft, ) from app.schemas.workflows import WorkflowControlBlockInsert @@ -205,6 +206,33 @@ async def get_for_user(self, *, actor: User, change_set_id: UUID) -> MCPControll warnings=_warnings_from_snapshot(change_set), ) + async def materialized_design( + self, *, actor: User, change_set_id: UUID, item_id: UUID + ) -> MCPMaterializedDesignResponse: + change_set = await self._get_change_set(change_set_id) + await self._projects.authorize(actor=actor, project_id=change_set.project_id, editing=False) + items = await self._change_sets.list_items(change_set.id) + item = next((candidate for candidate in items if candidate.id == item_id), None) + design_id = _materialized_design_id(item) + design = await self._session.get(TestDesign, design_id) + if ( + design is None + or design.project_id != change_set.project_id + or design.source_change_set_id != change_set.id + ): + raise _materialized_design_not_found() + return MCPMaterializedDesignResponse( + id=design.id, + project_id=design.project_id, + name=design.name, + status=design.status, + fingerprint=design.fingerprint, + source_change_set_id=change_set.id, + reviewed_by_id=design.reviewed_by_id, + reviewed_at=design.reviewed_at, + design=_materialized_design_document(design), + ) + async def control_block_preview( self, *, actor: User, change_set_id: UUID ) -> MCPControlBlockPreviewResponse: @@ -839,3 +867,41 @@ def _warnings_from_snapshot(change_set: AIChangeSet) -> list[str]: def _not_found() -> AppError: return AppError(code="MCP_CHANGE_SET_NOT_FOUND", message="受控变更集不存在", status_code=404) + + +def _materialized_design_id(item: AIChangeItem | None) -> UUID: + if ( + item is None + or item.item_type != "test_design" + or item.review_status != "accepted" + or item.materialized_resource_type != "test_design" + or item.materialized_resource_id is None + ): + raise _materialized_design_not_found() + return item.materialized_resource_id + + +def _materialized_design_not_found() -> AppError: + return AppError( + code="MCP_MATERIALIZED_DESIGN_NOT_FOUND", + message="该变更项没有可读取的已物化测试设计", + status_code=404, + ) + + +def _materialized_design_document(design: TestDesign) -> TestDesignDocument: + return TestDesignDocument.model_validate( + { + "intent": design.intent, + "knowledge_graph": design.knowledge_graph, + "state_model": design.state_model or None, + "scenarios": design.scenarios, + "oracles": design.oracles, + "coverage": design.coverage, + "evidence_refs": design.evidence_refs, + "warnings": design.warnings, + "confidence": design.confidence, + "review_requirements": design.review_requirements, + "test_case_refs": design.test_case_refs, + } + ) diff --git a/backend/tests/test_mcp_materialized_design.py b/backend/tests/test_mcp_materialized_design.py new file mode 100644 index 00000000..0eb0ccff --- /dev/null +++ b/backend/tests/test_mcp_materialized_design.py @@ -0,0 +1,155 @@ +"""M23 exact materialized design reads preserve review and project boundaries.""" + +from typing import Any +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import func, select +from test_mcp_read import _controlled_write_payload +from test_mcp_read import mcp_context as mcp_context + +from app.core.security import password_service, token_service +from app.domain.access import ProjectRole +from app.models.access import AuditLog, ProjectMember, User +from app.models.ai import AIChangeItem +from app.models.organizations import OrganizationMember +from app.models.test_design import TestDesign as DesignModel + + +async def _accepted_design(context: dict[str, Any]) -> tuple[str, str, str, dict[str, str]]: + client = context["client"] + headers = { + "Authorization": f"Bearer {context['human_token']}", + "X-Organization-ID": str(context["organization_id"]), + } + payload = _controlled_write_payload(context, objective="核对已授权设计 alice@example.com") + proposed = await client.post( + "/api/v1/mcp/write/change-sets", + headers={"Authorization": f"Bearer {context['write_token']}"}, + json=payload, + ) + assert proposed.status_code == 202, proposed.text + change_set = proposed.json()["data"] + root = f"/api/v1/mcp/write/change-sets/{change_set['id']}" + item = change_set["items"][0] + pending = await client.get(f"{root}/items/{item['id']}/materialized-design", headers=headers) + assert pending.status_code == 404 + approval = await client.post(f"{root}/approve", headers=headers, json={}) + assert approval.status_code == 200, approval.text + accepted = await client.post( + f"{root}/items/{item['id']}/accept", + headers=headers, + json={"approval_id": approval.json()["data"]["approval"]["id"]}, + ) + assert accepted.status_code == 200, accepted.text + design_id = accepted.json()["data"]["items"][0]["materialized_resource_id"] + return change_set["id"], item["id"], design_id, headers + + +@pytest.mark.asyncio +async def test_exact_materialized_design_reads_saved_asset_without_scanning_or_writes( + mcp_context: dict[str, Any], +) -> None: + change_id, item_id, design_id, headers = await _accepted_design(mcp_context) + async with mcp_context["sessions"]() as session: + item = await session.get(AIChangeItem, UUID(item_id)) + assert item is not None + content = dict(item.proposed_content) + content["intent"] = {**content["intent"], "objective": "后续变更提案不能冒充资产"} + item.proposed_content = content + await session.commit() + before = await session.scalar(select(func.count()).select_from(AuditLog)) + root = f"/api/v1/mcp/write/change-sets/{change_id}/items/{item_id}/materialized-design" + response = await mcp_context["client"].get(root, headers=headers) + assert response.status_code == 200, response.text + body = response.json() + assert body["id"] == design_id and body["source_change_set_id"] == change_id + assert body["project_id"] == str(mcp_context["project_id"]) + assert body["design"]["intent"]["objective"] == "核对已授权设计 alice@example.com" + assert body["status"] == "approved" and body["reviewed_at"] + async with mcp_context["sessions"]() as session: + assert await session.scalar(select(func.count()).select_from(AuditLog)) == before + wrong_org = await mcp_context["client"].get( + root, headers={**headers, "X-Organization-ID": str(uuid4())} + ) + assert wrong_org.status_code in (403, 404) + assert wrong_org.json()["error"]["trace_id"] + + +@pytest.mark.asyncio +async def test_materialized_design_allows_project_viewer_and_rejects_foreign_asset_or_item( + mcp_context: dict[str, Any], +) -> None: + change_id, item_id, design_id, headers = await _accepted_design(mcp_context) + async with mcp_context["sessions"]() as session: + viewer = User( + email="design-viewer@example.com", + display_name="设计只读者", + password_hash=password_service.hash("unused-viewer-password"), + is_active=True, + is_system_admin=False, + requires_password_change=False, + ) + session.add(viewer) + await session.flush() + session.add( + ProjectMember( + project_id=mcp_context["project_id"], user_id=viewer.id, role=ProjectRole.VIEWER + ) + ) + session.add( + OrganizationMember( + organization_id=mcp_context["organization_id"], user_id=viewer.id, role="member" + ) + ) + await session.commit() + viewer_headers = { + **headers, + "Authorization": f"Bearer {token_service.create_access_token(viewer.id)}", + } + root = f"/api/v1/mcp/write/change-sets/{change_id}/items" + allowed = await mcp_context["client"].get( + f"{root}/{item_id}/materialized-design", headers=viewer_headers + ) + assert allowed.status_code == 200, allowed.text + missing = await mcp_context["client"].get( + f"{root}/{uuid4()}/materialized-design", headers=headers + ) + assert missing.status_code == 404 and missing.json()["error"]["trace_id"] + async with mcp_context["sessions"]() as session: + design = await session.get(DesignModel, UUID(design_id)) + assert design is not None + design.project_id = mcp_context["other_project_id"] + await session.commit() + foreign = await mcp_context["client"].get( + f"{root}/{item_id}/materialized-design", headers=headers + ) + assert foreign.status_code == 404 and foreign.json()["error"]["trace_id"] + + +@pytest.mark.asyncio +async def test_materialized_design_rejects_invalid_review_and_resource_associations( + mcp_context: dict[str, Any], +) -> None: + change_id, item_id, design_id, headers = await _accepted_design(mcp_context) + root = f"/api/v1/mcp/write/change-sets/{change_id}/items/{item_id}/materialized-design" + async with mcp_context["sessions"]() as session: + item = await session.get(AIChangeItem, UUID(item_id)) + design = await session.get(DesignModel, UUID(design_id)) + assert item is not None and design is not None + for field, invalid, original in ( + ("review_status", "pending", "accepted"), + ("materialized_resource_type", "workflow", "test_design"), + ("item_type", "test_case", "test_design"), + ("change_set_id", uuid4(), UUID(change_id)), + ): + setattr(item, field, invalid) + await session.commit() + rejected = await mcp_context["client"].get(root, headers=headers) + assert rejected.status_code == 404 and rejected.json()["error"]["trace_id"] + setattr(item, field, original) + await session.commit() + design.source_change_set_id = uuid4() + await session.commit() + rejected = await mcp_context["client"].get(root, headers=headers) + assert rejected.status_code == 404 and rejected.json()["error"]["trace_id"] From 57bdf809ff5e6e31223e14ac9ad1efef218a85e8 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:35:02 +0800 Subject: [PATCH 17/35] fix(ai): respect OFF policy when accepting literal draft values --- backend/app/services/ai_change_sets.py | 6 +- .../test_ai_change_set_redaction_policy.py | 306 ++++++++++++++++++ .../2026-10-20/batch-24-off-policy-review.md | 30 ++ 3 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_ai_change_set_redaction_policy.py create mode 100644 docs/design/ui-v5/remediation/2026-10-20/batch-24-off-policy-review.md diff --git a/backend/app/services/ai_change_sets.py b/backend/app/services/ai_change_sets.py index 7cd1938d..de2c0ad8 100644 --- a/backend/app/services/ai_change_sets.py +++ b/backend/app/services/ai_change_sets.py @@ -687,6 +687,8 @@ def _rehydrate_workflow_definition( def _restore_redacted_value( proposed: JsonValue, current: JsonValue | object = _MISSING_REDACTED_SOURCE ) -> JsonValue: + if not redaction_enabled(): + return proposed if proposed == REDACTED: if current is _MISSING_REDACTED_SOURCE: raise AppError( @@ -993,7 +995,7 @@ def _test_case_create(title: str, content: dict[str, JsonValue]) -> AITestCaseDr message="AI Test Case 创建内容必须符合受支持的草稿字段", status_code=422, ) from error - if _contains_redacted(create.definition.model_dump(mode="json")): + if redaction_enabled() and _contains_redacted(create.definition.model_dump(mode="json")): raise AppError( code="AI_TEST_CASE_DRAFT_SECRET_BINDING_REQUIRED", message="AI Test Case 创建草稿不能包含脱敏占位符。请创建后重新绑定运行值", @@ -1017,7 +1019,7 @@ def _workflow_create(title: str, content: dict[str, JsonValue]) -> AIWorkflowDra message="AI Workflow 创建内容必须符合受支持的草稿字段", status_code=422, ) from error - if _contains_redacted(create.definition.model_dump(mode="json")): + if redaction_enabled() and _contains_redacted(create.definition.model_dump(mode="json")): raise AppError( code="AI_WORKFLOW_DRAFT_SECRET_BINDING_REQUIRED", message="AI Workflow 创建草稿不能包含脱敏占位符。请创建后重新绑定运行值", diff --git a/backend/tests/test_ai_change_set_redaction_policy.py b/backend/tests/test_ai_change_set_redaction_policy.py new file mode 100644 index 00000000..cf9b80bd --- /dev/null +++ b/backend/tests/test_ai_change_set_redaction_policy.py @@ -0,0 +1,306 @@ +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Literal +from uuid import UUID + +import pytest +from sqlalchemy import select +from test_quality_intelligence import ( + ADMIN_EMAIL, + QualityContext, + _login, + _project, + _workflow_definition, +) +from test_quality_intelligence import quality_context as quality_context + +from app.models.access import User +from app.models.ai import AIChangeItem, AIChangeSet +from app.models.test_assets import TestCase as CaseModel +from app.models.workflows import Workflow +from app.services.ai_change_sets import _target_hash + +AssetType = Literal["test_case", "workflow"] + + +@dataclass(frozen=True) +class ReviewTarget: + project_id: str + resource_id: str + definition: dict[str, Any] + + +async def _review_target( + context: QualityContext, headers: dict[str, str], asset_type: AssetType, mode: str +) -> ReviewTarget: + project_id = await _project(context.client, headers, f"审核策略 {asset_type} {mode}") + policy = await context.client.put( + f"/api/v1/projects/{project_id}/redaction-policy", + headers=headers, + json={"mode": mode}, + ) + assert policy.status_code == 200, policy.text + workflow_definition = _workflow_definition() + workflow_definition["variables"] = {"existing": "original-variable"} + workflow_definition["nodes"][0]["config"] = { + "existing": "original-config", + "items": ["first", "second"], + } + workflow = await context.client.post( + f"/api/v1/projects/{project_id}/workflows", + headers=headers, + json={"name": "人工维护流程", "definition": workflow_definition}, + ) + assert workflow.status_code == 201, workflow.text + if asset_type == "workflow": + workflow_id = workflow.json()["id"] + return ReviewTarget( + project_id, workflow_id, await _stored_definition(context, asset_type, workflow_id) + ) + environment = await context.client.post( + f"/api/v1/projects/{project_id}/environments", + headers=headers, + json={"name": "测试环境", "base_url": "https://example.test"}, + ) + assert environment.status_code == 201, environment.text + definition = { + "workflow_id": workflow.json()["id"], + "environment_id": environment.json()["id"], + "runtime_variables": {"existing": "original-variable"}, + "runtime_headers": {"X-Existing": "original-header"}, + } + case = await context.client.post( + f"/api/v1/projects/{project_id}/test-cases", + headers=headers, + json={"name": "人工维护用例", "definition": definition}, + ) + assert case.status_code == 201, case.text + case_id = case.json()["id"] + return ReviewTarget(project_id, case_id, await _stored_definition(context, asset_type, case_id)) + + +async def _review_item( + context: QualityContext, + target: ReviewTarget, + asset_type: AssetType, + action: str, + definition: dict[str, Any], +) -> tuple[UUID, UUID]: + async with context.sessions() as session: + actor_id = await session.scalar(select(User.id).where(User.email == ADMIN_EMAIL)) + assert actor_id is not None + resource = await session.get( + Workflow if asset_type == "workflow" else CaseModel, UUID(target.resource_id) + ) + assert resource is not None + change_set = AIChangeSet( + project_id=UUID(target.project_id), + title="人工审核草稿", + status="draft", + source_snapshot={}, + source_fingerprint="a" * 64, + created_by_id=actor_id, + ) + session.add(change_set) + await session.flush() + item = AIChangeItem( + change_set_id=change_set.id, + position=0, + item_type=asset_type, + action=action, + title="审核后草稿", + target_resource_id=resource.id if action == "update" else None, + target_snapshot_sha256=_target_hash(resource) if action == "update" else None, + proposed_content={"name": f"审核后 {action} 草稿", "definition": definition}, + ) + session.add(item) + await session.commit() + return change_set.id, item.id + + +def _literal_definition(target: ReviewTarget, asset_type: AssetType, field: str) -> dict[str, Any]: + definition = deepcopy(target.definition) + if asset_type == "test_case": + definition["runtime_variables"] = {field: "[REDACTED]"} + definition["runtime_headers"] = { + "X-Existing" if field == "existing" else "X-New": "[REDACTED]" + } + return definition + definition["variables"] = {field: "[REDACTED]"} + definition["nodes"][0]["config"] = { + field: "[REDACTED]", + "items": ["[REDACTED]", "literal [REDACTED] suffix", {"new": "[REDACTED]"}], + } + return definition + + +async def _stored_definition( + context: QualityContext, asset_type: AssetType, resource_id: str +) -> dict[str, Any]: + async with context.sessions() as session: + resource = await session.get( + Workflow if asset_type == "workflow" else CaseModel, UUID(resource_id) + ) + assert resource is not None + assert resource.current_version is None + return resource.draft_definition + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asset_type", ["workflow", "test_case"]) +@pytest.mark.parametrize("action", ["create", "update"]) +@pytest.mark.parametrize("field", ["existing", "new"]) +async def test_off_review_preserves_literal_redacted_values( + quality_context: QualityContext, asset_type: AssetType, action: str, field: str +) -> None: + headers = await _login(quality_context.client) + target = await _review_target(quality_context, headers, asset_type, "off") + definition = _literal_definition(target, asset_type, field) + change_id, item_id = await _review_item(quality_context, target, asset_type, action, definition) + response = await quality_context.client.post( + f"/api/v1/ai/change-sets/{change_id}/items/{item_id}/accept", + headers=headers, + json={"content": {"name": "审核后草稿", "definition": definition}}, + ) + assert response.status_code == 200, response.text + stored = await _stored_definition( + quality_context, asset_type, response.json()["materialized_resource_id"] + ) + for field_name in definition: + assert stored[field_name] == definition[field_name] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asset_type", ["workflow", "test_case"]) +@pytest.mark.parametrize("mode", ["off", "on"]) +async def test_review_update_preserves_omitted_runtime_fields( + quality_context: QualityContext, asset_type: AssetType, mode: str +) -> None: + headers = await _login(quality_context.client) + target = await _review_target(quality_context, headers, asset_type, mode) + definition = deepcopy(target.definition) + if asset_type == "workflow": + definition.pop("variables") + definition["nodes"][0].pop("config") + else: + definition.pop("runtime_variables") + definition.pop("runtime_headers") + change_id, item_id = await _review_item( + quality_context, target, asset_type, "update", definition + ) + response = await quality_context.client.post( + f"/api/v1/ai/change-sets/{change_id}/items/{item_id}/accept", + headers=headers, + json={}, + ) + assert response.status_code == 200, response.text + stored = await _stored_definition(quality_context, asset_type, target.resource_id) + for field_name in target.definition: + assert stored[field_name] == target.definition[field_name] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asset_type", ["workflow", "test_case"]) +async def test_review_redacted_guard_is_scoped_to_each_project_request( + quality_context: QualityContext, asset_type: AssetType +) -> None: + headers = await _login(quality_context.client) + off_target = await _review_target(quality_context, headers, asset_type, "off") + on_target = await _review_target(quality_context, headers, asset_type, "on") + original_on = await _stored_definition(quality_context, asset_type, on_target.resource_id) + for target, action, accepted in [ + (on_target, "create", False), + (off_target, "create", True), + (on_target, "update", False), + (off_target, "update", True), + ]: + definition = _literal_definition(target, asset_type, "new") + change_id, item_id = await _review_item( + quality_context, target, asset_type, action, definition + ) + response = await quality_context.client.post( + f"/api/v1/ai/change-sets/{change_id}/items/{item_id}/accept", + headers=headers, + json={}, + ) + if accepted: + assert response.status_code == 200, response.text + stored = await _stored_definition( + quality_context, asset_type, response.json()["materialized_resource_id"] + ) + for field_name in definition: + assert stored[field_name] == definition[field_name] + else: + assert response.status_code == 422, response.text + expected_code = ( + f"AI_{asset_type.upper()}_DRAFT_SECRET_BINDING_REQUIRED" + if action == "create" + else "AI_REDACTED_VALUE_INVALID" + ) + assert response.json()["error"]["code"] == expected_code + assert response.json()["error"]["trace_id"] + async with quality_context.sessions() as session: + item = await session.get(AIChangeItem, item_id) + assert item is not None + assert item.review_status == "pending" + assert ( + await _stored_definition(quality_context, asset_type, target.resource_id) + == original_on + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asset_type", ["workflow", "test_case"]) +async def test_on_review_restores_known_redacted_values( + quality_context: QualityContext, asset_type: AssetType +) -> None: + headers = await _login(quality_context.client) + target = await _review_target(quality_context, headers, asset_type, "on") + definition = deepcopy(target.definition) + if asset_type == "workflow": + definition["variables"] = {"existing": "[REDACTED]"} + definition["nodes"][0]["config"] = { + "existing": "[REDACTED]", + "items": ["[REDACTED]", "[REDACTED]"], + } + else: + definition["runtime_variables"] = {"existing": "[REDACTED]"} + definition["runtime_headers"] = {"X-Existing": "[REDACTED]"} + change_id, item_id = await _review_item( + quality_context, target, asset_type, "update", definition + ) + response = await quality_context.client.post( + f"/api/v1/ai/change-sets/{change_id}/items/{item_id}/accept", + headers=headers, + json={}, + ) + assert response.status_code == 200, response.text + assert ( + await _stored_definition(quality_context, asset_type, target.resource_id) + == target.definition + ) + + +@pytest.mark.asyncio +async def test_on_review_rejects_partial_edits_to_redacted_lists( + quality_context: QualityContext, +) -> None: + headers = await _login(quality_context.client) + target = await _review_target(quality_context, headers, "workflow", "on") + definition = deepcopy(target.definition) + definition["nodes"][0]["config"]["items"] = ["replacement", "[REDACTED]"] + change_id, item_id = await _review_item( + quality_context, target, "workflow", "update", definition + ) + response = await quality_context.client.post( + f"/api/v1/ai/change-sets/{change_id}/items/{item_id}/accept", + headers=headers, + json={}, + ) + assert response.status_code == 422, response.text + assert response.json()["error"]["code"] == "AI_REDACTED_LIST_STRUCTURE_INVALID" + assert response.json()["error"]["trace_id"] + assert ( + await _stored_definition(quality_context, "workflow", target.resource_id) + == target.definition + ) diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-24-off-policy-review.md b/docs/design/ui-v5/remediation/2026-10-20/batch-24-off-policy-review.md new file mode 100644 index 00000000..dd0be019 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-24-off-policy-review.md @@ -0,0 +1,30 @@ +# M22 审核路径 OFF 策略补充回执 + +对应冻结 [M22 审计](../../audits/2026-10-19/complete-audit.md#m22)、[整改方案](../../audits/2026-10-19/remediation-plan.md#m22)及[第二十四批回执](batch-24.md)。本次仅补齐审核物化时的 OFF 策略;开发基线为 `654b13ec658bc18dc5c3c05b5307831c5775023a`。 + +## 反例与修复 + +- OFF 下审核创建 Workflow 或 TestCase 时,字面量 `[REDACTED]` 被无条件占位符扫描误报为必须重新绑定运行值;更新已有键时会被静默换回旧值,新增键或改变嵌套列表时会被 422 拦截。 +- 在已有值恢复函数入口读取现有请求范围策略,OFF 直接返回已授权的拟写入值,不进入占位符判断、递归扫描或替换;两处创建守卫也仅在 ON 执行。生产代码改动仅 6 行差异。 +- 保留更新时未提供运行字段沿用现值的语义;ON 仍恢复已有占位符、拒绝未知占位符和含占位符列表的结构修改。继续使用既有编辑授权、目标快照冲突和标准错误 Trace。 +- 未增加全局可变状态、采集、日志、Secret 读取、引擎或共享写路径改动,也未修改冻结审计、冻结方案或共享进度。 + +## 行为证据 + +新增 `backend/tests/test_ai_change_set_redaction_policy.py` 共 17 项公开接受 API 用例:两种资产创建/更新、已有/新增字段、Workflow 嵌套列表与完整字面值、显式编辑与直接接受提案、省略运行字段、ON 恢复与列表守卫,以及同一客户端交替访问 ON/OFF 项目。断言实际持久化草稿值;拒绝时核对错误码、Trace、pending 状态和目标未变化。 + +修复前最终反例运行为 10 失败、7 通过;已有键的静默替换与创建/新增字段 422 均被复现。首轮测试夹具把未规范化的 Workflow 输入与存储规范化结果直接比较,修正为读取实际基线;首轮修复后交替项目用例遇到夹具草稿重名,改为按动作区分名称。上述失败日志全部保留,未以重试或放宽断言掩盖失败。 + +实际运行: + +- `make test-backend-targeted TARGETS='["tests/test_ai_change_set_redaction_policy.py","tests/test_quality_intelligence.py","tests/test_redaction_policy_off.py"]'`:68 项通过。 +- `uv run ruff check app/services/ai_change_sets.py tests/test_ai_change_set_redaction_policy.py`:通过。 +- `uv run ruff format --check app/services/ai_change_sets.py tests/test_ai_change_set_redaction_policy.py`:通过。 +- `uv run mypy app`:445 文件通过。 +- `git diff --check`:通过。 + +完整日志保留在本工作树忽略目录 `output/validation/m22-off-policy/`,最终测试日志为 `08-targeted-final.log`,静态检查为 `09-ruff-final.log`、`10-format-final.log`、`06-mypy.log`。针对性运行有 16 项 SQLAlchemy 警告,来源为合成提案没有 suggestion_id 时既有审核逻辑读取空主键;本次未更改该历史兼容逻辑。 + +## 完成边界 + +本次独立局部回归与自审未发现该修复的残余实际缺陷。未运行完整后端/前端门禁、Compose、浏览器、外部模型、GitHub 当前提交门禁、合并后 CI 或发布验收;这些结果由整合阶段另行记录。本回执不扩大第二十四批已声明的完成范围。 From 6f95c3d2f3b2e8490b9b147e572a363e37e19081 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:40:15 +0800 Subject: [PATCH 18/35] fix(ai): align review previews and bind forms to change-set scope --- .../ui-v5/remediation/2026-10-20/batch-24.md | 17 ++ .../features/ai/AIChangeReviewEvidence.tsx | 9 +- .../src/features/ai/ai-change-diff.test.ts | 86 +++++- frontend/src/features/ai/ai-change-diff.ts | 45 ++- .../AIChangeSetsPage.navigation.test.tsx | 256 ++++++++++++++++++ frontend/src/pages/AIChangeSetsPage.test.tsx | 52 ++++ frontend/src/pages/AIChangeSetsPage.tsx | 26 +- 7 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 frontend/src/pages/AIChangeSetsPage.navigation.test.tsx diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-24.md b/docs/design/ui-v5/remediation/2026-10-20/batch-24.md index 49a1efa9..5eda24a5 100644 --- a/docs/design/ui-v5/remediation/2026-10-20/batch-24.md +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-24.md @@ -35,3 +35,20 @@ ## 完成边界 功能、针对性验证、完整后端与上述真实浏览器专项已完成;完整前端、并行模块集成、最新提交的 GitHub Required Gate、合并后独立回归、真实外部模型和正式部署尚需分别确认。进度暂不将本批三项计为完整本地完成,待完整前端结果后更新。 + +## 独立复审:前端差异与审核归属(2026-10-22) + +复审复现了两个实际缺陷:定义对象浅替换会把后端保留的省略运行时字段展示为删除;同一项目从 focus B 返回 A 并确认放弃后,旧 B 表单仍保留,来源却已经切为 A。本节只记录前端补修;OFF 下占位符恢复问题由独立后端提交处理,旧完整后端结果不能作为组合修复后的验证结果。 + +- 差异按冻结目标类型推导:Test Case 省略 `runtime_variables/runtime_headers` 时保留冻结值;Workflow 省略 `variables` 及按节点 ID 匹配的配置字段时保留冻结值。显式提供的空对象仍按替换处理,删除节点不复活,不修改原基线,也不扫描或替换字面占位符。数组仍按完整替换比较。 +- 将原“拟写入草稿完整内容”明确为“按更新规则合并的草稿预览”,解释省略保留与显式替换,并说明接受时仍需服务端校验和规范化;不将客户端推导冒充最终规范化回执。ON 继续只展示已授权冻结投影,不读取当前资产或隐藏字段。 +- 审核选择用现有路由范围状态绑定项目和当前 ChangeSet,并显式保存 ChangeSet ID 核对来源。确认离开到另一 focus 后销毁旧表单及放行标识;取消导航保留当前输入。相同 ChangeSet 的详情刷新失败和重试成功均不清空 JSON 或备注。 +- 真实 Data Router、页面、查询 Hook 和 MSW 反例覆盖 B→A 导航、取消与确认放弃、新项再次触发导航保护、向新项 ID 提交、同项 503 后恢复、旧审核回执晚到不重开旧表单或宣告旧结果。另有页面级预览验证,确认省略的用例运行时字段保留在合并预览中,未被列为删除。 + +先增加差异与 focus 反例后修复。首轮 5 项失败、1 项通过,其中一项是测试试图从打开的审核窗口点击后台重试按钮;该项改为触发同一查询的显式重试,以检验输入保留,未修改产品保护逻辑。其余失败对应三个差异断言及旧审核表单未销毁。最终检查: + +- `make test-frontend-targeted TARGETS='["src/features/ai/ai-change-diff.test.ts","src/features/ai/ai-change-set-service.test.ts","src/features/ai/use-ai-change-sets.test.tsx","src/pages/AIChangeSetsPage.test.tsx","src/pages/AIChangeSetsPage.navigation.test.tsx"]'`:5 文件、31 项通过。 +- 全前端 `pnpm lint`(含 TypeScript)、`pnpm build`、本批 Prettier 及 `git diff --check` 通过。 +- 原始日志位于本工作树忽略目录 `output/validation/m22-review-frontend/`:`frontend-red.log`、`frontend-final.log`、`frontend-lint.log`、`frontend-build.log`、`format-check.log`。 + +本前端补修未修改后端、迁移、冻结审计、共享进度或验收环境,未运行完整前端覆盖率与真实浏览器;组合后的完整检查、GUI 和最新远端门禁仍由整合负责人分别记录。 diff --git a/frontend/src/features/ai/AIChangeReviewEvidence.tsx b/frontend/src/features/ai/AIChangeReviewEvidence.tsx index a33e6b40..5fbf8692 100644 --- a/frontend/src/features/ai/AIChangeReviewEvidence.tsx +++ b/frontend/src/features/ai/AIChangeReviewEvidence.tsx @@ -140,7 +140,7 @@ function FrozenBaseline({ baseline: AIReviewBaseline proposed: Record }) { - const after = proposedDraft(baseline.content, proposed) + const after = proposedDraft(baseline.content, proposed, baseline.target_type) const fields = changeFields(baseline.content, after) return ( @@ -161,6 +161,9 @@ function FrozenBaseline({ /> + + 省略的运行时参数和节点配置沿用冻结值,显式提供的值会替换。以下是合并后的待校验草稿,接受时仍由服务端校验并规范化定义。 + , + label: '按更新规则合并的草稿预览', + children: , }, ]} /> diff --git a/frontend/src/features/ai/ai-change-diff.test.ts b/frontend/src/features/ai/ai-change-diff.test.ts index 9ce26a71..d4e428b2 100644 --- a/frontend/src/features/ai/ai-change-diff.test.ts +++ b/frontend/src/features/ai/ai-change-diff.test.ts @@ -9,7 +9,7 @@ describe('frozen draft review', () => { definition: { nodes: [{ id: 'old' }], edges: ['old'] }, } const patch = { name: '修改流程', description: null, definition: { nodes: [{ id: 'new' }] } } - const after = proposedDraft(before, patch) + const after = proposedDraft(before, patch, 'workflow') expect(after.description).toBe('保持说明') expect(changeFields(before, after)).toEqual([ { path: '$.name', before: '原流程', after: '修改流程' }, @@ -18,4 +18,88 @@ describe('frozen draft review', () => { ]) expect(before.definition.nodes[0].id).toBe('old') }) + + it('keeps omitted test-case runtime maps and replaces explicitly supplied maps', () => { + const before = { + name: '原用例', + definition: { + workflow_id: 'workflow-1', + environment_id: 'environment-1', + runtime_variables: { region: 'cn-north' }, + runtime_headers: { 'X-Region': 'cn-north' }, + }, + } + const after = proposedDraft( + before, + { definition: { workflow_id: 'workflow-1', environment_id: 'environment-2' } }, + 'test_case', + ) + expect(after.definition).toEqual({ ...before.definition, environment_id: 'environment-2' }) + expect(changeFields(before, after)).toEqual([ + { path: '$.definition.environment_id', before: 'environment-1', after: 'environment-2' }, + ]) + const cleared = proposedDraft( + before, + { definition: { ...before.definition, runtime_variables: {}, runtime_headers: {} } }, + 'test_case', + ) + expect(cleared.definition).toMatchObject({ runtime_variables: {}, runtime_headers: {} }) + expect(before.definition.runtime_variables).toEqual({ region: 'cn-north' }) + }) + + it('preserves workflow variables and omitted node config by identity without reviving deleted nodes', () => { + const before = { + definition: { + variables: { region: 'cn-north' }, + nodes: [ + { id: 'first', name: '原节点', config: { expected: 200 } }, + { id: 'second', config: { expected: 201 } }, + { id: 'removed', config: { expected: 202 } }, + ], + edges: ['old'], + }, + } + const after = proposedDraft( + before, + { + definition: { + nodes: [ + { id: 'second', config: {} }, + { id: 'first', name: '新节点' }, + { id: 'new', config: { expected: 204 } }, + ], + edges: ['new'], + }, + }, + 'workflow', + ) + expect(after.definition).toEqual({ + variables: { region: 'cn-north' }, + nodes: [ + { id: 'second', config: {} }, + { id: 'first', name: '新节点', config: { expected: 200 } }, + { id: 'new', config: { expected: 204 } }, + ], + edges: ['new'], + }) + expect( + proposedDraft(before, { definition: { variables: {}, nodes: [] } }, 'workflow').definition, + ).toEqual({ variables: {}, nodes: [] }) + expect(before.definition.nodes).toHaveLength(3) + }) + + it('keeps explicitly provided literal placeholders without scanning or substituting them', () => { + const before = { + definition: { runtime_variables: { region: 'old-region' }, runtime_headers: {} }, + } + const after = proposedDraft( + before, + { definition: { runtime_variables: { region: '[REDACTED]' } } }, + 'test_case', + ) + expect(after.definition).toEqual({ + runtime_variables: { region: '[REDACTED]' }, + runtime_headers: {}, + }) + }) }) diff --git a/frontend/src/features/ai/ai-change-diff.ts b/frontend/src/features/ai/ai-change-diff.ts index 2ab1f173..3967d6d3 100644 --- a/frontend/src/features/ai/ai-change-diff.ts +++ b/frontend/src/features/ai/ai-change-diff.ts @@ -2,13 +2,56 @@ export type ChangeField = { path: string; before: unknown; after: unknown } export function proposedDraft( before: Record, patch: Record, + targetType: 'test_case' | 'workflow', ): Record { - return { + const after = { ...before, ...Object.fromEntries( Object.entries(patch).filter(([, value]) => value !== null && value !== undefined), ), } + if (isObject(before.definition) && isObject(patch.definition)) { + after.definition = + targetType === 'test_case' + ? retainOmitted(before.definition, patch.definition, [ + 'runtime_variables', + 'runtime_headers', + ]) + : workflowDefinition(before.definition, patch.definition) + } + return after +} + +function retainOmitted( + before: Record, + patch: Record, + fields: readonly string[], +): Record { + const after = { ...patch } + for (const field of fields) { + if (!Object.hasOwn(patch, field) && Object.hasOwn(before, field)) after[field] = before[field] + } + return after +} + +function workflowDefinition( + before: Record, + patch: Record, +): Record { + const after = retainOmitted(before, patch, ['variables']) + if (!Array.isArray(before.nodes) || !Array.isArray(patch.nodes)) return after + const previous = new Map>() + for (const node of before.nodes) { + if (isObject(node) && typeof node.id === 'string') previous.set(node.id, node) + } + after.nodes = patch.nodes.map((node: unknown) => mergedNode(previous, node)) + return after +} + +function mergedNode(before: Map>, node: unknown): unknown { + if (!isObject(node) || typeof node.id !== 'string') return node + const previous = before.get(node.id) + return previous ? retainOmitted(previous, node, ['config', 'configuration']) : node } export function changeFields(before: unknown, after: unknown, path = '$'): ChangeField[] { if (JSON.stringify(before) === JSON.stringify(after)) return [] diff --git a/frontend/src/pages/AIChangeSetsPage.navigation.test.tsx b/frontend/src/pages/AIChangeSetsPage.navigation.test.tsx new file mode 100644 index 00000000..81677960 --- /dev/null +++ b/frontend/src/pages/AIChangeSetsPage.navigation.test.tsx @@ -0,0 +1,256 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { App, ConfigProvider } from 'antd' +import { http, HttpResponse } from 'msw' +import { createMemoryRouter, RouterProvider } from 'react-router-dom' +import { beforeEach, expect, it } from 'vitest' + +import type { AIChangeItem, AIChangeSetDetail } from '../features/ai/ai-change-set-service' +import ProjectProvider from '../features/projects/ProjectProvider' +import { project, user } from '../test/fixtures' +import { server } from '../test/server' +import AIChangeSetsPage from './AIChangeSetsPage' + +const first = changeSet('a', '00000000-0000-4000-8000-000000000281') +const second = changeSet('b', '00000000-0000-4000-8000-000000000282') +const path = `/projects/${project.id}/ai-changes` + +beforeEach(() => { + server.use( + http.get('/api/v1/projects', () => + HttpResponse.json({ items: [project], total: 1, page: 1, page_size: 100 }), + ), + http.get('/api/v1/ai/status', () => HttpResponse.json({ enabled: true })), + http.get('/api/v1/v3/features', () => + HttpResponse.json({ impact_engine: true, quality_intelligence: true }), + ), + http.get('/api/v1/ai/change-sets', () => + HttpResponse.json({ items: [first, second], total: 2, page: 1, page_size: 20 }), + ), + http.get('/api/v1/ai/change-sets/:id', ({ params }) => + HttpResponse.json(params.id === first.id ? first : second), + ), + http.get(`/api/v1/projects/${project.id}/impact/runs`, () => + HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }), + ), + http.get(`/api/v1/projects/${project.id}/release-risks`, () => + HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }), + ), + ) +}) + +it('keeps a cancelled focus navigation and destroys the old review after discard is confirmed', async () => { + let acceptedPath = '' + server.use( + http.post('/api/v1/ai/change-sets/:id/items/:itemId/accept', ({ params }) => { + acceptedPath = `${params.id}/${params.itemId}` + return HttpResponse.json({ ...first.items[0], review_status: 'accepted' }) + }), + ) + const { router } = renderNavigationPage() + await openSecondReview() + fireEvent.change(screen.getByLabelText('审核备注'), { target: { value: 'B 的未保存审核' } }) + await act(async () => { + await router.navigate(-1) + }) + let guard = await navigationGuard() + await userEvent.click(within(guard).getByRole('button', { name: '继续编辑' })) + expect(router.state.location.search).toBe(`?focus=${second.id}`) + expect(screen.getByLabelText('审核备注')).toHaveValue('B 的未保存审核') + await act(async () => { + await router.navigate(-1) + }) + guard = await navigationGuard() + await userEvent.click(within(guard).getByRole('button', { name: '放弃修改并离开' })) + await waitFor(() => expect(router.state.location.search).toBe(`?focus=${first.id}`)) + await screen.findByText(first.items[0].title) + expect(screen.queryByLabelText('审核备注')).not.toBeInTheDocument() + expect(screen.queryByText('B 的未保存审核')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: '审核并接受' })) + const dialog = await screen.findByRole('dialog', { name: '编辑并接受变更项' }) + expect(within(dialog).getByLabelText('变更内容 JSON')).toHaveValue( + JSON.stringify(first.items[0].proposed_content, null, 2), + ) + expect(dialog).toHaveTextContent(first.impact_run_id) + fireEvent.change(within(dialog).getByLabelText('审核备注'), { target: { value: 'A 的新审核' } }) + await act(async () => { + await router.navigate(1) + }) + guard = await navigationGuard() + await userEvent.click(within(guard).getByRole('button', { name: '继续编辑' })) + expect(router.state.location.search).toBe(`?focus=${first.id}`) + expect(within(dialog).getByLabelText('审核备注')).toHaveValue('A 的新审核') + await userEvent.click(within(dialog).getByRole('button', { name: 'OK' })) + await waitFor(() => expect(acceptedPath).toBe(`${first.id}/${first.items[0].id}`)) +}) + +it('keeps edits during a refresh failure of the same change set and after its retry succeeds', async () => { + const { queryClient } = renderNavigationPage() + await openSecondReview() + fireEvent.change(screen.getByLabelText('审核备注'), { target: { value: '保留本项审核输入' } }) + let failed = true + server.use( + http.get(`/api/v1/ai/change-sets/${second.id}`, () => + failed + ? HttpResponse.json( + { + error: { + code: 'UNAVAILABLE', + message: '本项详情读取失败', + trace_id: 'same-focus-trace', + }, + }, + { status: 503 }, + ) + : HttpResponse.json(second), + ), + ) + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ['ai-change-set', project.id, second.id] }) + }) + expect(await screen.findByText(/本项详情读取失败.*same-focus-trace/)).toBeInTheDocument() + expect(screen.getByLabelText('审核备注')).toHaveValue('保留本项审核输入') + failed = false + await act(async () => { + await queryClient.refetchQueries({ queryKey: ['ai-change-set', project.id, second.id] }) + }) + await waitFor(() => + expect(screen.queryByText(/本项详情读取失败.*same-focus-trace/)).not.toBeInTheDocument(), + ) + expect(screen.getByLabelText('审核备注')).toHaveValue('保留本项审核输入') + expect(screen.getByLabelText('变更内容 JSON')).toHaveValue( + JSON.stringify(second.items[0].proposed_content, null, 2), + ) +}) + +it('does not reopen a previous review or announce its late receipt after focus changes', async () => { + let finish: (() => void) | undefined + let writes = 0 + server.use( + http.post( + `/api/v1/ai/change-sets/${second.id}/items/${second.items[0].id}/accept`, + async () => { + writes += 1 + await new Promise((resolve) => { + finish = resolve + }) + return HttpResponse.json({ + ...second.items[0], + review_status: 'accepted', + materialized_resource_type: 'workflow', + materialized_resource_id: 'old-result', + }) + }, + ), + ) + const { router } = renderNavigationPage() + await openSecondReview() + const oldDialog = screen.getByRole('dialog', { name: '编辑并接受变更项' }) + await userEvent.click(within(oldDialog).getByRole('button', { name: 'OK' })) + await waitFor(() => expect(writes).toBe(1)) + await act(async () => { + await router.navigate(-1) + }) + const guard = await navigationGuard() + await userEvent.click(within(guard).getByRole('button', { name: '放弃修改并离开' })) + await screen.findByText(first.items[0].title) + expect(screen.queryByLabelText('审核备注')).not.toBeInTheDocument() + await act(async () => { + finish?.() + }) + await waitFor(() => expect(screen.getByRole('button', { name: '审核并接受' })).toBeEnabled()) + expect(screen.queryByText('变更项已接受并生成草稿')).not.toBeInTheDocument() + expect(screen.queryByText('old-result')).not.toBeInTheDocument() + expect(router.state.location.search).toBe(`?focus=${first.id}`) + await userEvent.click(screen.getByRole('button', { name: '审核并接受' })) + expect(screen.getByLabelText('审核备注')).toHaveValue('') + expect(writes).toBe(1) +}) + +async function openSecondReview() { + await screen.findByText(second.items[0].title) + await userEvent.click(screen.getByRole('button', { name: '审核并接受' })) + await screen.findByRole('dialog', { name: '编辑并接受变更项' }) +} + +async function navigationGuard(): Promise { + const title = await screen.findByText('审核修改尚未保存', { selector: '.ant-modal-title' }) + return title.closest('.ant-modal') as HTMLElement +} + +function renderNavigationPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const router = createMemoryRouter( + [ + { + path: '/projects/:projectId/ai-changes', + element: ( + + + + ), + }, + ], + { + initialEntries: [`${path}?focus=${first.id}`, `${path}?focus=${second.id}`], + initialIndex: 1, + }, + ) + return { + router, + queryClient, + ...render( + + + + + + + , + ), + } +} + +function changeSet(suffix: string, id: string): AIChangeSetDetail { + const item: AIChangeItem = { + id: `${id.slice(0, -1)}${suffix === 'a' ? '3' : '4'}`, + position: 0, + item_type: 'workflow', + action: 'create', + title: `建议 ${suffix}`, + target_resource_id: null, + target_snapshot_sha256: null, + proposed_content: { + name: `草稿 ${suffix}`, + description: '', + definition: { schema_version: '1.0' }, + }, + review_status: 'pending', + review_note: '', + reviewed_by_id: null, + reviewed_at: null, + materialized_resource_type: null, + materialized_resource_id: null, + created_at: '2026-10-22T00:00:00Z', + updated_at: '2026-10-22T00:00:00Z', + } + return { + id, + project_id: project.id, + impact_run_id: `impact-${suffix}`, + release_risk_id: `risk-${suffix}`, + ai_job_id: `job-${suffix}`, + title: `变更集 ${suffix}`, + status: 'draft', + source_fingerprint: suffix.repeat(64), + created_by_id: user.id, + created_at: item.created_at, + updated_at: item.updated_at, + source_snapshot: {}, + review_baselines: [], + items: [item], + } +} diff --git a/frontend/src/pages/AIChangeSetsPage.test.tsx b/frontend/src/pages/AIChangeSetsPage.test.tsx index dd33ed18..de510f90 100644 --- a/frontend/src/pages/AIChangeSetsPage.test.tsx +++ b/frontend/src/pages/AIChangeSetsPage.test.tsx @@ -241,6 +241,58 @@ describe('AIChangeSetsPage', () => { expect(within(dialog).getByLabelText('审核备注')).toHaveValue('不可静默丢弃') }) + it('previews preserved test-case runtime values rather than reporting omitted fields as removed', async () => { + const targetId = '00000000-0000-4000-8000-000000000271' + const definition = { + workflow_id: 'workflow-original', + environment_id: 'environment-original', + runtime_variables: { region: 'frozen-case-region' }, + runtime_headers: { 'X-Region': 'frozen-case-header' }, + } + handlers({ + ...detail, + review_baselines: [ + { + target_type: 'test_case', + target_id: targetId, + snapshot_sha256: 'e'.repeat(64), + content: { name: '固定用例', description: '', definition, tags: [] }, + current_version: 1, + draft_revision: null, + updated_at: item.created_at, + redaction_mode: 'off', + redaction_policy_version: 1, + }, + ], + items: [ + { + ...item, + item_type: 'test_case', + action: 'update', + target_resource_id: targetId, + target_snapshot_sha256: 'e'.repeat(64), + proposed_content: { + definition: { workflow_id: definition.workflow_id, environment_id: 'environment-next' }, + }, + }, + ], + }) + renderPage() + expect(await screen.findByText('$.definition.environment_id', { exact: true })).toBeVisible() + expect( + screen.queryByText('$.definition.runtime_variables', { exact: true }), + ).not.toBeInTheDocument() + expect( + screen.queryByText('$.definition.runtime_headers', { exact: true }), + ).not.toBeInTheDocument() + await userEvent.click(screen.getByText('按更新规则合并的草稿预览')) + const preview = await screen.findByRole('region', { name: '按更新规则合并的草稿预览' }) + expect(preview).toHaveTextContent('frozen-case-region') + expect(preview).toHaveTextContent('frozen-case-header') + expect(preview).toHaveTextContent('environment-next') + expect(screen.getByText(/接受时仍由服务端校验并规范化定义/)).toBeVisible() + }) + it('shows suggestions to viewers without allowing generation or item review', async () => { handlers(detail) let writes = 0 diff --git a/frontend/src/pages/AIChangeSetsPage.tsx b/frontend/src/pages/AIChangeSetsPage.tsx index 939d0637..bd396ca0 100644 --- a/frontend/src/pages/AIChangeSetsPage.tsx +++ b/frontend/src/pages/AIChangeSetsPage.tsx @@ -26,6 +26,7 @@ import type { import { useAIChangeSets } from '../features/ai/use-ai-change-sets' import { useProjectContext } from '../features/projects/use-project-context' import { apiErrorWithTrace } from '../lib/api' +import { useRouteScopedState } from '../lib/use-route-scoped-state' import AIDraftProtection from '../features/ai/AIDraftProtection' import { AIChangeSource, @@ -33,6 +34,12 @@ import { AIChangeReviewReceipt, } from '../features/ai/AIChangeReviewEvidence' +type ReviewSelection = { + changeSetId: string + item: AIChangeItem + decision: 'accept' | 'reject' +} + export default function AIChangeSetsPage() { const { projectId } = useProjectContext() return @@ -42,10 +49,11 @@ function AIChangeSetWorkspace() { const [searchParams] = useSearchParams() const state = useAIChangeSets(searchParams.get('focus')) const [createOpen, setCreateOpen] = useState(false) - const [review, setReview] = useState<{ - item: AIChangeItem - decision: 'accept' | 'reject' - } | null>(null) + const [review, setReview] = useRouteScopedState( + state.projectId, + state.activeId, + null, + ) const detail = state.detail.data return ( <> @@ -121,7 +129,7 @@ function AIChangeSetWorkspace() { readOnly={!state.canReview || state.reviewing} onReview={(value) => { state.clearReviewError() - setReview(value) + if (detail) setReview({ ...value, changeSetId: detail.id }) }} /> @@ -381,7 +389,7 @@ function ReviewChangeItemDialog({ detail: AIChangeSetDetail | undefined apiError: string readOnly: boolean - value: { item: AIChangeItem; decision: 'accept' | 'reject' } | null + value: ReviewSelection | null submitting: boolean onClose: () => void onSubmit: ( @@ -391,13 +399,13 @@ function ReviewChangeItemDialog({ note: string, ) => Promise }) { - if (!value) return null + if (!value || detail?.id !== value.changeSetId) return null return ( void onSubmit: ( From 78c0cc3c74506c693b6f480eb9f25041c58719dc Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:41:44 +0800 Subject: [PATCH 19/35] fix(ui): improve governance and capability inspection readability --- .../2026-10-20/batch-29-integration.md | 34 +++++++++++++++++++ .../src/pages/OrganizationGovernancePage.tsx | 5 +-- .../src/pages/PlatformCapabilitiesPage.tsx | 6 ++++ frontend/src/styles.css | 3 +- 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 docs/design/ui-v5/remediation/2026-10-20/batch-29-integration.md diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-29-integration.md b/docs/design/ui-v5/remediation/2026-10-20/batch-29-integration.md new file mode 100644 index 00000000..2d22c194 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-29-integration.md @@ -0,0 +1,34 @@ +# 第二十九批:并行整改整合与页面复核 + +对应 [M22](batch-24.md)、[M23 后端结果读取](../../audits/2026-10-19/remediation-plan.md#m23)、[M24](batch-26-organization.md)、[M25](batch-27-execution-fabric.md)、[M26](batch-28-platform.md)。冻结审计与修改方案不变。本记录区分已执行专项、旧源码完整结果和待验收项。 + +## 独立复审与集成 + +- M25 独立复审以真实 `progress()` 和内存数据库复现了 Lease 关联事件的组织隔离问题。通过既有 Lease 解析任务归属,统一目录过滤、total 和精确任务历史;无法解析的关联记录不作为共享事件。新增两项反例先失败,最终37项通过;未增加日志或事件采集。 +- M22 独立复审补修分为两个并行提交:OFF 模式完整保留已授权字面值,详见[策略补修](batch-24-off-policy-review.md);前端按服务端保留规则推导草稿预览,并将审核表单绑定到项目和 ChangeSet,详见[前端补修](batch-24.md#独立复审前端差异与审核归属2026-10-22)。未以旧完整结果冒充补修后结果。 +- M23 后端新增关联已接受物化 TestDesign 的精确只读读取:验证所属 ChangeSet、item、类型、状态和实际资产的项目、来源关联。没有新增全局发现或自动接受入口,原受控审核、审批和修订守卫保持。 +- 整合树 `654b13ec658bc18dc5c3c05b5307831c5775023a`、后端树 `298da98b9e47dfb707ca88afb41038d3d4c3b031` 完整后端通过1929项、14跳过、2项既有警告,覆盖率91.15%;Ruff和445文件 mypy通过。后续 OFF 补修改变后端,正在重新完整检查,此处1929项只对应前述旧树。 + +## 页面可读性补充 + +实际平台页面原340px检查器不足以并排阅读新契约。桌面改为均衡双栏,库存表格在本栏内水平滚动,窄屏沿用单栏;窄屏点击库存对象后将对应检查器滚入视口,避免选中结果位于20行目录之后却没有定位反馈。组织密钥区改为简明中文标题,完整能力标识单独显示,保留安全能力判断合同。 + +整合后的6文件59项目标测试通过;补充可读性后组织与平台两个文件42项通过。二者有重叠,不相加为101项。格式、全量 lint/类型和构建通过;最终组合仍需完整前端和远端门禁。 + +## 隔离浏览器证据 + +使用13078端口独立 Compose 和 bundled Chromium 隔离会话,既有3000端口和个人浏览器资料未改。以下检查均为业务写入0、发布0、执行0: + +- 组织审计:现有1709条目录第六页返回200;同时筛选既有 M22 草稿的操作者、资源类型、资源ID和动作,服务端返回第一页且 total=1;只读详情对应同一资产。受控安全读取500显示标准错误及 Trace,期间不显示轮换操作,恢复后单独重试成功。390px视口的页面可用宽度375、整页宽375,没有整页横向溢出。 +- 平台契约:实际 `assertion.evaluate@2.0.0` 的输入、输出、配置三份下载分别为115、63、1759字节,SHA-256逐份与页面实际接口返回的完整格式化 Schema 相同;各阅读器搜索有效,策略声明提示、刷新后版本身份恢复正常。切插件清空能力检查器,安装保持禁用。390px视口无整页横向溢出;选中对象后检查器标题完整进入视口。 +- 执行面:读取既有取消任务 `37b36c00-4230-4be9-8594-f1eb90046006`,任务、当前任务 Lease/事件标题、URL刷新定位正确;可见页观察到真实队列再次GET,共两次。390px任务抽屉无整页横向溢出。当前没有真实 Pool/Lease,用这次只读页面检查不能证明百项分页或有效 Fence;该部分依据37项后端及9项前端反例,未运行准备的共享数据库 seed。 + +页面检查的早期脚本分别遇到隐藏 TabPane 的重复选择器、详情尚未返回便断言ID、Ant Design隐藏 radio input、溢出标签点击及不成立的窄屏固定坐标阈值。改为当前面板、实际可见标签、等待读取和真实视口边界后继续;没有强制点击、提高超时或增加测试重试。平台检查器宽度不足另外做了产品修正,不能混同为脚本选择器问题。 + +原始失败与最终日志均在主验收工作树忽略目录 `output/playwright/audit-20261019/`,包括 `remediation-organization-browser-r2.log`、`remediation-platform-browser-r3.log`(三份完整下载、版本恢复,后续隐藏input选择失败)、`remediation-platform-browser-resume.log`、`remediation-platform-mobile-selected-confirmed.log`、`remediation-fabric-browser-r2.log`、`remediation-r4-integrated-targeted.log`、`remediation-r4-readability-targeted.log`。移动组织及平台图已人工查看;桌面和移动截图均保留。 + +## GitHub 与剩余验收 + +PR145 的主分支独立 [Full Regression 39625538263](https://github.com/a3384379/FlowTest/actions/runs/39625538263) 已在合并提交 `cbd6410840b99f5fa7c2c55910a58456927fef23` 完成为 success。 + +PR146 已补修任务提交期间导航保护与确认弹窗层级,新 HEAD `373447988430b7fb3ef2620ffd65fe1922ea5636` 已推送;本次读取的全部一条审查线程已解决,线程和评论均无后续分页。最新正式门禁仍在执行,不能据旧提交或本地目标通过声称已合并。后续模块的完整前端、当前 HEAD 门禁、合并后回归、真实外部模型、正式 KMS、真实 Runner/故障转移和正式部署均另行记录。 diff --git a/frontend/src/pages/OrganizationGovernancePage.tsx b/frontend/src/pages/OrganizationGovernancePage.tsx index 2324a272..8b4ca9ce 100644 --- a/frontend/src/pages/OrganizationGovernancePage.tsx +++ b/frontend/src/pages/OrganizationGovernancePage.tsx @@ -1101,14 +1101,15 @@ function KeyRotationCard({ title={ - Organization Data Encryption Key Rotation + 组织密钥轮换 } > v{security.active_key_version} + {security.capability_name} 外部密钥提供方(只保存引用与指纹) - {security.capability_mode} + {security.capability_mode} (null) const [params, setParams] = useSearchParams() const isAdmin = useAuthStore((state) => Boolean(state.user?.is_system_admin)) const queryClient = useQueryClient() @@ -67,6 +69,9 @@ function PlatformWorkspace({ scope }: { scope: string }) { next.set('id', value.id) if (value.kind === 'capability') next.set('version', value.version) setParams(next) + if (window.matchMedia('(max-width: 1024px)').matches) { + inspectorRef.current?.scrollIntoView({ block: 'start' }) + } } const onSearch = (value: string) => { const next = new URLSearchParams(params) @@ -132,6 +137,7 @@ function PlatformWorkspace({ scope }: { scope: string }) { )}
String(index)} + size="small" + dataSource={targets} + pagination={{ pageSize: 10, showSizeChanger: false }} + scroll={{ x: 1000 }} + columns={[ + { + title: '目标', + render: (_, target) => ( + + ), + }, + { title: '操作', render: (_, target) => display(findAction(actions, target)?.action) }, + { + title: '版本(原 → 拟)', + render: (_, target) => versionChange(findAction(actions, target), target), + }, + { title: '拟用环境', dataIndex: 'environment_id', render: display }, + { title: '拟用重试上限', dataIndex: 'max_retries', render: display }, + { + title: '原指纹', + render: (_, target) => ( + + ), + }, + { + title: '拟用指纹', + render: (_, target) => ( + + ), + }, + { title: '原因', render: (_, target) => display(findAction(actions, target)?.reason) }, + ]} + expandable={{ + expandedRowRender: (target) => ( + + ), + }} + /> + )} + + ) +} + +function ReadTable({ + title, + value, + columns, +}: { + title: string + value: unknown + columns: import('antd').TableColumnsType +}) { + const rows = contentRows(value) + return ( +
+ {title} + {rows === null ? ( + {title}未提供或结构无效,请核对完整提案。 + ) : ( +
String(index)} + size="small" + dataSource={rows} + columns={columns} + pagination={{ pageSize: 8, showSizeChanger: false }} + scroll={{ x: 750 }} + locale={{ emptyText: `此提案没有${title}` }} + /> + )} + + ) +} + +function ValueList({ value, empty }: { value: unknown; empty: string }) { + if (!Array.isArray(value)) return 未提供 + if (!value.length) return {empty} + return ( +
    + {value.map((item, index) => ( +
  • {display(item)}
  • + ))} +
+ ) +} + +function AssetReference({ + projectId, + kind, + id, +}: { + projectId: string + kind: unknown + id: unknown +}) { + if (typeof id !== 'string' || !id) return <>未提供 + const encoded = encodeURIComponent(id) + if (kind === 'workflow') + return {id} + if (kind === 'case' || kind === 'suite') + return {id} + return {id}(类型未识别) +} +function CopyValue({ value }: { value: unknown }) { + return typeof value === 'string' ? ( + + {value} + + ) : ( + <>未提供 + ) +} +function findAction(actions: Content[] | null, target: Content): Content | undefined { + return actions?.find( + (action) => action.target_id === target.target_id && action.target_type === target.target_type, + ) +} +function versionChange(action: Content | undefined, target: Content): string { + return `${display(action?.current_version)} → ${display(action?.requested_version ?? target.target_version ?? target.workflow_version)}` +} +function record(value: unknown): Content { + return isRecord(value) ? value : {} +} +function isRecord(value: unknown): value is Content { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +function contentRows(value: unknown): Content[] | null { + return Array.isArray(value) && value.every(isRecord) ? value : null +} +function booleanLabel(value: unknown): string { + return typeof value === 'boolean' ? (value ? '已覆盖' : '未覆盖') : '未提供' +} +function display(value: unknown): string { + if (value === undefined || value === null) return '未提供' + return typeof value === 'string' ? value : JSON.stringify(value) +} diff --git a/frontend/src/features/mcp/mcp-change-set-service.ts b/frontend/src/features/mcp/mcp-change-set-service.ts index 4541ecd1..d45824ff 100644 --- a/frontend/src/features/mcp/mcp-change-set-service.ts +++ b/frontend/src/features/mcp/mcp-change-set-service.ts @@ -1,4 +1,5 @@ import { apiClient, type WorkflowDefinition } from '../../lib/api' +import type { TestDesignDocument } from '../test-engineering/test-engineering-service' export type MCPChangeItem = { id: string @@ -47,7 +48,7 @@ export type MCPChangeSet = { items: MCPChangeItem[] } -type MCPChangeSetEnvelope = { +export type MCPChangeSetEnvelope = { data: MCPChangeSet warnings: string[] trace_id: string @@ -101,3 +102,26 @@ export async function reviewMCPChangeItem( ) ).data } + +export type MCPMaterializedDesign = { + id: string + project_id: string + name: string + status: string + fingerprint: string + source_change_set_id: string + reviewed_by_id: string | null + reviewed_at: string | null + design: TestDesignDocument +} + +export async function getMCPMaterializedDesign( + changeSetId: string, + itemId: string, +): Promise { + return ( + await apiClient.get( + `/mcp/write/change-sets/${encodeURIComponent(changeSetId)}/items/${encodeURIComponent(itemId)}/materialized-design`, + ) + ).data +} diff --git a/frontend/src/pages/MCPChangeSetsPage.test.tsx b/frontend/src/pages/MCPChangeSetsPage.test.tsx index 65ef1a7e..fec9e29f 100644 --- a/frontend/src/pages/MCPChangeSetsPage.test.tsx +++ b/frontend/src/pages/MCPChangeSetsPage.test.tsx @@ -1,9 +1,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { App as AntdApp } from 'antd' +import { App as AntdApp, Button } from 'antd' import { http, HttpResponse } from 'msw' -import { describe, expect, it } from 'vitest' +import { useNavigate } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' import type { MCPChangeSet } from '../features/mcp/mcp-change-set-service' import ProjectTestProvider from '../test/ProjectTestProvider' @@ -154,6 +155,7 @@ describe('MCPChangeSetsPage', () => { ...current.items[0], review_status: 'accepted', materialized_resource_id: '00000000-0000-4000-8000-000000009011', + materialized_resource_type: 'workflow', }, ], } @@ -260,7 +262,14 @@ describe('MCPChangeSetsPage', () => { current = { ...current, status: 'accepted', - items: [{ ...current.items[0], review_status: 'accepted' }], + items: [ + { + ...current.items[0], + review_status: 'accepted', + materialized_resource_type: 'workflow', + materialized_resource_id: workflowId, + }, + ], } return HttpResponse.json(envelope(current)) }), @@ -297,7 +306,14 @@ describe('MCPChangeSetsPage', () => { current = { ...current, status: 'accepted', - items: [{ ...current.items[0], review_status: 'accepted' }], + items: [ + { + ...current.items[0], + review_status: 'accepted', + materialized_resource_type: 'test_plan', + materialized_resource_id: 'plan-1', + }, + ], } return HttpResponse.json(envelope(current)) }), @@ -312,6 +328,469 @@ describe('MCPChangeSetsPage', () => { expect(await screen.findByText('已接受')).toBeVisible() }) + it('shows envelope warnings, copyable trace, confidence and approval evidence', async () => { + const current = { + ...changeSet, + approval: { + id: 'approval-1', + decision: 'approved', + approved_by_id: user.id, + approved_at: '2026-10-22T01:02:03Z', + }, + governance: { + ...changeSet.governance, + reason_codes: ['low_confidence_assertion_review', 'future_reason'], + }, + } + server.use( + http.get(`/api/v1/mcp/write/change-sets/${changeSetId}`, () => + HttpResponse.json({ + ...envelope(current), + warnings: ['已有警告必须在审核前可见'], + trace_id: 'mcp-trace-exact', + }), + ), + ) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + expect(await screen.findByText('已有警告必须在审核前可见')).toBeVisible() + expect(screen.getByText('mcp-trace-exact')).toBeVisible() + expect(screen.getByText('90%')).toBeVisible() + expect(screen.getByText(/future_reason/)).toBeVisible() + expect(screen.getByText('approval-1')).toBeVisible() + expect(screen.getByText('2026-10-22T01:02:03Z')).toBeVisible() + const browser = userEvent.setup() + const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + await browser.click(screen.getByRole('button', { name: '复制 Trace ID' })) + expect(copy).toHaveBeenCalledWith('mcp-trace-exact') + }) + + it('shows fixed plan actions and explicitly identifies missing before environment and retry', async () => { + handlers(() => ({ + ...changeSet, + items: [ + { + ...changeSet.items[0], + proposed_content: { + test_plan_id: 'plan-precise', + rationale: '补充支付回归', + targets: [ + { + target_type: 'workflow', + target_id: 'workflow-precise', + target_version: 5, + workflow_version: 5, + environment_id: 'environment-new', + max_retries: 2, + runtime_variables: { tenant: 'blue' }, + runtime_headers: {}, + }, + ], + target_actions: [ + { + target_type: 'workflow', + target_id: 'workflow-precise', + action: 'update', + current_version: 4, + requested_version: 5, + current_fingerprint: 'old-fingerprint', + requested_fingerprint: 'new-fingerprint', + reason: '目标版本变化', + }, + ], + unpublished_dependencies: ['待发布依赖'], + }, + }, + ], + })) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + expect(await screen.findByText('测试计划更新预览')).toBeVisible() + expect(screen.getByText('environment-new')).toBeVisible() + expect(screen.getByText('4 → 5')).toBeVisible() + expect(screen.getByText('old-fingerprint')).toBeVisible() + expect(screen.getByText('new-fingerprint')).toBeVisible() + expect(screen.getByText(/未保存原环境、重试及运行参数/)).toBeVisible() + expect(screen.getByText('待发布依赖')).toBeVisible() + expect(screen.getByText('补充支付回归')).toBeVisible() + }) + + it('previews a test case as its fixed workflow and environment without invented assertions', async () => { + handlers(() => ({ + ...changeSet, + items: [ + { + ...changeSet.items[0], + item_type: 'test_case', + action: 'create', + proposed_content: { + name: '支付测试用例', + description: '真实提案说明', + tags: ['smoke'], + definition: { + workflow_id: 'workflow-fixed', + workflow_version: null, + environment_id: 'environment-fixed', + runtime_variables: { customer: 'authorized@example.com' }, + runtime_headers: {}, + }, + }, + }, + ], + })) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + expect(await screen.findByText('测试用例预览')).toBeVisible() + expect(screen.getByText('未固定,发布时需确认')).toBeVisible() + expect(screen.getByText('environment-fixed')).toBeVisible() + expect(screen.getByText(/断言沿用所引用工作流/)).toBeVisible() + }) + + it('keeps high-risk materialization disabled when a stored approval is rejected', async () => { + handlers(() => ({ + ...changeSet, + governance: { ...changeSet.governance, manual_approval_required: true }, + approval: { + id: 'rejected-approval', + decision: 'rejected', + approved_by_id: user.id, + approved_at: '2026-10-22T01:00:00Z', + }, + })) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + expect(await screen.findByRole('button', { name: '接受并物化' })).toBeDisabled() + expect(screen.getByText(/审批尚未通过/)).toBeVisible() + }) + + it('opens real materialized case and plan objects using their returned result types', async () => { + const accepted = { + ...changeSet, + status: 'accepted' as const, + items: [ + { + ...changeSet.items[0], + id: 'case-result-item', + item_type: 'test_case' as const, + review_status: 'accepted' as const, + materialized_resource_type: 'test_case', + materialized_resource_id: 'case-result', + proposed_content: {}, + }, + { + ...changeSet.items[0], + id: 'plan-result-item', + review_status: 'accepted' as const, + materialized_resource_type: 'test_plan', + materialized_resource_id: 'plan-result', + }, + ], + } + handlers(() => accepted) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + expect(await screen.findByRole('link', { name: '查看物化测试用例' })).toHaveAttribute( + 'href', + `/projects/${project.id}/assets?type=case&focus=case-result&version=draft`, + ) + expect(screen.getByRole('link', { name: '查看更新后的计划' })).toHaveAttribute( + 'href', + `/projects/${project.id}/tasks?focus=plan-result`, + ) + }) + + it('opens the saved design through an exact read instead of inventing a TestEngineering URL', async () => { + const savedId = 'saved-design-id' + const designItem = { + ...changeSet.items[0], + item_type: 'test_design' as const, + action: 'create' as const, + review_status: 'accepted' as const, + materialized_resource_type: 'test_design', + materialized_resource_id: savedId, + proposed_content: { + intent: { key: 'proposal', objective: '原始提案内容', acceptance_criteria: [] }, + oracles: [], + scenarios: [], + coverage: { entries: [] }, + evidence_refs: [], + warnings: [], + review_requirements: [], + }, + } + handlers(() => ({ ...changeSet, items: [designItem] })) + let reads = 0 + server.use( + http.get( + `/api/v1/mcp/write/change-sets/${changeSetId}/items/${itemId}/materialized-design`, + () => { + reads += 1 + return HttpResponse.json({ + id: savedId, + project_id: project.id, + source_change_set_id: changeSetId, + name: '已保存设计', + status: 'approved', + fingerprint: 'stored-fingerprint', + reviewed_by_id: user.id, + reviewed_at: '2026-10-22T01:00:00Z', + design: { + ...designItem.proposed_content, + intent: { + key: 'saved', + objective: '数据库中实际保存的设计', + acceptance_criteria: ['保存后的验收要求'], + }, + }, + }) + }, + ), + ) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + await userEvent.click(await screen.findByRole('button', { name: '查看已物化测试设计' })) + const drawer = screen.getByRole('dialog', { name: '已物化测试设计(只读)' }) + expect(await within(drawer).findByText('数据库中实际保存的设计')).toBeVisible() + expect(within(drawer).queryByText('原始提案内容')).not.toBeInTheDocument() + expect(within(drawer).getByText(savedId)).toBeVisible() + expect(screen.queryByRole('link', { name: /测试设计/ })).not.toBeInTheDocument() + expect(reads).toBe(1) + }) + + it('disables review after cached evidence or permissions fail to refresh', async () => { + handlers(() => changeSet) + const view = renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + const accept = await screen.findByRole('button', { name: '接受并物化' }) + expect(accept).toBeEnabled() + server.use( + http.get(`/api/v1/mcp/write/change-sets/${changeSetId}`, () => + HttpResponse.json( + { error: { code: 'UNAVAILABLE', message: '证据暂不可用', trace_id: 'mcp-read-stale' } }, + { status: 503 }, + ), + ), + ) + await act(async () => { + await view.queryClient.invalidateQueries({ queryKey: ['mcp-change-set'] }) + }) + expect(await screen.findByText(/保留的审核证据可能过期/)).toBeVisible() + expect(accept).toBeDisabled() + }) + + it('keeps a revision conflict visible without marking any item accepted', async () => { + handlers(() => changeSet) + server.use( + http.post(`/api/v1/mcp/write/change-sets/${changeSetId}/items/${itemId}/accept`, () => + HttpResponse.json( + { + error: { + code: 'TEST_PLAN_SUGGESTION_STALE', + message: '计划目标已变化,整个动作已拒绝', + trace_id: 'mcp-conflict-trace', + }, + }, + { status: 409 }, + ), + ), + ) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + await userEvent.click(await screen.findByRole('button', { name: '接受并物化' })) + expect(await screen.findByText('审核结果未确认')).toBeVisible() + expect(screen.getByText('Trace ID: mcp-conflict-trace')).toBeVisible() + expect(screen.queryByText('已接受')).not.toBeInTheDocument() + }) + + it('does not apply an old review receipt or success notice to another project', async () => { + handlers(() => changeSet) + let finish: (response: Response) => void = () => undefined + let called = false + const sibling = { ...project, id: '00000000-0000-4000-8000-000000009099', name: '另一个项目' } + const otherChange = '00000000-0000-4000-8000-000000009088' + server.use( + http.post(`/api/v1/mcp/write/change-sets/${changeSetId}/items/${itemId}/accept`, () => { + called = true + return new Promise((resolve) => { + finish = resolve + }) + }), + http.get(`/api/v1/mcp/write/change-sets/${otherChange}`, () => + HttpResponse.json( + envelope({ + ...changeSet, + id: otherChange, + project_id: sibling.id, + title: '另一个项目的审核', + items: [{ ...changeSet.items[0], id: 'other-item' }], + }), + ), + ), + ) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`, [project, sibling]) + await userEvent.click(await screen.findByRole('button', { name: '接受并物化' })) + await waitFor(() => expect(called).toBe(true)) + await userEvent.click(screen.getByRole('button', { name: '打开另一个项目提案' })) + expect(await screen.findByText('另一个项目的审核')).toBeVisible() + await act(async () => { + finish( + HttpResponse.json( + envelope({ + ...changeSet, + status: 'accepted', + items: [ + { + ...changeSet.items[0], + review_status: 'accepted', + materialized_resource_type: 'test_plan', + materialized_resource_id: 'old-plan', + }, + ], + }), + ), + ) + }) + expect(screen.queryByText('变更项已接受并物化')).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: '查看更新后的计划' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '接受并物化' })).toBeEnabled() + }) + + it('blocks review if a previously loaded project permission fails to refresh', async () => { + handlers(() => changeSet) + const view = renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + const accept = await screen.findByRole('button', { name: '接受并物化' }) + expect(accept).toBeEnabled() + server.use( + http.get(`/api/v1/projects/${project.id}/permissions`, () => + HttpResponse.json( + { + error: { + code: 'UNAVAILABLE', + message: '权限暂不可用', + trace_id: 'mcp-permission-stale', + }, + }, + { status: 503 }, + ), + ), + ) + await act(async () => { + await view.queryClient.invalidateQueries({ queryKey: ['project-permissions'] }) + }) + expect(await screen.findByText('权限暂不可用')).toBeVisible() + expect(accept).toBeDisabled() + expect(screen.getByRole('button', { name: '拒绝' })).toBeDisabled() + }) + + it('blocks other review actions while manual approval is in progress', async () => { + handlers(() => ({ + ...changeSet, + governance: { ...changeSet.governance, manual_approval_required: true }, + })) + let finish: (response: Response) => void = () => undefined + server.use( + http.post( + `/api/v1/mcp/write/change-sets/${changeSetId}/approve`, + () => + new Promise((resolve) => { + finish = resolve + }), + ), + ) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + await userEvent.click(await screen.findByRole('button', { name: '批准变更集' })) + expect(screen.getByRole('button', { name: '拒绝' })).toBeDisabled() + expect(screen.getByRole('button', { name: '接受并物化' })).toBeDisabled() + await act(async () => { + finish( + HttpResponse.json( + envelope({ + ...changeSet, + approval: { + id: 'approval-late', + decision: 'approved', + approved_by_id: user.id, + approved_at: '2026-10-22T02:00:00Z', + }, + }), + ), + ) + }) + }) + + it.each([ + { status: 'pending' as const, resourceType: 'workflow' }, + { status: 'accepted' as const, resourceType: 'test_case' }, + ])( + 'does not link a workflow result with $status review and $resourceType type', + async ({ status, resourceType }) => { + handlers(() => ({ + ...changeSet, + items: [ + { + ...changeSet.items[0], + item_type: 'workflow', + action: 'create', + review_status: status, + proposed_content: { name: '结果绑定反例', definition: workflowDefinition }, + materialized_resource_id: 'wrong-or-pending-id', + materialized_resource_type: resourceType, + }, + ], + })) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + await screen.findByText('结果绑定反例') + expect(screen.queryByRole('link', { name: '查看已创建草稿' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: '查看物化工作流' })).not.toBeInTheDocument() + if (status === 'accepted') + expect(screen.getByText('物化结果类型与变更项不匹配,无法提供安全定位')).toBeVisible() + }, + ) + + it.each(['missing-item', 'pending-item', 'wrong-result-type'])( + 'does not claim materialization for an inconsistent %s receipt', + async (kind) => { + handlers(() => changeSet) + server.use( + http.post(`/api/v1/mcp/write/change-sets/${changeSetId}/items/${itemId}/accept`, () => + HttpResponse.json( + envelope({ + ...changeSet, + items: + kind === 'missing-item' + ? [] + : [ + { + ...changeSet.items[0], + review_status: kind === 'pending-item' ? 'pending' : 'accepted', + materialized_resource_type: 'test_case', + materialized_resource_id: 'wrong-result', + }, + ], + }), + ), + ), + ) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + await userEvent.click(await screen.findByRole('button', { name: '接受并物化' })) + expect(await screen.findByText('审核结果未确认')).toBeVisible() + expect(screen.queryByText('变更项已接受并物化')).not.toBeInTheDocument() + expect(screen.queryByText('已接受')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '重新读取变更集' })).toBeEnabled() + expect(screen.getByRole('button', { name: '接受并物化' })).toBeDisabled() + }, + ) + + it('does not claim approval when the receipt has no approved decision', async () => { + const current = { + ...changeSet, + governance: { ...changeSet.governance, manual_approval_required: true }, + } + handlers(() => current) + server.use( + http.post(`/api/v1/mcp/write/change-sets/${changeSetId}/approve`, () => + HttpResponse.json(envelope(current)), + ), + ) + renderPage(`/projects/${project.id}/mcp-changes?focus=${changeSetId}`) + await userEvent.click(await screen.findByRole('button', { name: '批准变更集' })) + expect(await screen.findByText('审核结果未确认')).toBeVisible() + expect(screen.queryByText('MCP 变更集已完成人工批准')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '接受并物化' })).toBeDisabled() + }) + it('requires explicit approval before accepting a high-risk proposal', async () => { let current: MCPChangeSet = { ...changeSet, @@ -364,22 +843,39 @@ function envelope(data: MCPChangeSet) { return { data, warnings: [], trace_id: 'test-trace' } } -function renderPage(initialEntry: string) { +function renderPage(initialEntry: string, projects = [project]) { server.use( http.get('/api/v1/projects', () => - HttpResponse.json({ items: [project], total: 1, page: 1, page_size: 100 }), + HttpResponse.json({ items: projects, total: projects.length, page: 1, page_size: 100 }), ), ) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) - return render( + const view = render( + , ) + return { ...view, queryClient } +} + +function NavigationProbe() { + const navigate = useNavigate() + return ( + + ) } diff --git a/frontend/src/pages/MCPChangeSetsPage.tsx b/frontend/src/pages/MCPChangeSetsPage.tsx index 040cc7ee..2f91ddf1 100644 --- a/frontend/src/pages/MCPChangeSetsPage.tsx +++ b/frontend/src/pages/MCPChangeSetsPage.tsx @@ -1,7 +1,25 @@ import { AuditOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Alert, App, Button, Card, Flex, Space, Tag, Typography } from 'antd' +import { + Alert, + App, + Button, + Card, + Collapse, + Descriptions, + Flex, + Space, + Tag, + Typography, +} from 'antd' import { Link, useSearchParams } from 'react-router-dom' +import { useEffect, useRef, useState } from 'react' +import { useAuthStore } from '../features/auth/auth-store' +import MCPGovernanceSummary from '../features/mcp/MCPGovernanceSummary' +import MCPStructuredPreview from '../features/mcp/MCPStructuredPreview' +import MCPMaterializedResult from '../features/mcp/MCPMaterializedResult' +import ResponseCodeReader from '../features/api-console/ResponseCodeReader' +import QueryFailureNotice from '../components/QueryFailureNotice' import { approveMCPChangeSet, @@ -9,81 +27,130 @@ import { reviewMCPChangeItem, type MCPChangeItem, type MCPChangeSet, + type MCPChangeSetEnvelope, } from '../features/mcp/mcp-change-set-service' import ControlBlockGraphPreview from '../features/mcp/ControlBlockGraphPreview' import ControlWorkflowGraphPreview from '../features/mcp/ControlWorkflowGraphPreview' -import { apiErrorMessage, type WorkflowDefinition } from '../lib/api' +import { apiErrorMessage, apiErrorTraceId, type WorkflowDefinition } from '../lib/api' import { useProjectContext } from '../features/projects/use-project-context' import { useProjectCapabilities } from '../features/projects/use-project-capabilities' export default function MCPChangeSetsPage() { const { projectId } = useProjectContext() - return + const [params] = useSearchParams() + const actor = useAuthStore((state) => `${state.user?.id}:${state.organizationId}`) + return ( + + ) } -function MCPChangeSetWorkspace() { +function MCPChangeSetWorkspace({ scope }: { scope: string }) { const { projectId } = useProjectContext() const { canEdit, permissions } = useProjectCapabilities() const [searchParams] = useSearchParams() const focusId = searchParams.get('focus') const { message } = App.useApp() const queryClient = useQueryClient() + const active = useRef(true) + const [actionError, setActionError] = useState(null) + useEffect(() => { + active.current = true + return () => { + active.current = false + } + }, []) const changeSet = useQuery({ - queryKey: ['mcp-change-set', projectId, focusId], + queryKey: ['mcp-change-set', scope, projectId, focusId], queryFn: async () => { const result = await getMCPChangeSet(required(focusId)) if (result.data.project_id !== projectId) throw new Error('该变更集不属于当前项目,请核对项目和 ChangeSet ID。') + if (result.data.id !== focusId) + throw new Error('返回的变更集 ID 与当前定位不一致,请重新读取。') return result }, - enabled: Boolean(focusId), + enabled: Boolean(projectId && focusId), }) const approve = useMutation({ - mutationFn: () => approveMCPChangeSet(required(focusId), '前端人工批准'), + mutationFn: (changeSetId: string) => approveMCPChangeSet(changeSetId, '前端人工批准'), }) const review = useMutation({ - mutationFn: ({ itemId, decision }: { itemId: string; decision: 'accept' | 'reject' }) => - reviewMCPChangeItem( - required(focusId), - itemId, - decision, - changeSet.data?.data.approval?.id ?? null, - ), + mutationFn: ({ + changeSetId, + itemId, + decision, + approvalId, + }: { + changeSetId: string + itemId: string + decision: 'accept' | 'reject' + approvalId: string | null + }) => reviewMCPChangeItem(changeSetId, itemId, decision, approvalId), }) - async function refresh(): Promise { - await queryClient.invalidateQueries({ queryKey: ['mcp-change-set', projectId, focusId] }) + async function updateReceipt(result: MCPChangeSetEnvelope): Promise { + if (result.data.id !== focusId || result.data.project_id !== projectId) + throw new Error('审核回执与当前变更集或项目不一致,请重新读取。') + queryClient.setQueryData(['mcp-change-set', scope, projectId, focusId], result) + await queryClient.invalidateQueries({ queryKey: ['mcp-change-set', scope, projectId, focusId] }) } async function approveChangeSet(): Promise { - if (!editable) return + if (!editable || busy) return + setActionError(null) try { - await approve.mutateAsync() - await refresh() - void message.success('MCP 变更集已完成人工批准') + const result = await approve.mutateAsync(required(focusId)) + if (!active.current) return + confirmApprovalReceipt(result) + await updateReceipt(result) + if (active.current) void message.success('MCP 变更集已完成人工批准') } catch (error) { - void message.error(apiErrorMessage(error)) + if (active.current) { + setActionError(error) + void message.error(apiErrorMessage(error)) + } } } async function reviewItem(itemId: string, decision: 'accept' | 'reject'): Promise { - if (!editable) return + if (!editable || busy) return + setActionError(null) try { - await review.mutateAsync({ itemId, decision }) - await refresh() - if ( - decision === 'accept' && - data?.items.some((item) => item.id === itemId && item.item_type === 'workflow') + const result = await review.mutateAsync({ + changeSetId: required(focusId), + itemId, + decision, + approvalId: approvalIdentity(data), + }) + if (!active.current) return + confirmReviewReceipt(result, itemId, decision) + await updateReceipt(result) + await Promise.all( + materializedQueryPrefixes(result.data, itemId).map((prefix) => + queryClient.invalidateQueries({ queryKey: [prefix, projectId] }), + ), ) - await queryClient.invalidateQueries({ queryKey: ['workflows', data.project_id] }) - void message.success(decision === 'accept' ? '变更项已接受并物化' : '变更项已拒绝') + if (active.current) + void message.success(decision === 'accept' ? '变更项已接受并物化' : '变更项已拒绝') } catch (error) { - void message.error(apiErrorMessage(error)) + if (active.current) { + setActionError(error) + void message.error(apiErrorMessage(error)) + } } } + async function readAfterFailure(): Promise { + const [nextData, nextPermission] = await Promise.all([ + changeSet.refetch(), + permissions.refetch(), + ]) + if (active.current && nextData.isSuccess && nextPermission.isSuccess) setActionError(null) + } + const data = changeSet.data?.data - const editable = canEdit && Boolean(data) + const editable = editableRead(canEdit, permissions, changeSet) && actionError === null + const busy = approve.isPending || review.isPending return (
@@ -97,17 +164,10 @@ function MCPChangeSetWorkspace() { {!focusId ? ( ) : null} - {changeSet.isError ? ( - void changeSet.refetch()}>重试} - /> - ) : null} - {permissions.isError ? ( - - ) : null} + + + + @@ -137,12 +198,14 @@ type MCPReviewActions = { function MCPReviewContent({ data, + envelope, editable, approvePending, reviewPending, onApprove, onReview, }: MCPReviewActions & { + envelope: MCPChangeSetEnvelope approvePending: boolean onApprove: () => Promise }) { @@ -152,15 +215,17 @@ function MCPReviewContent({ {data.governance.risk_level} {data.id} - {data.governance.manual_approval_required && !data.approval ? ( + + {data.governance.manual_approval_required && data.approval?.decision !== 'approved' ? ( } loading={approvePending} onClick={() => void onApprove()} @@ -235,13 +300,121 @@ function MCPChangeItemCard({ baseRevision={data.base_revision ?? null} /> ) : null} -
{JSON.stringify(item.proposed_content, null, 2)}
+ + + + , + }, + ]} + />
) } +function ReadStaleNotice({ hasData, failed }: { hasData: boolean; failed: boolean }) { + if (!hasData || !failed) return null + return +} + +function ActionFailure({ error, onRead }: { error: unknown; onRead: () => Promise }) { + if (!error) return null + const traceId = apiErrorTraceId(error) + return ( + void onRead()}>重新读取变更集} + description={ + + {apiErrorMessage(error)} + {traceId && Trace ID: {traceId}} + + } + /> + ) +} + +function confirmApprovalReceipt(result: MCPChangeSetEnvelope): void { + if (result.data.approval?.decision !== 'approved' || !result.data.approval.id) + throw new Error('服务端回执未确认审批已通过,请重新读取变更集核对。') +} + +function confirmReviewReceipt( + result: MCPChangeSetEnvelope, + itemId: string, + decision: 'accept' | 'reject', +): void { + const item = result.data.items.find((candidate) => candidate.id === itemId) + const expected = decision === 'accept' ? 'accepted' : 'rejected' + if (!item || item.review_status !== expected) + throw new Error('服务端回执未确认被操作项的审核状态,请重新读取变更集核对。') + if (decision === 'accept' && !hasConfirmedMaterialization(item)) + throw new Error('服务端回执未确认匹配的物化结果,请重新读取变更集核对。') +} + +function hasConfirmedMaterialization(item: MCPChangeItem): boolean { + if (!item.materialized_resource_id) return false + if (item.item_type === 'test_plan_update') return item.materialized_resource_type === 'test_plan' + return ( + ['workflow', 'test_design', 'test_case'].includes(item.item_type) && + item.materialized_resource_type === item.item_type + ) +} + +function materializedQueryPrefixes(data: MCPChangeSet, itemId: string): string[] { + const item = data.items.find((candidate) => candidate.id === itemId) + if (!item || item.review_status !== 'accepted') return [] + if (item.materialized_resource_type === 'workflow') return ['workflows'] + if (item.materialized_resource_type === 'test_case') + return ['test-cases', 'test-case-options', 'asset-directory-counts'] + if (item.materialized_resource_type === 'test_plan') + return ['test-plans', 'test-plan', 'asset-plans'] + return [] +} + +function ItemReviewRecord({ item }: { item: MCPChangeItem }) { + return ( + + ) +} + +function editableRead( + canEdit: boolean, + permissions: { isSuccess: boolean; isError: boolean }, + changeSet: { isSuccess: boolean; isError: boolean }, +): boolean { + return ( + canEdit && + permissions.isSuccess && + !permissions.isError && + changeSet.isSuccess && + !changeSet.isError + ) +} +function approvalIdentity(data: MCPChangeSet | undefined): string | null { + return data?.approval?.id ?? null +} + function materializationDisabled(editable: boolean, pending: boolean, data: MCPChangeSet): boolean { - return !editable || pending || (data.governance.manual_approval_required && !data.approval) + return ( + !editable || + pending || + (data.governance.manual_approval_required && data.approval?.decision !== 'approved') + ) } function WorkflowReviewSummary({ @@ -304,11 +477,6 @@ function ControlWorkflowReviewSummary({ projectId={projectId} proposalId={item.id} /> - {item.materialized_resource_id ? ( - - 查看已创建草稿 - - ) : null} {(definition.regions ?? []).map((region) => ( From 0ec02520771cb6a15acd9a66fa7f290b73a729e6 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:51:12 +0800 Subject: [PATCH 22/35] fix(organization): lock member picker during pending updates --- .../2026-10-20/batch-26-organization.md | 12 ++++ .../pages/OrganizationGovernancePage.test.tsx | 55 +++++++++++++++++++ .../src/pages/OrganizationGovernancePage.tsx | 6 +- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-26-organization.md b/docs/design/ui-v5/remediation/2026-10-20/batch-26-organization.md index 865ef96e..47be6bed 100644 --- a/docs/design/ui-v5/remediation/2026-10-20/batch-26-organization.md +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-26-organization.md @@ -28,6 +28,18 @@ 原始日志保存在本批工作树 `output/m24-organization-20261022/`,包括 `frontend-red.log`、`backend-red.log`、`extra-red.log`、`frontend-final.log`、`backend-final.log`、`frontend-lint.log`、`frontend-build.log`、`backend-static.log` 和 `format-check.log`。日志只涉及测试合成账号与固定测试 token,没有真实凭据。 +## 独立复审:提交期间锁定管理员目录输入 + +在开发基线 `6922d261aa0efa40936b85d5b2e127d332dc427a` 上补充复审发现:系统管理员使用的共享 `DirectoryPicker` 将未传入的 `disabled` 默认为 `false`,覆盖 Antd Form 的禁用上下文。成员 A 的更新请求挂起时仍可改选成员 B;A 成功后表单重置,会静默清空尚未提交的 B。 + +本次仅在组织成员表单显式向 `OrganizationMemberPicker` 传入 `disabled={pending}`,不修改共享选择器默认行为、普通 Owner 的目录边界或生产后端。新增两个公开页面反例,分别使用真实目录选择/搜索输入与高级 UUID 输入:延迟 PUT 期间输入、模式切换和角色均禁用;尝试输入不改变值,重复点击只产生一次写入;返回成功后才清空。修复前两项均在输入未禁用的断言失败,原 25 项页面测试通过。 + +- `make test-frontend-targeted TARGETS='["src/pages/OrganizationGovernancePage.test.tsx","src/features/projects/DirectoryPicker.test.tsx","src/pages/organization-governance-rotation.test.tsx"]'`:3 文件、31 项通过,保留普通 Owner、错误恢复和轮换界面的已有回归。 +- 修改的两个 TSX 文件 ESLint(零警告)与 Prettier 检查通过;`pnpm build`(`tsc -b && vite build`)通过;`git diff --check` 通过。 +- 原始反例和最终日志分别保留在本修复工作树 `output/validation/m24-pending-picker/01-regression-before-fix.log`、`03-targeted-final.log`;静态与构建日志为同目录 `04-eslint.log`、`05-format.log`、`06-types-build.log`。 + +本次没有运行全量覆盖率、完整后端检查、共享 GUI、Compose 或 QA;整合后的完整门禁和环境验收仍需单独记录。 + ## 改动文件 - 前端:`OrganizationGovernancePage.tsx`、`OrganizationGovernancePage.test.tsx`、新增 `OrganizationAuditPanel.tsx` 与 `OrganizationQueryBoundary.tsx`、组织 `organization-service.ts`。 diff --git a/frontend/src/pages/OrganizationGovernancePage.test.tsx b/frontend/src/pages/OrganizationGovernancePage.test.tsx index d3b96e50..64069ed9 100644 --- a/frontend/src/pages/OrganizationGovernancePage.test.tsx +++ b/frontend/src/pages/OrganizationGovernancePage.test.tsx @@ -456,6 +456,61 @@ describe('OrganizationGovernancePage', () => { expect(screen.queryByText(`同名用户 (${user.email})`)).not.toBeInTheDocument(), ) }) + + it.each(['directory', 'advanced'] as const)( + 'locks the system administrator %s picker until the member update finishes', + async (mode) => { + let writes = 0 + let submitted: unknown + let release: (() => void) | undefined + server.use( + http.post('/api/v1/users/search', () => + HttpResponse.json({ items: [user], total: 1, page: 1, page_size: 20 }), + ), + http.put( + `/api/v1/organizations/${organizationId}/members/${user.id}`, + async ({ request }) => { + writes += 1 + submitted = await request.json() + await new Promise((resolve) => { + release = resolve + }) + return HttpResponse.json(member) + }, + ), + ) + renderPage() + const browser = userEvent.setup() + await screen.findByRole('combobox', { name: '组织用户' }) + if (mode === 'advanced') { + await browser.click(screen.getByRole('button', { name: '按 ID 输入' })) + await browser.type(screen.getByRole('textbox', { name: '组织用户 ID' }), user.id) + } else { + await browser.click(screen.getByRole('combobox', { name: '组织用户' })) + await browser.click(await screen.findByText(`${user.display_name} (${user.email})`)) + } + await browser.click(screen.getByRole('button', { name: '添加 / 更新' })) + await waitFor(() => expect(writes).toBe(1)) + const input = + mode === 'advanced' + ? screen.getByRole('textbox', { name: '组织用户 ID' }) + : screen.getByRole('combobox', { name: '组织用户' }) + expect(input).toBeDisabled() + expect(screen.getByRole('button', { name: /按 ID 输入|返回名称搜索/ })).toBeDisabled() + expect(screen.getByRole('combobox', { name: '成员角色' })).toBeDisabled() + await browser.type(input, 'unsubmitted-user', { skipClick: true }) + expect(input).toHaveValue(mode === 'advanced' ? user.id : '') + await browser.click(screen.getByRole('button', { name: '添加 / 更新' })) + expect(writes).toBe(1) + expect(submitted).toEqual({ user_id: user.id, role: 'member' }) + await act(async () => release?.()) + await waitFor(() => expect(screen.getByRole('button', { name: '添加 / 更新' })).toBeEnabled()) + const cleared = screen.getByRole('combobox', { name: '组织用户' }) + expect(cleared).toHaveValue('') + expect(cleared.closest('.ant-select')).not.toHaveTextContent(user.email) + expect(writes).toBe(1) + }, + ) }) function auditEntry(page: number) { diff --git a/frontend/src/pages/OrganizationGovernancePage.tsx b/frontend/src/pages/OrganizationGovernancePage.tsx index 8b4ca9ce..ee8f2df5 100644 --- a/frontend/src/pages/OrganizationGovernancePage.tsx +++ b/frontend/src/pages/OrganizationGovernancePage.tsx @@ -646,7 +646,11 @@ function OverviewTab({ name="user_id" rules={[{ required: true, message: '请选择用户或输入完整用户 ID' }]} > - + ({ value: candidate.version, label: `用例 v${candidate.version} · 流程 v${candidate.definition.workflow_version}`, @@ -1877,32 +1903,91 @@ function RunCaseDetails({ function RunCaseContext({ item, - workflow, - environment, + workflows, + environments, versions, + source, + version, + loading, }: { item: TestCase - workflow?: Workflow - environment?: Environment + workflows: Workflow[] + environments: Environment[] versions: TestCaseVersion[] + source: RunCaseSource | undefined + version: number | undefined + loading: boolean }) { const current = versions.find((candidate) => candidate.version === item.current_version) - const draftChanged = current ? hasDraftChanges(item, current, workflow) : true + const draftWorkflow = workflows.find( + (candidate) => candidate.id === item.draft_definition.workflow_id, + ) + const draftChanged = current ? hasDraftChanges(item, current, draftWorkflow) : null + const definition = selectedCaseRunDefinition(item, versions, source, version) return ( <> - - 流程:{workflow?.name ?? '流程不可用'} · 环境:{environment?.name ?? '环境不可用'} - + {item.current_version && ( 当前已发布用例 v{item.current_version}; - {draftChanged ? '草稿的执行定义有未发布修改' : '草稿执行定义与当前版本相同'} + {draftChanged === null + ? '草稿与当前版本的差异尚未确认' + : draftChanged + ? '草稿的执行定义有未发布修改' + : '草稿执行定义与当前版本相同'} )} ) } +function selectedCaseRunDefinition( + item: TestCase, + versions: TestCaseVersion[], + source: RunCaseSource | undefined, + version: number | undefined, +): TestCaseDefinition | undefined { + if (source === 'draft') return item.draft_definition + if (source === 'published') + return versions.find((candidate) => candidate.version === version)?.definition + return undefined +} + +function CaseRunTarget({ + definition, + workflows, + environments, + loading, +}: { + definition: TestCaseDefinition | undefined + workflows: Workflow[] + environments: Environment[] + loading: boolean +}) { + if (loading) return 正在读取已发布版本的运行目标 + if (!definition) + return 请选择有效的运行来源和版本以确认运行目标。 + const workflow = workflows.find((candidate) => candidate.id === definition.workflow_id) + const environment = environments.find((candidate) => candidate.id === definition.environment_id) + const workflowVersion = definition.workflow_version ?? workflow?.current_version + return ( + + 流程:{caseRunResourceLabel(definition.workflow_id, workflow)} · 流程版本: + {workflowVersion ? `v${workflowVersion}` : '发布时由服务端固定'} · 环境: + {caseRunResourceLabel(definition.environment_id, environment)} + + ) +} + +function caseRunResourceLabel(id: string, resource: { name: string } | undefined): string { + return resource ? `${resource.name}(${id})` : `${id}(名称未加载,以绑定 ID 为准)` +} + function hasDraftChanges(item: TestCase, current: TestCaseVersion, workflow?: Workflow): boolean { const draft = item.draft_definition const saved = current.definition diff --git a/frontend/src/pages/TestAssetsWorkspace.test.tsx b/frontend/src/pages/TestAssetsWorkspace.test.tsx index 800609fd..bc721ee3 100644 --- a/frontend/src/pages/TestAssetsWorkspace.test.tsx +++ b/frontend/src/pages/TestAssetsWorkspace.test.tsx @@ -1,7 +1,8 @@ import userEvent from '@testing-library/user-event' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate, type NavigateFunction } from 'react-router-dom' +import { useEffect } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { App as AntdApp, ConfigProvider } from 'antd' import { http, HttpResponse } from 'msw' import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -154,7 +155,7 @@ function mockWorkspace(viewer = false, otherCaseCount: number = 98) { return { pages, writes } } -function renderWorkspace(initialEntry?: string) { +function renderWorkspace(initialEntry?: string, onNavigate?: (navigate: NavigateFunction) => void) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( @@ -162,7 +163,7 @@ function renderWorkspace(initialEntry?: string) { - + @@ -341,6 +342,138 @@ it('runs the selected older case version directly without creating a plan', asyn expect(writes).not.toHaveBeenCalled() }) +it('keeps the new project run dialog intact when the previous project receipt arrives late', async () => { + mockWorkspace(false, 0) + const nextProject = { ...project, id: '00000000-0000-4000-8000-000000000019', name: '新项目' } + const nextWorkflow = { ...workflow, project_id: nextProject.id, id: 'next-workflow' } + const nextEnvironment = { ...environment, project_id: nextProject.id, id: 'next-environment' } + const nextCase = { + ...baseCase, + id: 'next-case', + project_id: nextProject.id, + name: '新项目用例', + folder_id: null, + draft_definition: { + ...baseCase.draft_definition, + workflow_id: nextWorkflow.id, + environment_id: nextEnvironment.id, + }, + } + const nextRoot = `/api/v1/projects/${nextProject.id}` + let release!: () => void + server.use( + http.get('/api/v1/projects', () => + HttpResponse.json({ + items: [project, nextProject], + total: 2, + page: 1, + page_size: 100, + }), + ), + http.get(nextRoot, () => HttpResponse.json(nextProject)), + http.get(`${nextRoot}/*`, ({ request }) => { + const resource = new URL(request.url).pathname.slice(nextRoot.length) + if (resource === '/permissions') + return HttpResponse.json({ + effective_role: 'owner', + capabilities: ['read', 'edit', 'execute'], + matrix: {}, + }) + if (resource === '/environments') return HttpResponse.json([nextEnvironment]) + if (resource === '/workflows') + return HttpResponse.json({ items: [nextWorkflow], total: 1, page: 1, page_size: 100 }) + if (resource === '/test-cases') + return HttpResponse.json({ items: [nextCase], total: 1, page: 1, page_size: 20 }) + if (resource === '/test-suites') + return HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }) + if (resource.endsWith('/versions')) + return HttpResponse.json([ + { + id: 'next-version', + test_case_id: nextCase.id, + version: 2, + definition: nextCase.draft_definition, + fingerprint: 'b'.repeat(64), + change_note: '', + created_by_id: user.id, + created_at: workflow.created_at, + }, + ]) + if (resource === '/test-assets/directory-counts') + return HttpResponse.json({ + case_total: 1, + suite_total: 0, + published_case_total: 1, + unfiled_cases: 1, + unfiled_suites: 0, + folders: [], + }) + return HttpResponse.json([]) + }), + http.post(`${root}/test-cases/${baseCase.id}/runs`, async () => { + await new Promise((resolve) => { + release = resolve + }) + return HttpResponse.json( + { + case_id: baseCase.id, + case_version: 2, + workflow_id: workflow.id, + workflow_version: 2, + execution_id: 'old-project-execution', + source: 'direct', + status: 'queued', + environment_id: environment.id, + started_at: workflow.created_at, + created_new_version: false, + }, + { status: 202 }, + ) + }), + ) + let navigate: NavigateFunction | undefined + const client = renderWorkspace(undefined, (value) => { + navigate = value + }) + const firstRow = (await screen.findByText(baseCase.name)).closest('tr')! + const firstRun = within(firstRow).getByText('运行').closest('button')! + expect(firstRun).toHaveRole('button') + await waitFor(() => expect(firstRun).toBeEnabled()) + fireEvent.click(firstRun) + const firstDialog = ( + await screen.findByText(`运行测试用例:${baseCase.name}`) + ).closest('[role=dialog]')! + fireEvent.click(within(firstDialog).getByLabelText('运行已发布用例版本')) + const submit = within(firstDialog).getByRole('button', { name: '运行已发布版本' }) + await waitFor(() => expect(submit).toBeEnabled()) + fireEvent.click(submit) + await waitFor(() => expect(release).toBeDefined()) + try { + expect(navigate).toBeDefined() + await act(async () => { + await navigate!(`/projects/${nextProject.id}/assets`) + }) + const nextRow = (await screen.findByText(nextCase.name)).closest('tr')! + const nextRun = within(nextRow).getByText('运行').closest('button')! + expect(nextRun).toHaveRole('button') + await waitFor(() => expect(nextRun).toBeEnabled()) + fireEvent.click(nextRun) + const nextDialog = ( + await screen.findByText(`运行测试用例:${nextCase.name}`) + ).closest('[role=dialog]')! + await act(async () => release()) + await waitFor(() => expect(client.isMutating()).toBe(0)) + expect(nextDialog).toBeInTheDocument() + expect(screen.queryByText('运行已提交')).not.toBeInTheDocument() + expect(screen.getByTestId('asset-location')).toHaveAttribute( + 'data-pathname', + `/projects/${nextProject.id}/assets`, + ) + } finally { + await act(async () => release()) + } +}) + it('adds the selected fixed case version to an existing plan without executing it', async () => { const { writes } = mockWorkspace() const submitted = vi.fn() @@ -385,10 +518,15 @@ it('adds the selected fixed case version to an existing plan without executing i expect(writes).not.toHaveBeenCalled() }) -function AssetLocation() { +function AssetLocation({ onNavigate }: { onNavigate?: (navigate: NavigateFunction) => void }) { + const location = useLocation() + const navigate = useNavigate() + useEffect(() => { + onNavigate?.(navigate) + }, [navigate, onNavigate]) return ( -