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/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/api/v1/endpoints/capabilities.py b/backend/app/api/v1/endpoints/capabilities.py index d9c55fbb..216caec4 100644 --- a/backend/app/api/v1/endpoints/capabilities.py +++ b/backend/app/api/v1/endpoints/capabilities.py @@ -1,3 +1,5 @@ +from uuid import UUID + from fastapi import APIRouter, Query from app.api.dependencies import CurrentUser, SessionDependency @@ -12,7 +14,7 @@ V3FeatureFlagsResponse, ) from app.schemas.common import Page -from app.services.capabilities import CapabilityService, CapabilityView +from app.services.capabilities import CapabilityService, CapabilityView, RunnerPoolView router = APIRouter() @@ -42,8 +44,11 @@ async def list_capabilities( current_user: CurrentUser, page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), + search: str = Query(default="", max_length=200), ) -> Page[CapabilityResponse]: - capabilities = await CapabilityService(session).list_capabilities(actor=current_user) + capabilities = await CapabilityService(session).list_capabilities( + actor=current_user, search=search + ) start = (page - 1) * page_size selected = capabilities[start : start + page_size] return Page( @@ -78,8 +83,9 @@ async def list_plugins( current_user: CurrentUser, page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), + search: str = Query(default="", max_length=200), ) -> Page[PluginResponse]: - plugins = await CapabilityService(session).list_plugins(actor=current_user) + plugins = await CapabilityService(session).list_plugins(actor=current_user, search=search) start = (page - 1) * page_size selected = plugins[start : start + page_size] return Page( @@ -109,30 +115,49 @@ async def list_runner_pools( current_user: CurrentUser, page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), + search: str = Query(default="", max_length=200), ) -> Page[RunnerPoolResponse]: - pools = await CapabilityService(session).list_runner_pools(actor=current_user) + pools = await CapabilityService(session).list_runner_pools(actor=current_user, search=search) start = (page - 1) * page_size selected = pools[start : start + page_size] return Page( - items=[ - RunnerPoolResponse( - id=view.pool.id, - name=view.pool.name, - runner_type=view.pool.runner_type, - network_zone=view.pool.network_zone, - labels=view.pool.labels, - max_concurrency=view.pool.max_concurrency, - enabled=view.pool.enabled, - runners=[RunnerResponse.model_validate(item) for item in view.runners], - ) - for view in selected - ], + items=[_runner_pool_response(view) for view in selected], total=len(pools), page=page, page_size=page_size, ) +@router.get("/plugins/{plugin_id}", response_model=PluginResponse) +async def get_plugin( + plugin_id: UUID, session: SessionDependency, current_user: CurrentUser +) -> PluginResponse: + return PluginResponse.model_validate( + await CapabilityService(session).get_plugin(actor=current_user, plugin_id=plugin_id) + ) + + +@router.get("/runner-pools/{pool_id}", response_model=RunnerPoolResponse) +async def get_runner_pool( + pool_id: UUID, session: SessionDependency, current_user: CurrentUser +) -> RunnerPoolResponse: + view = await CapabilityService(session).get_runner_pool(actor=current_user, pool_id=pool_id) + return _runner_pool_response(view) + + +def _runner_pool_response(view: RunnerPoolView) -> RunnerPoolResponse: + return RunnerPoolResponse( + id=view.pool.id, + name=view.pool.name, + runner_type=view.pool.runner_type, + network_zone=view.pool.network_zone, + labels=view.pool.labels, + max_concurrency=view.pool.max_concurrency, + enabled=view.pool.enabled, + runners=[RunnerResponse.model_validate(item) for item in view.runners], + ) + + def _capability_response(view: CapabilityView) -> CapabilityResponse: manifest = view.manifest return CapabilityResponse( 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/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/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/core/standalone_schema.py b/backend/app/core/standalone_schema.py index 492f4c78..4959a075 100644 --- a/backend/app/core/standalone_schema.py +++ b/backend/app/core/standalone_schema.py @@ -63,6 +63,12 @@ async def _ensure_incremental_columns(connection: AsyncConnection) -> None: await _ensure_governance_tables(connection) await _ensure_test_context_tables(connection) await _ensure_flow_spec_change_set_columns(connection) + await _add_column_if_missing( + connection, + table="ai_change_sets", + column="review_baselines", + definition="JSON NOT NULL DEFAULT '[]'", + ) await _ensure_s42_controlled_write_tables(connection) await _ensure_s62_test_plan_update_constraint(connection) await _ensure_s47_test_design_columns(connection) 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/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/repositories/capabilities.py b/backend/app/repositories/capabilities.py index b42aab59..617fb2c0 100644 --- a/backend/app/repositories/capabilities.py +++ b/backend/app/repositories/capabilities.py @@ -1,6 +1,6 @@ from uuid import UUID -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.capabilities import Capability, Plugin, Runner, RunnerPool @@ -30,8 +30,16 @@ async def list_plugins(self) -> list[Plugin]: ).all() ) - async def list_runner_pools(self) -> list[RunnerPool]: - rows = await self._session.scalars(select(RunnerPool).order_by(RunnerPool.name)) + async def list_runner_pools(self, *, organization_id: UUID | None = None) -> list[RunnerPool]: + statement = select(RunnerPool) + if organization_id is not None: + statement = statement.where( + or_( + RunnerPool.organization_id == organization_id, + RunnerPool.organization_id.is_(None), + ) + ) + rows = await self._session.scalars(statement.order_by(RunnerPool.name)) return list(rows.all()) async def list_runners(self, pool_id: UUID) -> list[Runner]: 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/repositories/runner_fabric.py b/backend/app/repositories/runner_fabric.py index 9ce544c3..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, 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 @@ -266,6 +266,114 @@ 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]: + 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_( + RunnerPool.organization_id == organization_id, + RunnerPool.organization_id.is_(None), + ) + ) + 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(event_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/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/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/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/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/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/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/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/backend/app/services/ai_change_sets.py b/backend/app/services/ai_change_sets.py index d9dad497..de2c0ad8 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"): @@ -655,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( @@ -961,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 创建草稿不能包含脱敏占位符。请创建后重新绑定运行值", @@ -985,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 创建草稿不能包含脱敏占位符。请创建后重新绑定运行值", @@ -1050,3 +1084,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/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/app/services/capabilities.py b/backend/app/services/capabilities.py index 3f310e60..8338dfef 100644 --- a/backend/app/services/capabilities.py +++ b/backend/app/services/capabilities.py @@ -1,9 +1,11 @@ from dataclasses import dataclass +from uuid import UUID from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings +from app.core.context import get_tenant_context from app.core.errors import AppError from app.domain.capabilities import CapabilityManifest, PluginManifest from app.engine.capabilities import builtin_capability_registry @@ -29,7 +31,9 @@ class CapabilityService: def __init__(self, session: AsyncSession) -> None: self._repository = CapabilityRepository(session) - async def list_capabilities(self, *, actor: User) -> tuple[CapabilityView, ...]: + async def list_capabilities( + self, *, actor: User, search: str = "" + ) -> tuple[CapabilityView, ...]: del actor builtins = tuple( CapabilityView( @@ -57,7 +61,13 @@ async def list_capabilities(self, *, actor: User) -> tuple[CapabilityView, ...]: enabled=record.enabled and settings.feature_plugin_registry_enabled, ) ) - return tuple((*builtins, *plugins)) + return tuple( + item + for item in (*builtins, *plugins) + if _matches_inventory( + search, item.manifest.id, item.manifest.display_name, item.manifest.version + ) + ) async def get_capability( self, @@ -72,15 +82,26 @@ async def get_capability( return capability raise AppError(code="CAPABILITY_NOT_FOUND", message="能力版本不存在", status_code=404) - async def list_plugins(self, *, actor: User) -> list[Plugin]: + async def list_plugins(self, *, actor: User, search: str = "") -> list[Plugin]: self._require_system_admin(actor) - return await self._repository.list_plugins() - - async def list_runner_pools(self, *, actor: User) -> tuple[RunnerPoolView, ...]: + return [ + item + for item in await self._repository.list_plugins() + if _matches_inventory(search, item.plugin_key, item.display_name, item.version) + ] + + async def list_runner_pools( + self, *, actor: User, search: str = "" + ) -> tuple[RunnerPoolView, ...]: self._require_system_admin(actor) - pools = await self._repository.list_runner_pools() + context = get_tenant_context() + pools = await self._repository.list_runner_pools( + organization_id=context.organization_id if context else None + ) views: list[RunnerPoolView] = [] for pool in pools: + if not _matches_inventory(search, pool.name, pool.runner_type, pool.network_zone): + continue views.append( RunnerPoolView( pool=pool, @@ -89,6 +110,20 @@ async def list_runner_pools(self, *, actor: User) -> tuple[RunnerPoolView, ...]: ) return tuple(views) + async def get_plugin(self, *, actor: User, plugin_id: UUID) -> Plugin: + plugins = await self.list_plugins(actor=actor) + for plugin in plugins: + if plugin.id == plugin_id: + return plugin + raise AppError(code="PLUGIN_NOT_FOUND", message="插件不存在", status_code=404) + + async def get_runner_pool(self, *, actor: User, pool_id: UUID) -> RunnerPoolView: + pools = await self.list_runner_pools(actor=actor) + for pool in pools: + if pool.pool.id == pool_id: + return pool + raise AppError(code="RUNNER_POOL_NOT_FOUND", message="Runner Pool 不存在", status_code=404) + @staticmethod def validate_plugin_manifest(*, actor: User, payload: object) -> PluginManifest: CapabilityService._require_system_admin(actor) @@ -130,3 +165,8 @@ def _builtin_enabled(manifest: CapabilityManifest) -> bool: if manifest.version == "3.0.0" and manifest.id in {"graphql.request", "grpc.call"}: return settings.feature_capability_sdk_enabled and settings.feature_multi_protocol_enabled return settings.feature_capability_sdk_enabled + + +def _matches_inventory(search: str, *values: str) -> bool: + needle = search.strip().casefold() + return not needle or any(needle in value.casefold() for value in values) 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/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/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/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_change_set_redaction_policy.py b/backend/tests/test_ai_change_set_redaction_policy.py new file mode 100644 index 00000000..c9e2ada8 --- /dev/null +++ b/backend/tests/test_ai_change_set_redaction_policy.py @@ -0,0 +1,338 @@ +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.core.redaction import project_redaction_policy +from app.models.access import Project, User +from app.models.ai import AIChangeItem, AIChangeSet, AIJob, AISuggestion +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 + project = await session.get(Project, UUID(target.project_id)) + assert project is not None + policy = project_redaction_policy(project) + job = AIJob( + project_id=project.id, + redaction_mode=policy.mode.value, + redaction_policy_version=policy.policy_version, + job_type="change_set", + status="completed", + sanitized_input={}, + input_sha256="a" * 64, + prompt_template_version="test-review-policy-v1", + model_name="synthetic-review-provider", + created_by_id=actor_id, + ) + session.add(job) + await session.flush() + proposal = {"name": f"审核后 {action} 草稿", "definition": definition} + suggestion = AISuggestion( + job_id=job.id, + position=0, + suggestion_type=asset_type, + title="审核后草稿", + content={ + "action": action, + "target_id": str(resource.id) if action == "update" else None, + **proposal, + }, + ) + change_set = AIChangeSet( + project_id=UUID(target.project_id), + ai_job_id=job.id, + title="人工审核草稿", + status="draft", + source_snapshot={}, + source_fingerprint="a" * 64, + created_by_id=actor_id, + ) + session.add_all([change_set, suggestion]) + await session.flush() + item = AIChangeItem( + change_set_id=change_set.id, + suggestion_id=suggestion.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=proposal, + ) + 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/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"] 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_capability_sdk.py b/backend/tests/test_capability_sdk.py index b7561fc5..54d01f2b 100644 --- a/backend/tests/test_capability_sdk.py +++ b/backend/tests/test_capability_sdk.py @@ -1,4 +1,5 @@ from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from datetime import UTC, datetime, timedelta from typing import Any from uuid import uuid4 @@ -7,6 +8,7 @@ import pytest from httpx import ASGITransport, AsyncClient from pydantic import ValidationError +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.pool import StaticPool @@ -35,6 +37,7 @@ from app.main import app from app.models import Base from app.models.access import User +from app.models.capabilities import Plugin, RunnerPool from app.runner.contracts import ( RunnerIdentity, RunnerLease, @@ -442,6 +445,121 @@ async def test_capability_api_auth_flags_admin_and_manifest_validation( assert invalid.status_code == 422 +@pytest.mark.asyncio +async def test_inventory_search_pagination_exact_read_and_admin_boundary( + capability_client: AsyncClient, +) -> None: + async with asynccontextmanager(app.dependency_overrides[get_session])() as session: + actor = await session.scalar(select(User).where(User.email == ADMIN_EMAIL)) + assert actor is not None + plugins = [ + Plugin( + plugin_key=f"inventory.plugin-{index:03}", + version="1.0.0", + display_name=f"插件 {index}", + oci_repository="registry.example/audit", + oci_digest=f"sha256:{index:064x}", + signature_identity="audit@example.com", + status="disabled", + manifest={}, + created_by_id=actor.id, + ) + for index in range(101) + ] + pools = [ + RunnerPool( + name=f"inventory.pool-{index:03}", + runner_type="general", + network_zone="audit", + max_concurrency=1, + enabled=False, + created_by_id=actor.id, + ) + for index in range(101) + ] + session.add_all([*plugins, *pools]) + await session.commit() + plugin_id, pool_id = plugins[-1].id, pools[-1].id + admin = await _login_headers(capability_client, ADMIN_EMAIL, ADMIN_PASSWORD) + regular = await _login_headers(capability_client, USER_EMAIL, USER_PASSWORD) + for root, identifier, field in ( + ("plugins", plugin_id, "plugin_key"), + ("runner-pools", pool_id, "name"), + ): + page = await capability_client.get(f"/api/v1/{root}?page=6&page_size=20", headers=admin) + assert page.status_code == 200 and page.json()["total"] == 101 + assert len(page.json()["items"]) == 1 + searched = await capability_client.get(f"/api/v1/{root}?search=100", headers=admin) + assert searched.json()["total"] == 1 + detail = await capability_client.get(f"/api/v1/{root}/{identifier}", headers=admin) + assert detail.status_code == 200 and detail.json()[field].endswith("100") + denied = await capability_client.get(f"/api/v1/{root}/{identifier}", headers=regular) + assert denied.status_code == 403 and denied.json()["error"]["trace_id"] + missing = await capability_client.get(f"/api/v1/{root}/{uuid4()}", headers=admin) + assert missing.status_code == 404 + filtered = await capability_client.get( + "/api/v1/capabilities?search=http.request", headers=regular + ) + assert filtered.status_code == 200 + assert all("http.request" in item["id"] for item in filtered.json()["items"]) + + +@pytest.mark.asyncio +async def test_platform_pool_inventory_uses_organization_and_shared_boundary( + capability_client: AsyncClient, +) -> None: + from app.core.context import reset_tenant_context, set_tenant_context + from app.core.errors import AppError + from app.domain.tenant import TenantContext + from app.services.capabilities import CapabilityService + + async with asynccontextmanager(app.dependency_overrides[get_session])() as session: + actor = await session.scalar(select(User).where(User.email == ADMIN_EMAIL)) + assert actor is not None + organization_id = uuid4() + pools = [ + RunnerPool( + name=f"scope-pool-{index}", + runner_type="general", + network_zone="audit", + max_concurrency=1, + enabled=True, + created_by_id=actor.id, + organization_id=scope, + ) + for index, scope in enumerate((organization_id, None, uuid4())) + ] + session.add_all(pools) + await session.commit() + token = set_tenant_context( + TenantContext( + organization_id=organization_id, + actor_id=actor.id, + role=None, + is_system_admin=True, + ) + ) + try: + service = CapabilityService(session) + views = await service.list_runner_pools(actor=actor) + assert {view.pool.id for view in views} == {pools[0].id, pools[1].id} + from app.api.v1.endpoints.capabilities import list_runner_pools + + page = await list_runner_pools( + session=session, current_user=actor, page=1, page_size=1, search="" + ) + assert page.total == 2 and len(page.items) == 1 + + assert ( + await service.get_runner_pool(actor=actor, pool_id=pools[1].id) + ).pool.id == pools[1].id + with pytest.raises(AppError) as error: + await service.get_runner_pool(actor=actor, pool_id=pools[2].id) + assert error.value.status_code == 404 + finally: + reset_tenant_context(token) + + def _manifest(**updates: object) -> CapabilityManifest: values: dict[str, object] = { "id": "vendor.echo", 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"] 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/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, diff --git a/backend/tests/test_runner_fabric.py b/backend/tests/test_runner_fabric.py index a114d61f..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, @@ -1283,6 +1283,273 @@ 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_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/backend/tests/test_standalone_ai_review_baselines.py b/backend/tests/test_standalone_ai_review_baselines.py new file mode 100644 index 00000000..6516ea70 --- /dev/null +++ b/backend/tests/test_standalone_ai_review_baselines.py @@ -0,0 +1,232 @@ +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from uuid import UUID, uuid4 + +import pytest +from sqlalchemy import Table, inspect, select, text +from sqlalchemy.ext.asyncio import ( + AsyncConnection, + AsyncEngine, + async_sessionmaker, + create_async_engine, +) + +from app.core import standalone_schema +from app.models import Base +from app.models.access import Project, User +from app.models.ai import AIChangeItem, AIChangeSet, AIJob, AISuggestion +from app.models.test_design import ChangeSetApproval + +Rows = tuple[tuple[object, ...], ...] + + +@dataclass(frozen=True) +class LegacyEvidence: + change_sets: Rows + items: Rows + approvals: Rows + + +@pytest.mark.asyncio +async def test_standalone_startup_adds_review_baselines_without_rebuilding_s62_evidence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'existing-s62.db'}") + monkeypatch.setattr(standalone_schema, "engine", engine) + try: + async with engine.begin() as connection: + await connection.execute(text("PRAGMA foreign_keys=ON")) + await connection.run_sync(Base.metadata.create_all) + ai_id, mcp_id = await _seed_reviewed_changes(engine) + async with engine.begin() as connection: + await connection.execute( + text("ALTER TABLE ai_change_sets DROP COLUMN review_baselines") + ) + columns = await connection.run_sync( + lambda sync: inspect(sync).get_columns("ai_change_sets") + ) + assert {column["name"] for column in columns} == { + column.name + for column in AIChangeSet.__table__.columns + if column.name != "review_baselines" + } + assert not await standalone_schema._flow_spec_tables_need_rebuild(connection) + original = await _evidence(connection) + + await standalone_schema.initialize_standalone_database() + async with engine.connect() as connection: + assert await _evidence(connection) == original + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as session: + records = (await session.scalars(select(AIChangeSet))).all() + assert {record.id for record in records} == {ai_id, mcp_id} + assert all(record.review_baselines == [] for record in records) + frozen = [ + { + "target_type": "workflow", + "target_id": str(uuid4()), + "snapshot_sha256": "e" * 64, + "content": { + "name": "新冻结的人工草稿", + "description": "已授权值 alice@example.com [REDACTED]", + "definition": {"variables": {"literal": "[REDACTED]"}}, + }, + "current_version": 1, + "draft_revision": 2, + "updated_at": "2026-10-22T00:00:00+00:00", + "redaction_mode": "off", + "redaction_policy_version": 1, + } + ] + ai_record = next(record for record in records if record.id == ai_id) + ai_record.review_baselines = frozen + await session.commit() + + async with engine.connect() as connection: + saved = await _evidence(connection) + for _ in range(2): + await standalone_schema.initialize_standalone_database() + async with engine.connect() as connection: + assert await _evidence(connection) == saved + assert (await connection.execute(text("PRAGMA foreign_key_check"))).all() == [] + async with sessions() as session: + ai_record = await session.get(AIChangeSet, ai_id) + mcp_record = await session.get(AIChangeSet, mcp_id) + assert ai_record is not None and ai_record.review_baselines == frozen + assert mcp_record is not None and mcp_record.review_baselines == [] + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_standalone_fresh_start_can_read_change_sets_and_keeps_baseline_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'fresh-review.db'}") + monkeypatch.setattr(standalone_schema, "engine", engine) + try: + await standalone_schema.initialize_standalone_database() + await standalone_schema.initialize_standalone_database() + await _seed_reviewed_changes(engine) + async with async_sessionmaker(engine)() as session: + records = (await session.scalars(select(AIChangeSet))).all() + assert len(records) == 2 + assert all(record.review_baselines == [] for record in records) + async with engine.connect() as connection: + columns = await connection.run_sync( + lambda sync: inspect(sync).get_columns("ai_change_sets") + ) + column = next(column for column in columns if column["name"] == "review_baselines") + assert column["nullable"] is False and column["default"] == "'[]'" + finally: + await engine.dispose() + + +async def _seed_reviewed_changes(engine: AsyncEngine) -> tuple[UUID, UUID]: + async with async_sessionmaker(engine, expire_on_commit=False)() as session: + actor = User( + email="legacy-review@example.com", display_name="旧审核人", password_hash="unused" + ) + session.add(actor) + await session.flush() + project = Project(name="旧审核项目", created_by_id=actor.id) + session.add(project) + await session.flush() + job = AIJob( + project_id=project.id, + job_type="change_set", + status="completed", + sanitized_input={"metadata": {"source": "legacy"}}, + input_sha256="a" * 64, + prompt_template_version="legacy-review-v1", + model_name="legacy-model", + created_by_id=actor.id, + ) + session.add(job) + await session.flush() + suggestion = AISuggestion( + job_id=job.id, + position=0, + suggestion_type="workflow", + title="旧 AI 建议", + content={"name": "旧草稿"}, + ) + session.add(suggestion) + ai = AIChangeSet( + project_id=project.id, + ai_job_id=job.id, + title="旧 AI 变更集", + status="accepted", + source_snapshot={"metadata": {"source": "ai-legacy"}}, + source_fingerprint="b" * 64, + source_type="ai", + actor_type="user", + actor_id=actor.id, + created_by_id=actor.id, + ) + reviewed_at = datetime(2026, 10, 20, tzinfo=UTC) + mcp = AIChangeSet( + project_id=project.id, + title="旧 MCP 变更集", + status="accepted", + source_snapshot={"governance": {"manual_approval_required": True}}, + source_fingerprint="c" * 64, + source_type="mcp", + source_ref="mcp://legacy/reviewed", + actor_type="service_account", + actor_id=actor.id, + applied_at=reviewed_at, + created_by_id=actor.id, + ) + session.add_all([ai, mcp]) + await session.flush() + session.add( + ChangeSetApproval( + change_set_id=mcp.id, + decision="approved", + note="保留旧审批", + approved_by_id=actor.id, + approved_at=reviewed_at, + ) + ) + for change_set, item_type, resource_type, suggestion_id in ( + (ai, "workflow", "workflow", suggestion.id), + (mcp, "test_plan_update", "test_plan", None), + ): + target_id = uuid4() if item_type == "test_plan_update" else None + session.add( + AIChangeItem( + change_set_id=change_set.id, + suggestion_id=suggestion_id, + position=0, + item_type=item_type, + action="update" if target_id else "create", + title="旧审核项", + target_resource_id=target_id, + target_snapshot_sha256="d" * 64 if target_id else None, + proposed_content={"source": "旧授权内容 alice@example.com"}, + review_status="accepted", + review_note="保留旧审核备注", + reviewed_by_id=actor.id, + reviewed_at=reviewed_at, + materialized_resource_type=resource_type, + materialized_resource_id=target_id or uuid4(), + ) + ) + await session.commit() + return ai.id, mcp.id + + +async def _evidence(connection: AsyncConnection) -> LegacyEvidence: + return LegacyEvidence( + change_sets=await _rows(connection, AIChangeSet.__table__), + items=await _rows(connection, AIChangeItem.__table__), + approvals=await _rows(connection, ChangeSetApproval.__table__), + ) + + +async def _rows(connection: AsyncConnection, table: Table) -> Rows: + columns = tuple(column for column in table.columns if column.name != "review_baselines") + result = await connection.execute(select(*columns).order_by(table.c.id)) + return tuple(tuple(row) for row in result) diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-22-validation.md b/docs/design/ui-v5/remediation/2026-10-20/batch-22-validation.md new file mode 100644 index 00000000..159e914a --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-22-validation.md @@ -0,0 +1,9 @@ +# 第二十二批验证补充 + +详见[完整模块回执](batch-22.md)。M20两项完成,本地60/73、余13、已合并48;本记录不代表最新PR门禁或正式发布验收。 + +- 后端树47228bd19aecf80f9d1c66cf43e7cf8941da5cbe:格式、Ruff、mypy445及完整1911项/14跳过/2既有警告通过,覆盖率91.14%。 +- 前端树2559df2206210f18a2445720ae924b620274018e:完整176文件1253项、无未处理异常、覆盖率86.10/82.40/85.91/88.62%,格式、lint/类型和构建通过。 +- 最终28项针对性测试及真实公开材料预览、人工接受后未发布草稿、受控网关失败、人工拒绝、URL刷新和390px布局通过。 +- 实际业务HTTP写入5、AI队列3、流程草稿1,发布/执行0;最终只读复核写入/Secret读取/页面异常0。没有重复执行掩盖失败。 +- 完整Compose S21、外部真实模型效果、正式部署及M22服务端专用审核开关边界仍独立验收。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-22.md b/docs/design/ui-v5/remediation/2026-10-20/batch-22.md new file mode 100644 index 00000000..ed792b74 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-22.md @@ -0,0 +1,65 @@ +# 第二十二批:M20 AI 助手 + +对应 F-AI-01、F-AI-02,均完成本地实现、完整检查和隔离浏览器验收。累计本地60/73项完成、13项待完成;已合并48项,最新R3门禁和R4交付单独核验。冻结审计和方案保持原样。 + +## 任务输入与公开来源 + +四类任务分别填写用例覆盖目标/范围、断言目标/字段路径/HTTP预期状态、流程步骤/完成条件、失败现象/期望及实际行为。失败归因无需Schema;高级Schema和元数据JSON保留,允许从有权接口固定版本、协议Schema或执行身份和错误摘要选择材料。 + +新来源读取由具名SQL列、typed reader和服务授权组成,仅查询本项目公开契约或执行身份、版本、状态、时间和错误摘要,不读取认证、请求头、请求正文、运行变量或执行快照。分页上限100、默认20、搜索按字面量处理,返回材料限制1MiB,API与Schema版本必须吻合;不存在和跨项目来源使用标准错误与trace。来源读取不入AI队列,不发起反射、仓库扫描或执行。 + +来源面板显式展开才查询,读取固定材料后必须点击使用;离页仍保留已核对详情。选择不会覆盖其他任务字段。最终预览包含任务摘要、来源及固定版本、Schema、样本状态、大小和完整HTTP负载;只有再次确认才提交。预览与实际payload一致,任务类型切换不会混入其他类型的隐藏字段。 + +显示当前ON/OFF、安装默认/项目覆盖和策略版本。OFF不扫描、遮盖或替换明确提交的材料;ON沿用服务端既有项目策略。本次样本默认不提交,只有具备权限且项目允许时,Owner显式勾选并填写后才发送。没有扩大日志、采集或Secret读取,也没有修改既有脱敏执行逻辑。 + +## 人工审核与后续处理 + +建议按断言、用例、流程或失败归因展示字段、规则、定义或节点摘要,保留高级JSON和已存原文;备注与接受/拒绝有独立步骤。接受流程或用例后显示API真正返回的草稿ID和入口;断言、归因或未知资产类型明确说明审核记录及缺失身份,不拼接猜测链接。 + +任务详情显示状态、错误代码/原因、创建和完成时间、既有处理策略;接口没有Trace字段时明确标注未提供。任务仍在运行、失败、完成无建议和已审核分别给出下一步说明,不自动重试。 + +job、suggestion、审核选择进入URL,可刷新回看已保存备注及结果。仅在任务项目和ID验证成功且完成后读取建议;SDK拒绝错项目、错任务、错版本和错审核响应。创建/审核/策略命令带原项目身份,导航后的旧响应不更新当前选择或弹出旧提示;创建失败也刷新原项目任务目录,便于发现持久化的失败任务。 + +审核失败保留输入并显示未确认状态,可显式重新读取服务端结果,避免把网络失败当成未保存或重复接受。待审核Viewer显示只读建议;权限失效后仍保护已经修改的内容。新建及审核均有关闭、路由离开和浏览器卸载保护,明确确认提交则允许跳转到真正返回的任务。 + +普通AI助手中的change_set任务只读并指向专用AI变更集模块。专用模块的服务端审核及开关边界继续在M22/F-AIC-03核验,本批不将其计为完成。 + +## 实现与行为测试 + +- [来源服务](../../../../../backend/app/services/ai_sources.py)、[具名列查询](../../../../../backend/app/repositories/ai_sources.py)、[公开DTO](../../../../../backend/app/schemas/ai_sources.py)、[路由](../../../../../backend/app/api/v1/endpoints/ai.py)。 +- [任务表单](../../../../../frontend/src/features/ai/CreateAIJobDialog.tsx)、[来源选择](../../../../../frontend/src/features/ai/AISourcePicker.tsx)、[审核](../../../../../frontend/src/features/ai/AISuggestionReviewDialog.tsx)、[草稿保护](../../../../../frontend/src/features/ai/AIDraftProtection.tsx)。 +- [页面](../../../../../frontend/src/pages/AIAssistantPage.tsx)、[查询与命令](../../../../../frontend/src/features/ai/use-ai-review.ts)、[SDK](../../../../../frontend/src/features/ai/ai-service.ts)。 +- [后端公开来源11项](../../../../../backend/tests/test_ai_public_sources.py)及既有AI17项;[页面回归](../../../../../frontend/src/pages/AIAssistantPage.test.tsx)、[整改行为](../../../../../frontend/src/pages/AIAssistantRemediation.test.tsx)、[命令隔离](../../../../../frontend/src/features/ai/use-ai-review.test.tsx)、[SDK身份](../../../../../frontend/src/features/ai/ai-service.test.ts)。最终四文件28项通过。 +- [S21既有验收](../../../../../frontend/e2e/s21-ai-review.spec.ts)适配明确预览/提交、人工编辑和已审核结果回看,保留原真实任务、接受状态及禁重复接受断言。 + +## 实际浏览器验收 + +专用项目fc3f1f13-763a-43c0-88ae-8d9f26925ca7,隔离Chromium/13078;原3000栈及个人浏览器保持。未调用外部收费模型,使用已有内部模拟网关。 + +| 场景 | 固定身份与实际结果 | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 公开材料 | 接口2b73ee97-a5a9-4d48-a20a-125c689b2e6f的v1,所选DTO与完整预览逐项一致,样本及Secret读取0,业务写入0 | +| 接受 | 任务a1786c9d-595a-4e58-9645-321453057ac4,建议cff98ee0-6acb-440b-87b0-8c66b512a542;修改名称和备注后,形成实际流程草稿eb8d660a-50cb-4c5e-8af7-d162b809a3d0,刷新可回看并打开同一草稿 | +| 网关失败 | 任务129daef2-973a-4318-ab2b-35f545311668;内部网关刻意不支持此失败归因任务,实际AI_GATEWAY_REJECTED,错误原因、时间和未提供Trace均可见,没有自动重试 | +| 拒绝 | 任务9afeaf03-1cec-4ec8-84ed-31d041c3beb9,建议f4169241-18b0-4ca5-9a25-df52d0d30863;明确拒绝后刷新保留备注,无资产身份及草稿入口 | + +本批业务HTTP写入5次:三次任务创建、一次接受、一次拒绝;AI队列3次(两次流程建议完成、一次受控网关拒绝),实际流程草稿1个,发布和执行均0。没有重放上述创建来替换失败记录。最后只读复核业务写入/Secret读取/页面异常均0,接受详情与失败归因预览在390px下document均390;桌面和手机最终截图已查看。 + +日志均在本地忽略目录output/playwright/audit-20261019:remediation-ai-public-browser.log、remediation-ai-accept-browser.log、remediation-ai-failure-browser.log、remediation-ai-reject-browser.log、remediation-ai-final-readonly-browser.log。截图以remediation-ai-final-accepted及remediation-ai-final-preview命名。 + +## 发现和补修的证据 + +新交互先复现三项缺失,再实施;普通Viewer待审核标题先一项失败,修正为只读建议后通过。增加审核返回失败后的原文保留/显式重读、旧命令延迟返回、未知资产和来源身份、路由及浏览器离开、明确提交不被自身跳转保护阻断等行为测试。 + +原拒绝测试把失败任务与待审核建议混在一起,与真实后端合同不符;改为完成任务下两个独立待审核建议,保留拒绝和非法接受JSON断言。轮询测试改为仅在任务完成后读取建议,并保留跨页选择与新增任务的原覆盖。初始新增用例遗漏项目目录处理器,修复测试准备后重新验证,没有忽略MSW异常。 + +公开来源UI测试曾超时。CPU采样定位到JSDOM深层样式继承和可访问角色查询;将深层定位限定在来源卡片,保留启用、展开、固定身份、跨页、预览payload及0写入断言,实际Chromium可见性和点击验证保持。用例从约75秒超时降到约4秒,没有增加超时、重试或降低覆盖率。静态测试环境把相邻Modal的aria-labelledby都设为test-id,改为通过确切标题定位对应弹窗后验证,未改产品DOM或放宽离开保护。 + +## 最终检查与边界 + +整合源码c416ebdbbd8cd7d9ca91818ecbbbc978343bd8d0:前端树2559df2206210f18a2445720ae924b620274018e,src树58e67728893d766959a4b34640de4dcb4c036340;后端树47228bd19aecf80f9d1c66cf43e7cf8941da5cbe。后续只有本回执文档变动。 + +- 后端完整格式/Ruff通过,mypy445文件通过;1911通过、14既有跳过、2既有警告,覆盖率91.14%。日志remediation-ai-policy-backend-full.log。 +- 前端完整格式、lint/类型、176文件1253项及构建通过,无未处理异常;覆盖率86.10/82.40/85.91/88.62%。输入冻结期间未修改源码。日志remediation-ai-integrated-frontend-full.log。 +- 最终针对性28项、完整业务浏览器路径及移动端通过;完整Compose S21运行、最新远端门禁、外部真实LLM效果和正式部署独立确认。内部模拟网关拒绝用例不构成真实模型失败归因质量签收。 +- 本次无数据库变更。没有新增AI自动接受、Apply、发布、执行或外部仓库扫描能力。M21至M26十三项继续实施,不能称为全站完成。 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/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..e8935dd7 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-24-off-policy-review.md @@ -0,0 +1,32 @@ +# 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/`。实现提交 `a88a8464a2eb7c2e4fa48e90b11769ae64edef59` 的原测试结果为 `08-targeted-final.log`(68 项通过、16 项 SQLAlchemy 警告),静态检查为 `09-ruff-final.log`、`10-format-final.log`、`06-mypy.log`。 + +后续独立测试修正将全部合成变更项绑定实际 AI Job 与 AISuggestion,并将项目策略固定到 Job,符合正常生成提案的关联结构;没有修改生产兼容逻辑、过滤警告或放宽断言。相同三个具名文件重新运行 68 项通过、0 警告(11.58 秒),完整日志为 `11-linked-suggestion-targeted.log`;新测试文件 Ruff 规则和格式检查通过,日志为 `12-linked-suggestion-ruff.log`、`13-linked-suggestion-format.log`。生产代码未变化,原 `mypy app` 结果继续对应相同生产源码;未把旧完整运行结果改称为新测试树的完整验证。 + +## 完成边界 + +本次独立局部回归与自审未发现该修复的残余实际缺陷。未运行完整后端/前端门禁、Compose、浏览器、外部模型、GitHub 当前提交门禁、合并后 CI 或发布验收;这些结果由整合阶段另行记录。本回执不扩大第二十四批已声明的完成范围。 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 new file mode 100644 index 00000000..3f134568 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-24.md @@ -0,0 +1,56 @@ +# 第二十四批:M22 AI 变更集 + +对应冻结审计 [F-AIC-01 / 02 / 03](../../audits/2026-10-19/complete-audit.md#m22) 和[修改方案](../../audits/2026-10-19/remediation-plan.md#m22)。本回执记录实施结果;原审计和方案保持冻结。 + +## 实施结果 + +- 审核面板同时呈现固定风险、影响分析、AI 任务、来源指纹和完整来源快照。目标资产在进入生成队列前冻结名称、说明、定义、标签及版本等已授权材料,独立存储为 `review_baselines`,不改变原 AI 输入指纹。生成期间资产漂移仍由服务端冲突校验阻止接受。 +- 修改项按目标类型、ID 和快照哈希匹配唯一冻结基线,展示修改前、修改后及逐字段差异,数组替换和空补丁按服务端语义处理。不能唯一匹配、旧记录没有基线时明确说明,不读取当前资产冒充历史。完整 JSON 可展开核对。 +- ON 使用已有策略投影,并明确可读投影与完整快照哈希的范围;OFF 显式跳过扫描、遮盖和替换。策略模式和版本随基线固定,未增加 Secret 读取、日志或诊断采集。 +- 展示审核人、时间和备注;接受成功后依据真实返回的资产类型、ID 打开 Workflow 或用例草稿,未知类型不猜测链接。普通 AI 助手仍禁止审核 `change_set` 类型,不能绕过专用接受流程。 +- 编辑 JSON 和备注后的关闭、路由切换、刷新具有输入保护;失败保留原材料,显示标准错误与 Trace,不自动重复提交。同一命令期间阻止重复写入;切项目、卸载后的旧响应不更新当前工作区。 +- AI 与质量智能关闭时,保留原项目读取授权下的列表、详情和物化草稿入口;关闭生成、审核写入及轮询,不持续请求被关闭的能力。创建仍同时要求 AI、质量智能、影响分析能力及编辑权限。 +- 风险目录使用服务端真实分页,分页留在创建窗口内;切页保留已选风险及同源影响。限定每页20项的选择器支持搜索,避免虚拟列表滚动导致选项不可点击。 +- 新增可回滚迁移 `0065`,旧记录默认空基线;不以当前定义回填旧历史。 + +## 针对性及完整后端验证 + +先以反例复现关闭历史返回503和基线缺失,修复后45项后端针对性测试通过,包括关闭态授权读取、外项目404与 Trace、禁止关闭态审核、普通 AI 审核409、生成期间漂移、ON/OFF 与迁移升降级。首次错误断言读取了错误的 Trace 层级,修正为标准 `error.trace_id`;实现阶段误用策略属性的失败亦保留,未将失败计为通过。 + +前端四文件24项通过:SDK 身份合同、差异语义、命令晚到/重复、关闭历史、输入保护与分页选择。格式、全量 lint/类型、构建通过。后端精确树 `96abd6303d40afe8717bb41f25799278fb7b91f7` 的完整检查通过:1917通过、14跳过、2项既有警告,覆盖率91.15%;Ruff与445文件 mypy 通过。 + +前端树 `4e622d950269658dae806cd14d520e3517d9b69e` 的全量覆盖率运行只有启动日志,进程已中断、没有退出结果,**不能计为通过**。各并行模块整合后的完整检查另行记录,不用针对性通过推定完整门禁成功。 + +## 隔离 Compose 浏览器验收 + +在独立 `flowtest-product-completion` 栈、13078端口及 bundled Chromium 隔离会话验证;未使用个人浏览器资料。项目 `fc3f1f13-763a-43c0-88ae-8d9f26925ca7` 为先前建立的隔离合成项目。 + +- 固定风险 `c20aec22-344e-4665-a21a-24cc597de495` 与影响 `3e22dcf4-c1ee-47b1-83da-838683a47e3e` 创建一次 ChangeSet `5a536ff8-9cad-4733-a45b-329799e2913f`,任务 `1894a0ea-70aa-41ff-b60c-69981b8f2e24`;使用内置 mock 网关,不代表真实模型质量。 +- 首次脚本在创建后误用中文关闭按钮名称;实际按钮为 Close。继续审核同一个对象,没有重建任务或重复入队。修改备注后关闭、取消放弃提示、保留输入及390px无横向整页溢出通过。 +- 只接受一次 item `2d7a67f3-968e-4bae-b309-8520efca2225`,生成草稿 `ac1d1d7e-0bb4-42b5-83aa-b54dc21a56b2`;实际审核记录、刷新恢复、打开该草稿通过。总计2次业务写请求、1次 AI 入队、1份草稿,发布和执行均为0。 +- 临时关闭 AI 与质量智能,真实列表与详情均返回200,历史备注和草稿入口保留、创建禁用;该阶段业务写入和执行均为0。随后恢复正常开关并确认容器 healthy 与 readiness 的数据库、存储、Redis 全部正常;重启过渡期的一次502不记为最终验收失败或成功。 + +日志位于主验收工作树忽略目录 `output/playwright/audit-20261019/`:`remediation-ai-changes-full-backend.log`、`remediation-ai-changes-full-frontend.log`、`remediation-ai-changes-create-accept-browser-r2.log`、`remediation-ai-changes-accept-resume-browser.log`、`remediation-ai-changes-closed-history-browser.log`。桌面及移动审核截图在同目录,移动图已人工查看。 + +## 完成边界 + +功能、针对性验证、完整后端与上述真实浏览器专项已完成;完整前端、并行模块集成、最新提交的 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/docs/design/ui-v5/remediation/2026-10-20/batch-25-mcp.md b/docs/design/ui-v5/remediation/2026-10-20/batch-25-mcp.md new file mode 100644 index 00000000..dd33ab0a --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-25-mcp.md @@ -0,0 +1,60 @@ +# M23 MCP 变更集整改回执 + +对应冻结审计:F-MCP-01、F-MCP-02。基线:`edb06bbc5259f0390b85bb45f731e72fd2fc8242`。后端精确只读合同已独立提交:`d3592576640c070b75e7ae9064d6e33028b14287`;前端和本回执另行提交。 + +## 实现 + +- 展示 Envelope 的实际 warnings 和可复制 Trace ID;治理区提供风险、置信度、原因代码、人工审核/审批要求、来源指纹及审批 ID、决定、人和时间。未知原因保留服务端代码。警告与服务端硬性审批、权限和修订冲突分别说明,不新增采集、扫描、日志或策略代换。 +- 测试设计按意图、验收、场景、预期/断言、覆盖、证据、警告和审核要求提供结构化只读预览;知识图谱/状态模型与完整原文可展开核对。 +- 测试用例展示引用工作流、原样保存的固定或未固定版本、环境与运行参数;不虚构独立断言。空版本明确写“未固定,发布时需确认”。 +- 测试计划更新按提案涉及目标展示操作、原/拟版本、原/拟指纹、拟用环境、重试、参数、依赖和理由。原环境、重试、运行参数及完整原项列表并未冻结,明确提示来源不足;不会把当前计划读取结果当成历史 before。旧空 target_actions 保留新增建议语义,结构无效与空列表分开反馈。 +- 原工作流创建图、控制块图形差异、基线修订号和服务端原子守卫保留。完整提案复用既有只读阅读器,可搜索、格式化、复制和下载完整内容,预览仍有既有限额。 +- 所有结果入口统一校验 accepted、物化类型和 ID;移除了旧工作流入口的仅 ID 判断。工作流、用例和计划使用已有真正消费者。TestEngineering 不支持 TestDesign ID,故未发明 `?design=`,也未将 MCP ID 塞进只接受 REST 提案的 `?proposal=`。 +- 测试设计结果使用新增 `GET /mcp/write/change-sets/{changeSet}/items/{item}/materialized-design`,在 MCP 页只读抽屉中打开实际保存资产。服务层沿既有精确 ChangeSet 授权,验证变更项所属、accepted、item/resource 类型、物化 ID 与实际资产 project/source_change_set;未知、未物化或错误关联返回标准错误及 Trace ID。读取不经过写入校验/敏感扫描,不增加 Audit。 +- 读取/权限刷新失败与被拒审批不能启用物化。审批和逐项审核共享忙碌守卫;用户、组织、项目和 focus 切换隔离缓存及迟到回调。写回执必须匹配当前 ChangeSet/project、requested item 的预期状态和物化类型/ID,审批必须确认 approved;不一致时不写缓存、不报成功,保持禁写,提供只读重新读取恢复。 +- 继续保持单次逐项审核请求,计划目标不拆成多个局部接受动作;没有自动批准、Apply、发布、执行或全局 MCP 发现。 + +## 涉及文件 + +后端:`app/api/v1/endpoints/mcp_controlled_write.py`、`app/schemas/test_design.py`、`app/services/mcp_controlled_write.py`、新增 `tests/test_mcp_materialized_design.py`。 + +前端:`MCPChangeSetsPage.tsx`、对应页面测试、`features/mcp/mcp-change-set-service.ts`;新增 `MCPGovernanceSummary.tsx`、`MCPStructuredPreview.tsx`、`MCPMaterializedResult.tsx`。未修改共享进度、冻结文档、数据库、依赖或 CI 治理。 + +## 针对性验证 + +先新增具名反例,再实现修复;红灯与后续完整日志保存在本工作树 `output/validation/m23-mcp/`(生成物未提交)。 + +- `make test-backend-targeted TARGETS='["tests/test_mcp_materialized_design.py", "tests/test_mcp_read.py", "tests/test_s51_mcp_flow_proposals.py", "tests/test_s62_planning_mcp.py"]'`:4 文件、34 项通过。新增结果读取覆盖未物化、实际保存内容而非后续提案、Viewer 允许读取、跨组织/跨项目/变更项所属/类型/状态/source 关联拒绝、标准 Trace ID,以及 OFF 下已有授权值原样返回且读取不增加 Audit。 +- 既有 MCP 定向用例继续验证高风险审批 ID、控制块修订冲突、计划指纹冲突和原子事务;未更改这些写路径。 +- `make test-frontend-targeted TARGETS='["src/pages/MCPChangeSetsPage.test.tsx", "src/features/api-console/ResponseCodeReader.test.tsx"]'`:2 文件、28 项通过,其中页面 24 项。包含两类图预览、五项最初审计反例、保存设计精确读取、409 不报成功、跨项目迟到回执、读取/权限刷新失败、审批忙碌、pending/错误结果类型,以及缺项/仍 pending/错误物化类型/无 approved 决定的不一致回执。 +- 受影响 Ruff 格式/规则、`mypy app`、ESLint、Prettier、TypeScript 及 `pnpm --dir frontend build` 通过。 +- 旧测试的简略接受响应已补齐实际后端始终返回的 materialized_resource_type/id;未降低断言、超时、重试或覆盖率门槛。 +- 独立只读复审发现并整改的旧工作流链接绕过、回执确认不足及前期审批/过期/迟到守卫问题均有行为反例;最终只读复核未发现新的实质缺陷。 + +## 最终交叉复审补修(2026-10-22) + +工作流物化成功原先只失效 `workflows` 目录,遗漏 `workflow` 精确详情。若目标在首100条目录之外,且其精确缓存仍处于生产默认15秒新鲜期,立即打开物化结果会继续显示旧修订和旧图。现在在原有项目范围内同时失效这两个缓存前缀;用例、计划、设计分支及后端写入、权限和修订守卫均未改变。 + +新增 `MCPWorkflowResultNavigation.test.tsx` 公开导航反例:预置新鲜精确草稿缓存与100条首目录,显式接受第101个对象的控制块提案,再点击实际结果链接,由真实 `useWorkflows` 消费者展示新修订和新增控制节点;不以仅核对 href 或缓存标志代替行为。夹具让精确缓存持续新鲜,避免缓存自然过期掩盖遗漏。随后切换到另一项目,验证该项目的新鲜草稿仍保留、没有被本次操作额外重读。接受请求仅一次,更新项目精确读取仅一次,不发布或执行。 + +反例先失败,输出仍为修订1和旧节点;补修后 MCP 页面、新导航反例及既有工作流保存/缓存并发三个具名文件共40项通过。原始日志在本工作树 `output/validation/m23-workflow-cache/`,红灯为 `red-control-navigation.log`,最终目标测试为 `targeted-final.log`。首次类型检查发现新夹具省略了响应必填的连线与运行策略字段,已补齐,保留失败日志 `lint-types.log`;未放宽产品类型或检查规则。最终前端 lint/类型、构建和修改文件格式通过,日志为 `lint-types-final.log`、`build.log`、`format-check.log`。 + +本次没有操作共享验收树、数据库或浏览器,没有运行全量、Compose、外部 MCP 客户端或正式远端门禁;最终整合与完整验收仍由主代理分别记录。 + +## 条件环境与未验收边界 + +本工作树未操作共享 QA 数据库、共享浏览器或 Compose,未创建/批准/接受真实 MCP 提案。真实 GUI 与 MCP 客户端验收、完整本地 `make check`、正式最新提交门禁、合并及合并后 CI,由整合负责人分别记录;上述局部验证不代表这些验收通过。 + +供整合负责人使用的最小 GUI 合成模板:`/tmp/m23-mcp-ui-fixture.template.json`。模板已用真实 `MCPControlledWriteCreate` 做纯 schema 校验,无持久化或执行。需替换项目、已发布工作流及环境 ID,并核对 workflow_version,使用独有名称/幂等键。propose 后产生测试设计与用例两个高风险待审项;可核对警告、低置信度原因、审批硬阻断、结构化内容及真实结果读取。它不发布或执行工作流;是否进行人工审批/接受由整合验收范围控制。 + +## 整合后的隔离 GUI 验收(2026-10-22) + +整合负责人在既有独立 Compose、13078端口和 bundled Chromium 隔离会话运行真实合同。采用上述模板的单独 TestDesign 部分,没有为页面验收额外创建用例、计划或被测系统运行。 + +- 在隔离合成项目 `fc3f1f13-763a-43c0-88ae-8d9f26925ca7` 创建最小权限临时账号 `9835e67d-9e1e-41c1-a919-5fb3a7b46a86`,仅用于提出本次合成提案;无论提案成功与否均在 finally 撤销,本次已确认撤销。Token只存在运行内存,没有输出到日志或文件。 +- ChangeSet `c5ab1c92-0a58-4baf-a641-e5a772d098e7`、item `2e1d1b1e-b433-4e8e-9be9-89d26dee71c6`:真实返回 high、治理置信度72%、3条警告及必须审批。审批前“接受并物化”禁用;场景、预期断言、覆盖内容、设计来源四个结构化区域和完整 Trace 正常呈现。空来源明确显示,不伪造证据。 +- 页面显式审批一次,approval `9847d68a-e909-49dd-9f97-c5220114d7c0`;再显式接受一次,实际 TestDesign `67781adb-5eba-4749-8431-bf1b4120232b`。打开只读抽屉,真实 GET 返回同一资产、项目、来源 ChangeSet 和保存目标内容;刷新后仍能打开该资产。 +- 提案准备3次业务写入(账号创建、提案、撤销),页面审批与接受2次,共5次;物化1份合成设计,AI入队、发布、执行均为0。其余读取检查业务写入0。没有重复创建提案或反复接受来替换失败结果。 +- 390px审核页可用宽度375、整页宽375;物化抽屉可用宽度390、整页宽390,均无整页横向溢出。桌面与移动截图保留。第一段导航脚本误用 `/mcp`,源码真实入口为 `/mcp-changes`,改正后使用同一提案继续,未重复写入。 + +原始日志位于主验收工作树忽略目录 `output/playwright/audit-20261019/`:`remediation-mcp-seed.log`、`remediation-mcp-review-browser.log`、`remediation-mcp-accept-browser.log`(`passed:true`)、`remediation-mcp-snapshot-r2.log`。真实外部 MCP 客户端连接、用例或计划物化、控制流生产运行和正式发布仍未由本次单设计专项验证;28项目标测试及既有后端原子/冲突合同与本专项分别记账。 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..52ddfb31 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-26-organization.md @@ -0,0 +1,74 @@ +# 第二十六批: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,没有真实凭据。 + +## 独立复审:提交期间锁定管理员目录输入 + +在开发基线 `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;整合后的完整门禁和环境验收仍需单独记录。 + +## 独立复审:自降级后刷新当前组织角色 + +开发基线 `9bac24cd191f661e74a7c268f6fd8a0704cbb88a`。成员更新原来只刷新成员目录,而“当前角色”、成员管理、服务账号、治理和密钥轮换权限来自独立的组织目录。因此非系统管理员将自己从 Admin 降为 Viewer,或存在另一位 Owner 时将自己从 Owner 降为 Viewer,服务端已成功但页面仍显示旧角色和管理操作;既有后端写授权仍会拒绝越权请求,本次不修改该权限合同。 + +成功回调现在并行等待成员目录和准确的 `['organizations']` 目录刷新。组织目录键不附加组织 ID;仅精确失效现有目录,不从成员响应推断组织权限。刷新完成前保持本次成员操作等待状态,避免旧角色期间继续提交或提前提示成功。 + +- 两条真实页面反例分别覆盖非系统 Admin、具有另一位 Owner 的当前 Owner 自降级:当前角色变为 Viewer,成员角色控件禁用,成员表单、账号签发/轮换/撤销、治理保存和密钥轮换操作收敛,PUT 负载及请求次数恰好一次。修复前均因仍显示旧角色而失败。 +- 第三条反例挂起组织目录刷新,确认成员操作持续等待、不能重复提交、不提前报成功,刷新完成后才显示新角色。修复前组织目录没有再次读取,反例失败。 +- `make test-frontend-targeted TARGETS='["src/pages/OrganizationGovernancePage.test.tsx","src/features/projects/DirectoryPicker.test.tsx","src/pages/organization-governance-rotation.test.tsx"]'`:3 文件、34 项通过。 +- 全前端 `pnpm lint`(ESLint 零警告及 TypeScript)和 `pnpm build`(`tsc -b && vite build`)通过;修改文件 Prettier、`git diff --check` 通过。 + +原始日志在本修复工作树忽略目录 `output/validation/m24-self-role/`:`02-role-regression-before-fix.log`、`03-pending-regression-before-fix.log`、`05-targeted-final.log`、`06-lint-types.log`、`07-types-build.log`、`08-final-format.log`。首轮反例曾误用不包含图标名称的精确标签定位,原日志 `01-regression-before-fix.log` 保留;修正定位后才取得上述业务红灯,没有修改产品标签、超时、重试或业务断言。 + +本次仅改组织页面的失效刷新、页面行为测试及本节回执;不改后端、共享选择器、冻结审计和方案、共享进度或验收环境。未运行完整覆盖率、Compose/GUI、最新远端门禁、合并后回归或正式部署;整合负责人分别记录。 + +## 改动文件 + +- 前端:`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/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..29ca6125 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-27-execution-fabric.md @@ -0,0 +1,42 @@ +# 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 刷新失败仍称有效问题已整改;未改变任何重试、超时或覆盖率门槛。 + +## 独立复审修复(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 通过不代表这些验收通过。 + +验收负责人可在隔离数据库启用 `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/docs/design/ui-v5/remediation/2026-10-20/batch-28-platform.md b/docs/design/ui-v5/remediation/2026-10-20/batch-28-platform.md new file mode 100644 index 00000000..56be4185 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-28-platform.md @@ -0,0 +1,35 @@ +# M26 平台管理整改回执 + +对应冻结审计:F-PLAT-01、F-PLAT-02。开发基线:M25 独立提交 `761b87b27b9be9afe83614de90b7e273e51ed108`;本模块独立提交。 + +## 实现 + +- 选择身份明确区分能力版本、插件、Runner Pool,写入 URL。切换库存模式清空旧选择;目录搜索、翻页与所选对象分离,页外对象通过精确只读接口恢复。未选择时提示选择对象,不隐式替换为首项。 +- 插件详情展示返回的版本、完整 Digest、仓库、签名声明身份、状态和时间;Runner 展示池、成员、标签、能力、负载、状态和心跳。执行面开启且当前有管理权限时才提供所选 Pool 的只读入口。 +- 能力检查器包含摘要、输入 Schema、输出 Schema、配置 Schema、策略与来源页签。复用现有只读阅读器,提供完整内容复制、搜索、格式化、换行和下载。顶层字段表最多预览 200 项;原文沿用 300 行或 64,000 字符的预览上限,完整复制和搜索不截断。 +- 完整哈希与 Digest 可复制。默认/最大超时、网络协议、DNS、Snapshot 配置/哈希/Digest/凭据材料策略和原始策略声明来自实际返回值;缺值显示未提供,未知策略按原值降级。没有固定成功安全宣言,也没有把能力声明当作安装或项目的实际采集策略。 +- 搜索在服务层覆盖对应完整库存后按现有 Page 合同分页。能力复用现有精确版本 GET;插件和 Pool 原接口只有目录,因此补充同角色边界的精确只读 GET。没有开放安装、令牌、凭据或执行写入。 +- 平台 Pool 目录、total、精确详情收紧到执行面已有的当前组织及共享 Pool 范围;无组织系统管理员保留全局合同。只调整读取边界,不改变相关写入。 +- 只显示当前模式的读取错误;刷新失败的缓存目录/详情明确标记为上次读取。查询缓存包含对象类型、版本、用户、组织和管理员状态,避免切换、迟到结果或权限撤销串详情。关闭态明确为已有库存只读检查。 + +## 涉及文件 + +- 后端:`app/api/v1/endpoints/capabilities.py`、`app/services/capabilities.py`、`app/repositories/capabilities.py`、`tests/test_capability_sdk.py`。 +- 前端:`PlatformCapabilitiesPage.tsx`、页面测试、`features/capabilities/capability-service.ts`、新增 `features/capabilities/PlatformInspector.tsx`。 +- 未修改冻结审计、共享进度、依赖、CI 治理、数据库模型或迁移。 + +## 针对性验证 + +- `make test-frontend-targeted TARGETS='["src/pages/PlatformCapabilitiesPage.test.tsx", "src/features/api-console/ResponseCodeReader.test.tsx"]'`:2 文件、21 项通过。 +- 页面反例包括能力切到空插件不残留、插件/Pool URL 页外恢复、库存第 101 项可达且保留选择、完整三份 Schema 复制与搜索、缺字段、未知及原型名称策略值、切模式后的旧错误清理、版本及组织迟到结果隔离、同用户管理员权限撤销和普通角色无管理读取。 +- 阅读器现有用例验证超出预览范围的搜索与完整复制、复制失败提示,以及格式化不改变原值。 +- `make test-backend-targeted TARGETS='["tests/test_capability_sdk.py", "tests/test_workflow_contract.py"]'`:2 文件、16 项通过。覆盖 101 条库存、目录搜索及分页、精确读取、管理员/普通角色边界、未知对象和标准 trace ID、组织及共享 Pool 的列表/total/精确读取,无组织管理员全局合同保持。 +- 受影响 Ruff 格式及规则、ESLint、Prettier、TypeScript 检查和 `mypy app` 通过。 +- 原始日志保留在本工作树 `output/validation/m26-platform/`(未提交生成物)。首轮页面测试失败为旧正则同时匹配错误通知与表格空态,改为精确公开文案断言后通过;未调整测试超时、重试或覆盖率门槛。 +- 独立只读子审指出的 Pool 组织范围、旧模式错误残留、未知策略原型属性三项问题均已修复,并增加公开行为或 API/服务反例。 + +## 条件环境与验收边界 + +尚未执行真实 GUI、完整 `make check`、前端正式构建、Compose 端到端、真实插件签名/安装或外部 Runner 接入;正式最新提交门禁、合并后 CI、发布验收由整合负责人分别记录。局部通过不代表这些验收通过。 + +真实 GUI 可使用原有兼容预览库存,无需启动任何任务:管理员进入 `/platform`,选择一条实际能力,再切换空插件模式核对检查器清空;重新选择能力,依次核对三份 Schema 及策略页、搜索/复制;通过返回的真实插件/Pool ID 恢复 URL,刷新及目录切页核对选择身份;切其他组织或普通角色核对库存边界与详情消失。插件安装按钮始终禁用。空插件/空 Pool 使用真实空态,不用示例记录替代;101 条库存和迟到响应已由隔离测试夹具验证,未向共享 QA 数据库写入合成库存。 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..259aaf96 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-29-integration.md @@ -0,0 +1,54 @@ +# 第二十九批:并行整改整合与页面复核 + +对应 [M22](batch-24.md)、[M23](batch-25-mcp.md)、[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#m22-frontend-review)。未以旧完整结果冒充补修后结果。 +- M23 后端新增关联已接受物化 TestDesign 的精确只读读取:验证所属 ChangeSet、item、类型、状态和实际资产的项目、来源关联。没有新增全局发现或自动接受入口,原受控审核、审批和修订守卫保持。 +- 整合树 `654b13ec658bc18dc5c3c05b5307831c5775023a`、后端树 `298da98b9e47dfb707ca88afb41038d3d4c3b031` 完整后端通过1929项、14跳过、2项既有警告,覆盖率91.15%;Ruff和445文件 mypy通过。后续 OFF 补修改变后端,此处1929项只对应前述旧树;最新完整结果见下段。 + +OFF 补修及正常 Suggestion 关联夹具整合后,后端树 `3402e1e7ffbe736256d8cb0509649e7d163854a3` 当时完整通过1946项、14跳过、2项既有警告,覆盖率91.18%;656文件格式、Ruff和445文件mypy通过。日志 `remediation-r4-final-backend-linked.log`,退出码0。此前缺关联夹具的完整结果有18项警告,仍保留在 `remediation-r4-final-backend.log`;没有过滤警告,改为真实AI Job→Suggestion→ChangeItem关联后消除新增16项。 + +最后组织复审复现系统管理员 DirectoryPicker 的默认 `disabled=false` 覆盖表单 pending 上下文,允许提交期间改选。显式向成员选择器传入 `disabled={pending}`,普通目录与高级ID两个新增反例先失败,补修后三文件31项通过;共享选择器默认行为、后端及其他角色目录权限均未改变。详见[组织回执](batch-26-organization.md)。原全量前端在发现此缺陷时中止为130,不能计为通过。 + +## 页面可读性补充 + +实际平台页面原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` 已推送;本次读取的全部一条审查线程已解决,线程和评论均无后续分页。正式运行39633269613的其余选中作业均通过,但前端177文件中一项审核编辑测试超时,1260项通过,Required Gate最终失败。完整失败日志 `remediation-pr146-373-failed-full.log` 保留;随后只调整测试查询范围及备注粘贴,同一用例 CPU 采样从21.970秒降至5.928秒,全部原行为断言保留;四文件30项及格式、lint/类型、构建通过。原超时、重试、门槛和产品源码均未改动,详见[CI补修回执](pr146-review-followup.md)。后续 HEAD `c89c0474776a432e3f05636489d118ba7a193b6f` 已推送并启动正式运行[39640086237](https://github.com/a3384379/FlowTest/actions/runs/39640086237)。R4剩余模块随后统一整合进同一PR;门禁以最新整合HEAD为准,上述旧树结果不替代整合验证。不能据旧提交或本地目标通过声称已合并。后续模块的完整前端、当前 HEAD 门禁、合并后回归、真实外部模型、正式 KMS、真实 Runner/故障转移和正式部署均另行记录。 + +## 最终交叉复审补充 + +文档只读核对确认冻结73个唯一问题均有台账及分批证据,26模块和全局均在映射中,本地相对链接和锚点可解析。台账 F-UX-01/02/03 的批次误记为R2,已按冻结方案修正为R1;问题总数、状态及冻结原文不变。 + +组织模块再次复审发现合法自降级后只刷新成员表而未刷新组织目录,页面仍沿用旧角色启用部分管理控件,后续写入会由后端正确拒绝。现已在成员更新成功后等待成员与准确组织目录刷新,3项新增反例先红后绿,相关3文件34项通过;不调整后端权限。整合前端 `6628ad3262ea570c368119a52224855b5cfeb566` 已完成格式、lint/类型和构建;第三次完整前端在发现这项缺陷后主动中止,退出130,日志 `remediation-r4-full-frontend-r3.log`,没有记为通过。全部交叉复审和补修整合后再运行最终完整前端,避免将旧树结果用于新源码。 + +## 稳定整合源码(2026-10-22) + +- [MCP补修](batch-25-mcp.md):物化工作流后同时失效本项目目录和精确详情。第101个对象的真实 useWorkflows 消费者反例先展示旧修订,修复后新节点、修订2及另一项目缓存保留均通过;三文件40项通过。 +- [用例运行补修](batch-30-case-run-review.md):运行目标严格采用所选发布版本或已保存草稿的定义;缺目录显示冻结ID。503后保留选择供显式重试,提交期间锁定来源、版本和取消/X/遮罩/Esc。原POST、发布指纹和执行合同保持;4文件39项、lint/类型、构建通过,独立只读复核无新增实质问题。跨项目沿用既有workspace key卸载隔离,新增Router characterization通过,未引入冗余会话守卫。 +- [Standalone补修](batch-31-standalone-review.md):完整S62旧库实际启动遗漏0065列已复现。复用幂等补列,保留旧AI/MCP记录、审批和物化字段及新冻结值;5文件78项、Ruff、mypy与架构检查通过。没有将缺列判定纳入旧FlowSpec表重建条件。 + +最新后端树 `7a69e02784f3944abe0976365c1c39524e58e77f` 完整通过1948项、14项既有跳过、2项既有警告,覆盖率91.21%;657文件格式、Ruff及445源文件mypy通过。源码记录为 `5a83ade8d03b3de6bb5d94ebeeec467dc8f67822`,完整日志 `remediation-r4-standalone-full-backend.log`、退出0;随后仅有前端/文档变动。前述1946项结果仅属于旧树。 + +最终前端树 `eb8761edfc9041b28707083b6117de2313887049` 已整合组织、MCP及用例补修,完整格式、lint/类型、覆盖率和构建将独立运行。ROOT不再变更前后端源码;本地全量与最新整合提交的GitHub完整门禁并行,待两者和审查线程确认后再合并。真实外部模型、外部MCP客户端、正式KMS、真实Runner/故障转移与正式部署仍需对应环境验收。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-30-case-run-review.md b/docs/design/ui-v5/remediation/2026-10-20/batch-30-case-run-review.md new file mode 100644 index 00000000..f0814223 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-30-case-run-review.md @@ -0,0 +1,38 @@ +# 第三十批:用例运行确认与失败恢复补修 + +基于独立交叉审查,在工作树 `task-plan-audit/FlowTest` 的 `a437c0af0e5c26009fefb961a0ba45ebd010fac9` 上补修 M08 原有直接运行流程。对应[冻结审计 M08](../../audits/2026-10-19/complete-audit.md#m08)与[实施方案](../../audits/2026-10-19/remediation-plan.md#m08)中应保留的固定版本、环境、直接运行和失败保护合同;冻结文件未修改。本回执不增加新的审计完成计数。 + +## 发现与修复 + +1. **发布版本的运行目标曾被草稿替代。** 例如用例 v1 固定生产环境 P,服务器草稿已改为测试环境 T;选择运行 v1 时,旧确认区仍显示 T,实际执行按 v1 使用 P。确认信息现由运行来源和所选发布版本的完整 `definition` 决定,展示对应流程、流程版本与环境。切换历史版本或草稿时即时更新;未选择来源、版本定义加载中或读取失败时,不显示草稿目标充当发布版本目标。 +2. **临时提交失败曾使按钮永久禁用。** 提交错误和版本读取错误现分开处理。503 后保留来源、版本和错误提示,用户可以留在原弹窗手工重试;失败、修改选择和恢复服务都不会自动再次提交。 +3. **慢提交时确认对象曾可被替换。** 请求期间锁定来源、版本、取消、关闭按钮、遮罩和 Escape,并以同步请求标识防止重复确认;这只锁定界面,不取消后端已提交的执行。正常操作中的“取消 A 再打开 B”路径因此被阻止。跨项目仍沿用既有按项目 ID 卸载工作区的隔离边界,没有新增会话守卫。 + +名称只从当前页面已经取得的流程、环境资料中匹配。若所选发布版本的对象不在目录中,直接显示该版本绑定的 ID,并注明“名称未加载,以绑定 ID 为准”;不会借用草稿名称,不追加目录抓取,也不因名称缺失增加执行限制。 + +执行 API、权限、发布与固定版本语义保持原合同:发布版本仍提交 `{ source: 'published', version }`;草稿仍需显式确认,并提交 `{ source: 'draft', publish_draft: true, expected_draft_fingerprint }`。本批不修改后端、数据库、诊断采集、日志或脱敏策略。 + +## 行为反例与具名验证 + +新增[运行确认行为测试](../../../../../frontend/src/pages/CaseRunReview.test.tsx),通过真实运行服务函数与 MSW HTTP 边界校验实际 POST: + +- 两个发布版本绑定不同流程、流程版本和环境,草稿再绑定第三个目标;切换所见目标准确,显式点击前没有 POST,最终提交保留所选发布版本。 +- 首次 503 后保留来源和历史版本,按钮恢复;没有自动重发,第二次明确点击才产生第二个相同输入的请求。 +- 目录没有发布版本对象时显示冻结 ID;加载和失败状态均不回退草稿目标。 +- 显式发布草稿时保留原指纹合同。 +- 受控慢 POST 期间来源、版本和全部弹窗关闭路径被锁定,重复点击不增加请求;请求完成后才恢复关闭。 +- 工作区测试通过 Router 程序导航切换项目后打开新用例,再释放旧请求,确认新弹窗保留且没有显示旧项目运行回执;此项是既有工作区卸载隔离的行为刻画,不声称它在本批修复前失败。 + +修复前首次业务反例为 **5 项失败、1 项通过**;独立复审补充的慢请求反例在来源选项未禁用处失败,原 6 项通过。修复后实际执行: + +- `CI=true make test-frontend-targeted TARGETS='["src/pages/CaseRunReview.test.tsx","src/pages/TestAssetsPage.test.tsx","src/pages/TestAssetsWorkspace.test.tsx","src/features/test-assets/test-asset-service.test.ts"]'`:**4 文件、39 项通过**。保留旧页面的固定版本提交、真实工作区直接运行、草稿保护和服务请求合同回归。 +- `pnpm lint`:全量 ESLint(零警告)与 `tsc -b --pretty false` 通过。 +- `pnpm build`、修改文件 Prettier 检查、文档相对链接与 `git diff --check` 通过。 + +完整原始日志保存在本工作树 `output/validation/m30-case-run-review/`:修复前 `02-regression-before-fix.log`、`06-pending-before-fix.log`;最终 `23-targeted-final.log`、`24-lint-types-final.log`、`25-build-final.log`、`26-format-links-final.log`。仅使用合成测试对象,没有真实凭据。 + +## 核查与完成边界 + +此前只读抽查还核对了 M02 接口跨页批量移组的选择、失败恢复及提交锁定,M07 节点与区域编辑会话保护,以及 M15 报告到冻结流程的节点、请求尝试和实例定位。未据此声称 M01—M21 所有产品路径已经重新验收,也未继续扩展旧模块重设计。 + +本批只完成以上 M08 补修及具名本地验证。未运行完整覆盖率、后端测试、Compose、浏览器、共享 QA、Windows 包、远端门禁、合并或部署。源代码整合后的完整验证由主代理单独执行;本地 39 项通过不能推定正式验收成功。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-31-standalone-review.md b/docs/design/ui-v5/remediation/2026-10-20/batch-31-standalone-review.md new file mode 100644 index 00000000..9fa74df9 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-31-standalone-review.md @@ -0,0 +1,29 @@ +# Standalone 审核基线兼容补修回执 + +日期:2026-10-22。开发基线:`1f720d581ae7679356aea6f7382a54f230266a3a`。范围为 M22 新增 `review_baselines` 在已有 Standalone SQLite 安装中的启动兼容。 + +## 问题与最小修复 + +已有完整 S62/FlowSpec 数据库可能仅缺少 0065 新增的 `ai_change_sets.review_baselines`。`Base.metadata.create_all` 不补已有表的列,原 Standalone 启动入口也未补该列,因此启动后 ORM 查询变更集会报 `no such column: ai_change_sets.review_baselines`。 + +`backend/app/core/standalone_schema.py` 在既有增量补列流程中复用 `_add_column_if_missing`,添加 `JSON NOT NULL DEFAULT '[]'`。旧行获得空列表;已有冻结值保持不变。没有把该列加入 FlowSpec 旧表重建判断,没有修改 Alembic 0065 的可逆升降级、版本标记或升级脚本合同,也未新增扫描、脱敏、日志或采集。 + +## 行为反例与验证 + +新增 `backend/tests/test_standalone_ai_review_baselines.py`:先用完整当前模型建立真实 SQLite 数据库,保存 AI/MCP 变更集、变更项、来源、审批和物化字段,再只移除该新增列,确认完整旧结构无需重建。通过真正的 `initialize_standalone_database` 入口启动后,ORM 可读取两类旧记录,基线均为空列表,所有原字段逐项保持,外键检查通过。随后保存包含 OFF 授权原值的新冻结基线,重复启动两次,验证新值和既有证据均保留。首次启动与后续启动分别比较快照,允许人工保存时合法更新 `updated_at`。另有真实新安装启动反例;既有 pre-S40 重建和迁移升降级测试继续执行。 + +修复前反例准确失败于 ORM 缺列,新安装用例通过;完整红灯日志保存在本工作树 `output/validation/batch31-standalone-review/red-startup.log`。 + +执行的具名测试: + +```sh +make test-backend-targeted TARGETS='["tests/test_standalone_ai_review_baselines.py", "tests/test_standalone_runtime.py", "tests/test_standalone_transfer.py", "tests/test_ai_review_baseline_migration.py", "tests/test_quality_intelligence.py"]' +``` + +- 5 文件、78 项通过,覆盖已有 Standalone、新安装、pre-S40 重建、便携数据转移、0065 升降级和 AI 审核关联行为。最终完整日志:`output/validation/batch31-standalone-review/targeted-final.log`。 +- 修改的 Python 文件 Ruff 格式和规则检查通过;`mypy app` 检查 445 个源文件通过;`lint-imports` 的领域隔离合同通过。对应日志同目录保存。 +- 首次目标清单含本开发基线不存在的 `test_ai_change_set_redaction_policy.py`,目标检查在执行 pytest 前拒绝,已保存 `targeted.log`;随后改用本树实际存在的上述 5 文件,没有复制其他工作树实现或放宽检查。 + +## 边界 + +本次仅修改启动补列、专属测试及本回执,未操作共享验收树、数据库或浏览器。未运行全量后端、Windows 打包、Compose、真实已安装 Windows 数据库升级或最新远端门禁。整合后生产后端树已变化,需由整合负责人重新执行完整后端与适用正式门禁;旧树的全量通过记录不能替代本次验证。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-32-compose-s22-review.md b/docs/design/ui-v5/remediation/2026-10-20/batch-32-compose-s22-review.md new file mode 100644 index 00000000..2f682227 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-32-compose-s22-review.md @@ -0,0 +1,27 @@ +# PR146 S22 浏览器验收同步回执 + +日期:2026-10-22。开发基线:`85da05813a63ddd892d348b1f4ad9b722d24ce25`。本次仅同步既有 S22 E2E 与 M26 已实现的平台对象选择、真实契约策略和深链接行为,不修改产品源码、后端或单元测试。 + +## 失败证据与原因 + +正式 run `39646234746` 的 `compose / smoke` 作业 `119383016263` 在第32步非 S29 浏览器验收失败,67项中66项通过。唯一失败为 `frontend/e2e/s22-capability-sdk.spec.ts` 原第26行:等待旧文案“固定 Schema 哈希”,首轮与既有自动重试均失败。完整原始作业日志保存在: + +`/Users/sqz/.codex/worktrees/ui-v5-audit-followup/FlowTest/output/playwright/audit-20261019/remediation-pr146-85da058-compose-39646234746-full.log` + +测试找到 HTTP 行后没有点击选择。M26 现在在无选择时展示空检查器,点击对象才读取精确版本详情,且策略展示改为实际 Manifest 字段,不再展示旧的通用 Secret 安全文案。选择后 URL 带 `id/version`,原刷新断言 `/platform$` 也需同步。这是验收路径漂移,日志和源代码未表明本次失败来自环境、网络超时或产品契约缺失。插件空态旧定位默认按子串匹配,本身并非另一项确定失败。 + +## 最小同步 + +- 保留可见菜单导航、分页寻找 HTTP 能力、安装按钮禁用、插件及 Runner 切换和池名称/网络区表头验证。 +- 显式点击 HTTP 能力,捕获真实精确版本 GET 响应,核对能力标识、64位完整 Schema 哈希和检查器版本。没有创建模拟响应或硬编码示例数据。 +- 打开“策略与来源”,验证服务端声明固定 Schema 哈希及加密凭据材料,并核对 UI 对应字段和“声明策略与实际请求级策略不同”的提示。未恢复旧的产品硬编码安全结论。 +- 刷新时保留同一 `id/version` URL,验证同版本和完整哈希恢复;切到空插件目录及 Runner 后验证旧能力检查器清空。 +- 未修改 Playwright 配置、超时、重试、视觉阈值、覆盖率门槛或 CI 治理。 + +## 验证与运行边界 + +修改文件的 Prettier、ESLint,以及包含 E2E 文件的专门 TypeScript 检查通过。`playwright test --config=playwright.config.ts --project=chromium --no-deps e2e/s22-capability-sdk.spec.ts --list` 识别1文件1测试;这仅为发现/加载检查,未启动浏览器。日志在本工作树 `output/validation/pr146-compose-s22/`。首次专门 TypeScript 命令因 TS6 要求显式 `--ignoreConfig` 被拒绝,已保存 `types.log`,修正命令后结果在 `types-final.log`,未放宽类型规则。 + +本地没有执行修改后的 S22 文件,没有运行全量、Compose 或浏览器,也未操作共享 QA、重置账号密码或导出会话。检查发现标准 `auth.setup.ts` 可能修改密码并保存认证状态,不能直接在共享 QA 上重复标准 setup。本次不创建临时配置或启动额外浏览器,由整合负责人通过已管理的隔离 bundled Chromium CLI 会话逐断言真实 GUI;该人工路径核验不能等同于实际执行本文件。正式新 head 的 Compose 独立测试数据库仍按既有 setup 与浏览器门禁实际验证本文件。 + +旧 head 的其他作业通过不能替代修复后新 head 的完整正式门禁。本地静态结果也不能证明此 S22 文件已通过真实浏览器验收。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-33-ai-viewer-test-review.md b/docs/design/ui-v5/remediation/2026-10-20/batch-33-ai-viewer-test-review.md new file mode 100644 index 00000000..c07f7e5e --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-33-ai-viewer-test-review.md @@ -0,0 +1,44 @@ +# PR146 AI Viewer 测试查询补修回执 + +日期:2026-10-22。开发基线:`9e7162d5e983ad55f022e4c6735bf537d19b348e`。仅修改 `frontend/src/pages/AIAssistantPage.test.tsx` 的三个既有用例和本回执,不修改产品源码、MSW 响应、权限、执行或诊断策略。 + +## 正式失败与诊断 + +PR146 正式运行 `39650707276` 在上述 head 的前端完整检查中,182文件共1352项通过、1项失败。唯一失败是 Viewer 待审建议可读及禁写用例超过原60秒上限,实际64.941秒;整个文件117.916秒,完整前端3696.77秒。该次其他选中作业(包括 Compose 67项、S22、后端1948项)通过,不能据此把 Required Gate 记为成功。完整原始失败日志保留在主验收工作树: + +`/Users/sqz/.codex/worktrees/ui-v5-audit-followup/FlowTest/output/playwright/audit-20261019/remediation-pr146-9e7162d-frontend-full.log` + +Viewer 本身没有 JSON 或备注逐字输入;夹具为已完成任务,MSW 立即返回且既有 QueryClient 禁止重试。使用相同 `CI=true`、测试名和 Node CPU 采样参数在 FIFTH 对照,确认主要成本来自角色查询、可访问名称和 JSDOM 样式计算,未发现长请求等待或新的产品缺陷。 + +| 相同具名采样 | 修改前 | 修改后 | +| ----------------------- | -------: | ------: | +| Viewer 用例耗时 | 12.850秒 | 7.474秒 | +| Viewer 角色查询调用链 | 9.812秒 | 4.547秒 | +| Viewer 可访问名称调用链 | 5.170秒 | 0.102秒 | +| Viewer 样式计算调用链 | 12.087秒 | 6.780秒 | +| Owner 接受用例耗时 | 3.349秒 | 3.162秒 | +| 拒绝及非法接受用例耗时 | 5.536秒 | 5.398秒 | + +调用链为包含时间,彼此重叠,不能相加;本地采样不等同于正式 CI 的机器负载及覆盖率成本。两个 Owner 用例仅小幅改善,不作显著提速结论。曾试验用公开标题定位 Modal 再显式核对 role/name/visible,Viewer 为7.912秒,没有额外收益,已撤回,最终没有增加该抽象。 + +## 最小改动与断言保留 + +- Viewer 从已显示的建议标题定位真实表格行,在该行查询接受、拒绝和查看按钮;新建按钮限定当前页面标题区。两处样本开关按已有标签定位,并显式核对 switch 角色、可见性、准确可访问名称及自身/祖先没有 `aria-hidden="true"`,保留原角色查询的公开可达语义。 +- 禁用按钮仍执行真实 `userEvent` 点击,之前和之后的零写入计数、全局无 Dialog、实际只读 Dialog 可见、备注 readonly、确认接受按钮缺席均保留。全局 Dialog 空断言继续查询整个 document,未改成会漏掉 Ant Design portal 的局部查询。 +- Owner 接受动作限定实际建议行,编辑控件和确认按钮限定实际审核 Dialog,备注改为真实点击后一次粘贴;编辑后的完整请求体和人工确认备注断言保持。 +- 拒绝后等待刚关闭的已审核 Dialog 真正卸载,然后仍核对全局没有任何 Dialog;拒绝请求体、第二行接受、非法 JSON 错误及零接受请求保持。 +- 其他四个短用例保持不变。没有使用 `hidden: true`、模拟样式或产品测试特判,也未改超时、worker、重试、覆盖率门槛、依赖及 CI 配置。 + +## 验证与边界 + +`CI=true make test-frontend-targeted TARGETS='["src/pages/AIAssistantPage.test.tsx", "src/pages/AIAssistantRemediation.test.tsx", "src/features/ai/ai-service.test.ts", "src/features/ai/use-ai-review.test.tsx"]'`:4文件、30项通过,包含本文件全部7个用例。修改文件格式、ESLint、TypeScript 和 `git diff --check` 通过。 + +完整诊断和具名日志保存在本工作树 `output/validation/pr146-viewer-test/`:`before/`、`after-visible/` 为 Viewer 最终同条件采样,`before-owner/`、`after-owner-final/` 为两个 Owner 用例最终同条件采样。`after/`、`after-owner/` 保留初次收窄结果,随后独立复核要求给标签定位补显式可见性/名称断言,最终已重新采样和验证;`after-final/` 保留已撤回 Modal 试验的记录。`viewer-comparison-final.json`、`owner-comparison-final.json`、`summarize_cpu.py` 可核对计算口径,`targeted-visible-final.log` 为最终4文件结果。按测试名筛选时其他用例未执行,最终具名检查未跳过这7个用例。 + +本次没有启动浏览器、操作共享 QA、密码或会话,也未运行完整前端覆盖率、Compose、后端或构建。测试专属改动的最新正式门禁仍需在整合源码冻结后执行;本地通过不能替代新 head 的 Required Gate、审查、合并及合并后回归。此前85本地完整前端和9e正式其他作业成功仍分别绑定其原始源码,不改写失败历史。 + +## 整合后的独立复核补充 + +精确整合 `60f931d` 的再次只读复核发现,上述开关最初的 role/name/visible 断言遗漏了祖先 `aria-hidden`:`toBeVisible` 检查 CSS 和 HTML hidden,但不能排除只在祖先设置 `aria-hidden="true"` 的元素。原角色查询默认会排除这种元素。两处均补充 `closest('[aria-hidden="true"]')` 为空,覆盖元素自身及全部元素祖先;其他角色查询、全局缺席断言及业务行为未改。 + +补充后同一4文件30项再次通过,耗时32.81秒,完整日志为主验收工作树 `output/playwright/audit-20261019/remediation-ai-aria-final-targeted.log`。上表 CPU 采样属于追加这两条断言前的测试源码,不冒充追加后的重新采样。整合负责人停止旧树完整前端 r5(退出130)并请求取消旧60f门禁39664876063;两者均不计为完整通过,最终整合源码另行运行完整前端和正式门禁。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-34-context-ci-review.md b/docs/design/ui-v5/remediation/2026-10-20/batch-34-context-ci-review.md new file mode 100644 index 00000000..af5178c3 --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-34-context-ci-review.md @@ -0,0 +1,40 @@ +# 第三十四批:上下文检查器具名测试查询成本补修 + +基于 `9e7162d5e983ad55f022e4c6735bf537d19b348e`,在独立工作树 `task-plan-audit/FlowTest` 核查 M21 的两个临界测试。对应[冻结审计 M21](../../audits/2026-10-19/complete-audit.md#m21)与[实施方案 F-CTX-01](../../audits/2026-10-19/remediation-plan.md#f-ctx-01);冻结文件未修改。本批仅调整[页面测试](../../../../../frontend/src/pages/ContextInspectorPage.test.tsx)的控件查询,不修改产品、接口、日志、诊断策略或 CI 配置,不增加审计完成计数。 + +## 证据与改动 + +正式 CI `39650707276` 中,`shows revision evidence, state knowledge and a proposal deep-link` 为 **53.324 秒**,`links a conflict to its exact frozen finding and reports filtered and total evidence counts without writes` 为 **56.856 秒**,均通过但接近既有 60 秒阈值。测试没有逐字输入,MSW 同步返回合成上下文,原有 `retry: false` 与 `motion: false` 保留。 + +在 Node `v22.22.0`、Vitest `4.1.10` 下,对两个具名测试分别执行修前、修后 CPU 采样,均使用 `CI=true` 与原有 pool、workers、timeout。角色查询的主要成本来自 `isInaccessible` 的 CSS 可见性链;修前该调用的包含耗时分别为 **5.942 秒**、**6.277 秒**。采样未出现 `getMissingError`、`prettyRoles` 或 `getRoles`,不能将静态发现的缺席角色诊断路径写成已证实原因。 + +最终只调整两个目标测试中的六处交互定位: + +- 第一例的三个页签,先在已有公开标签“上下文详情”的 section 内按页签文字定位单个 `role="tab"` 控件。 +- 第一例的查看证据按钮,以及第二例的定位冲突、查看证据按钮,使用同一详情区内已有公开 `aria-label` 精确定位。 +- 每个替代查询都显式检查原角色、准确的可访问名称、可见性及没有 `aria-hidden="true"` 祖先,再继续真实 `userEvent.click`。保留原有 heading、证据内容、Provider、警告、知识关系、Proposal 链接、固定来源修订与文件路径、URL evidence/revision、筛选后与总计数,以及 `reads === 1`、`writes === 0` 的全部断言。 + +第二例仅替换首个页签查询的试验没有收益,已撤回;该例原有首个 `findByRole` 和后续证据详情、清除筛选、确定性、警告筛选的角色查询保持原样。未更改 timeout、workers、重试、覆盖率或视觉阈值,没有使用 `hidden: true`。 + +## 具名采样与验证 + +以下为各一次同命令具名采样,反映本机结果,不等同于远端重跑后的耗时保证。CPU 列为包含耗时,调用栈相互重叠,不能相加。 + +| 测试 | 测试耗时:修前 → 修后 | 角色查询 CPU:修前 → 修后 | CSS CPU:修前 → 修后 | +| ----------------------------- | --------------------- | ------------------------- | -------------------- | +| Revision、知识与 Proposal | 9.948 → 6.941 秒 | 6.063 → 0.040 秒 | 9.096 → 6.189 秒 | +| 冲突、固定 Finding 与只读筛选 | 10.528 → 7.658 秒 | 6.542 → 0.397 秒 | 9.645 → 6.807 秒 | + +采样命令分别使用上述完整测试名作为 `-t`:`CI=true pnpm exec vitest run src/pages/ContextInspectorPage.test.tsx -t '<完整测试名>' --execArgv=--cpu-prof --execArgv=--cpu-prof-dir=<本工作树输出目录> --reporter=verbose --reporter=json --outputFile.json=<输出目录>/results.json`。目录与 JSON 均位于已忽略的 `output/validation/pr146-context-test/`;修前为 `before-revision/`、`before-conflict/`,最终修后为 `after-revision-buttons/`、`after-conflict-buttons/`,每个目录保留完整 `profile.log`、测试结果与原始 CPU profile,汇总为 `cpu-summary-final.json`。 + +最终实际执行并通过: + +- `CI=true make test-frontend-targeted TARGETS='["src/pages/ContextInspectorPage.test.tsx","src/features/context-inspector/use-context-inspector.test.tsx","src/features/context-inspector/use-context-page.test.tsx"]'`:**3 文件、12 项通过**,15.72 秒;覆盖完整页面测试及上下文切换、固定修订、分页 hooks。 +- 修改测试的 Prettier、ESLint(零警告)以及全前端 `pnpm exec tsc -b --pretty false` 通过。 +- 本回执的格式、相对链接与 `git diff --check` 通过。 + +日志分别为 `targeted-final.log`、`format-test-final.log`、`eslint-final.log`、`typecheck-final.log` 与 `format-links-final.log`。全部诊断只使用既有合成测试数据,没有浏览器、共享 QA 或账号操作。 + +## 完成边界 + +本批完成以上测试查询补修与具名本地验证。未运行完整前端覆盖率、后端、Compose、Windows、发布验收或远端门禁,未推送、合并或部署。既有其他提交的全量结果不能替代整合后当前提交的正式 CI;PR 与发布状态由主代理另行核查。 diff --git a/docs/design/ui-v5/remediation/2026-10-20/batch-35-organization-ci-review.md b/docs/design/ui-v5/remediation/2026-10-20/batch-35-organization-ci-review.md new file mode 100644 index 00000000..b18e22bd --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/batch-35-organization-ci-review.md @@ -0,0 +1,40 @@ +# PR146 组织审计测试查询补修回执 + +日期:2026-10-22。开发基线:`9e7162d5e983ad55f022e4c6735bf537d19b348e`。仅修改[组织治理测试](../../../../../frontend/src/pages/OrganizationGovernancePage.test.tsx)的两个既有审计用例和本回执;[M24 原回执](./batch-26-organization.md)的产品及权限边界保持。 + +## 诊断与对照 + +PR146 正式运行 `39650707276` 的完整前端日志中,这两例分别以55.364秒、54.570秒通过,接近既有60秒上限。该次完整前端另有 AI Viewer 超时,不能记为正式门禁成功。原始日志保留在主验收工作树 `output/playwright/audit-20261019/remediation-pr146-9e7162d-frontend-full.log`。 + +在 SIXTH 干净基线先逐例采样,再作补修。四次最终对照使用相同 Node v22.22.0、pnpm 11.25.0、`CI=true`、具名 `-t` 与 `--execArgv=--cpu-prof`;未传 pool、worker、timeout、retry 或 coverage 参数,沿用仓库配置。各智能体串行交接 CPU 窗口,采样期间没有并行测试、lint 或 types。 + +| 最终同条件对照 | 修改前 | 修改后 | +| ---------------------- | ---------------: | ---------------: | +| 第6页及筛选用例耗时 | 10.173秒 | 6.983秒 | +| 该例角色查询包含时间 | 6.063秒(49.9%) | 0.058秒(0.7%) | +| 该例可访问名称包含时间 | 0.206秒(1.7%) | 0.074秒(0.8%) | +| 该例样式计算包含时间 | 8.938秒(73.6%) | 6.061秒(68.8%) | +| 切组织及403用例耗时 | 10.185秒 | 7.207秒 | +| 该例角色查询包含时间 | 6.208秒(53.9%) | 0.061秒(0.7%) | +| 该例可访问名称包含时间 | 0.221秒(1.9%) | 0.097秒(1.1%) | +| 该例样式计算包含时间 | 9.174秒(79.6%) | 6.360秒(71.2%) | + +百分比以完整 Node profile 区间为分母,含测试装载;两例修改前/后区间分别为12.149/8.806秒、11.518/8.935秒。调用链统计分别匹配 `queryAllByRole`、`computeAccessibleName` 和 `getComputedStyle`;包含时间相互重叠,不能相加,也不等于测试自身占比。本地采样没有完整覆盖率及正式 CI 的机器负载,不能由此保证远端耗时。 + +首轮只收窄卡片、行和 Dialog 查询、复用 userEvent 并粘贴完整筛选值,两例为10.119秒、10.384秒,没有明显收益。最终再将审计 Tab 和该记录已有标签的查看按钮定位到单节点,逐节点核对原 role、name、可见性及 `aria-hidden="true"` 祖先,取得表中结果。没有把104字符输入宣称为已证实的主要瓶颈,也没有把首轮结果冒充最终结果。 + +## 语义与范围复核 + +- Tab 通过现有公开文本定位实际 tab,保留原 `/审计与安全/` 可访问名称匹配;查看按钮限定第6页实际记录行并按已有标签定位,精确检查 button 角色和名称。两者显式检查可见性与自身/祖先没有 `aria-hidden="true"`,没有启用隐藏元素查询。 +- Dialog、关闭、查询和治理组织 Select 继续使用默认可访问的角色查询。第6页资源先等真实行显示,再查看详情;原 Trace 和服务端允许详情断言保持。分页总数61、第6页请求及筛选后的第1页、page size、action、actor、resource type/id、起止时间全部 URL 断言保持。 +- 四项筛选仍通过真实点击后粘贴完整值进入表单,两项日期保持原 change 事件;没有直接调用业务服务或改造请求响应。原 MSW GET 夹具及 `onUnhandledRequest: 'error'` 保持,没有添加宽泛写入成功响应。 +- 切组织仍真实选择目录项、收到成员及审计403,并核对新组织请求从首页且不带旧 action。旧第6页资源和任意 Dialog 缺席继续查询全局 document,没有用旧卡片或已卸载节点替代全局负断言。 +- 未改其他28项用例、共享 helper、产品、权限、采集策略、依赖、motion、CI、超时、worker、重试或覆盖率门槛。 + +## 实际验证与边界 + +`CI=true make test-frontend-targeted TARGETS='["src/pages/OrganizationGovernancePage.test.tsx","src/features/projects/DirectoryPicker.test.tsx","src/pages/organization-governance-rotation.test.tsx"]'`:3文件、34项全部通过,耗时42.89秒;本文件30项全部执行。修改文件 Prettier、ESLint、全前端 `tsc -b --pretty false`、文档格式/本地链接及 `git diff --check` 通过。 + +完整证据位于本工作树 `output/validation/pr146-organization-test/`:`before-filters/`、`before-scope/` 为原9e基线;`after-filters/`、`after-scope/` 保留首轮无收益结果;`after-semantic-filters/`、`after-semantic-scope/` 对应最终行为,后续仅有 Prettier 换行。各目录含原始 profile、JSON 测试结果和日志;`comparison-final.json`、`comparison-percentages.json` 及 `summarize_cpu.py` 可核对口径;`targeted-final.log` 为完整34项具名结果。按名称采样时其他29项未运行,最终文件级具名检查没有跳过本文件用例。 + +本次未启动浏览器、操作共享 QA,也未运行完整前端覆盖率、Compose、后端或构建。测试专属补修仍需整合后的最新 head 正式门禁;本回执不改变61项已完成加12项本地补修、58项已合并的既有状态,不宣称73项最终验收或已合并。 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..69e134be --- /dev/null +++ b/docs/design/ui-v5/remediation/2026-10-20/pr146-review-followup.md @@ -0,0 +1,21 @@ +# 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项目标测试推定远端门禁成功。 + +2026-10-22 正式 CI 补修:`373447988430b7fb3ef2620ffd65fe1922ea5636` 的运行 `39633269613` 中,前端177文件共1260项通过,唯一失败为 `AIAssistantPage.test.tsx` 的拒绝及非法接受反例超过原60秒上限。该反例已经一次性写入 JSON,并非逐字输入 JSON;源码也没有完成态任务轮询或审核后重复读取循环。完整失败日志保留在主验收树 `output/playwright/audit-20261019/remediation-pr146-373-failed-full.log`。 + +在 SIXTH 使用相同单用例、`CI=true` 与 Node CPU 采样参数对照,原用例耗时21.970秒,其中可访问名称调用链15.732秒、role查询调用链19.059秒;这些包含时间相互重叠,不能相加。测试现在从可见建议标题定位对应表格行,再在该行查询原有具名按钮;弹窗操作与错误读取限定在对应弹窗内,备注通过真实点击及一次粘贴输入。拒绝回执改变审核状态后,重新取得实际重挂的“已审核建议”弹窗再关闭。修复后同一用例耗时5.928秒,可访问名称调用链降至0.160秒。全部原请求体、拒绝、关闭、非法 JSON 及零接受请求断言均保留,独立只读复核没有发现断言弱化。 + +本次产品源码、fixtures、MSW handlers、超时、重试、覆盖率门槛和 CI 配置均未改动。`CI=true make test-frontend-targeted` 运行助手页面、补修页面、AI service 和 review hook 四个具名文件,共30项通过;全量前端 lint/类型和构建通过。诊断 profile、原始与修复后日志均保留在 SIXTH 的 `output/validation/pr146-ci-frontend/`,不提交生成产物。该定向验证不代表最新提交的远端完整门禁或发布验收已通过。 + +V8 定向覆盖率首次仅运行助手页面7项,行为测试全部通过,但页面分支覆盖率76.74%,按原80%门槛失败;该日志保留为 `named-coverage.log`。纳入其余三个既有相关文件后,`CI=true pnpm test:coverage` 的30项全部通过;统计范围仅为 `src/pages/AIAssistantPage.tsx`,语句93.75%、分支87.20%、函数94.28%、行98.00%,原门槛未变。最终完整日志为 `four-coverage.log`,具名目标测试为 `fixed-four-targeted.log`,静态检查为 `lint-types.log`、`build.log` 和 `format-check.log`。覆盖率统计范围受本次定向检查限制,没有把这些百分比作为整个前端的结果。 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 b9d674d6..ce834cba 100644 --- a/docs/design/ui-v5/remediation/2026-10-20/progress.md +++ b/docs/design/ui-v5/remediation/2026-10-20/progress.md @@ -2,9 +2,9 @@ 依据:[完整审计](../../audits/2026-10-19/complete-audit.md)、[详细方案](../../audits/2026-10-19/remediation-plan.md)。审计基线 `420d2e0cd30ff3f5c0519df8fcfd26b3bc2704e4`。 -2026-10-20 至 21 日整改:**58 项已实现并完成本地验证,15 项待完成**(此前56项加发布门禁2项)。按73项等权计数,本地完成约79%,已合并48项约66%;不代表工时消耗比例。R0、R1、R2、R3全部完成本地验证,R4十五项继续实施。**不是73项全部完成,也不是全站设计签收。** +2026-10-20 至 22 日整改:**61 项已完成此前本地验证,余12项已实现并通过具名验证、等待完整整合验收**。按73项等权计数,已完成此前本地验证约84%,GitHub已合并58项约79%;不代表工时消耗比例。R0、R1、R2、R3已完成此前本地验证;R4的M20至M26统一整合交付,最新交叉复审见[整合回执](batch-29-integration.md)。最后复审补齐了组织自降级刷新、MCP精确详情缓存、用例运行目标/失败重试/提交锁定,以及旧Standalone启动补列。新后端完整1948项通过,覆盖率91.21%;最终前端完整检查与最新整合HEAD远端门禁待完成,尚未将这12项记为完整验收完成。**不是73项全部验收通过,也不是全站设计签收。** -状态指代码和本地验证;浏览器专项、GitHub交付与正式环境验收分开记录。详情见[首批实施与验证](batch-01.md)、[冻结断言来源补充整改](batch-02.md)与[完整本地验证记录](validation.md)。 +状态指代码和本地验证;浏览器专项、GitHub交付与正式环境验收分开记录。后续复审补修见[用例运行确认](batch-30-case-run-review.md)与[旧库启动升级](batch-31-standalone-review.md),冻结73项基线不改写。详情见[首批实施与验证](batch-01.md)、[冻结断言来源补充整改](batch-02.md)与[完整本地验证记录](validation.md)。 | 问题 | 模块 | 批次 | 状态 | | ---------- | ---- | ---- | -------------------- | @@ -63,24 +63,24 @@ | F-REL-01 | M19 | R0 | 已实现,本地验证通过 | | F-REL-02 | M19 | R3 | 已实现,本地验证通过 | | F-REL-03 | M19 | R3 | 已实现,本地验证通过 | -| F-AI-01 | M20 | R4 | 待实施 | -| F-AI-02 | M20 | R4 | 待实施 | -| F-CTX-01 | M21 | R4 | 待实施 | -| F-AIC-01 | M22 | R4 | 待实施 | -| F-AIC-02 | M22 | R4 | 待实施 | -| F-AIC-03 | M22 | R4 | 待实施 | -| F-MCP-01 | M23 | R4 | 待实施 | -| F-MCP-02 | M23 | R4 | 待实施 | -| F-ORG-01 | M24 | R4 | 待实施 | -| F-ORG-02 | M24 | R4 | 待实施 | -| F-ORG-03 | M24 | R4 | 待实施 | -| F-FAB-01 | M25 | R4 | 待实施 | -| F-FAB-02 | M25 | R4 | 待实施 | -| F-PLAT-01 | M26 | R4 | 待实施 | -| F-PLAT-02 | M26 | R4 | 待实施 | -| F-UX-01 | 全局 | R2 | 已实现,本地验证通过 | -| F-UX-02 | 全局 | R2 | 已实现,本地验证通过 | -| F-UX-03 | 全局 | R2 | 已实现,本地验证通过 | +| F-AI-01 | M20 | R4 | 已实现,本地验证通过 | +| F-AI-02 | M20 | R4 | 已实现,本地验证通过 | +| F-CTX-01 | M21 | R4 | 已实现,本地验证通过 | +| F-AIC-01 | M22 | R4 | 已实现,整合验收中 | +| F-AIC-02 | M22 | R4 | 已实现,整合验收中 | +| F-AIC-03 | M22 | R4 | 已实现,整合验收中 | +| F-MCP-01 | M23 | R4 | 已实现,整合验收中 | +| F-MCP-02 | M23 | R4 | 已实现,整合验收中 | +| F-ORG-01 | M24 | R4 | 已实现,整合验收中 | +| F-ORG-02 | M24 | R4 | 已实现,整合验收中 | +| F-ORG-03 | M24 | R4 | 已实现,整合验收中 | +| F-FAB-01 | M25 | R4 | 已实现,整合验收中 | +| F-FAB-02 | M25 | R4 | 已实现,整合验收中 | +| F-PLAT-01 | M26 | R4 | 已实现,整合验收中 | +| F-PLAT-02 | M26 | R4 | 已实现,整合验收中 | +| F-UX-01 | 全局 | R1 | 已实现,本地验证通过 | +| F-UX-02 | 全局 | R1 | 已实现,本地验证通过 | +| F-UX-03 | 全局 | R1 | 已实现,本地验证通过 | ## 已实现项与证据入口 @@ -340,3 +340,11 @@ F-QLT-01/02完成真实启用门禁统计、风险历史选择、页外原资产 ## M19 发布门禁来源与证据 F-REL-02、F-REL-03已实现并完成完整本地检查及实际浏览器验收,详见[第二十一批](batch-21.md)及[验证补充](batch-21-validation.md)。六类来源按固定身份、冻结策略及证据回看;错误关联禁写、手机布局和策略更新后快照保持已验证。当前58/73项本地完成、15项待完成,48项已合并;R3最新PR门禁与合并单独核验。 + +## 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 88ff369a..e7c25d1f 100644 --- a/docs/design/ui-v5/remediation/2026-10-20/validation.md +++ b/docs/design/ui-v5/remediation/2026-10-20/validation.md @@ -204,3 +204,9 @@ PR #142 的旧HEAD `b30027ef96424b6bfdefc97d5e8555fa6b6b16cf` 对应[正式运 ## M19及R3最终完整检查 详见[第二十一批](batch-21.md)与[验证补充](batch-21-validation.md)。后端最终树96acb10f98587be4a48ab0e68b2bd49b108b0151完整1900项、91.13%,前端最终树876afba22ad814708f2b20e8ab2a89c214aa2ddf完整174文件1229项、86.14/82.40/86.00/88.72%,格式、静态检查及构建通过,无未处理异常。真实六类来源、快照不变、精确回跳及390px布局通过;远端最新PR门禁与正式验收仍独立确认。当前本地58/73、已合并48。 + +## 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门禁、主分支与部署独立记账。 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/e2e/s22-capability-sdk.spec.ts b/frontend/e2e/s22-capability-sdk.spec.ts index 05520f96..c2d3598c 100644 --- a/frontend/e2e/s22-capability-sdk.spec.ts +++ b/frontend/e2e/s22-capability-sdk.spec.ts @@ -1,6 +1,7 @@ import { navigateMenu } from './support/navigation' import { expect, test } from '@playwright/test' +import type { Capability } from '../src/features/capabilities/capability-service' import { authenticate } from './support/auth' test('S22 能力版本、安全边界与平台深链接主路径', async ({ page }) => { @@ -23,17 +24,58 @@ test('S22 能力版本、安全边界与平台深链接主路径', async ({ page ) } await expect(httpCapability).toContainText('HTTP 请求') - await expect(page.getByText('固定 Schema 哈希')).toBeVisible() - await expect(page.getByText(/插件不会获得 Secret 明文/)).toBeVisible() + const inspector = page.getByRole('complementary', { name: '平台对象检查器' }) + await expect(inspector.getByText('选择当前库存对象查看详情')).toBeVisible() + const detailResponse = page.waitForResponse( + (response) => + new URL(response.url()).pathname.startsWith('/api/v1/capabilities/http.request/versions/') && + response.request().method() === 'GET', + ) + await httpCapability.getByRole('button', { name: 'HTTP 请求', exact: true }).click() + const response = await detailResponse + expect(response.ok()).toBeTruthy() + const capability: Capability = await response.json() + expect(capability.id).toBe('http.request') + expect(capability.schema_hash).toMatch(/^[a-f0-9]{64}$/) + await expect( + inspector.getByText(`${capability.id}@${capability.version}`, { exact: true }), + ).toBeVisible() + await expect(inspector.getByText(capability.schema_hash, { exact: true })).toBeVisible() + await inspector.getByRole('tab', { name: '策略与来源' }).click() + expect(capability.manifest.snapshot_policy.include_schema_hash).toBe(true) + await expect( + inspector.locator('.ant-descriptions-row').filter({ hasText: 'Snapshot 固定 Schema 哈希' }), + ).toContainText('是') + expect(capability.manifest.snapshot_policy.credential_material).toBe('encrypted') + await expect( + inspector.locator('.ant-descriptions-row').filter({ hasText: 'Snapshot 凭据材料策略' }), + ).toContainText(capability.manifest.snapshot_policy.credential_material) + await expect( + inspector.getByText('以下为能力声明的策略;实际诊断采集按安装与请求级项目策略执行'), + ).toBeVisible() await expect(page.getByRole('button', { name: '安装签名插件' })).toBeDisabled() + const selectedUrl = page.url() + expect(new URL(selectedUrl).searchParams.get('id')).toBe(capability.id) + expect(new URL(selectedUrl).searchParams.get('version')).toBe(capability.version) await page.reload() - await expect(page).toHaveURL(/\/platform$/) + await expect(page).toHaveURL(selectedUrl) await expect(page.getByRole('heading', { name: '能力与插件中心' })).toBeVisible() + await expect( + inspector.getByText(`${capability.id}@${capability.version}`, { exact: true }), + ).toBeVisible() + await expect(inspector.getByText(capability.schema_hash, { exact: true })).toBeVisible() await page.getByTitle('插件').click() - await expect(page.getByText('尚未安装管理员签名插件')).toBeVisible() + await expect( + page.getByText('尚未安装管理员签名插件或没有匹配结果', { exact: true }), + ).toBeVisible() + await expect(inspector.getByText('选择当前库存对象查看详情')).toBeVisible() + await expect( + inspector.getByText(`${capability.id}@${capability.version}`, { exact: true }), + ).toHaveCount(0) await page.getByTitle('Runner').click() await expect(page.getByRole('columnheader', { name: '池名称' })).toBeVisible() await expect(page.getByRole('columnheader', { name: '网络区' })).toBeVisible() + await expect(inspector.getByText('选择当前库存对象查看详情')).toBeVisible() }) diff --git a/frontend/e2e/s57-context-inspector.spec.ts b/frontend/e2e/s57-context-inspector.spec.ts index 43b1fc3d..ee0d9fc7 100644 --- a/frontend/e2e/s57-context-inspector.spec.ts +++ b/frontend/e2e/s57-context-inspector.spec.ts @@ -95,7 +95,9 @@ test('S57 Context Inspector 展示当前证据并深链到既有 Flow Proposal', await expect( page.getByRole('cell', { name: 'flowtest-java-spring 1.0.0', exact: true }), ).toBeVisible() - await expect(page.getByRole('cell', { name: /^OrderStatus\// }).first()).toBeVisible() + await page.getByRole('tab', { name: '知识关系', exact: true }).click() + await expect(page.getByText(/^OrderStatus\//).first()).toBeVisible() + await page.getByRole('tab', { name: '关联提案', exact: true }).click() await expect(page.getByRole('cell', { name: 'S57 订单流程提案', exact: true })).toBeVisible() await page.getByRole('link', { name: /打开 Proposal/ }).click() diff --git a/frontend/src/features/ai/AIChangeReviewEvidence.tsx b/frontend/src/features/ai/AIChangeReviewEvidence.tsx new file mode 100644 index 00000000..5fbf8692 --- /dev/null +++ b/frontend/src/features/ai/AIChangeReviewEvidence.tsx @@ -0,0 +1,261 @@ +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, baseline.target_type) + 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/AIDraftProtection.tsx b/frontend/src/features/ai/AIDraftProtection.tsx new file mode 100644 index 00000000..ee3d34c7 --- /dev/null +++ b/frontend/src/features/ai/AIDraftProtection.tsx @@ -0,0 +1,43 @@ +import { Modal, theme } 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) + const { token } = theme.useToken() + 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..ae9c5002 --- /dev/null +++ b/frontend/src/features/ai/CreateAIJobDialog.tsx @@ -0,0 +1,530 @@ +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, onCreated?: () => void) => 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, onCreated?: () => void) => 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 + if ( + await onCreate(input, () => { + allowLeaveRef.current = true + }) + ) { + setDirty(false) + onClose() + } else { + allowLeaveRef.current = false + setError('提交结果未确认,已保留材料。请先核对任务列表和错误信息,避免重复提交。') + } + } + return ( + <> + + { + if (!submitting) { + if (dirty) setDiscard(true) + else 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 ( + + ) +} + +function materialVersionLabel(source: AIPublicSource | null): string | number { + if (!source) return '手动材料,无来源版本' + return source.metadata.version ?? '使用来源固定 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..d4e428b2 --- /dev/null +++ b/frontend/src/features/ai/ai-change-diff.test.ts @@ -0,0 +1,105 @@ +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, 'workflow') + 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') + }) + + 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 new file mode 100644 index 00000000..3967d6d3 --- /dev/null +++ b/frontend/src/features/ai/ai-change-diff.ts @@ -0,0 +1,65 @@ +export type ChangeField = { path: string; before: unknown; after: unknown } +export function proposedDraft( + before: Record, + patch: Record, + targetType: 'test_case' | 'workflow', +): Record { + 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 [] + 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/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-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/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..e8c508b4 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,137 @@ 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 canReview = canEdit && selectedJob.isSuccess && selectedJob.data.job_type !== 'change_set' 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, + onCreated?: () => void, + ): 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 + onCreated?.() + 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 || + !canReview || + !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, + canReview, + permissions: access.permissions, status, jobs, selectedJob, selectedJobId: activeJobId, - selectJob: setSelectedJobId, + selectJob, suggestions, createJob, creating: createMutation.isPending, @@ -121,5 +189,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/features/capabilities/PlatformInspector.tsx b/frontend/src/features/capabilities/PlatformInspector.tsx new file mode 100644 index 00000000..034b391e --- /dev/null +++ b/frontend/src/features/capabilities/PlatformInspector.tsx @@ -0,0 +1,327 @@ +import { Alert, Descriptions, Space, Table, Tabs, Typography } from 'antd' + +import ResponseCodeReader from '../api-console/ResponseCodeReader' +import type { + Capability, + CapabilityManifest, + PlatformObject, + Plugin, + RunnerPool, +} from './capability-service' + +export default function PlatformInspector({ + value, + runnerEnabled, +}: { + value: PlatformObject + runnerEnabled: boolean +}) { + if (value.kind === 'capability') return + if (value.kind === 'plugin') return + return +} + +function CapabilityInspector({ capability }: { capability: Capability }) { + const manifest = capability.manifest + return ( + <> + 能力契约检查器 + + {capability.id}@{capability.version} + + , + }, + { + key: 'input', + label: '输入 Schema', + children: , + }, + { + key: 'output', + label: '输出 Schema', + children: , + }, + { + key: 'configuration', + label: '配置 Schema', + children: , + }, + { + key: 'policies', + label: '策略与来源', + children: , + }, + ]} + /> + + ) +} + +function CapabilitySummary({ capability }: { capability: Capability }) { + const manifest = capability.manifest + return ( + }, + { label: 'Runner', children: text(capability.runner_type) }, + { label: '网络', children: networkLabel(capability.network_access) }, + { label: '默认超时', children: seconds(manifest?.timeout_policy?.default_seconds) }, + { label: '最大超时', children: seconds(manifest?.timeout_policy?.maximum_seconds) }, + { label: '凭据类型', children: list(manifest?.credential_types) }, + { + label: 'Snapshot Schema 哈希', + children: bool(manifest?.snapshot_policy?.include_schema_hash), + }, + { label: '来源', children: text(capability.source) }, + { label: '插件标识', children: text(capability.plugin_id) }, + { label: '插件 Digest', children: }, + ]} + /> + ) +} + +function CapabilityPolicies({ + manifest, + capability, +}: { + manifest: CapabilityManifest | undefined + capability: Capability +}) { + return ( + + + + , + }, + ]} + /> + {capability.plugin_id && ( + + 查找来源插件 + + )} + + + + ) +} + +function NetworkPolicyDetails({ + value, +}: { + value: CapabilityManifest['network_policy'] | undefined +}) { + return ( + + ) +} +function SnapshotPolicyDetails({ + value, +}: { + value: CapabilityManifest['snapshot_policy'] | undefined +}) { + return ( + + ) +} +function policySource(capability: Capability) { + const manifest = capability.manifest + return { + network_policy: manifest?.network_policy, + timeout_policy: manifest?.timeout_policy, + snapshot_policy: manifest?.snapshot_policy, + redaction_policy: manifest?.redaction_policy, + credential_types: manifest?.credential_types, + plugin_id: manifest?.plugin_id, + plugin_digest: manifest?.plugin_digest, + source: capability.source, + schema_hash: capability.schema_hash, + } +} + +function SchemaContract({ title, value }: { title: string; value: unknown }) { + const properties = schemaProperties(value) + return ( + + + 字段结构最多预览所选版本的前 200 个顶层字段;下方可搜索、复制完整原始契约。 + +
(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/features/context-inspector/ContextEvidenceBrowser.tsx b/frontend/src/features/context-inspector/ContextEvidenceBrowser.tsx new file mode 100644 index 00000000..b37d0003 --- /dev/null +++ b/frontend/src/features/context-inspector/ContextEvidenceBrowser.tsx @@ -0,0 +1,361 @@ +import { + Alert, + Button, + Card, + Checkbox, + Descriptions, + Empty, + Flex, + Select, + Space, + List, + Tag, + Typography, +} from 'antd' +import { providerTypeLabel } from './context-presentation' +import ResponseCodeReader from '../api-console/ResponseCodeReader' +import type { + ContextDetail, + ContextEvidenceItem, + EvidenceProviderType, +} from './context-inspector-service' +import { + evidenceReference, + selectedConflict, + type ContextNavigation, + type ContextSelection, +} from './context-navigation' + +export type ContextEvidenceProps = { + detail: ContextDetail + selection: ContextSelection + onNavigate: (patch: ContextNavigation) => void +} +export default function ContextEvidenceBrowser({ + detail, + selection, + onNavigate, +}: ContextEvidenceProps) { + const conflict = selectedConflict(detail.revision.conflict_snapshot.conflicts, selection.conflict) + const source = detail.evidence_items.find((item) => item.id === selection.sourceEvidence) + const invalidScope = scopeError(selection, Boolean(conflict), Boolean(source)) + const items = invalidScope + ? [] + : detail.evidence_items.filter((item) => + matchesEvidence(item, selection, conflict?.finding_fingerprints, source), + ) + const selected = selection.evidence + ? evidenceReference(selection.evidence, detail.evidence_items) + : null + function clear() { + onNavigate({ + providerType: null, + certainty: null, + warnings: null, + conflict: null, + sourceEvidence: null, + }) + } + return ( + + + + onNavigate({ certainty: value === 'all' ? null : value })} + options={[ + { value: 'all', label: '全部确定性' }, + { value: 'deterministic', label: '确定性' }, + { value: 'review', label: '需复核' }, + ]} + /> + onNavigate({ warnings: event.target.checked ? '1' : null })} + > + 仅含警告 + + + + + {source ? ( + + ) : null} + {invalidScope ? : null} + + 显示 {items.length} / {detail.evidence_items.length} 条证据 + + {detail.evidence_count !== detail.evidence_items.length ? ( + + ) : null} + + + {detail.evidence_items.length === 0 ? ( + + ) : ( + + size="small" + pagination={{ pageSize: 20, hideOnSinglePage: true }} + dataSource={items} + renderItem={(item) => ( + onNavigate({ evidence: item.id })} + > + 查看详情 + , + ]} + > +
+ {item.finding.statement} + + {providerTypeLabel(item.source_type)} + + {item.deterministic ? '确定性' : '需复核'} + + + 警告 {item.warnings.length} 条 + + +
+
+ )} + /> + )} +
+ +
+ ) +} +function scopeError( + selection: ContextSelection, + hasConflict: boolean, + hasSource: boolean, +): string | null { + if (selection.conflict !== null && !hasConflict) return '指定冲突不在当前固定修订中' + if (selection.sourceEvidence !== null && !hasSource) return '指定来源不在当前固定修订中' + if (selection.certainty && !['deterministic', 'review'].includes(selection.certainty)) + return '确定性筛选参数无效,请清除证据筛选' + if (selection.warnings && selection.warnings !== '1') return '警告筛选参数无效,请清除证据筛选' + return null +} +function matchesEvidence( + item: ContextEvidenceItem, + selection: ContextSelection, + fingerprints: string[] | undefined, + source: ContextEvidenceItem | undefined, +): boolean { + return ( + matchesType(item, selection) && + matchesFlags(item, selection) && + (!fingerprints || fingerprints.includes(item.fingerprint)) && + sameSource(item, source) + ) +} +function matchesType(item: ContextEvidenceItem, selection: ContextSelection): boolean { + return !selection.providerType || selection.providerType === item.source_type +} +function matchesFlags(item: ContextEvidenceItem, selection: ContextSelection): boolean { + if (selection.certainty === 'deterministic' && !item.deterministic) return false + if (selection.certainty === 'review' && item.deterministic) return false + return selection.warnings !== '1' || item.warnings.length > 0 +} +function sameSource(item: ContextEvidenceItem, source: ContextEvidenceItem | undefined): boolean { + return ( + !source || + (item.source_type === source.source_type && + item.source_ref === source.source_ref && + item.source_revision === source.source_revision) + ) +} +function EvidenceDetails({ + detail, + item, + hiddenByFilter, + onNavigate, +}: { + detail: ContextDetail + item: ContextEvidenceItem + hiddenByFilter: boolean + onNavigate: (patch: ContextNavigation) => void +}) { + return ( + + {hiddenByFilter ? ( + + ) : null} + {item.finding.statement} + {detail.revision_id}, + }, + { + key: 'id', + label: '证据ID', + children: {item.id}, + }, + { + key: 'provider', + label: 'Provider', + children: `${item.provider_name} ${item.provider_version}`, + }, + { + key: 'source', + label: '来源引用', + children: ( + + {item.source_ref} + + ), + }, + { + key: 'revision', + label: '来源修订', + children: {item.source_revision}, + }, + { + key: 'path', + label: '字段或文件路径', + children: ( + + {item.finding.source_path} + + ), + }, + { + key: 'subject', + label: '主题引用', + children: ( + + {item.subject_ref} + + ), + }, + { + key: 'fingerprint', + label: '证据指纹', + children: ( + + {item.fingerprint} + + ), + }, + { key: 'confidence', label: '置信度', children: String(item.confidence) }, + ]} + /> + + {item.warnings.map((warning, index) => ( + + ))} + + 来源内容为不可信材料,仅按已载入的固定修订展示。 + + + + ) +} + +function EvidenceSelection({ + detail, + selection, + selected, + items, + onNavigate, +}: ContextEvidenceProps & { selected: ContextEvidenceItem | null; items: ContextEvidenceItem[] }) { + return ( + <> + {' '} + {selection.evidence && !selected ? ( + + ) : null} + {selected ? ( + item.id === selected.id)} + onNavigate={onNavigate} + /> + ) : ( + + )} + + ) +} + +function ConflictReferences({ + conflict, + items, +}: { + conflict: ReturnType + items: ContextEvidenceItem[] +}) { + if (!conflict) return null + const matched = items.filter((item) => + conflict.finding_fingerprints.includes(item.fingerprint), + ).length + return ( + <> + + + {conflict.finding_fingerprints.map((fingerprint) => ( + + {fingerprint} + + ))} + + + ) +} diff --git a/frontend/src/features/context-inspector/ContextKnowledgePanel.tsx b/frontend/src/features/context-inspector/ContextKnowledgePanel.tsx new file mode 100644 index 00000000..8fb95733 --- /dev/null +++ b/frontend/src/features/context-inspector/ContextKnowledgePanel.tsx @@ -0,0 +1,150 @@ +import { Alert, Button, Card, Descriptions, Empty, Flex, List, Space, Tag, Typography } from 'antd' +import type { KnowledgeNode } from './context-inspector-service' +import type { ContextEvidenceProps } from './ContextEvidenceBrowser' +import { evidenceReference } from './context-navigation' + +export default function ContextKnowledgePanel({ + detail, + selection, + onNavigate, +}: ContextEvidenceProps) { + const { nodes, edges } = detail.revision.knowledge_snapshot + const selected = nodes.find((node) => node.id === selection.knowledge) + const labels = new Map(nodes.map((node) => [node.id, node.label])) + const related = selected + ? edges.filter((edge) => edge.source === selected.id || edge.target === selected.id) + : edges + function selectNode(id: string) { + onNavigate({ knowledge: id }) + } + return ( + + + {nodes.length === 0 ? ( + + ) : ( + + size="small" + pagination={{ pageSize: 15, hideOnSinglePage: true }} + dataSource={nodes} + renderItem={(node) => ( + selectNode(node.id)} + > + 查看节点 + , + ]} + > +
+ {node.label} {node.kind} + + {node.facts.some( + (fact) => fact.name === 'requires_review' && fact.value === 'true', + ) + ? '需复核' + : '未声明需复核'} + +
+
+ )} + /> + )} +
+ {selection.knowledge && !selected ? ( + + ) : null} + {selected ? ( + + {selected.id} + ({ + key: String(index), + label: fact.name, + children: ( + + {fact.value} + + ), + }))} + /> + {selected.facts + .filter((fact) => fact.name === 'evidence_ref') + .map((fact, index) => { + const evidence = evidenceReference(fact.value, detail.evidence_items) + return evidence ? ( + + ) : ( + + ) + })} + + ) : null} + + + 当前关系 {related.length} / 总关系 {edges.length} + + {selected ? ( + + ) : null} + ( + + + {nodes.some((node) => node.id === edge.source) ? ( + + ) : ( + {edge.source}(节点未提供) + )} + → + {edge.relation} + → + {nodes.some((node) => node.id === edge.target) ? ( + + ) : ( + {edge.target}(节点未提供) + )} + + + )} + /> + +
+ ) +} diff --git a/frontend/src/features/context-inspector/context-inspector-service.ts b/frontend/src/features/context-inspector/context-inspector-service.ts index eecd0e14..697df84e 100644 --- a/frontend/src/features/context-inspector/context-inspector-service.ts +++ b/frontend/src/features/context-inspector/context-inspector-service.ts @@ -152,5 +152,12 @@ export async function listContexts( } export async function getContext(projectId: string, contextId: string): Promise { - return (await apiClient.get(`/projects/${projectId}/contexts/${contextId}`)).data + const detail = ( + await apiClient.get( + `/projects/${projectId}/contexts/${encodeURIComponent(contextId)}`, + ) + ).data + if (detail.project_id !== projectId || detail.id !== contextId) + throw new Error('上下文响应项目或ID不匹配') + return detail } diff --git a/frontend/src/features/context-inspector/context-navigation.ts b/frontend/src/features/context-inspector/context-navigation.ts new file mode 100644 index 00000000..4ea0058d --- /dev/null +++ b/frontend/src/features/context-inspector/context-navigation.ts @@ -0,0 +1,89 @@ +import type { + ContextConflict, + ContextEvidenceItem, + EvidenceProviderType, +} from './context-inspector-service' +export type ContextPanel = 'overview' | 'conflicts' | 'evidence' | 'knowledge' | 'proposals' +export type ContextNavigation = { + panel?: ContextPanel + evidence?: string | null + knowledge?: string | null + conflict?: string | null + providerType?: EvidenceProviderType | null + certainty?: string | null + warnings?: string | null + sourceEvidence?: string | null +} +export type ContextSelection = { + panel: ContextPanel + evidence: string | null + knowledge: string | null + conflict: string | null + providerType: string | null + certainty: string | null + warnings: string | null + sourceEvidence: string | null +} +const panels: readonly string[] = ['overview', 'conflicts', 'evidence', 'knowledge', 'proposals'] +export function readContextSelection(params: URLSearchParams): ContextSelection { + const panel = params.get('panel') + return { + panel: panels.includes(panel ?? '') ? (panel as ContextPanel) : 'overview', + evidence: params.get('evidence') || null, + knowledge: params.get('knowledge') || null, + conflict: params.get('conflict') || null, + providerType: params.get('providerType') || null, + certainty: params.get('certainty') || null, + warnings: params.get('warnings') || null, + sourceEvidence: params.get('sourceEvidence') || null, + } +} +export function writeContextNavigation( + current: URLSearchParams, + patch: ContextNavigation, +): URLSearchParams { + const next = new URLSearchParams(current) + for (const [key, value] of Object.entries(patch)) { + if (value === null) next.delete(key) + else if (value !== undefined) next.set(key, value) + } + return next +} +export function clearContextSelection(current: URLSearchParams): URLSearchParams { + const next = new URLSearchParams(current) + for (const key of [ + 'revision', + 'panel', + 'evidence', + 'knowledge', + 'conflict', + 'providerType', + 'certainty', + 'warnings', + 'sourceEvidence', + ]) + next.delete(key) + return next +} +export function evidenceReference( + ref: string, + items: ContextEvidenceItem[], +): ContextEvidenceItem | null { + const matches = items.filter((item) => + [item.id, item.fingerprint, `evidence://context/${item.fingerprint}`].includes(ref), + ) + return matches.length === 1 ? matches[0] : null +} +export function selectedConflict( + conflicts: ContextConflict[], + index: string | null, +): ContextConflict | null { + if (index === null || !/^(0|[1-9]\d*)$/.test(index)) return null + const value = Number(index) + return Number.isSafeInteger(value) ? (conflicts[value] ?? null) : null +} +export function contextPageNumber(raw: string | null): number { + if (!raw || !/^[1-9]\d*$/.test(raw)) return 1 + const value = Number(raw) + return Number.isSafeInteger(value) ? value : 1 +} diff --git a/frontend/src/features/context-inspector/context-presentation.ts b/frontend/src/features/context-inspector/context-presentation.ts new file mode 100644 index 00000000..9c6d620f --- /dev/null +++ b/frontend/src/features/context-inspector/context-presentation.ts @@ -0,0 +1,16 @@ +import type { EvidenceProviderType } from './context-inspector-service' +export function providerTypeLabel(value: EvidenceProviderType): string { + const labels: Record = { + repository: '仓库', + contract: '契约', + data_profile: '数据画像', + service_topology: '服务拓扑', + existing_test: '已有测试', + workflow: '工作流', + runtime: '运行时', + change: '变更', + user_confirmed_rule: '人工规则', + database: '数据库', + } + return labels[value] ?? value +} diff --git a/frontend/src/features/context-inspector/use-context-inspector.test.tsx b/frontend/src/features/context-inspector/use-context-inspector.test.tsx new file mode 100644 index 00000000..04209799 --- /dev/null +++ b/frontend/src/features/context-inspector/use-context-inspector.test.tsx @@ -0,0 +1,58 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { createMemoryRouter, RouterProvider, useParams } from 'react-router-dom' +import type { ReactNode } from 'react' +import { http, HttpResponse } from 'msw' +import { expect, it, vi } from 'vitest' +import { server } from '../../test/server' +import { useContextInspector } from './use-context-inspector' + +vi.mock('../projects/use-project-context', () => ({ + useProjectContext: () => ({ projectId: useParams().projectId ?? null }), +})) +it('ignores the earlier context response after navigation and pins only the requested context revision', async () => { + let release!: () => void, + started = false + const barrier = new Promise((resolve) => { + release = resolve + }) + const first = { id: 'first', project_id: 'project', revision_id: 'r1' }, + second = { id: 'second', project_id: 'project', revision_id: 'r2' } + server.use( + http.get('/api/v1/projects/project/contexts', () => + HttpResponse.json({ items: [first, second], total: 2, page: 1, page_size: 20 }), + ), + http.get('/api/v1/projects/project/contexts/:id', async ({ params }) => { + if (params.id === 'first') { + started = true + await barrier + return HttpResponse.json(first) + } + return HttpResponse.json(second) + }), + ) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + let router!: ReturnType + function Wrapper({ children }: { children: ReactNode }) { + router ??= createMemoryRouter( + [ + { + path: '/projects/:projectId/*', + element: {children}, + }, + ], + { initialEntries: ['/projects/project/contexts'] }, + ) + return + } + const { result } = renderHook(() => useContextInspector(), { wrapper: Wrapper }) + await waitFor(() => expect(started).toBe(true)) + await act(async () => { + await router.navigate('/projects/project/contexts?focus=second&revision=r2') + release() + }) + await waitFor(() => expect(result.current.detail.data?.id).toBe('second')) + expect(result.current.activeId).toBe('second') + expect(new URLSearchParams(router.state.location.search).get('focus')).toBe('second') + expect(new URLSearchParams(router.state.location.search).get('revision')).toBe('r2') +}) diff --git a/frontend/src/features/context-inspector/use-context-inspector.ts b/frontend/src/features/context-inspector/use-context-inspector.ts index 90c52ee8..069ffc21 100644 --- a/frontend/src/features/context-inspector/use-context-inspector.ts +++ b/frontend/src/features/context-inspector/use-context-inspector.ts @@ -1,25 +1,105 @@ import { useQuery } from '@tanstack/react-query' - -import { useRouteScopedSelection } from '../../lib/use-route-scoped-state' +import { useContext, useEffect } from 'react' +import { UNSAFE_DataRouterContext, useLocation, useSearchParams } from 'react-router-dom' import { useProjectContext } from '../projects/use-project-context' import { getContext } from './context-inspector-service' import { useContextPage } from './use-context-page' +import { + clearContextSelection, + contextPageNumber, + readContextSelection, + writeContextNavigation, + type ContextNavigation, +} from './context-navigation' export function useContextInspector(initialContextId?: string) { - const { projectId } = useProjectContext() - const [selectedId, setSelectedId] = useRouteScopedSelection(projectId, initialContextId ?? null) - const { contexts, page, setPage } = useContextPage(projectId) - const items = contexts.data?.items ?? [] - const activeId = selectedId ?? items.at(0)?.id ?? null + const { projectId } = useProjectContext(), + location = useLocation(), + router = useContext(UNSAFE_DataRouterContext) + const [params, setParams] = useSearchParams() + const page = contextPageNumber(params.get('contextPage')) + const { contexts } = useContextPage(projectId, true, page) + const activeId = activeContextId(params, initialContextId, contexts.data?.items) const detail = useQuery({ queryKey: ['context-inspector-detail', projectId, activeId], queryFn: () => getContext(required(projectId), required(activeId)), enabled: Boolean(projectId && activeId), }) - return { projectId, contexts, detail, activeId, select: setSelectedId, page, setPage } + const expectedRevision = params.get('revision') + const revisionMismatch = Boolean( + detail.isSuccess && expectedRevision && detail.data.revision_id !== expectedRevision, + ) + const selection = readContextSelection(params) + const catalogCurrent = !contexts.isPlaceholderData && contexts.data?.page === page + useEffect(() => { + if (!detail.isSuccess || expectedRevision || !activeId) return + if (!params.has('focus') && !catalogCurrent) return + if (router && router.router.state.location.key !== location.key) return + const next = new URLSearchParams(params) + next.set('focus', activeId) + next.set('revision', detail.data.revision_id) + setParams(next, { replace: true }) + }, [ + detail.isSuccess, + detail.data, + expectedRevision, + activeId, + catalogCurrent, + router, + location.key, + params, + setParams, + ]) + function select(id: string) { + const next = clearContextSelection(params) + next.set('focus', id) + const summary = contexts.data?.items.find((item) => item.id === id) + if (summary) next.set('revision', summary.revision_id) + setParams(next) + } + function navigate(patch: ContextNavigation) { + if (!detail.isSuccess || revisionMismatch) return + const next = writeContextNavigation(params, patch) + next.set('focus', detail.data.id) + next.set('revision', detail.data.revision_id) + setParams(next) + } + function showCurrentRevision() { + if (!detail.isSuccess) return + const next = clearContextSelection(params) + next.set('focus', detail.data.id) + next.set('revision', detail.data.revision_id) + setParams(next) + } + function setPage(value: number) { + const next = new URLSearchParams(params) + next.set('contextPage', String(value)) + setParams(next) + } + return { + projectId, + contexts, + detail, + activeId, + select, + page, + setPage, + selection, + navigate, + expectedRevision, + revisionMismatch, + showCurrentRevision, + } } - function required(value: string | null): string { if (!value) throw new Error('请选择项目') return value } + +function activeContextId( + params: URLSearchParams, + initialId: string | undefined, + items?: { id: string }[], +): string | null { + return params.get('focus') ?? initialId ?? items?.[0]?.id ?? null +} diff --git a/frontend/src/features/context-inspector/use-context-page.ts b/frontend/src/features/context-inspector/use-context-page.ts index 458e7b3a..5662b087 100644 --- a/frontend/src/features/context-inspector/use-context-page.ts +++ b/frontend/src/features/context-inspector/use-context-page.ts @@ -3,9 +3,9 @@ import { useState } from 'react' import { listContexts } from './context-inspector-service' -export function useContextPage(projectId: string | null, enabled = true) { +export function useContextPage(projectId: string | null, enabled = true, controlledPage?: number) { const [selection, setSelection] = useState({ projectId, page: 1 }) - const page = selection.projectId === projectId ? selection.page : 1 + const page = controlledPage ?? (selection.projectId === projectId ? selection.page : 1) const contexts = useQuery({ queryKey: ['context-page', projectId, page], queryFn: () => { 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/features/mcp/MCPGovernanceSummary.tsx b/frontend/src/features/mcp/MCPGovernanceSummary.tsx new file mode 100644 index 00000000..4c7ace3c --- /dev/null +++ b/frontend/src/features/mcp/MCPGovernanceSummary.tsx @@ -0,0 +1,117 @@ +import { Alert, Button, Collapse, Descriptions, Space, Typography } from 'antd' +import { useState } from 'react' + +import { confidenceLabel } from '../test-engineering/design-confidence' +import type { MCPChangeSetEnvelope } from './mcp-change-set-service' + +export default function MCPGovernanceSummary({ envelope }: { envelope: MCPChangeSetEnvelope }) { + const data = envelope.data + const governance = data.governance + return ( + + + {governance.reason_codes.map((reason, index) => ( +
  • + {reasonLabel(reason)}({reason}) +
  • + ))} + + ) : ( + '未提供额外原因' + ), + }, + { label: '来源引用', children: data.source_ref ?? '未提供' }, + { + label: '来源指纹', + children: {data.source_fingerprint}, + }, + ]} + /> + {data.approval && ( + + )} + + + 警告供人工核对;硬性审批、权限与修订冲突仍由服务端拒绝。 + + {envelope.warnings.length ? ( + envelope.warnings.map((warning, index) => ( + + )) + ) : ( + 本次读取没有附加警告。 + )} + +
    + ), + }, + ]} + /> + + ) +} + +function TraceIdentity({ traceId }: { traceId: string }) { + const [copied, setCopied] = useState<{ traceId: string; status: string } | null>(null) + const status = copied?.traceId === traceId ? copied.status : null + async function copy() { + try { + await navigator.clipboard.writeText(traceId) + setCopied({ traceId, status: 'Trace ID 已复制' }) + } catch { + setCopied({ traceId, status: 'Trace ID 复制失败,请手动复制' }) + } + } + return ( + + + Trace ID:{traceId || '未提供'} + + + {status && {status}} + + ) +} +function reasonLabel(value: string): string { + if (value === 'low_confidence_assertion_review') return '低置信度预期需要人工审核' + if (value === 'manual_approval_required') return '高风险变更需要人工审批' + return '未映射原因,保留服务端代码供核对' +} + +function booleanLabel(value: unknown): string { + return typeof value === 'boolean' ? (value ? '是' : '否') : '未提供' +} diff --git a/frontend/src/features/mcp/MCPMaterializedResult.tsx b/frontend/src/features/mcp/MCPMaterializedResult.tsx new file mode 100644 index 00000000..d00af836 --- /dev/null +++ b/frontend/src/features/mcp/MCPMaterializedResult.tsx @@ -0,0 +1,136 @@ +import { useQuery } from '@tanstack/react-query' +import { Alert, Button, Collapse, Descriptions, Drawer, Typography } from 'antd' +import { useState } from 'react' +import { Link } from 'react-router-dom' + +import QueryFailureNotice from '../../components/QueryFailureNotice' +import ResponseCodeReader from '../api-console/ResponseCodeReader' +import { DesignPreview } from './MCPStructuredPreview' +import { getMCPMaterializedDesign, type MCPChangeItem } from './mcp-change-set-service' + +export default function MCPMaterializedResult({ + item, + projectId, + changeSetId, +}: { + item: MCPChangeItem + projectId: string + changeSetId: string +}) { + if (item.review_status !== 'accepted') return null + const id = item.materialized_resource_id + if (!id) return + if (item.item_type === 'test_design' && item.materialized_resource_type === 'test_design') + return ( + + ) + const result = resultPath(item, projectId, id) + if (!result) return + return ( + + {result.label} + {id} + + ) +} + +function resultPath( + item: MCPChangeItem, + projectId: string, + id: string, +): { path: string; label: string } | null { + const encoded = encodeURIComponent(id) + if (item.item_type === 'test_case' && item.materialized_resource_type === 'test_case') + return { + path: `/projects/${projectId}/assets?type=case&focus=${encoded}&version=draft`, + label: '查看物化测试用例', + } + if (item.item_type === 'test_plan_update' && item.materialized_resource_type === 'test_plan') + return { path: `/projects/${projectId}/tasks?focus=${encoded}`, label: '查看更新后的计划' } + if (item.item_type === 'workflow' && item.materialized_resource_type === 'workflow') + return { + path: `/projects/${projectId}/workflows?focus=${encoded}`, + label: item.action === 'create' ? '查看已创建草稿' : '查看物化工作流', + } + return null +} + +function MaterializedDesign({ + itemId, + designId, + projectId, + changeSetId, +}: { + itemId: string + designId: string + projectId: string + changeSetId: string +}) { + const [open, setOpen] = useState(false) + const query = useQuery({ + queryKey: ['mcp-materialized-design', projectId, changeSetId, itemId, designId], + queryFn: async () => { + const result = await getMCPMaterializedDesign(changeSetId, itemId) + if ( + result.id !== designId || + result.project_id !== projectId || + result.source_change_set_id !== changeSetId + ) + throw new Error('物化测试设计不属于当前变更集或项目,请核对来源。') + return result + }, + enabled: open, + }) + return ( + <> + + {designId} + setOpen(false)} + size="min(1080px, 100vw)" + > + + + {query.isPending && 正在读取已保存设计} + {query.isError && query.data && ( + + )} + {query.data && ( + <> + {query.data.fingerprint}, + }, + { label: '审核人 ID', children: query.data.reviewed_by_id ?? '未提供' }, + { label: '审核时间', children: query.data.reviewed_at ?? '未提供' }, + ]} + /> + + , + }, + ]} + /> + + )} + + + ) +} diff --git a/frontend/src/features/mcp/MCPStructuredPreview.tsx b/frontend/src/features/mcp/MCPStructuredPreview.tsx new file mode 100644 index 00000000..864b9ba2 --- /dev/null +++ b/frontend/src/features/mcp/MCPStructuredPreview.tsx @@ -0,0 +1,348 @@ +import { Alert, Collapse, Descriptions, Space, Table, Typography } from 'antd' +import { Link } from 'react-router-dom' + +import ResponseCodeReader from '../api-console/ResponseCodeReader' +import { confidenceLabel } from '../test-engineering/design-confidence' +import type { MCPChangeItem } from './mcp-change-set-service' + +type Content = Record + +export default function MCPStructuredPreview({ + item, + projectId, +}: { + item: MCPChangeItem + projectId: string +}) { + if (item.item_type === 'test_design') return + if (item.item_type === 'test_case') + return + if (item.item_type === 'test_plan_update') + return + return null +} + +export function DesignPreview({ content }: { content: Content }) { + const intent = record(content.intent) + return ( + + 测试设计预览 + , + }, + { + label: '审核要求', + children: , + }, + ]} + /> + + + + + + + ), + }, + ]} + /> + + ) +} + +function CasePreview({ content, projectId }: { content: Content; projectId: string }) { + const definition = record(content.definition) + return ( + + 测试用例预览 + + ), + }, + { + label: '工作流版本', + children: + definition.workflow_version === null + ? '未固定,发布时需确认' + : display(definition.workflow_version), + }, + { label: '环境', children: display(definition.environment_id) }, + ]} + /> + + 断言沿用所引用工作流;此用例提案没有独立断言定义。 + + + ), + }, + ]} + /> + + ) +} + +function PlanPreview({ content, projectId }: { content: Content; projectId: string }) { + const targets = contentRows(content.targets) + const actions = contentRows(content.target_actions) + return ( + + 测试计划更新预览 + + {content.test_plan_id} + + ) : ( + '未提供' + ), + }, + { label: '变更依据', children: display(content.rationale) }, + { + label: '未发布依赖', + children: , + }, + ]} + /> + + {actions !== null && !actions.length && ( + + )} + {actions === null && ( + + )} + {targets === null ? ( + 目标列表未提供或结构无效,请核对完整提案。 + ) : ( +
    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/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/AIAssistantPage.test.tsx b/frontend/src/pages/AIAssistantPage.test.tsx index 43674580..de248e11 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' @@ -67,17 +67,32 @@ describe('AIAssistantPage', () => { return HttpResponse.json({}) }), ) - renderPage('viewer') - expect(await screen.findByText(suggestion.title)).toBeVisible() - expect(screen.getByRole('button', { name: /新建 AI 任务/ })).toBeDisabled() - expect(screen.getByRole('switch', { name: '允许提交脱敏样本' })).toBeDisabled() + const page = renderPage('viewer') + const browser = userEvent.setup() + const title = await screen.findByText(suggestion.title) + expect(title).toBeVisible() + const actions = within(title.closest('tr')!) + const header = within(page.container.querySelector('.page-heading')!) + expect(header.getByRole('button', { name: /新建 AI 任务/ })).toBeDisabled() + const sharing = screen.getByLabelText('允许 Owner 提交样本') + expect(sharing).toHaveRole('switch') + expect(sharing).toBeVisible() + expect(sharing).toHaveAccessibleName('允许 Owner 提交样本') + expect(sharing.closest('[aria-hidden="true"]')).toBeNull() + expect(sharing).toBeDisabled() for (const name of [/接受/, /拒绝/]) { - const button = screen.getByRole('button', { name }) + const button = actions.getByRole('button', { name }) expect(button).toBeDisabled() - await userEvent.click(button) + await browser.click(button) } expect(writes).toBe(0) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await browser.click(actions.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 +163,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 () => { @@ -187,21 +202,31 @@ describe('AIAssistantPage', () => { renderPage() const browser = userEvent.setup() - expect(await screen.findByText('校验成功状态码')).toBeVisible() + const title = await screen.findByText('校验成功状态码') + expect(title).toBeVisible() + const actions = within(title.closest('tr')!) expect(screen.getByText('模型:flowtest-eval-model')).toBeVisible() - await browser.click(screen.getByRole('switch', { name: '允许提交脱敏样本' })) + const sharing = screen.getByLabelText('允许 Owner 提交样本') + expect(sharing).toHaveRole('switch') + expect(sharing).toBeVisible() + expect(sharing).toHaveAccessibleName('允许 Owner 提交样本') + expect(sharing.closest('[aria-hidden="true"]')).toBeNull() + await browser.click(sharing) await waitFor(() => expect(sharingEnabled).toBe(true)) - await browser.click(screen.getByRole('button', { name: /接受/ })) - expect(screen.getByRole('dialog', { name: '接受并生成草稿' })).toBeInTheDocument() - const content = screen.getByLabelText('建议内容') + await browser.click(actions.getByRole('button', { name: /接受/ })) + const dialog = screen.getByRole('dialog', { name: '接受建议' }) + expect(dialog).toBeInTheDocument() + await browser.click(within(dialog).getByRole('tab', { name: '高级 JSON' })) + const content = within(dialog).getByLabelText('建议内容') fireEvent.change(content, { target: { value: JSON.stringify({ expression: 'status_code', operator: 'equals', expected: 201 }), }, }) - await browser.type(screen.getByLabelText('审核备注'), '人工确认') - await browser.click(screen.getByRole('button', { name: 'OK' })) + await browser.click(within(dialog).getByLabelText('审核备注')) + await browser.paste('人工确认') + await browser.click(within(dialog).getByRole('button', { name: '确认接受' })) await waitFor(() => expect(reviewed).toEqual({ content: { expression: 'status_code', operator: 'equals', expected: 201 }, @@ -213,7 +238,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 +248,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' }) }), @@ -240,18 +265,32 @@ describe('AIAssistantPage', () => { renderPage() const browser = userEvent.setup() - expect(await screen.findByText('校验成功状态码')).toBeVisible() - expect(screen.getByText('failed')).toBeVisible() - await browser.click(screen.getByRole('button', { name: /拒绝/ })) - expect(screen.getByRole('dialog', { name: '拒绝建议' })).toBeInTheDocument() - await browser.type(screen.getByLabelText('审核备注'), '人工拒绝') - await browser.click(screen.getByRole('button', { name: 'OK' })) + const firstTitle = await screen.findByText('校验成功状态码') + expect(firstTitle).toBeVisible() + expect(screen.getByText('completed')).toBeVisible() + await browser.click(within(firstTitle.closest('tr')!).getByRole('button', { name: /拒绝/ })) + const rejectionDialog = screen.getByRole('dialog', { name: '拒绝建议' }) + expect(rejectionDialog).toBeInTheDocument() + await browser.click(within(rejectionDialog).getByLabelText('审核备注')) + await browser.paste('人工拒绝') + await browser.click(within(rejectionDialog).getByRole('button', { name: '确认拒绝' })) await waitFor(() => expect(rejected).toEqual({ note: '人工拒绝' })) - await browser.click(screen.getByRole('button', { name: /接受/ })) - fireEvent.change(screen.getByLabelText('建议内容'), { target: { value: '[]' } }) - await browser.click(screen.getByRole('button', { name: 'OK' })) - expect(await screen.findByText('建议内容必须是 JSON 对象')).toBeInTheDocument() + const reviewedDialog = await screen.findByRole('dialog', { name: '已审核建议' }) + await browser.click(within(reviewedDialog).getByRole('button', { name: /关\s*闭/ })) + await waitFor(() => expect(reviewedDialog).not.toBeInTheDocument()) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + const secondTitle = screen.getByText('另一条状态码建议') + await browser.click(within(secondTitle.closest('tr')!).getByRole('button', { name: /接受/ })) + const acceptanceDialog = screen.getByRole('dialog', { name: '接受建议' }) + await browser.click(within(acceptanceDialog).getByRole('tab', { name: '高级 JSON' })) + fireEvent.change(within(acceptanceDialog).getByLabelText('建议内容'), { + target: { value: '[]' }, + }) + await browser.click(within(acceptanceDialog).getByRole('button', { name: '确认接受' })) + expect( + await within(acceptanceDialog).findByText('建议内容必须是 JSON 对象'), + ).toBeInTheDocument() expect(acceptRequests).toBe(0) }) @@ -282,10 +321,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 +361,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..955ad6ba 100644 --- a/frontend/src/pages/AIAssistantPage.tsx +++ b/frontend/src/pages/AIAssistantPage.tsx @@ -2,81 +2,109 @@ 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 { Link } from 'react-router-dom' +import { projectPath } from '../features/projects/project-routing' +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 +120,12 @@ function AIErrorAlert({ state }: { state: AIReviewState }) { type="error" showIcon title="AI 工作区加载失败" - description={apiErrorMessage(error)} + description={apiErrorWithTrace(error)} action={ @@ -105,89 +133,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 +183,104 @@ 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 +288,67 @@ 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, - }) - } - > - - ({ + showSearch + optionFilterProp="label" + virtual={false} + options={options.map((item) => ({ value: item.id, label: `${item.title} · 风险 ${item.score}`, }))} /> +
    `${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' 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', 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..fe52c5f3 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', 'workflow'] + 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) => ( diff --git a/frontend/src/pages/MCPWorkflowResultNavigation.test.tsx b/frontend/src/pages/MCPWorkflowResultNavigation.test.tsx new file mode 100644 index 00000000..c104a5ba --- /dev/null +++ b/frontend/src/pages/MCPWorkflowResultNavigation.test.tsx @@ -0,0 +1,274 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { 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 { Link, useLocation, useSearchParams } from 'react-router-dom' +import { afterEach, expect, it } from 'vitest' + +import { useAuthStore } from '../features/auth/auth-store' +import { DraftSessionProvider } from '../features/drafts/DraftSessionProvider' +import type { MCPChangeSet } from '../features/mcp/mcp-change-set-service' +import { useProjectContext } from '../features/projects/use-project-context' +import { useWorkflows } from '../features/workflows/use-workflows' +import type { WorkflowDefinition } from '../lib/api' +import { authenticateTestUser } from '../test/auth' +import { project, user, workflow } from '../test/fixtures' +import ProjectTestProvider from '../test/ProjectTestProvider' +import { server } from '../test/server' +import MCPChangeSetsPage from './MCPChangeSetsPage' + +const foreignProject = { ...project, id: 'other-project', name: '另一个项目' } +const foreignWorkflow = { + ...workflow, + id: 'other-workflow', + project_id: foreignProject.id, + name: '另一个项目的草稿', + draft_revision: 17, +} +const changeSetId = 'workflow-change-set' +const itemId = 'workflow-change-item' +const controlNode: WorkflowDefinition['nodes'][number] = { + id: 'group', + type: 'capability', + name: '物化后的节点', + position: { x: 150, y: 0 }, + config: {}, + capability_id: 'flow.control.group', + capability_version: '1.0.0', + configuration: { body: { kind: 'inline', region_id: 'body' } }, + bindings: [], +} +const controlRegions: NonNullable = [ + { + id: 'body', + owner_node_id: controlNode.id, + role: 'body', + nodes: [ + { + id: 'wait', + type: 'delay', + name: '等待一秒', + position: { x: 0, y: 0 }, + config: { seconds: 1 }, + }, + ], + edges: [], + entry_node_id: 'wait', + exit_node_ids: ['wait'], + inputs: {}, + outputs: {}, + }, +] + +afterEach(() => { + localStorage.clear() + useAuthStore.setState({ user: null }) +}) + +it('opens the newly materialized off-page workflow instead of its fresh cached draft and preserves other projects', async () => { + authenticateTestUser(user) + let current = workflow + let writes = 0 + const reads: string[] = [] + let proposal = workflowProposal() + installWorkflowReads() + server.use( + http.get('/api/v1/projects', () => + HttpResponse.json({ items: [project, foreignProject], total: 2, page: 1, page_size: 100 }), + ), + http.get(`/api/v1/projects/${foreignProject.id}`, () => HttpResponse.json(foreignProject)), + http.get('/api/v1/projects/:projectId/workflows', ({ params }) => + HttpResponse.json({ + items: Array.from({ length: 100 }, (_, index) => ({ + ...workflow, + project_id: params.projectId, + id: `listed-workflow-${index}`, + })), + total: 101, + page: 1, + page_size: 100, + }), + ), + http.get('/api/v1/projects/:projectId/workflows/:workflowId', ({ params }) => { + reads.push(String(params.projectId)) + return HttpResponse.json(params.projectId === foreignProject.id ? foreignWorkflow : current) + }), + http.get(`/api/v1/mcp/write/change-sets/${changeSetId}`, () => + HttpResponse.json({ data: proposal, warnings: [], trace_id: 'workflow-cache-trace' }), + ), + http.post(`/api/v1/mcp/write/change-sets/${changeSetId}/items/${itemId}/accept`, () => { + writes += 1 + current = { + ...workflow, + draft_revision: 2, + draft_definition: { + ...workflow.draft_definition, + schema_version: '4.0', + nodes: [...workflow.draft_definition.nodes, controlNode], + edges: [ + ...workflow.draft_definition.edges.map((edge) => + edge.id === 'api-end' ? { ...edge, target: controlNode.id } : edge, + ), + { + id: 'group-end', + source: controlNode.id, + target: 'end', + condition: null, + mappings: [], + }, + ], + regions: controlRegions, + run_policy: { + request_budget: 100, + max_runtime_seconds: null, + cleanup_request_budget: null, + force_cancel_skips_cleanup: false, + }, + }, + } + proposal = { + ...proposal, + status: 'accepted', + items: [ + { + ...proposal.items[0], + review_status: 'accepted', + materialized_resource_type: 'workflow', + materialized_resource_id: workflow.id, + }, + ], + } + return HttpResponse.json({ data: proposal, warnings: [], trace_id: 'accepted-cache-trace' }) + }), + ) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + // Keep exact drafts fresh so expiry cannot conceal a missing invalidation. + client.setQueryDefaults(['workflow'], { staleTime: Infinity }) + client.setQueryData(['workflow', project.id, workflow.id], workflow) + client.setQueryData(['workflow', foreignProject.id, foreignWorkflow.id], foreignWorkflow) + render( + + + + + + + + + + + , + ) + const browser = userEvent.setup() + await browser.click(await screen.findByRole('button', { name: '接受并物化' })) + await browser.click(await screen.findByRole('link', { name: '查看物化工作流' })) + const reader = await screen.findByRole('region', { name: '实际工作流草稿' }) + expect(await within(reader).findByText('物化后的节点')).toBeVisible() + expect(within(reader).getByLabelText('草稿修订')).toHaveTextContent('2') + expect(writes).toBe(1) + expect(reads).toEqual([project.id]) + + await browser.click(screen.getByRole('link', { name: '查看另一项目草稿' })) + await waitFor(() => expect(screen.getByLabelText('草稿修订')).toHaveTextContent('17')) + expect(screen.getByLabelText('工作流名称')).toHaveTextContent(foreignWorkflow.name) + expect(reads).toEqual([project.id]) +}) + +function ResultRoute() { + const location = useLocation() + const { projectId } = useProjectContext() + return location.pathname.endsWith('/workflows') ? ( + + ) : ( + + ) +} + +function WorkflowDraftReader() { + const [params] = useSearchParams() + const state = useWorkflows(params.get('focus') ?? undefined) + return ( +
    + {state.selectedWorkflow?.name} + {state.selectedWorkflow?.draft_revision} + {state.draftDefinition.nodes.map((node) => ( +

    {node.name}

    + ))} + + 查看另一项目草稿 + +
    + ) +} + +function installWorkflowReads() { + for (const path of [ + '/api/v1/projects/:projectId/apis', + '/api/v1/projects/:projectId/files', + '/api/v1/projects/:projectId/workflow-executions', + '/api/v1/graphql/schemas', + '/api/v1/grpc/descriptors', + '/api/v1/event-sources', + ]) + server.use( + http.get(path, () => HttpResponse.json({ items: [], total: 0, page: 1, page_size: 100 })), + ) + server.use( + http.get('/api/v1/projects/:projectId/environments', () => HttpResponse.json([])), + http.get('/api/v1/credentials', () => HttpResponse.json([])), + ) +} + +function workflowProposal(): MCPChangeSet { + return { + id: changeSetId, + project_id: project.id, + title: '更新页外工作流', + workflow_id: workflow.id, + base_revision: 1, + status: 'draft', + source_type: 'mcp', + source_ref: 'mcp://proposal/workflow-cache', + actor_type: 'service_account', + source_fingerprint: 'a'.repeat(64), + created_by_id: user.id, + created_at: workflow.created_at, + updated_at: workflow.updated_at, + applied_at: null, + governance: { + confidence: 0.9, + risk_level: 'medium', + requires_review: true, + manual_approval_required: false, + reason_codes: [], + }, + approval: null, + items: [ + { + id: itemId, + position: 0, + item_type: 'workflow', + action: 'update', + title: '插入控制块', + target_resource_id: workflow.id, + proposed_content: { + expected_revision: 1, + edge_id: 'api-end', + node: controlNode, + regions: controlRegions, + request_budget: 100, + }, + review_status: 'pending', + review_note: '', + reviewed_by_id: null, + reviewed_at: null, + materialized_resource_type: null, + materialized_resource_id: null, + }, + ], + } +} 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..5b6dd028 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,569 @@ 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(['admin', 'owner'] as const)( + 'refreshes the current %s role and permission controls after self-demotion', + async (initialRole) => { + authenticateTestUser({ ...user, is_system_admin: false }) + let currentRole: 'admin' | 'owner' | 'viewer' = initialRole + let directoryReads = 0 + const submissions: unknown[] = [] + server.use( + http.get('/api/v1/organizations', () => { + directoryReads += 1 + return HttpResponse.json([{ ...organization, role: currentRole, member_count: 2 }]) + }), + http.get(`/api/v1/organizations/${organizationId}/members`, () => + HttpResponse.json([ + { ...member, role: currentRole }, + { ...member, id: 'other-owner', user_id: organizationId, role: 'owner' }, + ]), + ), + http.put( + `/api/v1/organizations/${organizationId}/members/${user.id}`, + async ({ request }) => { + submissions.push(await request.json()) + currentRole = 'viewer' + return HttpResponse.json({ ...member, role: currentRole }) + }, + ), + ) + renderPage() + const browser = userEvent.setup() + await screen.findByText('当前角色') + const organizationInfo = within(screen.getByText('当前角色').closest('table') as HTMLElement) + expect(organizationInfo.getByText(initialRole)).toBeVisible() + await browser.click(screen.getByRole('tab', { name: /审计与安全/ })) + await screen.findByText('密钥轮换能力已确认') + if (initialRole === 'owner') + expect(screen.getByRole('button', { name: '创建轮换版本' })).toBeVisible() + await browser.click(screen.getByRole('tab', { name: /组织与角色/ })) + await browser.click(screen.getByRole('combobox', { name: `更新 ${user.id} 角色` })) + await browser.click( + screen.getByText('只读成员 Viewer', { selector: '.ant-select-item-option-content' }), + ) + + await waitFor(() => + expect( + within(screen.getByText('当前角色').closest('table') as HTMLElement).getByText('viewer'), + ).toBeVisible(), + ) + expect(directoryReads).toBe(2) + expect(submissions).toEqual([{ user_id: user.id, role: 'viewer' }]) + expect(screen.getByRole('combobox', { name: `更新 ${user.id} 角色` })).toBeDisabled() + expect(screen.queryByRole('button', { name: '添加 / 更新' })).not.toBeInTheDocument() + await browser.click(screen.getByRole('tab', { name: 'Service Account' })) + const accounts = within(screen.getByRole('tabpanel')) + expect(accounts.queryByRole('button', { name: '签发令牌' })).not.toBeInTheDocument() + expect(accounts.queryByRole('button', { name: '轮换' })).not.toBeInTheDocument() + expect(accounts.queryByRole('button', { name: '撤销' })).not.toBeInTheDocument() + await browser.click(screen.getByRole('tab', { name: '配额与 Runner' })) + expect(screen.queryByRole('button', { name: '保存治理策略' })).not.toBeInTheDocument() + await browser.click(screen.getByRole('tab', { name: /审计与安全/ })) + expect(screen.queryByRole('button', { name: '创建轮换版本' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Rollback' })).not.toBeInTheDocument() + expect(submissions).toHaveLength(1) + }, + ) + + it('keeps a self-demotion pending until the organization directory finishes refreshing', async () => { + authenticateTestUser({ ...user, is_system_admin: false }) + let writes = 0 + let releaseDirectory: (() => void) | undefined + server.use( + http.get('/api/v1/organizations', async () => { + if (writes) { + await new Promise((resolve) => { + releaseDirectory = resolve + }) + } + return HttpResponse.json([{ ...organization, role: writes ? 'viewer' : 'admin' }]) + }), + http.get(`/api/v1/organizations/${organizationId}/members`, () => + HttpResponse.json([{ ...member, role: writes ? 'viewer' : 'admin' }]), + ), + http.put(`/api/v1/organizations/${organizationId}/members/${user.id}`, () => { + writes += 1 + return HttpResponse.json({ ...member, role: 'viewer' }) + }), + ) + renderPage() + const browser = userEvent.setup() + await browser.click(await screen.findByRole('combobox', { name: `更新 ${user.id} 角色` })) + await browser.click( + screen.getByText('只读成员 Viewer', { selector: '.ant-select-item-option-content' }), + ) + try { + await waitFor(() => expect(releaseDirectory).toBeTypeOf('function')) + const selfRole = await screen.findByRole('combobox', { name: `更新 ${user.id} 角色` }) + expect(selfRole).toBeDisabled() + const submit = screen.getByRole('button', { name: '添加 / 更新' }) + expect(submit).toBeDisabled() + await browser.click(submit) + expect(writes).toBe(1) + expect(screen.queryByText('组织角色已更新')).not.toBeInTheDocument() + } finally { + await act(async () => releaseDirectory?.()) + } + await waitFor(() => + expect( + within(screen.getByText('当前角色').closest('table') as HTMLElement).getByText('viewer'), + ).toBeVisible(), + ) + expect(screen.queryByRole('button', { name: '添加 / 更新' })).not.toBeInTheDocument() + expect(writes).toBe(1) + }) + + 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 browser = userEvent.setup() + 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('组织与角色') + const auditTab = screen.getByText('审计与安全').closest('[role="tab"]')! + expect(auditTab).toHaveRole('tab') + expect(auditTab).toHaveAccessibleName(/审计与安全/) + expect(auditTab).toBeVisible() + expect(auditTab.closest('[aria-hidden="true"]')).toBeNull() + await browser.click(auditTab) + expect(await screen.findByText('共 61 条')).toBeVisible() + const audit = within(screen.getByText('组织审计查询').closest('.ant-card')!) + await browser.click(audit.getByTitle('6')) + const resource = await audit.findByText('audit-resource-6') + expect(resource).toBeVisible() + expect(requests.at(-1)?.searchParams.get('page')).toBe('6') + const view = within(resource.closest('tr')!).getByLabelText('查看审计详情', { + selector: 'button', + }) + expect(view).toHaveRole('button') + expect(view).toHaveAccessibleName('查看审计详情') + expect(view).toBeVisible() + expect(view.closest('[aria-hidden="true"]')).toBeNull() + await browser.click(view) + const details = await screen.findByRole('dialog') + expect(details).toHaveTextContent('audit-trace-6') + expect(details).toHaveTextContent('仅服务端已授权详情') + await browser.click(within(details).getByRole('button', { name: '关闭详情' })) + for (const [label, value] of [ + ['审计动作', 'organization.updated'], + ['操作者 ID', user.id], + ['资源类型', 'organization'], + ['资源 ID', organizationId], + ] as const) { + await browser.click(audit.getByLabelText(label)) + await browser.paste(value) + } + fireEvent.change(audit.getByLabelText('开始时间'), { target: { value: '2026-10-01T12:00' } }) + fireEvent.change(audit.getByLabelText('结束时间'), { target: { value: '2026-10-21T12:00' } }) + await browser.click(audit.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 browser = userEvent.setup() + 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('组织与角色') + const auditTab = screen.getByText('审计与安全').closest('[role="tab"]')! + expect(auditTab).toHaveRole('tab') + expect(auditTab).toHaveAccessibleName(/审计与安全/) + expect(auditTab).toBeVisible() + expect(auditTab.closest('[aria-hidden="true"]')).toBeNull() + await browser.click(auditTab) + await screen.findByText('共 61 条') + const audit = within(screen.getByText('组织审计查询').closest('.ant-card')!) + await browser.click(audit.getByTitle('6')) + const resource = await audit.findByText('audit-resource-6') + expect(resource).toBeVisible() + const view = within(resource.closest('tr')!).getByLabelText('查看审计详情', { + selector: 'button', + }) + expect(view).toHaveRole('button') + expect(view).toHaveAccessibleName('查看审计详情') + expect(view).toBeVisible() + expect(view.closest('[aria-hidden="true"]')).toBeNull() + await browser.click(view) + const details = await screen.findByRole('dialog') + expect(details).toHaveTextContent('audit-resource-6') + await browser.click(within(details).getByRole('button', { name: '关闭详情' })) + await browser.click(screen.getByRole('combobox', { name: '治理组织' })) + await browser.click( + await screen.findByText('其他组织 · other-org', { + selector: '.ant-select-item-option-content', + }), + ) + await screen.findByText('无权读取组织成员') + const otherAuditTab = screen.getByText('审计与安全').closest('[role="tab"]')! + expect(otherAuditTab).toHaveRole('tab') + expect(otherAuditTab).toHaveAccessibleName(/审计与安全/) + expect(otherAuditTab).toBeVisible() + expect(otherAuditTab.closest('[aria-hidden="true"]')).toBeNull() + await browser.click(otherAuditTab) + 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(), + ) + }) + + 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) { + 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..1adfce47 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), @@ -472,10 +502,12 @@ function useOrganizationWorkspace({ mutationFn: ({ userId, role }: { userId: string; role: OrganizationRole }) => upsertOrganizationMember(organization.id, userId, role), onSuccess: async () => { - await invalidate('organization-members') + await Promise.all([ + invalidate('organization-members'), + queryClient.invalidateQueries({ queryKey: ['organizations'], exact: true }), + ]) onMessage('success', '组织角色已更新') }, - onError: () => onMessage('error', '组织角色更新失败'), }) const accountMutation = useMutation({ mutationFn: (input: ServiceAccountFormValues) => createServiceAccount(organization.id, input), @@ -540,7 +572,6 @@ function useOrganizationWorkspace({ governance, members, accounts, - audit, runner, security, supportBundle, @@ -558,14 +589,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 +625,81 @@ 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 +1278,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) +} 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..8eb4abfa 100644 --- a/frontend/src/pages/PlatformCapabilitiesPage.tsx +++ b/frontend/src/pages/PlatformCapabilitiesPage.tsx @@ -1,427 +1,407 @@ -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 { useRef } from 'react' +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 inspectorRef = useRef(null) + 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) + if (window.matchMedia('(max-width: 1024px)').matches) { + inspectorRef.current?.scrollIntoView({ block: 'start' }) + } + } + 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 +} diff --git a/frontend/src/pages/TestAssetsPage.test.tsx b/frontend/src/pages/TestAssetsPage.test.tsx index 311d7011..cb30480d 100644 --- a/frontend/src/pages/TestAssetsPage.test.tsx +++ b/frontend/src/pages/TestAssetsPage.test.tsx @@ -631,8 +631,8 @@ describe('TestAssetsPage', () => { workflow.id === runCase.draft_definition.workflow_id, - )} - environment={state.environments.data?.find( - (environment) => environment.id === runCase.draft_definition.environment_id, - )} + workflows={pageItems(state.workflows.data)} + environments={state.environments.data ?? []} submitting={state.runningCase} onClose={() => setRunCase(null)} onRun={async (input) => { @@ -1713,8 +1710,8 @@ export function RunCaseDialog({ item, initialVersion, projectId, - workflow, - environment, + workflows, + environments, submitting, onClose, onRun, @@ -1722,8 +1719,8 @@ export function RunCaseDialog({ item: TestCase initialVersion?: number projectId: string - workflow?: Workflow - environment?: Environment + workflows: Workflow[] + environments: Environment[] submitting: boolean onClose: () => void onRun: (input: RunCaseInput) => Promise @@ -1734,32 +1731,49 @@ export function RunCaseDialog({ ) const [version, setVersion] = useState(initialVersion ?? item.current_version ?? undefined) const [submitError, setSubmitError] = useState(null) + const [confirming, setConfirming] = useState(false) + const submittingRef = useRef(false) + const busy = submitting || confirming const error = loadError ?? submitError - const canRun = canRunCase(source, version, versions, item.draft_fingerprint, loading, error) + const canRun = canRunCase(source, version, versions, item.draft_fingerprint, loading, loadError) const confirm = async () => { - if (!source) return + if (!source || !canRun || busy || submittingRef.current) return + submittingRef.current = true + setConfirming(true) try { setSubmitError(null) await onRun(caseRunInput(source, version, item.draft_fingerprint)) } catch (reason) { setSubmitError(apiErrorMessage(reason)) + } finally { + submittingRef.current = false + setConfirming(false) } } return ( { + if (!busy && !submittingRef.current) onClose() + }} + closable={!busy} + mask={{ closable: !busy }} + keyboard={!busy} + cancelText="取消" + cancelButtonProps={{ disabled: busy }} onOk={() => void confirm()} - confirmLoading={submitting} - okButtonProps={{ disabled: !canRun }} + confirmLoading={busy} + okButtonProps={{ disabled: !canRun || busy }} okText={source === 'draft' ? '发布并运行' : '运行已发布版本'} > - onSource(event.target.value)}> + onSource(event.target.value)} + disabled={disabled} + > {item.current_version && 运行已发布用例版本} 发布已保存草稿并运行 @@ -1858,6 +1883,7 @@ function RunCaseDetails({