From 62186e7994146d2df442b888ba0653d894125056 Mon Sep 17 00:00:00 2001 From: redbotu Date: Wed, 8 Apr 2026 21:42:43 +0800 Subject: [PATCH] feat: scaffold demo backend and task workflow --- .gitignore | 6 + backend/README.md | 115 ++++ backend/app/__init__.py | 1 + backend/app/adapters/__init__.py | 1 + backend/app/adapters/approval/__init__.py | 1 + backend/app/adapters/approval/base.py | 11 + backend/app/adapters/approval/demo_adapter.py | 15 + backend/app/adapters/identity/__init__.py | 1 + backend/app/adapters/identity/base.py | 9 + backend/app/adapters/identity/demo_adapter.py | 12 + backend/app/adapters/software_a/__init__.py | 1 + backend/app/adapters/software_a/base.py | 19 + .../app/adapters/software_a/demo_adapter.py | 19 + backend/app/api/__init__.py | 1 + backend/app/api/agent/__init__.py | 1 + backend/app/api/agent/tasks.py | 333 ++++++++++ backend/app/api/demo/__init__.py | 1 + backend/app/api/demo/approval.py | 128 ++++ backend/app/api/demo/identity.py | 108 ++++ backend/app/api/demo/software_a.py | 81 +++ backend/app/api/edge/__init__.py | 1 + backend/app/api/edge/tasks.py | 162 +++++ backend/app/core/__init__.py | 1 + backend/app/core/config.py | 22 + backend/app/core/constants.py | 45 ++ backend/app/core/time.py | 21 + backend/app/db/__init__.py | 1 + backend/app/db/base.py | 5 + backend/app/db/session.py | 28 + backend/app/main.py | 65 ++ backend/app/models/__init__.py | 1 + backend/app/models/approval.py | 21 + backend/app/models/audit_log.py | 21 + backend/app/models/edge_node.py | 20 + backend/app/models/edge_task.py | 27 + backend/app/models/task.py | 32 + backend/app/models/tool_call.py | 24 + backend/app/repositories/__init__.py | 1 + .../app/repositories/approval_repository.py | 38 ++ backend/app/repositories/audit_repository.py | 21 + backend/app/repositories/edge_repository.py | 65 ++ backend/app/repositories/task_repository.py | 27 + .../app/repositories/tool_call_repository.py | 21 + backend/app/schemas/__init__.py | 1 + backend/app/schemas/approval.py | 49 ++ backend/app/schemas/common.py | 25 + backend/app/schemas/edge.py | 70 ++ backend/app/schemas/identity.py | 39 ++ backend/app/schemas/software_a.py | 52 ++ backend/app/schemas/task.py | 142 +++++ backend/app/services/__init__.py | 1 + backend/app/services/approval_service.py | 164 +++++ backend/app/services/edge_service.py | 279 ++++++++ backend/app/services/identity_service.py | 56 ++ backend/app/services/intent_service.py | 61 ++ backend/app/services/risk_service.py | 17 + backend/app/services/software_a_service.py | 61 ++ backend/app/services/task_service.py | 395 ++++++++++++ backend/pyproject.toml | 20 + backend/tests/test_task_api.py | 601 ++++++++++++++++++ docs/智能化部署agent-demo最小DDL设计.md | 241 +++++++ docs/智能化部署agent-demo首批OpenAPI.yaml | 285 +++++++++ 智能化部署agent-demo后端项目骨架设计.md | 65 +- 智能化部署agent-demo接口定义说明.md | 14 + 智能化部署agent-当前进度总结.md | 227 +++++-- 65 files changed, 4328 insertions(+), 71 deletions(-) create mode 100644 .gitignore create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/adapters/__init__.py create mode 100644 backend/app/adapters/approval/__init__.py create mode 100644 backend/app/adapters/approval/base.py create mode 100644 backend/app/adapters/approval/demo_adapter.py create mode 100644 backend/app/adapters/identity/__init__.py create mode 100644 backend/app/adapters/identity/base.py create mode 100644 backend/app/adapters/identity/demo_adapter.py create mode 100644 backend/app/adapters/software_a/__init__.py create mode 100644 backend/app/adapters/software_a/base.py create mode 100644 backend/app/adapters/software_a/demo_adapter.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/agent/__init__.py create mode 100644 backend/app/api/agent/tasks.py create mode 100644 backend/app/api/demo/__init__.py create mode 100644 backend/app/api/demo/approval.py create mode 100644 backend/app/api/demo/identity.py create mode 100644 backend/app/api/demo/software_a.py create mode 100644 backend/app/api/edge/__init__.py create mode 100644 backend/app/api/edge/tasks.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/constants.py create mode 100644 backend/app/core/time.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/approval.py create mode 100644 backend/app/models/audit_log.py create mode 100644 backend/app/models/edge_node.py create mode 100644 backend/app/models/edge_task.py create mode 100644 backend/app/models/task.py create mode 100644 backend/app/models/tool_call.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/approval_repository.py create mode 100644 backend/app/repositories/audit_repository.py create mode 100644 backend/app/repositories/edge_repository.py create mode 100644 backend/app/repositories/task_repository.py create mode 100644 backend/app/repositories/tool_call_repository.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/approval.py create mode 100644 backend/app/schemas/common.py create mode 100644 backend/app/schemas/edge.py create mode 100644 backend/app/schemas/identity.py create mode 100644 backend/app/schemas/software_a.py create mode 100644 backend/app/schemas/task.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/approval_service.py create mode 100644 backend/app/services/edge_service.py create mode 100644 backend/app/services/identity_service.py create mode 100644 backend/app/services/intent_service.py create mode 100644 backend/app/services/risk_service.py create mode 100644 backend/app/services/software_a_service.py create mode 100644 backend/app/services/task_service.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/test_task_api.py create mode 100644 docs/智能化部署agent-demo最小DDL设计.md create mode 100644 docs/智能化部署agent-demo首批OpenAPI.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1180da --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +data/ +__pycache__/ +.pytest_cache/ +*.pyc +*.egg-info/ diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..6ddad79 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,115 @@ +# Smart Deploy Agent Demo Backend + +## Setup + +```bash +python -m venv .venv +.venv\\Scripts\\python -m pip install -e backend +``` + +## Run + +```bash +.venv\\Scripts\\python -m uvicorn app.main:app --reload --app-dir backend +``` + +## Test + +The lightweight API verification can run with in-memory SQLite: + +```bash +set PYTHONPATH=backend +set DATABASE_URL=sqlite:///:memory: +.venv\\Scripts\\python -m pytest backend/tests -q -p no:cacheprovider +``` + +## Runtime Notes + +This repo currently defaults to: + +1. database: `sqlite:///./data/agent_demo.db` +2. demo cache / queue: no Redis dependency +3. edge defaults: + `edge_id=edge-shanghai-001` + `tool_name=http_health_check` +4. demo operator defaults: + `alice(u1001)` for task execution + `bob(u2001)` for approval + +In the current sandbox, file-based SQLite may fail with `disk I/O error`. +For tests and local verification here, use: + +```bash +set DATABASE_URL=sqlite:///:memory: +``` + +## Implemented API Scope + +Current backend includes: + +1. agent task + `POST /api/agent/tasks` + `POST /api/agent/tasks/{task_id}/confirm` + `POST /api/agent/tasks/{task_id}/cancel` + `GET /api/agent/tasks/{task_id}` + `GET /api/agent/tasks/{task_id}/report` +2. demo identity + `POST /api/demo/identity/login` + `GET /api/demo/identity/me` + `GET /api/demo/identity/users/{user_id}/permissions` + `POST /api/demo/identity/token/introspect` +3. demo approval + `POST /api/demo/approval/requests` + `GET /api/demo/approval/requests/{approval_id}` + `POST /api/demo/approval/requests/{approval_id}/decision` + `GET /api/demo/approval/requests` +4. demo software-a + `POST /api/demo/software-a/deploy-tasks` + `GET /api/demo/software-a/deploy-tasks/{software_a_task_id}` + `POST /api/demo/software-a/permissions/check` +5. edge + `POST /api/agent/edge/heartbeat` + `POST /api/agent/edge/tasks/pull` + `POST /api/agent/edge/tasks/report` + `POST /api/agent/edge/events` + +Current execution flow: + +1. create task +2. confirm task +3. high-risk task enters approval flow +4. check `software-a demo` permission +5. create `software-a demo` deploy task +6. create default edge verification step +7. edge pulls and reports verification result +8. task reaches `SUCCEEDED` / `FAILED` / `CANCELLED` +9. task detail/report returns software-a status, approval trace, tool trace, verification trace and audit trace + +Demo failure semantics currently include: + +1. if `app_code` or `version` contains `fail`, `software-a demo` returns a failed deploy task +2. approval rejection moves task to `CANCELLED` +3. failed edge report moves task to `FAILED` + +## Current Verification Baseline + +Automated tests currently cover: + +1. create / confirm / get task +2. high-risk approval path +3. identity and software-a demo APIs +4. edge heartbeat / pull / report +5. edge event report +6. task report trace aggregation +7. cancel running task + +Current baseline: `14 passed` + +## Next Focus + +Recommended next implementation steps: + +1. compute and persist `duration_ms` +2. refine richer result summaries and audit details +3. add more idempotency and rollback tests +4. then continue with local edge-agent bootstrap and second-batch OpenAPI diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/adapters/__init__.py b/backend/app/adapters/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/adapters/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/adapters/approval/__init__.py b/backend/app/adapters/approval/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/adapters/approval/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/adapters/approval/base.py b/backend/app/adapters/approval/base.py new file mode 100644 index 0000000..278660e --- /dev/null +++ b/backend/app/adapters/approval/base.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from app.schemas.approval import CreateApprovalRequest + + +class ApprovalAdapter(ABC): + @abstractmethod + def create_request(self, payload: CreateApprovalRequest): + raise NotImplementedError diff --git a/backend/app/adapters/approval/demo_adapter.py b/backend/app/adapters/approval/demo_adapter.py new file mode 100644 index 0000000..f1cee55 --- /dev/null +++ b/backend/app/adapters/approval/demo_adapter.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.adapters.approval.base import ApprovalAdapter +from app.schemas.approval import CreateApprovalRequest +from app.services.approval_service import ApprovalService + + +class DemoApprovalAdapter(ApprovalAdapter): + def __init__(self, db: Session, timezone_name: str) -> None: + self.service = ApprovalService(db, timezone_name) + + def create_request(self, payload: CreateApprovalRequest): + return self.service.create_request(payload) diff --git a/backend/app/adapters/identity/__init__.py b/backend/app/adapters/identity/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/adapters/identity/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/adapters/identity/base.py b/backend/app/adapters/identity/base.py new file mode 100644 index 0000000..d88dffb --- /dev/null +++ b/backend/app/adapters/identity/base.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class IdentityAdapter(ABC): + @abstractmethod + def login(self, username: str, password: str) -> tuple[str, dict] | None: + raise NotImplementedError diff --git a/backend/app/adapters/identity/demo_adapter.py b/backend/app/adapters/identity/demo_adapter.py new file mode 100644 index 0000000..fdf3b75 --- /dev/null +++ b/backend/app/adapters/identity/demo_adapter.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from app.adapters.identity.base import IdentityAdapter +from app.services.identity_service import IdentityService + + +class DemoIdentityAdapter(IdentityAdapter): + def __init__(self) -> None: + self.service = IdentityService() + + def login(self, username: str, password: str) -> tuple[str, dict] | None: + return self.service.login(username, password) diff --git a/backend/app/adapters/software_a/__init__.py b/backend/app/adapters/software_a/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/adapters/software_a/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/adapters/software_a/base.py b/backend/app/adapters/software_a/base.py new file mode 100644 index 0000000..c99af6a --- /dev/null +++ b/backend/app/adapters/software_a/base.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from app.schemas.software_a import CreateDeployTaskRequest + + +class SoftwareAAdapter(ABC): + @abstractmethod + def create_deploy_task(self, payload: CreateDeployTaskRequest) -> dict: + raise NotImplementedError + + @abstractmethod + def get_deploy_task(self, software_a_task_id: str) -> dict | None: + raise NotImplementedError + + @abstractmethod + def check_permission(self, action_type: str, env: str, approval_status: str | None = None) -> tuple[bool, str]: + raise NotImplementedError diff --git a/backend/app/adapters/software_a/demo_adapter.py b/backend/app/adapters/software_a/demo_adapter.py new file mode 100644 index 0000000..08c4ce2 --- /dev/null +++ b/backend/app/adapters/software_a/demo_adapter.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from app.adapters.software_a.base import SoftwareAAdapter +from app.schemas.software_a import CreateDeployTaskRequest +from app.services.software_a_service import SoftwareAService + + +class DemoSoftwareAAdapter(SoftwareAAdapter): + def __init__(self, timezone_name: str) -> None: + self.service = SoftwareAService(timezone_name) + + def create_deploy_task(self, payload: CreateDeployTaskRequest) -> dict: + return self.service.create_deploy_task(payload) + + def get_deploy_task(self, software_a_task_id: str) -> dict | None: + return self.service.get_deploy_task(software_a_task_id) + + def check_permission(self, action_type: str, env: str, approval_status: str | None = None) -> tuple[bool, str]: + return self.service.check_permission(action_type, env, approval_status) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/agent/__init__.py b/backend/app/api/agent/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/agent/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/agent/tasks.py b/backend/app/api/agent/tasks.py new file mode 100644 index 0000000..35030e5 --- /dev/null +++ b/backend/app/api/agent/tasks.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import json +from typing import Annotated +from uuid import uuid4 + +from fastapi import APIRouter, Depends, Header, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.constants import ERROR_CODE_OK +from app.core.time import format_now +from app.db.session import get_db +from app.repositories.approval_repository import ApprovalRepository +from app.repositories.audit_repository import AuditRepository +from app.repositories.edge_repository import EdgeTaskRepository +from app.repositories.tool_call_repository import ToolCallRepository +from app.schemas.common import ApiResponse +from app.schemas.task import ( + ApprovalTraceItem, + AuditTraceItem, + CancelTaskRequest, + ConfirmTaskData, + ConfirmTaskRequest, + CreateTaskData, + CreateTaskRequest, + ParsedIntent, + TaskBasic, + TaskDetailData, + TaskReportData, + ToolTraceItem, + ToolCallItem, + VerificationTraceItem, +) +from app.services.task_service import TaskConflictError, TaskNotFoundError, TaskService +from app.services.task_service import TaskPermissionError + +router = APIRouter(prefix="/api/agent/tasks", tags=["agent-task"]) + + +def build_request_id(header_value: str | None) -> str: + return header_value or f"req-{uuid4().hex[:12]}" + + +@router.post("", response_model=ApiResponse[CreateTaskData]) +def create_task( + payload: CreateTaskRequest, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[CreateTaskData]: + settings = get_settings() + request_id = build_request_id(x_request_id) + service = TaskService(db, settings.default_timezone) + task = service.create_task(payload, request_id) + + missing_slots = json.loads(task.missing_slots_json) + next_action = "CONFIRM_TASK" if not missing_slots else "FILL_MISSING_SLOTS" + + return ApiResponse[CreateTaskData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=CreateTaskData( + task_id=task.task_id, + parsed_intent=ParsedIntent(**json.loads(task.parsed_intent_json)), + missing_slots=missing_slots, + risk_level=task.risk_level, + task_status=task.task_status, + next_action=next_action, + ), + timestamp=format_now(settings.default_timezone), + ) + + +@router.post("/{task_id}/confirm", response_model=ApiResponse[ConfirmTaskData]) +def confirm_task( + task_id: str, + payload: ConfirmTaskRequest, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[ConfirmTaskData]: + settings = get_settings() + request_id = build_request_id(x_request_id) + service = TaskService(db, settings.default_timezone) + + try: + task, approval_id = service.confirm_task(task_id, payload, request_id=request_id) + except TaskNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": exc.code, "message": "task not found"}, + ) from exc + except TaskConflictError as exc: + message = exc.args[0] if exc.args else "task state conflict" + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": exc.code, "message": message}, + ) from exc + except TaskPermissionError as exc: + message = exc.args[0] if exc.args else "permission denied" + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": exc.code, "message": message}, + ) from exc + + return ApiResponse[ConfirmTaskData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="task confirmed", + data=ConfirmTaskData( + task_id=task.task_id, + task_status=task.task_status, + approval_status=task.approval_status, + approval_id=approval_id, + software_a_task_id=task.software_a_task_id, + software_a_task_status=task.software_a_task_status, + ), + timestamp=format_now(settings.default_timezone), + ) + + +@router.post("/{task_id}/cancel", response_model=ApiResponse[ConfirmTaskData]) +def cancel_task( + task_id: str, + payload: CancelTaskRequest, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[ConfirmTaskData]: + settings = get_settings() + request_id = build_request_id(x_request_id) + service = TaskService(db, settings.default_timezone) + + try: + task = service.cancel_task(task_id, payload.reason, request_id=request_id) + except TaskNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": exc.code, "message": "task not found"}, + ) from exc + except TaskConflictError as exc: + message = exc.args[0] if exc.args else "task state conflict" + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": exc.code, "message": message}, + ) from exc + + return ApiResponse[ConfirmTaskData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="task cancelled", + data=ConfirmTaskData( + task_id=task.task_id, + task_status=task.task_status, + approval_status=task.approval_status, + approval_id=None, + software_a_task_id=task.software_a_task_id, + software_a_task_status=task.software_a_task_status, + ), + timestamp=format_now(settings.default_timezone), + ) + + +@router.get("/{task_id}", response_model=ApiResponse[TaskDetailData]) +def get_task( + task_id: str, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[TaskDetailData]: + settings = get_settings() + request_id = build_request_id(x_request_id) + service = TaskService(db, settings.default_timezone) + + try: + task = service.get_task(task_id) + except TaskNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": exc.code, "message": "task not found"}, + ) from exc + + edge_tasks = EdgeTaskRepository(db).list_by_task_id(task_id) + tool_calls = ToolCallRepository(db).list_by_task_id(task_id) + verification_result = None + if edge_tasks: + latest_edge_task = edge_tasks[0] + if latest_edge_task.success is not None: + verification_result = { + "http_ok": bool(latest_edge_task.success), + "process_ok": None, + "port_ok": None, + "log_error_count": 0 if latest_edge_task.success else 1, + } + + return ApiResponse[TaskDetailData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=TaskDetailData( + task_id=task.task_id, + task_status=task.task_status, + approval_status=task.approval_status, + risk_level=task.risk_level, + intent=ParsedIntent(**json.loads(task.parsed_intent_json)), + software_a_task_id=task.software_a_task_id, + software_a_task_status=task.software_a_task_status, + tool_calls=[ + ToolCallItem( + tool_name=item.tool_name, + success=bool(item.success), + ) + for item in tool_calls + ], + verification_result=verification_result, + summary=task.summary, + ), + timestamp=format_now(settings.default_timezone), + ) + + +@router.get("/{task_id}/report", response_model=ApiResponse[TaskReportData]) +def get_task_report( + task_id: str, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[TaskReportData]: + settings = get_settings() + request_id = build_request_id(x_request_id) + service = TaskService(db, settings.default_timezone) + + try: + task = service.get_task(task_id) + except TaskNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": exc.code, "message": "task not found"}, + ) from exc + + approval = ApprovalRepository(db).get_by_task_id(task_id) + tool_calls = ToolCallRepository(db).list_by_task_id(task_id) + edge_tasks = EdgeTaskRepository(db).list_by_task_id(task_id) + audit_logs = AuditRepository(db).list_by_task_id(task_id) + + approval_trace = [] + if approval: + approval_trace.append( + ApprovalTraceItem( + approval_id=approval.approval_id, + approval_status=approval.approval_status, + risk_level=approval.risk_level, + approvers=json.loads(approval.approver_user_ids_json), + reason=approval.reason, + created_at=approval.created_at, + updated_at=approval.updated_at, + ) + ) + + tool_trace = [ + ToolTraceItem( + tool_call_id=item.tool_call_id, + request_id=item.request_id, + operator_user_id=item.operator_user_id, + operator_user_name=item.operator_user_name, + tool_name=item.tool_name, + success=bool(item.success), + duration_ms=item.duration_ms, + started_at=item.started_at, + finished_at=item.finished_at, + request_payload=json.loads(item.request_payload_json), + response_payload=json.loads(item.response_payload_json), + ) + for item in tool_calls + ] + + verification_trace = [ + VerificationTraceItem( + step_id=item.step_id, + edge_id=item.edge_id, + tool_name=item.tool_name, + step_status=item.step_status, + success=None if item.success is None else bool(item.success), + message=item.message, + params=json.loads(item.params_json), + result_data=json.loads(item.result_data_json), + evidence=json.loads(item.evidence_json), + started_at=item.started_at, + finished_at=item.finished_at, + ) + for item in edge_tasks + ] + + audit_trace = [ + AuditTraceItem( + audit_id=item.audit_id, + request_id=item.request_id, + action=item.action, + result=item.result, + operator_user_id=item.operator_user_id, + operator_user_name=item.operator_user_name, + target=item.target, + detail=json.loads(item.detail_json), + timestamp=item.timestamp, + ) + for item in audit_logs + ] + + return ApiResponse[TaskReportData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=TaskReportData( + task_basic=TaskBasic( + task_id=task.task_id, + task_status=task.task_status, + approval_status=task.approval_status, + risk_level=task.risk_level, + created_at=task.created_at, + updated_at=task.updated_at, + confirmed_at=task.confirmed_at, + ), + intent_snapshot=ParsedIntent(**json.loads(task.parsed_intent_json)), + approval_trace=approval_trace, + tool_trace=tool_trace, + verification_trace=verification_trace, + result_summary=task.summary, + audit_trace=audit_trace, + ), + timestamp=format_now(settings.default_timezone), + ) diff --git a/backend/app/api/demo/__init__.py b/backend/app/api/demo/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/demo/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/demo/approval.py b/backend/app/api/demo/approval.py new file mode 100644 index 0000000..1b0cd08 --- /dev/null +++ b/backend/app/api/demo/approval.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +from typing import Annotated +from uuid import uuid4 + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.core.constants import ERROR_CODE_OK +from app.core.config import get_settings +from app.core.time import format_now +from app.db.session import get_db +from app.schemas.approval import ( + ApprovalDecisionRequest, + ApprovalDetailData, + ApprovalListData, + CreateApprovalData, + CreateApprovalRequest, +) +from app.schemas.common import ApiResponse +from app.services.approval_service import ApprovalConflictError, ApprovalNotFoundError, ApprovalService + +router = APIRouter(prefix="/api/demo/approval", tags=["demo-approval"]) + + +def build_request_id(header_value: str | None) -> str: + return header_value or f"req-{uuid4().hex[:12]}" + + +def to_detail_data(approval) -> ApprovalDetailData: + return ApprovalDetailData( + approval_id=approval.approval_id, + task_id=approval.task_id, + approval_status=approval.approval_status, + risk_level=approval.risk_level, + approvers=json.loads(approval.approver_user_ids_json), + reason=approval.reason, + created_at=approval.created_at, + updated_at=approval.updated_at, + ) + + +@router.post("/requests", response_model=ApiResponse[CreateApprovalData]) +def create_request( + payload: CreateApprovalRequest, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[CreateApprovalData]: + request_id = build_request_id(x_request_id) + approval = ApprovalService(db, get_settings().default_timezone).create_request(payload) + return ApiResponse[CreateApprovalData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="approval created", + data=CreateApprovalData(approval_id=approval.approval_id, approval_status=approval.approval_status), + timestamp=format_now(get_settings().default_timezone), + ) + + +@router.get("/requests/{approval_id}", response_model=ApiResponse[ApprovalDetailData]) +def get_request( + approval_id: str, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[ApprovalDetailData]: + request_id = build_request_id(x_request_id) + service = ApprovalService(db, get_settings().default_timezone) + try: + approval = service.get_request(approval_id) + except ApprovalNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": exc.code, "message": "approval not found"}) from exc + return ApiResponse[ApprovalDetailData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=to_detail_data(approval), + timestamp=format_now(get_settings().default_timezone), + ) + + +@router.post("/requests/{approval_id}/decision", response_model=ApiResponse[ApprovalDetailData]) +def decide_request( + approval_id: str, + payload: ApprovalDecisionRequest, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[ApprovalDetailData]: + request_id = build_request_id(x_request_id) + service = ApprovalService(db, get_settings().default_timezone) + try: + approval = service.decide(approval_id, payload) + except ApprovalNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": exc.code, "message": "approval not found"}) from exc + except ApprovalConflictError as exc: + message = exc.args[0] if exc.args else "approval conflict" + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": exc.code, "message": message}) from exc + return ApiResponse[ApprovalDetailData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=to_detail_data(approval), + timestamp=format_now(get_settings().default_timezone), + ) + + +@router.get("/requests", response_model=ApiResponse[ApprovalListData]) +def list_pending_requests( + approval_status: str = Query(default="PENDING"), + approver_user_id: str | None = Query(default=None), + db: Annotated[Session, Depends(get_db)] = None, + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[ApprovalListData]: + request_id = build_request_id(x_request_id) + if approval_status != "PENDING": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail={"code": "INVALID_PARAM", "message": "当前仅支持查询 PENDING 审批单"}) + approvals = ApprovalService(db, get_settings().default_timezone).list_pending(approver_user_id) + return ApiResponse[ApprovalListData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=ApprovalListData(approvals=[to_detail_data(item) for item in approvals]), + timestamp=format_now(get_settings().default_timezone), + ) diff --git a/backend/app/api/demo/identity.py b/backend/app/api/demo/identity.py new file mode 100644 index 0000000..61f4a5f --- /dev/null +++ b/backend/app/api/demo/identity.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import Annotated +from uuid import uuid4 + +from fastapi import APIRouter, Header, HTTPException, status + +from app.adapters.identity.demo_adapter import DemoIdentityAdapter +from app.core.constants import ERROR_CODE_OK +from app.core.time import format_now +from app.schemas.common import ApiResponse +from app.schemas.identity import ( + IdentityUser, + LoginData, + LoginRequest, + PermissionsData, + TokenIntrospectData, + TokenIntrospectRequest, +) +from app.services.identity_service import IdentityService + +router = APIRouter(prefix="/api/demo/identity", tags=["demo-identity"]) + + +def build_request_id(header_value: str | None) -> str: + return header_value or f"req-{uuid4().hex[:12]}" + + +@router.post("/login", response_model=ApiResponse[LoginData]) +def login( + payload: LoginRequest, + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[LoginData]: + request_id = build_request_id(x_request_id) + adapter = DemoIdentityAdapter() + result = adapter.login(payload.username, payload.password) + if not result: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail={"code": "UNAUTHORIZED", "message": "invalid username or password"}) + access_token, user = result + identity_user = IdentityService.to_identity_user(user) + return ApiResponse[LoginData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="login success", + data=LoginData(access_token=access_token, expires_in_seconds=7200, user=identity_user), + timestamp=format_now("Asia/Shanghai"), + ) + + +@router.get("/me", response_model=ApiResponse[IdentityUser]) +def me( + authorization: Annotated[str | None, Header(alias="Authorization")] = None, + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[IdentityUser]: + request_id = build_request_id(x_request_id) + token = (authorization or "").removeprefix("Bearer ").strip() + user = IdentityService().get_user_by_token(token) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail={"code": "UNAUTHORIZED", "message": "invalid token"}) + return ApiResponse[IdentityUser]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=IdentityService.to_identity_user(user), + timestamp=format_now("Asia/Shanghai"), + ) + + +@router.get("/users/{user_id}/permissions", response_model=ApiResponse[PermissionsData]) +def get_permissions(user_id: str, x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None) -> ApiResponse[PermissionsData]: + request_id = build_request_id(x_request_id) + user = IdentityService().get_permissions(user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "NOT_FOUND", "message": "user not found"}) + return ApiResponse[PermissionsData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=PermissionsData( + user_id=user["user_id"], + roles=user["roles"], + permissions=user["permissions"], + allowed_envs=user["allowed_envs"], + allowed_apps=user["allowed_apps"], + ), + timestamp=format_now("Asia/Shanghai"), + ) + + +@router.post("/token/introspect", response_model=ApiResponse[TokenIntrospectData]) +def introspect_token( + payload: TokenIntrospectRequest, + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[TokenIntrospectData]: + request_id = build_request_id(x_request_id) + user = IdentityService().get_user_by_token(payload.access_token) + data = TokenIntrospectData(active=bool(user), user=IdentityService.to_identity_user(user) if user else None) + return ApiResponse[TokenIntrospectData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=data, + timestamp=format_now("Asia/Shanghai"), + ) diff --git a/backend/app/api/demo/software_a.py b/backend/app/api/demo/software_a.py new file mode 100644 index 0000000..cdf6465 --- /dev/null +++ b/backend/app/api/demo/software_a.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import Annotated +from uuid import uuid4 + +from fastapi import APIRouter, Header, HTTPException, status + +from app.core.constants import ERROR_CODE_OK +from app.core.time import format_now +from app.schemas.common import ApiResponse +from app.schemas.software_a import ( + CreateDeployTaskData, + CreateDeployTaskRequest, + DeployTaskDetailData, + PermissionCheckData, + PermissionCheckRequest, +) +from app.services.software_a_service import SoftwareAService + +router = APIRouter(prefix="/api/demo/software-a", tags=["demo-software-a"]) + + +def build_request_id(header_value: str | None) -> str: + return header_value or f"req-{uuid4().hex[:12]}" + + +@router.post("/deploy-tasks", response_model=ApiResponse[CreateDeployTaskData]) +def create_deploy_task( + payload: CreateDeployTaskRequest, + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[CreateDeployTaskData]: + request_id = build_request_id(x_request_id) + service = SoftwareAService("Asia/Shanghai") + task = service.create_deploy_task(payload) + return ApiResponse[CreateDeployTaskData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="deploy task created", + data=CreateDeployTaskData( + software_a_task_id=task["software_a_task_id"], + task_status=task["task_status"], + ), + timestamp=format_now("Asia/Shanghai"), + ) + + +@router.get("/deploy-tasks/{software_a_task_id}", response_model=ApiResponse[DeployTaskDetailData]) +def get_deploy_task( + software_a_task_id: str, + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[DeployTaskDetailData]: + request_id = build_request_id(x_request_id) + task = SoftwareAService("Asia/Shanghai").get_deploy_task(software_a_task_id) + if not task: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "NOT_FOUND", "message": "software_a task not found"}) + return ApiResponse[DeployTaskDetailData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=DeployTaskDetailData(**task), + timestamp=format_now("Asia/Shanghai"), + ) + + +@router.post("/permissions/check", response_model=ApiResponse[PermissionCheckData]) +def check_permission( + payload: PermissionCheckRequest, + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[PermissionCheckData]: + request_id = build_request_id(x_request_id) + allowed, reason = SoftwareAService("Asia/Shanghai").check_permission(payload.action_type, payload.env) + return ApiResponse[PermissionCheckData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=PermissionCheckData(allowed=allowed, reason=reason), + timestamp=format_now("Asia/Shanghai"), + ) diff --git a/backend/app/api/edge/__init__.py b/backend/app/api/edge/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/edge/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/edge/tasks.py b/backend/app/api/edge/tasks.py new file mode 100644 index 0000000..fbe8e9f --- /dev/null +++ b/backend/app/api/edge/tasks.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +from typing import Annotated +from uuid import uuid4 + +from fastapi import APIRouter, Depends, Header, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.constants import ERROR_CODE_OK +from app.core.time import format_now +from app.db.session import get_db +from app.schemas.common import ApiResponse +from app.schemas.edge import ( + EdgeEventData, + EdgeEventRequest, + EdgeHeartbeatData, + EdgeHeartbeatRequest, + EdgePullTasksData, + EdgePullTasksRequest, + EdgeTaskItem, + EdgeTaskReportData, + EdgeTaskReportRequest, +) +from app.services.edge_service import EdgeService, EdgeTaskConflictError, EdgeTaskNotFoundError + +router = APIRouter(prefix="/api/agent/edge", tags=["agent-edge"]) + + +def build_request_id(header_value: str | None) -> str: + return header_value or f"req-{uuid4().hex[:12]}" + + +@router.post("/heartbeat", response_model=ApiResponse[EdgeHeartbeatData]) +def heartbeat( + payload: EdgeHeartbeatRequest, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[EdgeHeartbeatData]: + settings = get_settings() + request_id = build_request_id(x_request_id) + node = EdgeService(db, settings.default_timezone).heartbeat( + edge_id=payload.edge_id, + hostname=payload.hostname, + os_type=payload.os_type, + agent_version=payload.agent_version, + capabilities=payload.capabilities, + ) + return ApiResponse[EdgeHeartbeatData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=EdgeHeartbeatData( + edge_id=node.edge_id, + node_status=node.node_status, + last_heartbeat_at=node.last_heartbeat_at, + ), + timestamp=format_now(settings.default_timezone), + ) + + +@router.post("/tasks/pull", response_model=ApiResponse[EdgePullTasksData]) +def pull_tasks( + payload: EdgePullTasksRequest, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[EdgePullTasksData]: + settings = get_settings() + request_id = build_request_id(x_request_id) + tasks = EdgeService(db, settings.default_timezone).pull_tasks(payload.edge_id, payload.max_tasks) + return ApiResponse[EdgePullTasksData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=EdgePullTasksData( + tasks=[ + EdgeTaskItem( + task_id=item.task_id, + step_id=item.step_id, + tool_name=item.tool_name, + params=json.loads(item.params_json), + expire_at=item.expire_at, + ) + for item in tasks + ] + ), + timestamp=format_now(settings.default_timezone), + ) + + +@router.post("/tasks/report", response_model=ApiResponse[EdgeTaskReportData]) +def report_task( + payload: EdgeTaskReportRequest, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[EdgeTaskReportData]: + settings = get_settings() + request_id = build_request_id(x_request_id) + try: + edge_task, task_status = EdgeService(db, settings.default_timezone).report_task( + edge_id=payload.edge_id, + step_id=payload.step_id, + success=payload.success, + message=payload.message, + data=payload.data, + evidence=payload.evidence, + started_at=payload.started_at, + finished_at=payload.finished_at, + ) + except EdgeTaskNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": exc.code, "message": "edge step not found"}) from exc + except EdgeTaskConflictError as exc: + message = exc.args[0] if exc.args else "edge task conflict" + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": exc.code, "message": message}) from exc + + if payload.task_id != edge_task.task_id or payload.tool_name != edge_task.tool_name: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "CONFLICT", "message": "task_id or tool_name mismatch"}) + + return ApiResponse[EdgeTaskReportData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=EdgeTaskReportData( + task_id=edge_task.task_id, + step_id=edge_task.step_id, + step_status=edge_task.step_status, + task_status=task_status, + ), + timestamp=format_now(settings.default_timezone), + ) + + +@router.post("/events", response_model=ApiResponse[EdgeEventData]) +def report_event( + payload: EdgeEventRequest, + db: Annotated[Session, Depends(get_db)], + x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None, +) -> ApiResponse[EdgeEventData]: + settings = get_settings() + request_id = build_request_id(x_request_id) + EdgeService(db, settings.default_timezone).record_event( + edge_id=payload.edge_id, + event_type=payload.event_type, + message=payload.message, + detail=payload.detail, + ) + return ApiResponse[EdgeEventData]( + request_id=request_id, + success=True, + code=ERROR_CODE_OK, + message="success", + data=EdgeEventData( + edge_id=payload.edge_id, + event_type=payload.event_type, + accepted=True, + ), + timestamp=format_now(settings.default_timezone), + ) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..7c56b3f --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class Settings: + app_name: str = "smart-deploy-agent-demo" + app_env: str = os.getenv("APP_ENV", "demo") + app_port: int = int(os.getenv("APP_PORT", "8000")) + default_timezone: str = os.getenv("DEFAULT_TIMEZONE", "Asia/Shanghai") + database_url: str = os.getenv("DATABASE_URL", "sqlite:///./data/agent_demo.db") + + +def get_settings() -> Settings: + return Settings() + + +def ensure_runtime_directories() -> None: + Path("data").mkdir(parents=True, exist_ok=True) diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py new file mode 100644 index 0000000..bb9b0f5 --- /dev/null +++ b/backend/app/core/constants.py @@ -0,0 +1,45 @@ +TASK_STATUS_CREATED = "CREATED" +TASK_STATUS_PENDING_CONFIRM = "PENDING_CONFIRM" +TASK_STATUS_PENDING_APPROVAL = "PENDING_APPROVAL" +TASK_STATUS_RUNNING = "RUNNING" +TASK_STATUS_VERIFYING = "VERIFYING" +TASK_STATUS_SUCCEEDED = "SUCCEEDED" +TASK_STATUS_FAILED = "FAILED" +TASK_STATUS_PARTIAL_SUCCEEDED = "PARTIAL_SUCCEEDED" +TASK_STATUS_CANCELLED = "CANCELLED" + +APPROVAL_STATUS_NOT_REQUIRED = "NOT_REQUIRED" +APPROVAL_STATUS_PENDING = "PENDING" +APPROVAL_STATUS_APPROVED = "APPROVED" +APPROVAL_STATUS_REJECTED = "REJECTED" +APPROVAL_STATUS_EXPIRED = "EXPIRED" + +RISK_LEVEL_LOW = "LOW" +RISK_LEVEL_MEDIUM = "MEDIUM" +RISK_LEVEL_HIGH = "HIGH" + +ACTION_TYPE_DEPLOY = "DEPLOY" + +ERROR_CODE_OK = "OK" +ERROR_CODE_INVALID_PARAM = "INVALID_PARAM" +ERROR_CODE_NOT_FOUND = "NOT_FOUND" +ERROR_CODE_CONFLICT = "CONFLICT" +ERROR_CODE_PERMISSION_DENIED = "PERMISSION_DENIED" +ERROR_CODE_EXECUTION_FAILED = "EXECUTION_FAILED" + +DECISION_APPROVED = "APPROVED" +DECISION_REJECTED = "REJECTED" + +SOFTWARE_A_TASK_STATUS_RUNNING = "RUNNING" +SOFTWARE_A_TASK_STATUS_SUCCEEDED = "SUCCEEDED" +SOFTWARE_A_TASK_STATUS_FAILED = "FAILED" + +EDGE_NODE_STATUS_ONLINE = "ONLINE" + +EDGE_STEP_STATUS_PENDING = "PENDING" +EDGE_STEP_STATUS_RUNNING = "RUNNING" +EDGE_STEP_STATUS_SUCCEEDED = "SUCCEEDED" +EDGE_STEP_STATUS_FAILED = "FAILED" +EDGE_STEP_STATUS_CANCELLED = "CANCELLED" + +SOFTWARE_A_TASK_STATUS_CANCELLED = "CANCELLED" diff --git a/backend/app/core/time.py b/backend/app/core/time.py new file mode 100644 index 0000000..6a87fc6 --- /dev/null +++ b/backend/app/core/time.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + +def resolve_timezone(timezone_name: str): + try: + return ZoneInfo(timezone_name) + except ZoneInfoNotFoundError: + fallback_mapping = { + "Asia/Shanghai": timezone(timedelta(hours=8)), + "Asia/Hong_Kong": timezone(timedelta(hours=8)), + "UTC": UTC, + } + return fallback_mapping.get(timezone_name, UTC) + + +def format_now(timezone_name: str) -> str: + current = datetime.now(resolve_timezone(timezone_name)) + return current.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..e6f16ef --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.config import get_settings + +settings = get_settings() + +engine_kwargs = {"future": True} + +if settings.database_url.startswith("sqlite"): + engine_kwargs["connect_args"] = {"check_same_thread": False} + if settings.database_url.endswith(":memory:"): + engine_kwargs["poolclass"] = StaticPool + +engine = create_engine(settings.database_url, **engine_kwargs) + +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False, class_=Session) + + +def get_db() -> Session: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2ebbac5 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse + +from app.api.agent.tasks import router as task_router +from app.api.demo.approval import router as demo_approval_router +from app.api.demo.identity import router as demo_identity_router +from app.api.demo.software_a import router as demo_software_a_router +from app.api.edge.tasks import router as edge_router +from app.core.config import ensure_runtime_directories, get_settings +from app.core.time import format_now +from app.db.base import Base +from app.db.session import engine +from app.models.approval import ApprovalRequest +from app.models.audit_log import AuditLog +from app.models.edge_node import EdgeNode +from app.models.edge_task import EdgeTask +from app.models.task import Task +from app.models.tool_call import ToolCall + +settings = get_settings() + +@asynccontextmanager +async def lifespan(_: FastAPI): + ensure_runtime_directories() + Base.metadata.create_all(bind=engine) + yield + + +app = FastAPI( + title="智能化部署 Agent Demo Backend", + version="0.1.0", + lifespan=lifespan, +) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse: + detail = exc.detail if isinstance(exc.detail, dict) else {"code": "INTERNAL_ERROR", "message": str(exc.detail)} + return JSONResponse( + status_code=exc.status_code, + content={ + "request_id": "req-exception", + "success": False, + "code": detail.get("code", "INTERNAL_ERROR"), + "message": detail.get("message", "request failed"), + "data": {}, + "timestamp": format_now(settings.default_timezone), + }, + ) + + +@app.get("/healthz") +def healthz() -> dict[str, str]: + return {"status": "ok"} + + +app.include_router(task_router) +app.include_router(demo_identity_router) +app.include_router(demo_approval_router) +app.include_router(demo_software_a_router) +app.include_router(edge_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/models/approval.py b/backend/app/models/approval.py new file mode 100644 index 0000000..282ae3a --- /dev/null +++ b/backend/app/models/approval.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ApprovalRequest(Base): + __tablename__ = "approval_request" + + approval_id: Mapped[str] = mapped_column(Text, primary_key=True) + task_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + approval_status: Mapped[str] = mapped_column(Text, nullable=False, index=True) + risk_level: Mapped[str] = mapped_column(Text, nullable=False) + operator_user_id: Mapped[str | None] = mapped_column(Text, nullable=True) + operator_user_name: Mapped[str | None] = mapped_column(Text, nullable=True) + approver_user_ids_json: Mapped[str] = mapped_column(Text, nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[str] = mapped_column(Text, nullable=False) + updated_at: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..2d68308 --- /dev/null +++ b/backend/app/models/audit_log.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AuditLog(Base): + __tablename__ = "audit_log" + + audit_id: Mapped[str] = mapped_column(Text, primary_key=True) + task_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + request_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + action: Mapped[str] = mapped_column(Text, nullable=False, index=True) + operator_user_id: Mapped[str | None] = mapped_column(Text, nullable=True) + operator_user_name: Mapped[str | None] = mapped_column(Text, nullable=True) + target: Mapped[str | None] = mapped_column(Text, nullable=True) + result: Mapped[str] = mapped_column(Text, nullable=False) + detail_json: Mapped[str] = mapped_column(Text, nullable=False) + timestamp: Mapped[str] = mapped_column(Text, nullable=False, index=True) diff --git a/backend/app/models/edge_node.py b/backend/app/models/edge_node.py new file mode 100644 index 0000000..1f03a27 --- /dev/null +++ b/backend/app/models/edge_node.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class EdgeNode(Base): + __tablename__ = "edge_node" + + edge_id: Mapped[str] = mapped_column(Text, primary_key=True) + hostname: Mapped[str] = mapped_column(Text, nullable=False) + os_type: Mapped[str] = mapped_column(Text, nullable=False) + agent_version: Mapped[str] = mapped_column(Text, nullable=False) + capabilities_json: Mapped[str] = mapped_column(Text, nullable=False) + node_status: Mapped[str] = mapped_column(Text, nullable=False, index=True) + last_heartbeat_at: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[str] = mapped_column(Text, nullable=False) + updated_at: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/app/models/edge_task.py b/backend/app/models/edge_task.py new file mode 100644 index 0000000..d86cdd1 --- /dev/null +++ b/backend/app/models/edge_task.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from sqlalchemy import Integer, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class EdgeTask(Base): + __tablename__ = "edge_task" + + edge_task_id: Mapped[str] = mapped_column(Text, primary_key=True) + step_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True, index=True) + task_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + edge_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + tool_name: Mapped[str] = mapped_column(Text, nullable=False) + params_json: Mapped[str] = mapped_column(Text, nullable=False) + step_status: Mapped[str] = mapped_column(Text, nullable=False, index=True) + success: Mapped[int | None] = mapped_column(Integer, nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + result_data_json: Mapped[str] = mapped_column(Text, nullable=False) + evidence_json: Mapped[str] = mapped_column(Text, nullable=False) + expire_at: Mapped[str] = mapped_column(Text, nullable=False) + started_at: Mapped[str | None] = mapped_column(Text, nullable=True) + finished_at: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[str] = mapped_column(Text, nullable=False) + updated_at: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..e8dc291 --- /dev/null +++ b/backend/app/models/task.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Task(Base): + __tablename__ = "task" + + task_id: Mapped[str] = mapped_column(Text, primary_key=True) + session_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + request_id: Mapped[str | None] = mapped_column(Text, nullable=True) + input_text: Mapped[str] = mapped_column(Text, nullable=False) + channel: Mapped[str] = mapped_column(Text, nullable=False) + action_type: Mapped[str | None] = mapped_column(Text, nullable=True) + app_code: Mapped[str | None] = mapped_column(Text, nullable=True) + env: Mapped[str | None] = mapped_column(Text, nullable=True) + version: Mapped[str | None] = mapped_column(Text, nullable=True) + software_a_task_id: Mapped[str | None] = mapped_column(Text, nullable=True) + software_a_task_status: Mapped[str | None] = mapped_column(Text, nullable=True) + risk_level: Mapped[str] = mapped_column(Text, nullable=False) + approval_status: Mapped[str] = mapped_column(Text, nullable=False) + task_status: Mapped[str] = mapped_column(Text, nullable=False, index=True) + parsed_intent_json: Mapped[str] = mapped_column(Text, nullable=False) + missing_slots_json: Mapped[str] = mapped_column(Text, nullable=False) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[str] = mapped_column(Text, nullable=False, index=True) + updated_at: Mapped[str] = mapped_column(Text, nullable=False) + confirmed_at: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/models/tool_call.py b/backend/app/models/tool_call.py new file mode 100644 index 0000000..ac6916a --- /dev/null +++ b/backend/app/models/tool_call.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from sqlalchemy import Integer, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ToolCall(Base): + __tablename__ = "tool_call" + + tool_call_id: Mapped[str] = mapped_column(Text, primary_key=True) + task_id: Mapped[str] = mapped_column(Text, nullable=False, index=True) + request_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + operator_user_id: Mapped[str | None] = mapped_column(Text, nullable=True) + operator_user_name: Mapped[str | None] = mapped_column(Text, nullable=True) + step_id: Mapped[str | None] = mapped_column(Text, nullable=True) + tool_name: Mapped[str] = mapped_column(Text, nullable=False, index=True) + request_payload_json: Mapped[str] = mapped_column(Text, nullable=False) + response_payload_json: Mapped[str] = mapped_column(Text, nullable=False) + success: Mapped[int] = mapped_column(Integer, nullable=False) + duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + started_at: Mapped[str | None] = mapped_column(Text, nullable=True) + finished_at: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/repositories/approval_repository.py b/backend/app/repositories/approval_repository.py new file mode 100644 index 0000000..73ac9a8 --- /dev/null +++ b/backend/app/repositories/approval_repository.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.approval import ApprovalRequest + + +class ApprovalRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def add(self, approval: ApprovalRequest) -> ApprovalRequest: + self.db.add(approval) + self.db.commit() + self.db.refresh(approval) + return approval + + def update(self, approval: ApprovalRequest) -> ApprovalRequest: + self.db.add(approval) + self.db.commit() + self.db.refresh(approval) + return approval + + def get_by_approval_id(self, approval_id: str) -> ApprovalRequest | None: + statement = select(ApprovalRequest).where(ApprovalRequest.approval_id == approval_id) + return self.db.execute(statement).scalar_one_or_none() + + def get_by_task_id(self, task_id: str) -> ApprovalRequest | None: + statement = select(ApprovalRequest).where(ApprovalRequest.task_id == task_id) + return self.db.execute(statement).scalar_one_or_none() + + def list_pending(self, approver_user_id: str | None = None) -> list[ApprovalRequest]: + statement = select(ApprovalRequest).where(ApprovalRequest.approval_status == "PENDING") + approvals = list(self.db.execute(statement).scalars()) + if approver_user_id is None: + return approvals + return [item for item in approvals if approver_user_id in item.approver_user_ids_json] diff --git a/backend/app/repositories/audit_repository.py b/backend/app/repositories/audit_repository.py new file mode 100644 index 0000000..03f1247 --- /dev/null +++ b/backend/app/repositories/audit_repository.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.audit_log import AuditLog + + +class AuditRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def add(self, audit_log: AuditLog) -> AuditLog: + self.db.add(audit_log) + self.db.commit() + self.db.refresh(audit_log) + return audit_log + + def list_by_task_id(self, task_id: str) -> list[AuditLog]: + statement = select(AuditLog).where(AuditLog.task_id == task_id).order_by(AuditLog.timestamp.asc()) + return list(self.db.execute(statement).scalars()) diff --git a/backend/app/repositories/edge_repository.py b/backend/app/repositories/edge_repository.py new file mode 100644 index 0000000..643b950 --- /dev/null +++ b/backend/app/repositories/edge_repository.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.edge_node import EdgeNode +from app.models.edge_task import EdgeTask + + +class EdgeNodeRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def add_or_update(self, node: EdgeNode) -> EdgeNode: + self.db.add(node) + self.db.commit() + self.db.refresh(node) + return node + + def get_by_edge_id(self, edge_id: str) -> EdgeNode | None: + statement = select(EdgeNode).where(EdgeNode.edge_id == edge_id) + return self.db.execute(statement).scalar_one_or_none() + + +class EdgeTaskRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def add(self, edge_task: EdgeTask) -> EdgeTask: + self.db.add(edge_task) + self.db.commit() + self.db.refresh(edge_task) + return edge_task + + def update(self, edge_task: EdgeTask) -> EdgeTask: + self.db.add(edge_task) + self.db.commit() + self.db.refresh(edge_task) + return edge_task + + def get_by_step_id(self, step_id: str) -> EdgeTask | None: + statement = select(EdgeTask).where(EdgeTask.step_id == step_id) + return self.db.execute(statement).scalar_one_or_none() + + def list_by_task_id(self, task_id: str) -> list[EdgeTask]: + statement = select(EdgeTask).where(EdgeTask.task_id == task_id).order_by(EdgeTask.created_at.desc()) + return list(self.db.execute(statement).scalars()) + + def list_pending_by_edge_id(self, edge_id: str) -> list[EdgeTask]: + statement = ( + select(EdgeTask) + .where(EdgeTask.edge_id == edge_id) + .where(EdgeTask.step_status == "PENDING") + .order_by(EdgeTask.created_at.asc()) + ) + return list(self.db.execute(statement).scalars()) + + def list_active_by_task_id(self, task_id: str) -> list[EdgeTask]: + statement = ( + select(EdgeTask) + .where(EdgeTask.task_id == task_id) + .where(EdgeTask.step_status.in_(["PENDING", "RUNNING"])) + .order_by(EdgeTask.created_at.asc()) + ) + return list(self.db.execute(statement).scalars()) diff --git a/backend/app/repositories/task_repository.py b/backend/app/repositories/task_repository.py new file mode 100644 index 0000000..ab83df9 --- /dev/null +++ b/backend/app/repositories/task_repository.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.task import Task + + +class TaskRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def add(self, task: Task) -> Task: + self.db.add(task) + self.db.commit() + self.db.refresh(task) + return task + + def update(self, task: Task) -> Task: + self.db.add(task) + self.db.commit() + self.db.refresh(task) + return task + + def get_by_task_id(self, task_id: str) -> Task | None: + statement = select(Task).where(Task.task_id == task_id) + return self.db.execute(statement).scalar_one_or_none() diff --git a/backend/app/repositories/tool_call_repository.py b/backend/app/repositories/tool_call_repository.py new file mode 100644 index 0000000..747781b --- /dev/null +++ b/backend/app/repositories/tool_call_repository.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.tool_call import ToolCall + + +class ToolCallRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def add(self, tool_call: ToolCall) -> ToolCall: + self.db.add(tool_call) + self.db.commit() + self.db.refresh(tool_call) + return tool_call + + def list_by_task_id(self, task_id: str) -> list[ToolCall]: + statement = select(ToolCall).where(ToolCall.task_id == task_id).order_by(ToolCall.started_at.asc()) + return list(self.db.execute(statement).scalars()) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/schemas/approval.py b/backend/app/schemas/approval.py new file mode 100644 index 0000000..eed981e --- /dev/null +++ b/backend/app/schemas/approval.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ApprovalOperator(BaseModel): + user_id: str + user_name: str + + +class ApprovalTarget(BaseModel): + app_code: str + env: str + + +class CreateApprovalRequest(BaseModel): + task_id: str + risk_level: str + operator: ApprovalOperator + action_type: str + target: ApprovalTarget + reason: str + approvers: list[str] = Field(default_factory=list) + + +class CreateApprovalData(BaseModel): + approval_id: str + approval_status: str + + +class ApprovalDecisionRequest(BaseModel): + decision: str + comment: str | None = None + operator: ApprovalOperator + + +class ApprovalDetailData(BaseModel): + approval_id: str + task_id: str + approval_status: str + risk_level: str + approvers: list[str] + reason: str | None = None + created_at: str + updated_at: str + + +class ApprovalListData(BaseModel): + approvals: list[ApprovalDetailData] diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..1f6862c --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, Field + +DataT = TypeVar("DataT") + + +class ApiResponse(BaseModel, Generic[DataT]): + request_id: str + success: bool + code: str + message: str + data: DataT + timestamp: str + + +class ErrorResponse(BaseModel): + request_id: str + success: bool = False + code: str + message: str + data: dict[str, Any] = Field(default_factory=dict) + timestamp: str diff --git a/backend/app/schemas/edge.py b/backend/app/schemas/edge.py new file mode 100644 index 0000000..25b2b87 --- /dev/null +++ b/backend/app/schemas/edge.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class EdgeHeartbeatRequest(BaseModel): + edge_id: str + hostname: str + os_type: str + agent_version: str + capabilities: list[str] = Field(default_factory=list) + + +class EdgeHeartbeatData(BaseModel): + edge_id: str + node_status: str + last_heartbeat_at: str + + +class EdgePullTasksRequest(BaseModel): + edge_id: str + max_tasks: int = 5 + + +class EdgeTaskItem(BaseModel): + task_id: str + step_id: str + tool_name: str + params: dict[str, Any] + expire_at: str + + +class EdgePullTasksData(BaseModel): + tasks: list[EdgeTaskItem] + + +class EdgeTaskReportRequest(BaseModel): + edge_id: str + task_id: str + step_id: str + tool_name: str + success: bool + code: str + message: str + data: dict[str, Any] = Field(default_factory=dict) + evidence: dict[str, Any] = Field(default_factory=dict) + started_at: str + finished_at: str + + +class EdgeTaskReportData(BaseModel): + task_id: str + step_id: str + step_status: str + task_status: str + + +class EdgeEventRequest(BaseModel): + edge_id: str + event_type: str + message: str + detail: dict[str, Any] = Field(default_factory=dict) + + +class EdgeEventData(BaseModel): + edge_id: str + event_type: str + accepted: bool diff --git a/backend/app/schemas/identity.py b/backend/app/schemas/identity.py new file mode 100644 index 0000000..51ff85f --- /dev/null +++ b/backend/app/schemas/identity.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str + + +class IdentityUser(BaseModel): + user_id: str + user_name: str + display_name: str + roles: list[str] + tenant_id: str + + +class LoginData(BaseModel): + access_token: str + expires_in_seconds: int + user: IdentityUser + + +class PermissionsData(BaseModel): + user_id: str + roles: list[str] + permissions: list[str] + allowed_envs: list[str] + allowed_apps: list[str] + + +class TokenIntrospectRequest(BaseModel): + access_token: str + + +class TokenIntrospectData(BaseModel): + active: bool + user: IdentityUser | None = None diff --git a/backend/app/schemas/software_a.py b/backend/app/schemas/software_a.py new file mode 100644 index 0000000..7233467 --- /dev/null +++ b/backend/app/schemas/software_a.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class SoftwareAOperator(BaseModel): + user_id: str + user_name: str + + +class DeployOptions(BaseModel): + graceful: bool = True + + +class CreateDeployTaskRequest(BaseModel): + operator: SoftwareAOperator + tenant_id: str + app_code: str + env: str + version: str + target_nodes: list[str] = Field(default_factory=list) + deploy_options: DeployOptions = Field(default_factory=DeployOptions) + + +class CreateDeployTaskData(BaseModel): + software_a_task_id: str + task_status: str + + +class DeployTaskDetailData(BaseModel): + software_a_task_id: str + task_status: str + progress_percent: int + app_code: str + env: str + version: str + target_nodes: list[str] + started_at: str + finished_at: str | None = None + error_detail: str | None = None + + +class PermissionCheckRequest(BaseModel): + operator: SoftwareAOperator + action_type: str + app_code: str + env: str + + +class PermissionCheckData(BaseModel): + allowed: bool + reason: str diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py new file mode 100644 index 0000000..0b6b429 --- /dev/null +++ b/backend/app/schemas/task.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class ParsedIntent(BaseModel): + action_type: str | None = None + app_code: str | None = None + env: str | None = None + version: str | None = None + + +class CreateTaskRequest(BaseModel): + input_text: str + channel: str + session_id: str + tenant_id: str + context: dict[str, Any] = Field(default_factory=dict) + + +class CreateTaskData(BaseModel): + task_id: str + parsed_intent: ParsedIntent + missing_slots: list[str] + risk_level: str + task_status: str + next_action: str + + +class ConfirmTaskRequest(BaseModel): + confirmed: bool + comment: str | None = None + + +class CancelTaskRequest(BaseModel): + reason: str | None = None + + +class ConfirmTaskData(BaseModel): + task_id: str + task_status: str + approval_status: str + approval_id: str | None = None + software_a_task_id: str | None = None + software_a_task_status: str | None = None + + +class ToolCallItem(BaseModel): + tool_name: str + success: bool + + +class VerificationResult(BaseModel): + process_ok: bool | None = None + port_ok: bool | None = None + http_ok: bool | None = None + log_error_count: int | None = None + + +class TaskDetailData(BaseModel): + task_id: str + task_status: str + approval_status: str + risk_level: str + intent: ParsedIntent + software_a_task_id: str | None = None + software_a_task_status: str | None = None + tool_calls: list[ToolCallItem] + verification_result: VerificationResult | None = None + summary: str | None = None + + +class TaskBasic(BaseModel): + task_id: str + task_status: str + approval_status: str + risk_level: str + created_at: str + updated_at: str + confirmed_at: str | None = None + + +class ApprovalTraceItem(BaseModel): + approval_id: str + approval_status: str + risk_level: str + approvers: list[str] + reason: str | None = None + created_at: str + updated_at: str + + +class ToolTraceItem(BaseModel): + tool_call_id: str + request_id: str | None = None + operator_user_id: str | None = None + operator_user_name: str | None = None + tool_name: str + success: bool + duration_ms: int | None = None + started_at: str | None = None + finished_at: str | None = None + request_payload: dict[str, Any] + response_payload: dict[str, Any] + + +class VerificationTraceItem(BaseModel): + step_id: str + edge_id: str + tool_name: str + step_status: str + success: bool | None = None + message: str | None = None + params: dict[str, Any] + result_data: dict[str, Any] + evidence: dict[str, Any] + started_at: str | None = None + finished_at: str | None = None + + +class AuditTraceItem(BaseModel): + audit_id: str + request_id: str | None = None + action: str + result: str + operator_user_id: str | None = None + operator_user_name: str | None = None + target: str | None = None + detail: dict[str, Any] + timestamp: str + + +class TaskReportData(BaseModel): + task_basic: TaskBasic + intent_snapshot: ParsedIntent + approval_trace: list[ApprovalTraceItem] + tool_trace: list[ToolTraceItem] + verification_trace: list[VerificationTraceItem] + result_summary: str | None = None + audit_trace: list[AuditTraceItem] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/services/approval_service.py b/backend/app/services/approval_service.py new file mode 100644 index 0000000..024a37c --- /dev/null +++ b/backend/app/services/approval_service.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from app.core.constants import ( + APPROVAL_STATUS_APPROVED, + APPROVAL_STATUS_PENDING, + APPROVAL_STATUS_REJECTED, + DECISION_APPROVED, + DECISION_REJECTED, + ERROR_CODE_CONFLICT, + ERROR_CODE_NOT_FOUND, + TASK_STATUS_CANCELLED, + TASK_STATUS_PENDING_APPROVAL, + TASK_STATUS_RUNNING, +) +from app.core.time import format_now +from app.models.audit_log import AuditLog +from app.models.approval import ApprovalRequest +from app.repositories.audit_repository import AuditRepository +from app.repositories.approval_repository import ApprovalRepository +from app.repositories.task_repository import TaskRepository +from app.schemas.approval import ApprovalDecisionRequest, CreateApprovalRequest + + +class ApprovalConflictError(Exception): + code = ERROR_CODE_CONFLICT + + +class ApprovalNotFoundError(Exception): + code = ERROR_CODE_NOT_FOUND + + +class ApprovalService: + def __init__(self, db: Session, timezone_name: str) -> None: + self.db = db + self.timezone_name = timezone_name + self.repository = ApprovalRepository(db) + self.audit_repository = AuditRepository(db) + self.task_repository = TaskRepository(db) + + def create_request(self, payload: CreateApprovalRequest) -> ApprovalRequest: + current_time = format_now(self.timezone_name) + approval = ApprovalRequest( + approval_id=f"ap-{uuid4().hex[:12]}", + task_id=payload.task_id, + approval_status=APPROVAL_STATUS_PENDING, + risk_level=payload.risk_level, + operator_user_id=payload.operator.user_id, + operator_user_name=payload.operator.user_name, + approver_user_ids_json=json.dumps(payload.approvers, ensure_ascii=False), + reason=payload.reason, + created_at=current_time, + updated_at=current_time, + ) + created_approval = self.repository.add(approval) + self._write_audit_log( + task_id=payload.task_id, + request_id=None, + action="APPROVAL_REQUEST_CREATED", + result="PENDING", + target=payload.target.app_code, + operator_user_id=payload.operator.user_id, + operator_user_name=payload.operator.user_name, + detail={ + "approval_id": created_approval.approval_id, + "approvers": payload.approvers, + "reason": payload.reason, + }, + ) + return created_approval + + def get_request(self, approval_id: str) -> ApprovalRequest: + approval = self.repository.get_by_approval_id(approval_id) + if not approval: + raise ApprovalNotFoundError() + return approval + + def list_pending(self, approver_user_id: str | None = None) -> list[ApprovalRequest]: + return self.repository.list_pending(approver_user_id) + + def decide(self, approval_id: str, payload: ApprovalDecisionRequest, request_id: str | None = None) -> ApprovalRequest: + approval = self.repository.get_by_approval_id(approval_id) + if not approval: + raise ApprovalNotFoundError() + if approval.approval_status != APPROVAL_STATUS_PENDING: + raise ApprovalConflictError("当前审批单状态不允许重复决策。") + + task = self.task_repository.get_by_task_id(approval.task_id) + if not task: + raise ApprovalConflictError("审批关联任务不存在,无法继续决策。") + if task.task_status != TASK_STATUS_PENDING_APPROVAL or task.approval_status != APPROVAL_STATUS_PENDING: + raise ApprovalConflictError("审批关联任务已不处于待审批状态。") + + current_time = format_now(self.timezone_name) + if payload.decision == DECISION_APPROVED: + approval.approval_status = APPROVAL_STATUS_APPROVED + self._update_task_after_decision(approval.task_id, TASK_STATUS_RUNNING, APPROVAL_STATUS_APPROVED, request_id=request_id) + elif payload.decision == DECISION_REJECTED: + approval.approval_status = APPROVAL_STATUS_REJECTED + self._update_task_after_decision(approval.task_id, TASK_STATUS_CANCELLED, APPROVAL_STATUS_REJECTED, request_id=request_id) + else: + raise ApprovalConflictError("decision 仅支持 APPROVED 或 REJECTED。") + + approval.updated_at = current_time + updated_approval = self.repository.update(approval) + self._write_audit_log( + task_id=updated_approval.task_id, + request_id=request_id, + action="APPROVAL_DECISION", + result=updated_approval.approval_status, + target=payload.operator.user_name, + operator_user_id=payload.operator.user_id, + operator_user_name=payload.operator.user_name, + detail={"approval_id": updated_approval.approval_id, "comment": payload.comment}, + ) + return updated_approval + + def _update_task_after_decision(self, task_id: str, task_status: str, approval_status: str, request_id: str | None = None) -> None: + task = self.task_repository.get_by_task_id(task_id) + if not task: + raise ApprovalConflictError("审批关联任务不存在,无法更新任务状态。") + if task.task_status != TASK_STATUS_PENDING_APPROVAL or task.approval_status != APPROVAL_STATUS_PENDING: + raise ApprovalConflictError("审批关联任务已不处于待审批状态。") + task.task_status = task_status + task.approval_status = approval_status + task.updated_at = format_now(self.timezone_name) + if approval_status == APPROVAL_STATUS_APPROVED: + task.summary = "审批已通过,准备调用 software-a demo 执行。" + self.task_repository.update(task) + from app.services.task_service import TaskService + + TaskService(self.db, self.timezone_name).execute_task(task_id, request_id=request_id) + else: + task.summary = "审批已驳回,任务已取消。" + self.task_repository.update(task) + + def _write_audit_log( + self, + task_id: str, + request_id: str | None, + action: str, + result: str, + target: str | None, + operator_user_id: str | None, + operator_user_name: str | None, + detail: dict, + ) -> AuditLog: + audit_log = AuditLog( + audit_id=f"audit-{uuid4().hex[:12]}", + task_id=task_id, + request_id=request_id, + action=action, + operator_user_id=operator_user_id, + operator_user_name=operator_user_name, + target=target, + result=result, + detail_json=json.dumps(detail, ensure_ascii=False), + timestamp=format_now(self.timezone_name), + ) + return self.audit_repository.add(audit_log) diff --git a/backend/app/services/edge_service.py b/backend/app/services/edge_service.py new file mode 100644 index 0000000..f28f3e3 --- /dev/null +++ b/backend/app/services/edge_service.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import json +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from app.core.constants import ( + EDGE_NODE_STATUS_ONLINE, + EDGE_STEP_STATUS_CANCELLED, + EDGE_STEP_STATUS_FAILED, + EDGE_STEP_STATUS_PENDING, + EDGE_STEP_STATUS_RUNNING, + EDGE_STEP_STATUS_SUCCEEDED, + ERROR_CODE_CONFLICT, + ERROR_CODE_NOT_FOUND, + TASK_STATUS_FAILED, + TASK_STATUS_RUNNING, + TASK_STATUS_SUCCEEDED, + TASK_STATUS_VERIFYING, +) +from app.core.time import format_now +from app.models.edge_node import EdgeNode +from app.models.edge_task import EdgeTask +from app.models.audit_log import AuditLog +from app.models.tool_call import ToolCall +from app.repositories.audit_repository import AuditRepository +from app.repositories.edge_repository import EdgeNodeRepository, EdgeTaskRepository +from app.repositories.task_repository import TaskRepository +from app.repositories.tool_call_repository import ToolCallRepository + + +class EdgeTaskConflictError(Exception): + code = ERROR_CODE_CONFLICT + + +class EdgeTaskNotFoundError(Exception): + code = ERROR_CODE_NOT_FOUND + + +class EdgeService: + def __init__(self, db: Session, timezone_name: str) -> None: + self.db = db + self.timezone_name = timezone_name + self.node_repository = EdgeNodeRepository(db) + self.edge_task_repository = EdgeTaskRepository(db) + self.task_repository = TaskRepository(db) + self.audit_repository = AuditRepository(db) + self.tool_call_repository = ToolCallRepository(db) + + def heartbeat(self, edge_id: str, hostname: str, os_type: str, agent_version: str, capabilities: list[str]) -> EdgeNode: + current_time = format_now(self.timezone_name) + node = self.node_repository.get_by_edge_id(edge_id) + if node: + node.hostname = hostname + node.os_type = os_type + node.agent_version = agent_version + node.capabilities_json = json.dumps(capabilities, ensure_ascii=False) + node.node_status = EDGE_NODE_STATUS_ONLINE + node.last_heartbeat_at = current_time + node.updated_at = current_time + return self.node_repository.add_or_update(node) + + node = EdgeNode( + edge_id=edge_id, + hostname=hostname, + os_type=os_type, + agent_version=agent_version, + capabilities_json=json.dumps(capabilities, ensure_ascii=False), + node_status=EDGE_NODE_STATUS_ONLINE, + last_heartbeat_at=current_time, + created_at=current_time, + updated_at=current_time, + ) + return self.node_repository.add_or_update(node) + + def schedule_default_verification(self, task_id: str, edge_id: str = "edge-shanghai-001") -> EdgeTask: + task = self.task_repository.get_by_task_id(task_id) + if not task: + raise EdgeTaskNotFoundError() + if task.task_status != TASK_STATUS_RUNNING: + raise EdgeTaskConflictError("当前任务状态不允许创建 edge 验证步骤。") + if self.edge_task_repository.list_active_by_task_id(task_id): + raise EdgeTaskConflictError("当前任务已存在待处理的 edge 验证步骤。") + + current_time = format_now(self.timezone_name) + step_id = f"step-{uuid4().hex[:12]}" + edge_task = EdgeTask( + edge_task_id=f"edge-task-{uuid4().hex[:12]}", + step_id=step_id, + task_id=task_id, + edge_id=edge_id, + tool_name="http_health_check", + params_json=json.dumps( + { + "url": f"http://{task.app_code or 'localhost'}.{task.env or 'env'}.demo/actuator/health", + "timeout_ms": 3000, + }, + ensure_ascii=False, + ), + step_status=EDGE_STEP_STATUS_PENDING, + success=None, + message=None, + result_data_json="{}", + evidence_json="{}", + expire_at=current_time, + started_at=None, + finished_at=None, + created_at=current_time, + updated_at=current_time, + ) + created_edge_task = self.edge_task_repository.add(edge_task) + self._write_audit_log( + task_id=task_id, + request_id=None, + action="EDGE_TASK_SCHEDULED", + result="PENDING", + target=edge_id, + operator_user_id=None, + operator_user_name=None, + detail={"step_id": created_edge_task.step_id, "tool_name": created_edge_task.tool_name}, + ) + return created_edge_task + + def pull_tasks(self, edge_id: str, max_tasks: int) -> list[EdgeTask]: + items = self.edge_task_repository.list_pending_by_edge_id(edge_id)[:max_tasks] + current_time = format_now(self.timezone_name) + pulled_items: list[EdgeTask] = [] + for item in items: + task = self.task_repository.get_by_task_id(item.task_id) + if not task or task.task_status != TASK_STATUS_RUNNING: + item.step_status = EDGE_STEP_STATUS_CANCELLED + item.updated_at = current_time + item.message = "task state no longer allows edge execution" + self.edge_task_repository.update(item) + continue + + item.step_status = EDGE_STEP_STATUS_RUNNING + item.started_at = current_time + item.updated_at = current_time + self.edge_task_repository.update(item) + + task.task_status = TASK_STATUS_VERIFYING + task.updated_at = current_time + task.summary = "任务已进入边缘验证阶段。" + self.task_repository.update(task) + pulled_items.append(item) + return pulled_items + + def report_task(self, edge_id: str, step_id: str, success: bool, message: str, data: dict, evidence: dict, started_at: str, finished_at: str) -> tuple[EdgeTask, str]: + edge_task = self.edge_task_repository.get_by_step_id(step_id) + if not edge_task: + raise EdgeTaskNotFoundError() + if edge_task.edge_id != edge_id: + raise EdgeTaskConflictError("edge_id 与任务归属不一致。") + if edge_task.step_status not in {EDGE_STEP_STATUS_RUNNING, EDGE_STEP_STATUS_PENDING}: + raise EdgeTaskConflictError("当前步骤状态不允许重复回传。") + + task = self.task_repository.get_by_task_id(edge_task.task_id) + if not task: + raise EdgeTaskConflictError("edge 步骤关联任务不存在。") + if task.task_status not in {TASK_STATUS_RUNNING, TASK_STATUS_VERIFYING}: + raise EdgeTaskConflictError("当前任务状态不允许回传 edge 结果。") + + edge_task.step_status = EDGE_STEP_STATUS_SUCCEEDED if success else EDGE_STEP_STATUS_FAILED + edge_task.success = 1 if success else 0 + edge_task.message = message + edge_task.result_data_json = json.dumps(data, ensure_ascii=False) + edge_task.evidence_json = json.dumps(evidence, ensure_ascii=False) + edge_task.started_at = started_at + edge_task.finished_at = finished_at + edge_task.updated_at = format_now(self.timezone_name) + updated_edge_task = self.edge_task_repository.update(edge_task) + self._write_tool_call( + task_id=updated_edge_task.task_id, + request_id=None, + operator_user_id=edge_id, + operator_user_name=edge_id, + step_id=updated_edge_task.step_id, + tool_name=updated_edge_task.tool_name, + request_payload=json.loads(updated_edge_task.params_json), + response_payload={"data": data, "evidence": evidence, "message": message}, + success=success, + started_at=started_at, + finished_at=finished_at, + ) + + task_status = TASK_STATUS_RUNNING + task.task_status = TASK_STATUS_SUCCEEDED if success else TASK_STATUS_FAILED + task.updated_at = format_now(self.timezone_name) + task.summary = "边缘验证通过,任务完成。" if success else "边缘验证失败,任务失败。" + self.task_repository.update(task) + task_status = task.task_status + self._write_audit_log( + task_id=task.task_id, + request_id=None, + action="EDGE_TASK_REPORTED", + result=task.task_status, + target=edge_id, + operator_user_id=edge_id, + operator_user_name=edge_id, + detail={"step_id": step_id, "tool_name": edge_task.tool_name, "message": message}, + ) + + return updated_edge_task, task_status + + def record_event(self, edge_id: str, event_type: str, message: str, detail: dict) -> AuditLog: + current_time = format_now(self.timezone_name) + audit = AuditLog( + audit_id=f"audit-{uuid4().hex[:12]}", + task_id=f"edge-event:{edge_id}", + action=f"EDGE_EVENT:{event_type}", + operator_user_id=edge_id, + operator_user_name=edge_id, + target=edge_id, + result="REPORTED", + detail_json=json.dumps({"message": message, "detail": detail}, ensure_ascii=False), + timestamp=current_time, + ) + self.db.add(audit) + self.db.commit() + self.db.refresh(audit) + return audit + + def _write_tool_call( + self, + task_id: str, + request_id: str | None, + operator_user_id: str | None, + operator_user_name: str | None, + step_id: str | None, + tool_name: str, + request_payload: dict, + response_payload: dict, + success: bool, + started_at: str | None, + finished_at: str | None, + ) -> ToolCall: + tool_call = ToolCall( + tool_call_id=f"tool-call-{uuid4().hex[:12]}", + task_id=task_id, + request_id=request_id, + operator_user_id=operator_user_id, + operator_user_name=operator_user_name, + step_id=step_id, + tool_name=tool_name, + request_payload_json=json.dumps(request_payload, ensure_ascii=False), + response_payload_json=json.dumps(response_payload, ensure_ascii=False), + success=1 if success else 0, + duration_ms=None, + started_at=started_at, + finished_at=finished_at, + ) + return self.tool_call_repository.add(tool_call) + + def _write_audit_log( + self, + task_id: str, + request_id: str | None, + action: str, + result: str, + target: str | None, + operator_user_id: str | None, + operator_user_name: str | None, + detail: dict, + ) -> AuditLog: + audit_log = AuditLog( + audit_id=f"audit-{uuid4().hex[:12]}", + task_id=task_id, + request_id=request_id, + action=action, + operator_user_id=operator_user_id, + operator_user_name=operator_user_name, + target=target, + result=result, + detail_json=json.dumps(detail, ensure_ascii=False), + timestamp=format_now(self.timezone_name), + ) + return self.audit_repository.add(audit_log) diff --git a/backend/app/services/identity_service.py b/backend/app/services/identity_service.py new file mode 100644 index 0000000..b37e8a9 --- /dev/null +++ b/backend/app/services/identity_service.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from app.schemas.identity import IdentityUser + + +class IdentityService: + DEMO_USERS = { + "alice": { + "user_id": "u1001", + "user_name": "alice", + "display_name": "Alice", + "roles": ["DEPLOY_OPERATOR"], + "tenant_id": "tenant-demo", + "permissions": ["task:create", "task:confirm", "software_a:deploy"], + "allowed_envs": ["test", "staging"], + "allowed_apps": ["order-service", "user-service"], + }, + "bob": { + "user_id": "u2001", + "user_name": "bob", + "display_name": "Bob", + "roles": ["APPROVER"], + "tenant_id": "tenant-demo", + "permissions": ["approval:decision"], + "allowed_envs": ["prod"], + "allowed_apps": ["order-service", "user-service"], + }, + } + + def login(self, username: str, _: str) -> tuple[str, dict] | None: + user = self.DEMO_USERS.get(username) + if not user: + return None + return f"demo-token-{username}", user + + def get_user_by_token(self, access_token: str) -> dict | None: + if not access_token.startswith("demo-token-"): + return None + username = access_token.removeprefix("demo-token-") + return self.DEMO_USERS.get(username) + + def get_permissions(self, user_id: str) -> dict | None: + for user in self.DEMO_USERS.values(): + if user["user_id"] == user_id: + return user + return None + + @staticmethod + def to_identity_user(user: dict) -> IdentityUser: + return IdentityUser( + user_id=user["user_id"], + user_name=user["user_name"], + display_name=user["display_name"], + roles=user["roles"], + tenant_id=user["tenant_id"], + ) diff --git a/backend/app/services/intent_service.py b/backend/app/services/intent_service.py new file mode 100644 index 0000000..4dee72e --- /dev/null +++ b/backend/app/services/intent_service.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import re + +from app.core.constants import ACTION_TYPE_DEPLOY + + +class IntentService: + ENV_MAPPING = { + "测试环境": "test", + "测试": "test", + "test": "test", + "预发环境": "staging", + "预发": "staging", + "staging": "staging", + "生产环境": "prod", + "生产": "prod", + "prod": "prod", + } + + def parse(self, input_text: str) -> tuple[dict[str, str | None], list[str]]: + parsed_intent: dict[str, str | None] = { + "action_type": ACTION_TYPE_DEPLOY if self._is_deploy(input_text) else None, + "app_code": self._extract_app_code(input_text), + "env": self._extract_env(input_text), + "version": self._extract_version(input_text), + } + + missing_slots = [ + slot_name + for slot_name, value in parsed_intent.items() + if slot_name in {"action_type", "app_code", "env", "version"} and not value + ] + return parsed_intent, missing_slots + + def _is_deploy(self, input_text: str) -> bool: + lowered = input_text.lower() + return "部署" in input_text or "deploy" in lowered + + def _extract_app_code(self, input_text: str) -> str | None: + patterns = [ + r"把\s*([A-Za-z0-9_-]+)", + r"deploy\s+([A-Za-z0-9_-]+)", + r"\b([A-Za-z0-9_-]+-service)\b", + ] + for pattern in patterns: + match = re.search(pattern, input_text, re.IGNORECASE) + if match: + return match.group(1) + return None + + def _extract_env(self, input_text: str) -> str | None: + lowered = input_text.lower() + for key, value in self.ENV_MAPPING.items(): + if key.lower() in lowered: + return value + return None + + def _extract_version(self, input_text: str) -> str | None: + match = re.search(r"\b\d+\.\d+\.\d+(?:[-._A-Za-z0-9]+)?\b", input_text) + return match.group(0) if match else None diff --git a/backend/app/services/risk_service.py b/backend/app/services/risk_service.py new file mode 100644 index 0000000..a82df9f --- /dev/null +++ b/backend/app/services/risk_service.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from app.core.constants import RISK_LEVEL_HIGH, RISK_LEVEL_LOW, RISK_LEVEL_MEDIUM + + +class RiskService: + def evaluate(self, parsed_intent: dict[str, str | None]) -> str: + env = parsed_intent.get("env") + action_type = parsed_intent.get("action_type") + + if env == "prod": + return RISK_LEVEL_HIGH + if action_type == "DEPLOY": + if env in {"test", "staging"}: + return RISK_LEVEL_MEDIUM + return RISK_LEVEL_LOW + return RISK_LEVEL_LOW diff --git a/backend/app/services/software_a_service.py b/backend/app/services/software_a_service.py new file mode 100644 index 0000000..5585136 --- /dev/null +++ b/backend/app/services/software_a_service.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from uuid import uuid4 + +from app.core.constants import ( + SOFTWARE_A_TASK_STATUS_FAILED, + SOFTWARE_A_TASK_STATUS_RUNNING, + SOFTWARE_A_TASK_STATUS_SUCCEEDED, +) +from app.core.time import format_now +from app.schemas.software_a import CreateDeployTaskRequest + + +class SoftwareAService: + _deploy_tasks: dict[str, dict] = {} + + def __init__(self, timezone_name: str) -> None: + self.timezone_name = timezone_name + + def create_deploy_task(self, payload: CreateDeployTaskRequest) -> dict: + task_id = f"sa-task-{uuid4().hex[:12]}" + should_fail = self._should_fail_deploy(payload) + task_status = SOFTWARE_A_TASK_STATUS_FAILED if should_fail else SOFTWARE_A_TASK_STATUS_RUNNING + error_detail = self._build_error_detail(payload) if should_fail else None + task = { + "software_a_task_id": task_id, + "task_status": task_status, + "progress_percent": 100, + "app_code": payload.app_code, + "env": payload.env, + "version": payload.version, + "target_nodes": payload.target_nodes, + "started_at": format_now(self.timezone_name), + "finished_at": format_now(self.timezone_name), + "error_detail": error_detail, + } + self._deploy_tasks[task_id] = task + return task + + def get_deploy_task(self, software_a_task_id: str) -> dict | None: + task = self._deploy_tasks.get(software_a_task_id) + if not task: + return None + if task["task_status"] == SOFTWARE_A_TASK_STATUS_FAILED: + return task + task["task_status"] = SOFTWARE_A_TASK_STATUS_SUCCEEDED + task["progress_percent"] = 100 + return task + + def check_permission(self, action_type: str, env: str, approval_status: str | None = None) -> tuple[bool, str]: + if env == "prod" and action_type in {"STOP_SERVICE", "RESTART_SERVICE", "DEPLOY"} and approval_status != "APPROVED": + return False, "生产环境动作默认需要额外审批" + return True, "" + + def _should_fail_deploy(self, payload: CreateDeployTaskRequest) -> bool: + app_code = payload.app_code.lower() + version = payload.version.lower() + return "fail" in app_code or "fail" in version + + def _build_error_detail(self, payload: CreateDeployTaskRequest) -> str: + return f"demo deploy failed for app={payload.app_code}, env={payload.env}, version={payload.version}" diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py new file mode 100644 index 0000000..5f3be99 --- /dev/null +++ b/backend/app/services/task_service.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import json +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from app.core.constants import ( + APPROVAL_STATUS_APPROVED, + APPROVAL_STATUS_NOT_REQUIRED, + APPROVAL_STATUS_PENDING, + EDGE_STEP_STATUS_CANCELLED, + ERROR_CODE_CONFLICT, + ERROR_CODE_NOT_FOUND, + ERROR_CODE_PERMISSION_DENIED, + RISK_LEVEL_HIGH, + SOFTWARE_A_TASK_STATUS_CANCELLED, + SOFTWARE_A_TASK_STATUS_FAILED, + TASK_STATUS_CANCELLED, + TASK_STATUS_CREATED, + TASK_STATUS_FAILED, + TASK_STATUS_PENDING_APPROVAL, + TASK_STATUS_PENDING_CONFIRM, + TASK_STATUS_RUNNING, + TASK_STATUS_SUCCEEDED, + TASK_STATUS_VERIFYING, +) +from app.schemas.approval import ApprovalOperator, ApprovalTarget, CreateApprovalRequest +from app.schemas.software_a import CreateDeployTaskRequest, DeployOptions, SoftwareAOperator +from app.core.time import format_now +from app.adapters.approval.demo_adapter import DemoApprovalAdapter +from app.adapters.software_a.demo_adapter import DemoSoftwareAAdapter +from app.models.audit_log import AuditLog +from app.models.tool_call import ToolCall +from app.models.task import Task +from app.repositories.audit_repository import AuditRepository +from app.repositories.edge_repository import EdgeTaskRepository +from app.repositories.task_repository import TaskRepository +from app.repositories.tool_call_repository import ToolCallRepository +from app.schemas.task import ConfirmTaskRequest, CreateTaskRequest +from app.services.intent_service import IntentService +from app.services.risk_service import RiskService +from app.services.edge_service import EdgeService + + +class TaskConflictError(Exception): + code = ERROR_CODE_CONFLICT + + +class TaskPermissionError(Exception): + code = ERROR_CODE_PERMISSION_DENIED + + +class TaskNotFoundError(Exception): + code = ERROR_CODE_NOT_FOUND + + +class TaskService: + def __init__(self, db: Session, timezone_name: str) -> None: + self.db = db + self.timezone_name = timezone_name + self.repository = TaskRepository(db) + self.audit_repository = AuditRepository(db) + self.edge_task_repository = EdgeTaskRepository(db) + self.tool_call_repository = ToolCallRepository(db) + self.intent_service = IntentService() + self.risk_service = RiskService() + + def _require_task_status(self, task: Task, allowed_statuses: set[str], action: str) -> None: + if task.task_status not in allowed_statuses: + allowed_text = ", ".join(sorted(allowed_statuses)) + raise TaskConflictError(f"当前任务状态不允许执行 {action},期望状态: {allowed_text},当前状态: {task.task_status}。") + + def create_task(self, payload: CreateTaskRequest, request_id: str | None) -> Task: + parsed_intent, missing_slots = self.intent_service.parse(payload.input_text) + risk_level = self.risk_service.evaluate(parsed_intent) + current_time = format_now(self.timezone_name) + task_status = TASK_STATUS_PENDING_CONFIRM if not missing_slots else TASK_STATUS_CREATED + summary = None if not missing_slots else "存在缺失槽位,需补充后再确认。" + + task = Task( + task_id=self._build_id("task"), + session_id=payload.session_id, + tenant_id=payload.tenant_id, + request_id=request_id, + input_text=payload.input_text, + channel=payload.channel, + action_type=parsed_intent.get("action_type"), + app_code=parsed_intent.get("app_code"), + env=parsed_intent.get("env"), + version=parsed_intent.get("version"), + software_a_task_id=None, + software_a_task_status=None, + risk_level=risk_level, + approval_status=APPROVAL_STATUS_NOT_REQUIRED, + task_status=task_status, + parsed_intent_json=json.dumps(parsed_intent, ensure_ascii=False), + missing_slots_json=json.dumps(missing_slots, ensure_ascii=False), + summary=summary, + created_at=current_time, + updated_at=current_time, + confirmed_at=None, + ) + created_task = self.repository.add(task) + self._write_audit_log( + task_id=created_task.task_id, + request_id=request_id, + action="CREATE_TASK", + result="OK", + target=created_task.app_code, + operator_user_id="u1001", + operator_user_name="alice", + detail={ + "request_id": request_id, + "parsed_intent": parsed_intent, + "missing_slots": missing_slots, + "risk_level": risk_level, + }, + ) + return created_task + + def confirm_task(self, task_id: str, payload: ConfirmTaskRequest, request_id: str | None = None) -> tuple[Task, str | None]: + task = self.repository.get_by_task_id(task_id) + if not task: + raise TaskNotFoundError() + self._require_task_status(task, {TASK_STATUS_PENDING_CONFIRM}, "CONFIRM_TASK") + if not payload.confirmed: + raise TaskConflictError("当前版本仅支持 confirmed=true。") + + current_time = format_now(self.timezone_name) + task.confirmed_at = current_time + task.updated_at = current_time + approval_id = None + + if task.risk_level == RISK_LEVEL_HIGH: + task.task_status = TASK_STATUS_PENDING_APPROVAL + task.approval_status = APPROVAL_STATUS_PENDING + task.summary = "高风险任务已确认,等待审批。" + approval = DemoApprovalAdapter(self.db, self.timezone_name).create_request( + CreateApprovalRequest( + task_id=task.task_id, + risk_level=task.risk_level, + operator=ApprovalOperator(user_id="u1001", user_name="alice"), + action_type=task.action_type or "DEPLOY", + target=ApprovalTarget(app_code=task.app_code or "unknown-app", env=task.env or "unknown-env"), + reason=payload.comment or "高风险任务待审批", + approvers=["u2001"], + ) + ) + approval_id = approval.approval_id + self._write_audit_log( + task_id=task.task_id, + request_id=request_id, + action="CREATE_APPROVAL_REQUEST", + result="PENDING", + target=task.app_code, + operator_user_id="u1001", + operator_user_name="alice", + detail={"approval_id": approval_id, "reason": payload.comment}, + ) + else: + task.task_status = TASK_STATUS_RUNNING + task.approval_status = APPROVAL_STATUS_NOT_REQUIRED + task.summary = "任务已确认,准备调用 software-a demo 执行。" + updated_task = self.repository.update(task) + self._write_audit_log( + task_id=updated_task.task_id, + request_id=request_id, + action="CONFIRM_TASK", + result="OK", + target=updated_task.app_code, + operator_user_id="u1001", + operator_user_name="alice", + detail={ + "approval_status": updated_task.approval_status, + "task_status": updated_task.task_status, + "comment": payload.comment, + }, + ) + if updated_task.risk_level != RISK_LEVEL_HIGH: + updated_task = self.execute_task(updated_task.task_id, request_id=request_id) + + return updated_task, approval_id + + def get_task(self, task_id: str) -> Task: + task = self.repository.get_by_task_id(task_id) + if not task: + raise TaskNotFoundError() + return self.refresh_software_a_status(task) + + def refresh_software_a_status(self, task: Task) -> Task: + if not task.software_a_task_id: + return task + software_a_task = DemoSoftwareAAdapter(self.timezone_name).get_deploy_task(task.software_a_task_id) + if software_a_task: + task.software_a_task_status = software_a_task["task_status"] + if software_a_task["task_status"] == SOFTWARE_A_TASK_STATUS_FAILED and task.task_status not in {TASK_STATUS_FAILED, TASK_STATUS_CANCELLED}: + task.task_status = TASK_STATUS_FAILED + task.summary = f"software-a demo 执行失败: {software_a_task.get('error_detail') or 'unknown error'}" + task.updated_at = format_now(self.timezone_name) + task = self.repository.update(task) + return task + + def _build_id(self, prefix: str) -> str: + return f"{prefix}-{uuid4().hex[:12]}" + + def execute_task(self, task_id: str, request_id: str | None = None) -> Task: + task = self.repository.get_by_task_id(task_id) + if not task: + raise TaskNotFoundError() + self._require_task_status(task, {TASK_STATUS_RUNNING}, "EXECUTE_TASK") + if not task.app_code or not task.env or not task.version: + raise TaskConflictError("当前任务缺少 software-a 执行所需的关键字段。") + if task.software_a_task_id: + raise TaskConflictError("当前任务已创建 software-a 执行任务,不允许重复执行。") + if self.edge_task_repository.list_active_by_task_id(task.task_id): + raise TaskConflictError("当前任务已存在待处理的 edge 验证步骤,不允许重复调度。") + + allowed, reason = DemoSoftwareAAdapter(self.timezone_name).check_permission( + task.action_type or "DEPLOY", + task.env, + task.approval_status, + ) + if not allowed: + raise TaskPermissionError(f"software-a 权限校验未通过: {reason}") + + if task.action_type == "DEPLOY": + tool_started_at = format_now(self.timezone_name) + deploy_result = DemoSoftwareAAdapter(self.timezone_name).create_deploy_task( + CreateDeployTaskRequest( + operator=SoftwareAOperator(user_id="u1001", user_name="alice"), + tenant_id=task.tenant_id, + app_code=task.app_code, + env=task.env, + version=task.version, + target_nodes=self._default_target_nodes(task.env), + deploy_options=DeployOptions(graceful=True), + ) + ) + tool_finished_at = format_now(self.timezone_name) + task.software_a_task_id = deploy_result["software_a_task_id"] + task.software_a_task_status = deploy_result["task_status"] + deploy_success = deploy_result["task_status"] != SOFTWARE_A_TASK_STATUS_FAILED + task.task_status = TASK_STATUS_RUNNING if deploy_success else TASK_STATUS_FAILED + task.summary = ( + "software-a demo 部署任务已创建,等待边缘验证。" + if deploy_success + else f"software-a demo 执行失败: {deploy_result.get('error_detail') or 'unknown error'}" + ) + self._write_tool_call( + task_id=task.task_id, + request_id=request_id, + operator_user_id="u1001", + operator_user_name="alice", + tool_name="software_a_deploy", + request_payload={ + "app_code": task.app_code, + "env": task.env, + "version": task.version, + "tenant_id": task.tenant_id, + }, + response_payload=deploy_result, + success=deploy_success, + started_at=tool_started_at, + finished_at=tool_finished_at, + ) + elif task.approval_status == APPROVAL_STATUS_APPROVED: + task.task_status = TASK_STATUS_RUNNING + task.summary = "审批通过后任务已进入执行阶段。" + + updated_task = self.repository.update(task) + self._write_audit_log( + task_id=updated_task.task_id, + request_id=request_id, + action="EXECUTE_TASK", + result="OK" if updated_task.task_status != TASK_STATUS_FAILED else "FAILED", + target=updated_task.app_code, + operator_user_id="u1001", + operator_user_name="alice", + detail={ + "software_a_task_id": updated_task.software_a_task_id, + "software_a_task_status": updated_task.software_a_task_status, + "summary": updated_task.summary, + }, + ) + if updated_task.task_status == TASK_STATUS_RUNNING: + EdgeService(self.db, self.timezone_name).schedule_default_verification(updated_task.task_id) + return updated_task + + def cancel_task(self, task_id: str, reason: str | None = None, request_id: str | None = None) -> Task: + task = self.repository.get_by_task_id(task_id) + if not task: + raise TaskNotFoundError() + self._require_task_status( + task, + { + TASK_STATUS_CREATED, + TASK_STATUS_PENDING_CONFIRM, + TASK_STATUS_PENDING_APPROVAL, + TASK_STATUS_RUNNING, + TASK_STATUS_VERIFYING, + }, + "CANCEL_TASK", + ) + + current_time = format_now(self.timezone_name) + task.task_status = TASK_STATUS_CANCELLED + task.updated_at = current_time + task.summary = reason or "任务已取消。" + if task.software_a_task_status == "RUNNING": + task.software_a_task_status = SOFTWARE_A_TASK_STATUS_CANCELLED + updated_task = self.repository.update(task) + + for edge_task in self.edge_task_repository.list_active_by_task_id(task_id): + edge_task.step_status = EDGE_STEP_STATUS_CANCELLED + edge_task.updated_at = current_time + edge_task.message = reason or "任务取消" + self.edge_task_repository.update(edge_task) + + self._write_audit_log( + task_id=updated_task.task_id, + request_id=request_id, + action="CANCEL_TASK", + result="CANCELLED", + target=updated_task.app_code, + operator_user_id="u1001", + operator_user_name="alice", + detail={"reason": reason}, + ) + return updated_task + + def _default_target_nodes(self, env: str) -> list[str]: + mapping = { + "test": ["10.0.0.12"], + "staging": ["10.0.1.12"], + "prod": ["10.0.2.12", "10.0.2.13"], + } + return mapping.get(env, ["10.0.0.12"]) + + def _write_tool_call( + self, + task_id: str, + request_id: str | None, + operator_user_id: str | None, + operator_user_name: str | None, + tool_name: str, + request_payload: dict, + response_payload: dict, + success: bool, + started_at: str | None, + finished_at: str | None, + step_id: str | None = None, + duration_ms: int | None = None, + ) -> ToolCall: + tool_call = ToolCall( + tool_call_id=f"tool-call-{uuid4().hex[:12]}", + task_id=task_id, + request_id=request_id, + operator_user_id=operator_user_id, + operator_user_name=operator_user_name, + step_id=step_id, + tool_name=tool_name, + request_payload_json=json.dumps(request_payload, ensure_ascii=False), + response_payload_json=json.dumps(response_payload, ensure_ascii=False), + success=1 if success else 0, + duration_ms=duration_ms, + started_at=started_at, + finished_at=finished_at, + ) + return self.tool_call_repository.add(tool_call) + + def _write_audit_log( + self, + task_id: str, + request_id: str | None, + action: str, + result: str, + target: str | None, + operator_user_id: str | None, + operator_user_name: str | None, + detail: dict, + ) -> AuditLog: + audit_log = AuditLog( + audit_id=f"audit-{uuid4().hex[:12]}", + task_id=task_id, + request_id=request_id, + action=action, + operator_user_id=operator_user_id, + operator_user_name=operator_user_name, + target=target, + result=result, + detail_json=json.dumps(detail, ensure_ascii=False), + timestamp=format_now(self.timezone_name), + ) + return self.audit_repository.add(audit_log) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..7c2b34a --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "smart-deploy-agent-demo-backend" +version = "0.1.0" +description = "Smart deploy agent demo backend" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0,<1.0.0", + "httpx>=0.28.0,<1.0.0", + "uvicorn[standard]>=0.30.0,<1.0.0", + "sqlalchemy>=2.0.0,<3.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0,<9.0.0", +] + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/backend/tests/test_task_api.py b/backend/tests/test_task_api.py new file mode 100644 index 0000000..250fd6a --- /dev/null +++ b/backend/tests/test_task_api.py @@ -0,0 +1,601 @@ +import os + +from fastapi.testclient import TestClient + +os.environ["DATABASE_URL"] = "sqlite:///:memory:" + +from app.main import app + + +def test_task_create_confirm_get() -> None: + with TestClient(app) as client: + create_response = client.post( + "/api/agent/tasks", + headers={"X-Request-Id": "req-test-create-001"}, + json={ + "input_text": "deploy order-service 1.2.3 to test", + "channel": "WEB", + "session_id": "sess-001", + "tenant_id": "tenant-demo", + "context": {"preferred_env": "test"}, + }, + ) + assert create_response.status_code == 200 + task_id = create_response.json()["data"]["task_id"] + + confirm_response = client.post( + f"/api/agent/tasks/{task_id}/confirm", + headers={"X-Request-Id": "req-test-confirm-001"}, + json={"confirmed": True, "comment": "确认执行"}, + ) + assert confirm_response.status_code == 200 + assert confirm_response.json()["data"]["software_a_task_id"] is not None + assert confirm_response.json()["data"]["software_a_task_status"] == "RUNNING" + + get_response = client.get(f"/api/agent/tasks/{task_id}") + assert get_response.status_code == 200 + assert get_response.json()["data"]["task_id"] == task_id + assert get_response.json()["data"]["software_a_task_id"] is not None + assert get_response.json()["data"]["software_a_task_status"] == "SUCCEEDED" + assert get_response.json()["data"]["tool_calls"][0]["tool_name"] == "software_a_deploy" + + +def test_high_risk_task_creates_approval_and_can_be_approved() -> None: + with TestClient(app) as client: + create_response = client.post( + "/api/agent/tasks", + json={ + "input_text": "deploy order-service 1.2.3 to prod", + "channel": "WEB", + "session_id": "sess-002", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + assert create_response.status_code == 200 + task_id = create_response.json()["data"]["task_id"] + + confirm_response = client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "need approval"}, + ) + assert confirm_response.status_code == 200 + approval_id = confirm_response.json()["data"]["approval_id"] + assert approval_id is not None + assert confirm_response.json()["data"]["task_status"] == "PENDING_APPROVAL" + + approval_response = client.get(f"/api/demo/approval/requests/{approval_id}") + assert approval_response.status_code == 200 + assert approval_response.json()["data"]["approval_status"] == "PENDING" + + decision_response = client.post( + f"/api/demo/approval/requests/{approval_id}/decision", + json={ + "decision": "APPROVED", + "comment": "approved", + "operator": {"user_id": "u2001", "user_name": "bob"}, + }, + ) + assert decision_response.status_code == 200 + assert decision_response.json()["data"]["approval_status"] == "APPROVED" + + get_task_response = client.get(f"/api/agent/tasks/{task_id}") + assert get_task_response.status_code == 200 + assert get_task_response.json()["data"]["task_status"] == "RUNNING" + assert get_task_response.json()["data"]["approval_status"] == "APPROVED" + assert get_task_response.json()["data"]["software_a_task_id"] is not None + assert get_task_response.json()["data"]["software_a_task_status"] == "SUCCEEDED" + + +def test_demo_identity_and_software_a_endpoints() -> None: + with TestClient(app) as client: + login_response = client.post( + "/api/demo/identity/login", + json={"username": "alice", "password": "demo-password"}, + ) + assert login_response.status_code == 200 + token = login_response.json()["data"]["access_token"] + + me_response = client.get( + "/api/demo/identity/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert me_response.status_code == 200 + assert me_response.json()["data"]["user_name"] == "alice" + + deploy_response = client.post( + "/api/demo/software-a/deploy-tasks", + json={ + "operator": {"user_id": "u1001", "user_name": "alice"}, + "tenant_id": "tenant-demo", + "app_code": "order-service", + "env": "test", + "version": "1.2.3", + "target_nodes": ["10.0.0.12"], + "deploy_options": {"graceful": True}, + }, + ) + assert deploy_response.status_code == 200 + software_a_task_id = deploy_response.json()["data"]["software_a_task_id"] + + query_response = client.get(f"/api/demo/software-a/deploy-tasks/{software_a_task_id}") + assert query_response.status_code == 200 + assert query_response.json()["data"]["task_status"] == "SUCCEEDED" + + +def test_edge_heartbeat_pull_and_report_flow() -> None: + with TestClient(app) as client: + heartbeat_response = client.post( + "/api/agent/edge/heartbeat", + json={ + "edge_id": "edge-shanghai-001", + "hostname": "customer-host-01", + "os_type": "WINDOWS", + "agent_version": "0.1.0", + "capabilities": ["http_health_check"], + }, + ) + assert heartbeat_response.status_code == 200 + assert heartbeat_response.json()["data"]["node_status"] == "ONLINE" + + create_response = client.post( + "/api/agent/tasks", + json={ + "input_text": "deploy order-service 1.2.3 to test", + "channel": "WEB", + "session_id": "sess-003", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + task_id = create_response.json()["data"]["task_id"] + + confirm_response = client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "confirm"}, + ) + assert confirm_response.status_code == 200 + + pull_response = client.post( + "/api/agent/edge/tasks/pull", + json={"edge_id": "edge-shanghai-001", "max_tasks": 5}, + ) + assert pull_response.status_code == 200 + tasks = pull_response.json()["data"]["tasks"] + matched_tasks = [item for item in tasks if item["task_id"] == task_id] + assert len(matched_tasks) == 1 + step_id = matched_tasks[0]["step_id"] + assert matched_tasks[0]["tool_name"] == "http_health_check" + + report_response = client.post( + "/api/agent/edge/tasks/report", + json={ + "edge_id": "edge-shanghai-001", + "task_id": task_id, + "step_id": step_id, + "tool_name": "http_health_check", + "success": True, + "code": "OK", + "message": "200 OK", + "data": {"status_code": 200, "latency_ms": 45}, + "evidence": {"response_body": "{\"status\":\"UP\"}"}, + "started_at": "2026-04-08 20:20:00.000", + "finished_at": "2026-04-08 20:20:00.100", + }, + ) + assert report_response.status_code == 200 + assert report_response.json()["data"]["task_status"] == "SUCCEEDED" + + get_response = client.get(f"/api/agent/tasks/{task_id}") + assert get_response.status_code == 200 + assert get_response.json()["data"]["task_status"] == "SUCCEEDED" + assert get_response.json()["data"]["software_a_task_id"] is not None + assert get_response.json()["data"]["software_a_task_status"] == "SUCCEEDED" + assert get_response.json()["data"]["verification_result"]["http_ok"] is True + + +def test_edge_event_report_endpoint() -> None: + with TestClient(app) as client: + event_response = client.post( + "/api/agent/edge/events", + json={ + "edge_id": "edge-shanghai-001", + "event_type": "AGENT_EXCEPTION", + "message": "executor timeout", + "detail": {"tool_name": "http_health_check", "timeout_ms": 3000}, + }, + ) + assert event_response.status_code == 200 + assert event_response.json()["data"]["accepted"] is True + assert event_response.json()["data"]["event_type"] == "AGENT_EXCEPTION" + + +def test_task_report_contains_traces() -> None: + with TestClient(app) as client: + client.post( + "/api/agent/edge/heartbeat", + json={ + "edge_id": "edge-shanghai-001", + "hostname": "customer-host-01", + "os_type": "WINDOWS", + "agent_version": "0.1.0", + "capabilities": ["http_health_check"], + }, + ) + + create_response = client.post( + "/api/agent/tasks", + headers={"X-Request-Id": "req-report-create-001"}, + json={ + "input_text": "deploy order-service 1.2.3 to test", + "channel": "WEB", + "session_id": "sess-004", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + task_id = create_response.json()["data"]["task_id"] + + client.post( + f"/api/agent/tasks/{task_id}/confirm", + headers={"X-Request-Id": "req-report-confirm-001"}, + json={"confirmed": True, "comment": "confirm"}, + ) + pull_response = client.post( + "/api/agent/edge/tasks/pull", + json={"edge_id": "edge-shanghai-001", "max_tasks": 5}, + ) + step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id][0] + client.post( + "/api/agent/edge/tasks/report", + json={ + "edge_id": "edge-shanghai-001", + "task_id": task_id, + "step_id": step["step_id"], + "tool_name": step["tool_name"], + "success": True, + "code": "OK", + "message": "200 OK", + "data": {"status_code": 200}, + "evidence": {"response_body": "{\"status\":\"UP\"}"}, + "started_at": "2026-04-08 20:20:00.000", + "finished_at": "2026-04-08 20:20:00.100", + }, + ) + + report_response = client.get(f"/api/agent/tasks/{task_id}/report") + assert report_response.status_code == 200 + payload = report_response.json()["data"] + assert payload["task_basic"]["task_id"] == task_id + assert len(payload["tool_trace"]) >= 2 + assert len(payload["verification_trace"]) >= 1 + assert len(payload["audit_trace"]) >= 3 + assert payload["approval_trace"] == [] + assert any(item["request_id"] == "req-report-confirm-001" for item in payload["tool_trace"]) + assert any(item["operator_user_name"] == "alice" for item in payload["tool_trace"]) + assert any(item["request_id"] == "req-report-create-001" for item in payload["audit_trace"]) + + +def test_cancel_running_task() -> None: + with TestClient(app) as client: + create_response = client.post( + "/api/agent/tasks", + json={ + "input_text": "deploy order-service 1.2.3 to test", + "channel": "WEB", + "session_id": "sess-005", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + task_id = create_response.json()["data"]["task_id"] + client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "confirm"}, + ) + + cancel_response = client.post( + f"/api/agent/tasks/{task_id}/cancel", + json={"reason": "manual stop"}, + ) + assert cancel_response.status_code == 200 + assert cancel_response.json()["data"]["task_status"] == "CANCELLED" + assert cancel_response.json()["data"]["software_a_task_status"] == "CANCELLED" + + get_response = client.get(f"/api/agent/tasks/{task_id}") + assert get_response.status_code == 200 + assert get_response.json()["data"]["task_status"] == "CANCELLED" + + +def test_confirm_twice_returns_conflict() -> None: + with TestClient(app) as client: + create_response = client.post( + "/api/agent/tasks", + json={ + "input_text": "deploy order-service 1.2.3 to test", + "channel": "WEB", + "session_id": "sess-006", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + task_id = create_response.json()["data"]["task_id"] + + first_confirm = client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "confirm"}, + ) + assert first_confirm.status_code == 200 + + second_confirm = client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "confirm again"}, + ) + assert second_confirm.status_code == 409 + assert second_confirm.json()["code"] == "CONFLICT" + + +def test_approval_decision_conflicts_after_task_cancelled() -> None: + with TestClient(app) as client: + create_response = client.post( + "/api/agent/tasks", + json={ + "input_text": "deploy order-service 1.2.3 to prod", + "channel": "WEB", + "session_id": "sess-007", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + task_id = create_response.json()["data"]["task_id"] + + confirm_response = client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "need approval"}, + ) + approval_id = confirm_response.json()["data"]["approval_id"] + + cancel_response = client.post( + f"/api/agent/tasks/{task_id}/cancel", + json={"reason": "manual stop before approval"}, + ) + assert cancel_response.status_code == 200 + assert cancel_response.json()["data"]["task_status"] == "CANCELLED" + + decision_response = client.post( + f"/api/demo/approval/requests/{approval_id}/decision", + json={ + "decision": "APPROVED", + "comment": "approved too late", + "operator": {"user_id": "u2001", "user_name": "bob"}, + }, + ) + assert decision_response.status_code == 409 + assert decision_response.json()["code"] == "CONFLICT" + + +def test_duplicate_edge_report_returns_conflict() -> None: + with TestClient(app) as client: + client.post( + "/api/agent/edge/heartbeat", + json={ + "edge_id": "edge-shanghai-001", + "hostname": "customer-host-01", + "os_type": "WINDOWS", + "agent_version": "0.1.0", + "capabilities": ["http_health_check"], + }, + ) + create_response = client.post( + "/api/agent/tasks", + json={ + "input_text": "deploy order-service 1.2.3 to test", + "channel": "WEB", + "session_id": "sess-008", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + task_id = create_response.json()["data"]["task_id"] + client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "confirm"}, + ) + + pull_response = client.post( + "/api/agent/edge/tasks/pull", + json={"edge_id": "edge-shanghai-001", "max_tasks": 5}, + ) + step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id][0] + + first_report = client.post( + "/api/agent/edge/tasks/report", + json={ + "edge_id": "edge-shanghai-001", + "task_id": task_id, + "step_id": step["step_id"], + "tool_name": step["tool_name"], + "success": True, + "code": "OK", + "message": "200 OK", + "data": {"status_code": 200}, + "evidence": {"response_body": "{\"status\":\"UP\"}"}, + "started_at": "2026-04-08 20:20:00.000", + "finished_at": "2026-04-08 20:20:00.100", + }, + ) + assert first_report.status_code == 200 + + second_report = client.post( + "/api/agent/edge/tasks/report", + json={ + "edge_id": "edge-shanghai-001", + "task_id": task_id, + "step_id": step["step_id"], + "tool_name": step["tool_name"], + "success": True, + "code": "OK", + "message": "200 OK", + "data": {"status_code": 200}, + "evidence": {"response_body": "{\"status\":\"UP\"}"}, + "started_at": "2026-04-08 20:20:00.000", + "finished_at": "2026-04-08 20:20:00.100", + }, + ) + assert second_report.status_code == 409 + assert second_report.json()["code"] == "CONFLICT" + + +def test_task_fails_when_software_a_deploy_fails() -> None: + with TestClient(app) as client: + create_response = client.post( + "/api/agent/tasks", + json={ + "input_text": "deploy fail-service 1.2.3-fail to test", + "channel": "WEB", + "session_id": "sess-009", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + assert create_response.status_code == 200 + task_id = create_response.json()["data"]["task_id"] + + confirm_response = client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "confirm"}, + ) + assert confirm_response.status_code == 200 + assert confirm_response.json()["data"]["task_status"] == "FAILED" + assert confirm_response.json()["data"]["software_a_task_status"] == "FAILED" + + get_response = client.get(f"/api/agent/tasks/{task_id}") + assert get_response.status_code == 200 + assert get_response.json()["data"]["task_status"] == "FAILED" + assert get_response.json()["data"]["software_a_task_status"] == "FAILED" + + pull_response = client.post( + "/api/agent/edge/tasks/pull", + json={"edge_id": "edge-shanghai-001", "max_tasks": 10}, + ) + assert pull_response.status_code == 200 + assert all(item["task_id"] != task_id for item in pull_response.json()["data"]["tasks"]) + + +def test_demo_software_a_endpoint_can_return_failed_task() -> None: + with TestClient(app) as client: + deploy_response = client.post( + "/api/demo/software-a/deploy-tasks", + json={ + "operator": {"user_id": "u1001", "user_name": "alice"}, + "tenant_id": "tenant-demo", + "app_code": "fail-service", + "env": "test", + "version": "1.2.3-fail", + "target_nodes": ["10.0.0.12"], + "deploy_options": {"graceful": True}, + }, + ) + assert deploy_response.status_code == 200 + assert deploy_response.json()["data"]["task_status"] == "FAILED" + software_a_task_id = deploy_response.json()["data"]["software_a_task_id"] + + query_response = client.get(f"/api/demo/software-a/deploy-tasks/{software_a_task_id}") + assert query_response.status_code == 200 + assert query_response.json()["data"]["task_status"] == "FAILED" + assert query_response.json()["data"]["error_detail"] is not None + + +def test_high_risk_task_can_be_rejected() -> None: + with TestClient(app) as client: + create_response = client.post( + "/api/agent/tasks", + json={ + "input_text": "deploy order-service 1.2.3 to prod", + "channel": "WEB", + "session_id": "sess-010", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + task_id = create_response.json()["data"]["task_id"] + + confirm_response = client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "need approval"}, + ) + approval_id = confirm_response.json()["data"]["approval_id"] + + decision_response = client.post( + f"/api/demo/approval/requests/{approval_id}/decision", + json={ + "decision": "REJECTED", + "comment": "rejected", + "operator": {"user_id": "u2001", "user_name": "bob"}, + }, + ) + assert decision_response.status_code == 200 + assert decision_response.json()["data"]["approval_status"] == "REJECTED" + + get_task_response = client.get(f"/api/agent/tasks/{task_id}") + assert get_task_response.status_code == 200 + assert get_task_response.json()["data"]["task_status"] == "CANCELLED" + assert get_task_response.json()["data"]["approval_status"] == "REJECTED" + + +def test_edge_failure_marks_task_failed() -> None: + with TestClient(app) as client: + client.post( + "/api/agent/edge/heartbeat", + json={ + "edge_id": "edge-shanghai-001", + "hostname": "customer-host-01", + "os_type": "WINDOWS", + "agent_version": "0.1.0", + "capabilities": ["http_health_check"], + }, + ) + create_response = client.post( + "/api/agent/tasks", + json={ + "input_text": "deploy order-service 1.2.3 to test", + "channel": "WEB", + "session_id": "sess-011", + "tenant_id": "tenant-demo", + "context": {}, + }, + ) + task_id = create_response.json()["data"]["task_id"] + client.post( + f"/api/agent/tasks/{task_id}/confirm", + json={"confirmed": True, "comment": "confirm"}, + ) + pull_response = client.post( + "/api/agent/edge/tasks/pull", + json={"edge_id": "edge-shanghai-001", "max_tasks": 5}, + ) + step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id][0] + + report_response = client.post( + "/api/agent/edge/tasks/report", + json={ + "edge_id": "edge-shanghai-001", + "task_id": task_id, + "step_id": step["step_id"], + "tool_name": step["tool_name"], + "success": False, + "code": "HTTP_500", + "message": "health check failed", + "data": {"status_code": 500}, + "evidence": {"response_body": "{\"status\":\"DOWN\"}"}, + "started_at": "2026-04-08 20:20:00.000", + "finished_at": "2026-04-08 20:20:00.100", + }, + ) + assert report_response.status_code == 200 + assert report_response.json()["data"]["task_status"] == "FAILED" + + get_response = client.get(f"/api/agent/tasks/{task_id}") + assert get_response.status_code == 200 + assert get_response.json()["data"]["task_status"] == "FAILED" + assert get_response.json()["data"]["verification_result"]["http_ok"] is False diff --git a/docs/智能化部署agent-demo最小DDL设计.md b/docs/智能化部署agent-demo最小DDL设计.md new file mode 100644 index 0000000..05d9e0b --- /dev/null +++ b/docs/智能化部署agent-demo最小DDL设计.md @@ -0,0 +1,241 @@ +# 智能化部署 Agent Demo 最小 DDL 设计 + +更新时间:2026-04-08 + +## 1. 文档目的 + +本文档用于定义 demo 第一阶段的最小数据库表结构,直接服务于以下目标: + +1. 支撑 `POST /api/agent/tasks` +2. 支撑 `POST /api/agent/tasks/{task_id}/confirm` +3. 支撑 `GET /api/agent/tasks/{task_id}` +4. 为后续审批、审计、工具调用留出可扩展落点 + +当前约束: + +1. demo 默认数据库采用 `SQLite` +2. 时间字段统一采用 `yyyy-MM-dd HH:mm:ss.SSS` +3. JSON 字段统一采用 `snake_case` +4. 复杂对象先以 JSON 字符串形式存储,后续切换 PostgreSQL 时可平滑升级为 `JSONB` + +--- + +## 2. 本轮最小表清单 + +第一批仅落以下 4 张表: + +1. `task` +2. `approval_request` +3. `tool_call` +4. `audit_log` + +说明: + +1. `task` 是主链路核心表。 +2. `approval_request` 为高风险任务确认后进入审批预留。 +3. `tool_call` 为后续软件 A demo / edge 验证接入预留。 +4. `audit_log` 为关键动作审计预留。 + +--- + +## 3. 表结构设计 + +## 3.1 task + +用途: + +1. 存储自然语言原始输入。 +2. 存储结构化意图快照。 +3. 存储风险等级、审批状态和任务状态。 +4. 存储结果摘要与时间戳。 + +字段: + +1. `task_id` `TEXT` 主键 +2. `session_id` `TEXT` 非空 +3. `tenant_id` `TEXT` 非空 +4. `request_id` `TEXT` 可空 +5. `input_text` `TEXT` 非空 +6. `channel` `TEXT` 非空 +7. `action_type` `TEXT` 可空 +8. `app_code` `TEXT` 可空 +9. `env` `TEXT` 可空 +10. `version` `TEXT` 可空 +11. `risk_level` `TEXT` 非空 +12. `approval_status` `TEXT` 非空 +13. `task_status` `TEXT` 非空 +14. `parsed_intent_json` `TEXT` 非空 +15. `missing_slots_json` `TEXT` 非空 +16. `summary` `TEXT` 可空 +17. `created_at` `TEXT` 非空 +18. `updated_at` `TEXT` 非空 +19. `confirmed_at` `TEXT` 可空 + +建议索引: + +1. `idx_task_session_id` +2. `idx_task_tenant_id` +3. `idx_task_status` +4. `idx_task_created_at` + +## 3.2 approval_request + +用途: + +1. 为 `HIGH` 风险任务记录审批状态。 +2. 为后续审批 demo 接口联调预留。 + +字段: + +1. `approval_id` `TEXT` 主键 +2. `task_id` `TEXT` 非空 +3. `approval_status` `TEXT` 非空 +4. `risk_level` `TEXT` 非空 +5. `operator_user_id` `TEXT` 可空 +6. `operator_user_name` `TEXT` 可空 +7. `approver_user_ids_json` `TEXT` 非空 +8. `reason` `TEXT` 可空 +9. `created_at` `TEXT` 非空 +10. `updated_at` `TEXT` 非空 + +建议索引: + +1. `idx_approval_task_id` +2. `idx_approval_status` + +## 3.3 tool_call + +用途: + +1. 记录软件 A demo、edge 验证和后续工具调用轨迹。 + +字段: + +1. `tool_call_id` `TEXT` 主键 +2. `task_id` `TEXT` 非空 +3. `step_id` `TEXT` 可空 +4. `tool_name` `TEXT` 非空 +5. `request_payload_json` `TEXT` 非空 +6. `response_payload_json` `TEXT` 非空 +7. `success` `INTEGER` 非空 +8. `duration_ms` `INTEGER` 可空 +9. `started_at` `TEXT` 可空 +10. `finished_at` `TEXT` 可空 + +建议索引: + +1. `idx_tool_call_task_id` +2. `idx_tool_call_tool_name` + +## 3.4 audit_log + +用途: + +1. 记录任务创建、确认、审批等待和后续执行摘要。 + +字段: + +1. `audit_id` `TEXT` 主键 +2. `task_id` `TEXT` 非空 +3. `action` `TEXT` 非空 +4. `operator_user_id` `TEXT` 可空 +5. `operator_user_name` `TEXT` 可空 +6. `target` `TEXT` 可空 +7. `result` `TEXT` 非空 +8. `detail_json` `TEXT` 非空 +9. `timestamp` `TEXT` 非空 + +建议索引: + +1. `idx_audit_task_id` +2. `idx_audit_action` +3. `idx_audit_timestamp` + +--- + +## 4. SQLite 参考 DDL + +```sql +CREATE TABLE IF NOT EXISTS task ( + task_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + request_id TEXT, + input_text TEXT NOT NULL, + channel TEXT NOT NULL, + action_type TEXT, + app_code TEXT, + env TEXT, + version TEXT, + risk_level TEXT NOT NULL, + approval_status TEXT NOT NULL, + task_status TEXT NOT NULL, + parsed_intent_json TEXT NOT NULL, + missing_slots_json TEXT NOT NULL, + summary TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + confirmed_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_task_session_id ON task (session_id); +CREATE INDEX IF NOT EXISTS idx_task_tenant_id ON task (tenant_id); +CREATE INDEX IF NOT EXISTS idx_task_status ON task (task_status); +CREATE INDEX IF NOT EXISTS idx_task_created_at ON task (created_at); + +CREATE TABLE IF NOT EXISTS approval_request ( + approval_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + approval_status TEXT NOT NULL, + risk_level TEXT NOT NULL, + operator_user_id TEXT, + operator_user_name TEXT, + approver_user_ids_json TEXT NOT NULL, + reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_approval_task_id ON approval_request (task_id); +CREATE INDEX IF NOT EXISTS idx_approval_status ON approval_request (approval_status); + +CREATE TABLE IF NOT EXISTS tool_call ( + tool_call_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + step_id TEXT, + tool_name TEXT NOT NULL, + request_payload_json TEXT NOT NULL, + response_payload_json TEXT NOT NULL, + success INTEGER NOT NULL, + duration_ms INTEGER, + started_at TEXT, + finished_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_tool_call_task_id ON tool_call (task_id); +CREATE INDEX IF NOT EXISTS idx_tool_call_tool_name ON tool_call (tool_name); + +CREATE TABLE IF NOT EXISTS audit_log ( + audit_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + action TEXT NOT NULL, + operator_user_id TEXT, + operator_user_name TEXT, + target TEXT, + result TEXT NOT NULL, + detail_json TEXT NOT NULL, + timestamp TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_task_id ON audit_log (task_id); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action); +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log (timestamp); +``` + +--- + +## 5. 后续演进建议 + +1. 当 demo 从单机联调进入多人并发联调时,优先切换到 `PostgreSQL`。 +2. 切换后可将 `parsed_intent_json`、`missing_slots_json`、`detail_json` 等字段升级为 `JSONB`。 +3. 若后续引入独立 worker 和多实例抢任务,再补 `task_step`、`edge_task` 等表。 diff --git a/docs/智能化部署agent-demo首批OpenAPI.yaml b/docs/智能化部署agent-demo首批OpenAPI.yaml new file mode 100644 index 0000000..1a68746 --- /dev/null +++ b/docs/智能化部署agent-demo首批OpenAPI.yaml @@ -0,0 +1,285 @@ +openapi: 3.1.0 +info: + title: 智能化部署 Agent Demo 首批 OpenAPI + version: 0.1.0 + description: | + 首批 OpenAPI 草案仅覆盖 demo 第一阶段的三条主接口。 +servers: + - url: http://localhost:8000 +tags: + - name: agent-task + description: Agent 主任务接口 +paths: + /api/agent/tasks: + post: + tags: + - agent-task + summary: 创建任务 + operationId: createTask + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateTaskRequest" + responses: + "200": + description: 创建成功 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: + $ref: "#/components/schemas/CreateTaskData" + /api/agent/tasks/{task_id}/confirm: + post: + tags: + - agent-task + summary: 确认任务 + operationId: confirmTask + parameters: + - $ref: "#/components/parameters/TaskId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ConfirmTaskRequest" + responses: + "200": + description: 确认成功 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: + $ref: "#/components/schemas/ConfirmTaskData" + /api/agent/tasks/{task_id}: + get: + tags: + - agent-task + summary: 查询任务详情 + operationId: getTask + parameters: + - $ref: "#/components/parameters/TaskId" + responses: + "200": + description: 查询成功 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiResponse" + - type: object + properties: + data: + $ref: "#/components/schemas/TaskDetailData" +components: + parameters: + TaskId: + name: task_id + in: path + required: true + schema: + type: string + schemas: + ApiResponse: + type: object + required: + - request_id + - success + - code + - message + - data + - timestamp + properties: + request_id: + type: string + success: + type: boolean + code: + type: string + message: + type: string + data: + type: object + timestamp: + type: string + description: yyyy-MM-dd HH:mm:ss.SSS + CreateTaskRequest: + type: object + required: + - input_text + - channel + - session_id + - tenant_id + properties: + input_text: + type: string + channel: + type: string + example: WEB + session_id: + type: string + tenant_id: + type: string + context: + type: object + additionalProperties: true + ParsedIntent: + type: object + properties: + action_type: + $ref: "#/components/schemas/ActionType" + app_code: + type: string + nullable: true + env: + type: string + nullable: true + version: + type: string + nullable: true + CreateTaskData: + type: object + required: + - task_id + - parsed_intent + - missing_slots + - risk_level + - task_status + - next_action + properties: + task_id: + type: string + parsed_intent: + $ref: "#/components/schemas/ParsedIntent" + missing_slots: + type: array + items: + type: string + risk_level: + $ref: "#/components/schemas/RiskLevel" + task_status: + $ref: "#/components/schemas/TaskStatus" + next_action: + type: string + ConfirmTaskRequest: + type: object + required: + - confirmed + properties: + confirmed: + type: boolean + comment: + type: string + nullable: true + ConfirmTaskData: + type: object + required: + - task_id + - task_status + - approval_status + properties: + task_id: + type: string + task_status: + $ref: "#/components/schemas/TaskStatus" + approval_status: + $ref: "#/components/schemas/ApprovalStatus" + ToolCall: + type: object + required: + - tool_name + - success + properties: + tool_name: + type: string + success: + type: boolean + VerificationResult: + type: object + properties: + process_ok: + type: boolean + port_ok: + type: boolean + http_ok: + type: boolean + log_error_count: + type: integer + TaskDetailData: + type: object + required: + - task_id + - task_status + - approval_status + - risk_level + - intent + - tool_calls + properties: + task_id: + type: string + task_status: + $ref: "#/components/schemas/TaskStatus" + approval_status: + $ref: "#/components/schemas/ApprovalStatus" + risk_level: + $ref: "#/components/schemas/RiskLevel" + intent: + $ref: "#/components/schemas/ParsedIntent" + tool_calls: + type: array + items: + $ref: "#/components/schemas/ToolCall" + verification_result: + oneOf: + - $ref: "#/components/schemas/VerificationResult" + - type: "null" + summary: + type: string + nullable: true + RiskLevel: + type: string + enum: + - LOW + - MEDIUM + - HIGH + TaskStatus: + type: string + enum: + - CREATED + - PENDING_CONFIRM + - PENDING_APPROVAL + - RUNNING + - VERIFYING + - SUCCEEDED + - FAILED + - PARTIAL_SUCCEEDED + - CANCELLED + ApprovalStatus: + type: string + enum: + - NOT_REQUIRED + - PENDING + - APPROVED + - REJECTED + - EXPIRED + ActionType: + type: string + enum: + - DEPLOY + - PUSH_CONFIG + - START_SERVICE + - STOP_SERVICE + - RESTART_SERVICE + - ROLLBACK + - QUERY_LOG + - HEALTH_CHECK + - CALL_THIRD_PARTY_API diff --git a/智能化部署agent-demo后端项目骨架设计.md b/智能化部署agent-demo后端项目骨架设计.md index 77a1d77..64a4529 100644 --- a/智能化部署agent-demo后端项目骨架设计.md +++ b/智能化部署agent-demo后端项目骨架设计.md @@ -50,12 +50,15 @@ demo 阶段建议采用: ## 2.2 部署单元建议 -demo 阶段建议至少包含 4 个运行单元: +demo 阶段建议至少包含 2 个运行单元: 1. `agent-backend` 2. `edge-agent` -3. `postgres` -4. `redis` + +说明: + +1. demo 阶段默认采用 `SQLite`,以本地文件方式随 `agent-backend` 一起运行,不额外部署数据库单元。 +2. demo 阶段不引入 `Redis` 强依赖,任务轮询、简单队列和状态流转先通过数据库表和后台 worker 承接。 可选: @@ -77,9 +80,14 @@ demo 阶段建议至少包含 4 个运行单元: 2. FastAPI 3. Pydantic 4. SQLAlchemy -5. PostgreSQL -6. Redis -7. LangGraph +5. SQLite +6. LangGraph + +补充说明: + +1. demo 默认数据库为 `SQLite`。 +2. Repository 和 ORM 层需预留切换 `PostgreSQL` 的能力。 +3. demo 阶段不将 `Redis` 作为启动前置依赖。 ## 3.2 选择理由 @@ -96,10 +104,12 @@ demo 阶段建议至少包含 4 个运行单元: 2. Pydantic 模型与接口文档天然匹配。 3. 适合 demo 阶段快速落地。 -### 3.2.3 为什么保留 PostgreSQL 和 Redis +### 3.2.3 为什么 demo 默认使用 SQLite 且不强依赖 Redis -1. PostgreSQL 用于结构化任务、审批、审计落库。 -2. Redis 用于任务队列、幂等键、短期上下文和轮询状态。 +1. `SQLite` 安装成本最低,更适合 demo 阶段快速打通闭环。 +2. `SQLite` 足以支撑单体服务、低并发联调和最小可运行演示。 +3. 当前主链路优先级高于基础设施完整性,队列能力可先通过数据库表和 worker 轮询承接。 +4. `Redis` 可在后续进入多实例部署、强幂等或高并发调度阶段时再评估引入,优先评估 `Valkey` 或兼容方案。 ## 3.3 如果团队必须走 Java @@ -108,8 +118,7 @@ demo 阶段建议至少包含 4 个运行单元: 1. Spring Boot 2. Spring Web 3. JPA / MyBatis -4. PostgreSQL -5. Redis +4. SQLite / PostgreSQL 但 demo 阶段的 Agent 编排效率通常不如 Python 方案。 @@ -594,6 +603,15 @@ edge-agent/ 4. 工具按操作系统分类适配。 5. 所有执行结果结构化回传。 +## 9.3 本地 Agent 交付格式 + +demo 阶段正式确认: + +1. Windows 使用 `zip` 便携包交付。 +2. Linux 使用 `tar.gz` 自包含运行目录交付。 +3. 两个平台均不依赖客户现场预装 Python。 +4. 单文件可执行包不作为第一阶段默认方案。 + --- ## 10. 配置与环境变量建议 @@ -603,15 +621,20 @@ edge-agent/ 1. `APP_ENV` 2. `APP_PORT` 3. `DATABASE_URL` -4. `REDIS_URL` -5. `LLM_BASE_URL` -6. `LLM_API_KEY` -7. `SOFTWARE_A_BASE_URL` -8. `SOFTWARE_A_TIMEOUT_MS` -9. `IDENTITY_BASE_URL` -10. `APPROVAL_BASE_URL` -11. `EDGE_TOKEN_SECRET` -12. `DEFAULT_TIMEZONE` +4. `LLM_BASE_URL` +5. `LLM_API_KEY` +6. `SOFTWARE_A_BASE_URL` +7. `SOFTWARE_A_TIMEOUT_MS` +8. `IDENTITY_BASE_URL` +9. `APPROVAL_BASE_URL` +10. `EDGE_TOKEN_SECRET` +11. `DEFAULT_TIMEZONE` + +说明: + +1. demo 阶段 `DATABASE_URL` 默认建议为 `sqlite:///./data/agent_demo.db`。 +2. 如后续切换 `PostgreSQL`,优先只调整配置,不改动 service 层接口。 +3. demo 阶段不要求 `REDIS_URL`。 本地 Agent 建议至少支持: @@ -635,6 +658,8 @@ edge-agent/ 5. `POST /api/agent/tasks` 6. `POST /api/agent/tasks/{task_id}/confirm` 7. `GET /api/agent/tasks/{task_id}` +8. 最小 DDL 文档。 +9. 首批 OpenAPI 草案。 ## 11.2 第二批完成 diff --git a/智能化部署agent-demo接口定义说明.md b/智能化部署agent-demo接口定义说明.md index a5149b1..92805f0 100644 --- a/智能化部署agent-demo接口定义说明.md +++ b/智能化部署agent-demo接口定义说明.md @@ -30,6 +30,20 @@ 3. 真实软件 A 适配细节。 4. 所有部署场景的扩展字段。 +### 1.4 本轮首批 OpenAPI 落地范围 + +本轮 OpenAPI 草案仅覆盖第一批主链路接口: + +1. `POST /api/agent/tasks` +2. `POST /api/agent/tasks/{task_id}/confirm` +3. `GET /api/agent/tasks/{task_id}` + +说明: + +1. 软件 A demo、身份 demo、审批 demo、edge 接口继续保留在本文档中作为后续实现输入。 +2. 首批代码骨架只要求先打通以上三条主接口。 +3. 后续扩展 OpenAPI 时,优先保持当前对象模型和错误码不变。 + --- ## 2. 总体约定 diff --git a/智能化部署agent-当前进度总结.md b/智能化部署agent-当前进度总结.md index 447b1ac..47bb961 100644 --- a/智能化部署agent-当前进度总结.md +++ b/智能化部署agent-当前进度总结.md @@ -6,9 +6,11 @@ 当前阶段已完成从"需求方案"到"技术架构"再到"接口定义"和"demo 后端骨架"的文档化收敛,整体处于: -**方案已成型、文档体系已建立、技术路线已基本明确、代码尚未开始实现** +**方案已成型、文档体系已建立、技术路线已基本明确、demo 后端代码骨架已开始实现** -当前产出重点仍然是文档设计,不是代码开发。 +当前产出重点已经从纯文档设计切换为: + +**文档收口 + demo 代码骨架落地 + 主链路验证** --- @@ -28,7 +30,13 @@ 4. `智能化部署agent-demo后端项目骨架设计.md` 用于描述 demo 后端的推荐技术栈、项目结构、模块职责、数据库表建议、代码落点和开发顺序。 -5. `智能化部署agent-技术架构设计说明书.backup-20260408-141109.md` +5. `docs/智能化部署agent-demo最小DDL设计.md` + 用于沉淀 demo 阶段最小可运行的数据表结构。 + +6. `docs/智能化部署agent-demo首批OpenAPI.yaml` + 用于沉淀第一批已收口接口的 OpenAPI 草案。 + +7. `智能化部署agent-技术架构设计说明书.backup-20260408-141109.md` 为技术架构说明书备份文件。 --- @@ -106,6 +114,49 @@ demo 接口定义文档已覆盖: 6. 本地 Agent 骨架建议。 7. 开发顺序建议。 +### 3.7 demo 后端初始化代码已开始落地 + +当前已完成以下代码层工作: + +1. 已生成 FastAPI demo 后端项目基础目录。 +2. 已补充 `pyproject.toml`、基础 `README` 和 `.gitignore`。 +3. 已实现 `task`、`approval_request`、`tool_call`、`audit_log` 对应的最小模型和数据库初始化逻辑。 +4. 已打通三条主接口: + `POST /api/agent/tasks`、`POST /api/agent/tasks/{task_id}/confirm`、`GET /api/agent/tasks/{task_id}` +5. 已实现最小 `identity demo`、`approval demo`、`software-a demo` 接口。 +6. 已将高风险任务确认后的审批创建流程接入后端主链路。 +7. 已实现最小 `edge` 心跳、拉取任务、回传结果接口。 +8. 已将默认验证任务接入 edge 调度主链路。 +9. 已将 `software-a demo` 部署任务创建接入主执行链。 +10. 已将 `tool_call` 和 `audit_log` 接入主链路关键动作。 +11. 已实现任务报告接口,可返回审批、工具、验证、审计轨迹。 +12. 已实现任务取消接口,并将 `request_id`、`operator` 维度写入关键审计和工具调用记录。 +13. 已补充自动化测试,并基于内存 SQLite 完成首轮通过验证。 +14. 已完成任务状态机第一轮收紧,补上重复确认、审批后任务状态漂移、edge 重复回传等冲突校验。 +15. 已补上首轮失败分支细化,包括 software-a demo 执行失败、审批驳回、edge 验证失败三条主失败路径。 + +### 3.8 当前代码可运行范围 + +截至当前回合,后端代码已具备以下最小可运行范围: + +1. 任务创建、确认、查询、取消。 +2. 高风险任务确认后自动创建审批单。 +3. 审批通过后进入执行链,审批驳回后进入取消态。 +4. 执行链包含: + software-a 权限校验 -> software-a demo 部署任务创建 -> edge 默认验证任务创建 -> edge 拉取 -> edge 回传。 +5. 任务详情接口可返回: + 当前状态、software-a 状态、工具调用摘要、验证结果摘要。 +6. 任务报告接口可返回: + `task_basic`、`intent_snapshot`、`approval_trace`、`tool_trace`、`verification_trace`、`result_summary`、`audit_trace` +7. edge 侧已支持: + 心跳、拉取任务、回传结果、上报异常事件。 + +当前测试基线: + +1. 共 14 条测试通过。 +2. 使用 `sqlite:///:memory:` 做回归验证。 +3. 当前主链路已不是“只有接口壳”,而是具备最小闭环行为。 + --- ## 4. 当前已明确的核心技术结论 @@ -155,7 +206,7 @@ demo 接口定义文档已覆盖: 2. 如果以 demo 快速落地和减少安装成本为优先,可以先用 SQLite。 3. 后续试点或正式化阶段再切换 PostgreSQL。 -该结论属于当前建议,尚未完整回写到骨架设计文档中。 +该结论已在本轮决策、最小 DDL 和当前后端实现中落地。 ### 4.6 开源和商用许可判断 @@ -165,71 +216,143 @@ demo 接口定义文档已覆盖: 2. Redis 的许可证情况相对复杂,不建议在文档中简单视为"低风险宽松开源"。 3. 如果确实需要 Redis 类组件,后续应评估 Valkey 或在 demo 阶段先不强依赖缓存中间件。 -该结论属于当前建议,尚未完整回写到骨架设计文档中。 +该结论已收口为当前 demo 阶段“不引入 Redis 强依赖”的正式实现策略。 + +### 4.7 本轮正式落地决策 + +本轮已正式确认以下落地决策,后续实现与文档以此为准: + +1. demo 数据库默认采用 `SQLite`,后续试点和正式化阶段再切换 `PostgreSQL`。 +2. demo 阶段不引入 `Redis` 强依赖,缓存能力默认弱化,任务队列先采用数据库表 + 后台轮询方式承接。 +3. 用户端 `edge-agent` 交付格式正式确认为: + Windows 使用 `zip` 便携包,Linux 使用 `tar.gz` 自包含运行目录。 +4. 文档补充策略正式确认为: + 只补最小 DDL 和首批 OpenAPI 草案,不一次性扩展到全部表和全部接口。 +5. 开发顺序正式确认为: + 先补最小 DDL 和首批 OpenAPI,再直接进入 FastAPI demo 后端骨架开发。 + +### 4.8 当前代码层关键实现约定 + +以下约定虽然部分未完整回写到全部设计文档,但当前代码实现已经以此为准: + +1. 任务主状态机当前主要覆盖: + `CREATED` -> `PENDING_CONFIRM` -> `RUNNING` -> `VERIFYING` -> `SUCCEEDED` / `FAILED` / `CANCELLED` +2. 高风险任务路径为: + `PENDING_CONFIRM` -> `PENDING_APPROVAL` -> `RUNNING` +3. `software-a demo` 当前在任务详情查询时会同步刷新状态,因此: + 确认接口返回的 `software_a_task_status` 可能是 `RUNNING`,而后续查询任务详情时可能已变为 `SUCCEEDED` +4. 当前 demo 中的 operator 默认使用: + `alice(u1001)` 作为任务发起和执行方,`bob(u2001)` 作为审批人 +5. 当前 edge 默认验证工具为: + `http_health_check` +6. 当前默认 edge 节点 ID 为: + `edge-shanghai-001` +7. 当前任务报告中的 `tool_trace` 和 `audit_trace` 已包含 `request_id` 和 operator 信息,后续扩展应保持兼容。 +8. 当前已补上的状态约束包括: + 重复确认拦截、重复执行拦截、审批决策前必须仍处于 `PENDING_APPROVAL`、edge 重复回传拦截、非 `RUNNING` 任务不再下发 edge 执行。 +9. 当前 demo 已支持可控失败模拟: + 若 `app_code` 或 `version` 包含 `fail`,则 `software-a demo` 会返回失败任务,用于联调失败分支。 --- -## 5. 当前尚未开始的部分 +## 5. 当前待补强的部分 -目前尚未开始以下工作: +当前还未收口,或仅实现了最小版本的工作包括: -1. demo 后端初始化代码。 -2. 本地 Agent 初始化代码。 -3. 数据库建表脚本。 -4. OpenAPI 文档生成。 -5. 软件 A demo 服务实现。 -6. 身份 demo 服务实现。 -7. 审批 demo 服务实现。 -8. 验证插件实现。 -9. 部署脚本和运行脚本。 -10. 测试用例与联调脚本。 +1. 本地 `edge-agent` 初始化代码与打包脚本。 +2. 文件型 SQLite / PostgreSQL 实库运行验证。 +3. 身份 demo / 审批 demo 与任务主链路的权限、审批决策联动细化。 +4. `duration_ms` 等执行指标的真实计算与回填。 +5. 更真实的验证插件实现。 +6. 部署脚本和运行脚本完善。 +7. OpenAPI 扩展到第二批接口。 +8. 更多测试用例与联调脚本。 + +### 5.1 当前已知环境限制 + +以下问题不是当前代码逻辑错误,而是当前运行环境限制: + +1. 当前对话环境下,文件型 SQLite 落盘会出现 `disk I/O error`。 +2. 因此当前自动化验证统一采用: + `DATABASE_URL=sqlite:///:memory:` +3. 当前测试命令需禁用 pytest cache provider,否则可能因写缓存目录失败出现噪音告警。 +4. PowerShell 内联脚本在中文字符串场景下可能有编码干扰,因此测试样例优先使用 ASCII 文本。 --- -## 6. 当前存在的待落地事项 +## 6. 当前待落地重点 -虽然整体文档体系已比较完整,但仍有几项内容尚未真正落到可运行层面: +当前不是继续补基础文档,而是继续补强现有可运行链路。优先级建议收敛为: -1. 是否正式确认 demo 阶段使用 SQLite 还是 PostgreSQL。 -2. 是否正式确认 Redis 在 demo 阶段保留、替换还是去掉。 -3. 是否正式确认用户端 Agent 的交付格式: - Windows zip 包、Linux tar.gz 包,或单文件可执行包。 -4. 是否继续补数据库 DDL 文档。 -5. 是否继续补 OpenAPI 草案。 -6. 是否直接开始生成 demo 后端代码骨架。 +1. 回填执行指标: + 重点补 `duration_ms`、更完整的执行结果摘要与审计信息。 +2. 增补失败路径与幂等性测试: + 重点补重复请求、重复回传、异常回滚等场景。 +3. 继续丰富结果摘要与审计细节: + 让失败原因在详情和报告里更直观可见。 +4. 然后再继续: + 本地 `edge-agent` 骨架、第二批 OpenAPI、更多联调能力。 + +当前状态: + +**SQLite / 去 Redis / 最小 DDL / 首批 OpenAPI / FastAPI 骨架 / 三条主接口 / demo adapter / edge 接口,均已完成第一轮落地。** --- ## 7. 建议的下一步 -按当前进度,建议后续按以下顺序推进: +按当前进度,建议后续直接按以下顺序推进: -### 路线 A:继续补文档后再开发 - -1. 补数据库 DDL 设计。 -2. 补 OpenAPI 草案。 -3. 将 SQLite / PostgreSQL、Redis / Valkey、用户端 Python 便携运行方案正式回写到文档。 - -适合: - -1. 先把设计收口到可以评审。 -2. 由多人协作开发前需要统一边界。 - -### 路线 B:直接进入 demo 代码骨架 - -1. 生成 FastAPI 后端项目初始化代码。 -2. 生成核心目录结构和空实现。 -3. 先打通 `POST /api/agent/tasks`、确认任务、查询任务三条主接口。 -4. 再补软件 A demo adapter、身份 demo adapter、审批 demo adapter。 - -适合: - -1. 尽快进入实现阶段。 -2. 通过代码反推细节问题。 +1. 计算并持久化 `duration_ms`。 +2. 增补状态冲突、失败回滚、重复上报等测试。 +3. 丰富结果摘要与失败原因呈现。 +4. 再进入本地 `edge-agent` 初始化代码和第二批 OpenAPI。 当前更推荐: -**先补最小 DDL 和 OpenAPI 草案,然后直接进入 demo 后端代码骨架。** +**继续迭代代码主链路,不再回到“大段补文档”的节奏。** + +### 7.1 如果下一轮需要快速续接,优先做什么 + +如果后续上下文被裁剪,建议下一轮直接先读取本文件,然后按以下顺序继续: + +1. 优先读取: + `backend/README.md` +2. 再读取关键代码入口: + `backend/app/main.py` + `backend/app/api/agent/tasks.py` + `backend/app/services/task_service.py` + `backend/app/services/approval_service.py` + `backend/app/services/edge_service.py` +3. 再读取测试: + `backend/tests/test_task_api.py` + +下一步推荐顺序: + +1. 计算并回填 `duration_ms`。 +2. 再补失败路径和幂等性测试。 +3. 再补结果摘要和失败原因展示。 +4. 再补本地 Agent 初始化代码或第二批 OpenAPI。 + +### 7.2 如果上下文快满,有什么影响 + +主要影响是: + +1. 对话里的临时上下文可能被裁剪。 +2. 已写入仓库的代码和文档不会受影响。 +3. 因此续接时优先读本文件和 `backend/README.md`,成本可控。 + +结论: + +**上下文快满不会影响现有代码成果,只会增加下一轮续接时重新装载上下文的成本。** + +当前推荐命令: + +```bash +set PYTHONPATH=backend +set DATABASE_URL=sqlite:///:memory: +.venv\Scripts\python -m pytest backend/tests -q -p no:cacheprovider +``` --- @@ -239,4 +362,6 @@ demo 接口定义文档已覆盖: **方案文档 -> 技术架构 -> 接口定义 -> 后端骨架** -下一步已经可以从"写文档"切换到"写 demo 代码"。 +当前已经完成从"写文档"切换到"写 demo 代码"的第一步,下一步进入: + +**duration_ms 回填 -> 失败结果呈现增强 -> 本地 Agent 与联调能力继续补齐**