feat: scaffold demo backend and task workflow
This commit is contained in:
parent
37e0572b1a
commit
62186e7994
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
115
backend/README.md
Normal file
115
backend/README.md
Normal file
@ -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
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/app/adapters/__init__.py
Normal file
1
backend/app/adapters/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/app/adapters/approval/__init__.py
Normal file
1
backend/app/adapters/approval/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
11
backend/app/adapters/approval/base.py
Normal file
11
backend/app/adapters/approval/base.py
Normal file
@ -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
|
||||||
15
backend/app/adapters/approval/demo_adapter.py
Normal file
15
backend/app/adapters/approval/demo_adapter.py
Normal file
@ -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)
|
||||||
1
backend/app/adapters/identity/__init__.py
Normal file
1
backend/app/adapters/identity/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
9
backend/app/adapters/identity/base.py
Normal file
9
backend/app/adapters/identity/base.py
Normal file
@ -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
|
||||||
12
backend/app/adapters/identity/demo_adapter.py
Normal file
12
backend/app/adapters/identity/demo_adapter.py
Normal file
@ -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)
|
||||||
1
backend/app/adapters/software_a/__init__.py
Normal file
1
backend/app/adapters/software_a/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
19
backend/app/adapters/software_a/base.py
Normal file
19
backend/app/adapters/software_a/base.py
Normal file
@ -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
|
||||||
19
backend/app/adapters/software_a/demo_adapter.py
Normal file
19
backend/app/adapters/software_a/demo_adapter.py
Normal file
@ -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)
|
||||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/app/api/agent/__init__.py
Normal file
1
backend/app/api/agent/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
333
backend/app/api/agent/tasks.py
Normal file
333
backend/app/api/agent/tasks.py
Normal file
@ -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),
|
||||||
|
)
|
||||||
1
backend/app/api/demo/__init__.py
Normal file
1
backend/app/api/demo/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
128
backend/app/api/demo/approval.py
Normal file
128
backend/app/api/demo/approval.py
Normal file
@ -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),
|
||||||
|
)
|
||||||
108
backend/app/api/demo/identity.py
Normal file
108
backend/app/api/demo/identity.py
Normal file
@ -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"),
|
||||||
|
)
|
||||||
81
backend/app/api/demo/software_a.py
Normal file
81
backend/app/api/demo/software_a.py
Normal file
@ -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"),
|
||||||
|
)
|
||||||
1
backend/app/api/edge/__init__.py
Normal file
1
backend/app/api/edge/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
162
backend/app/api/edge/tasks.py
Normal file
162
backend/app/api/edge/tasks.py
Normal file
@ -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),
|
||||||
|
)
|
||||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
22
backend/app/core/config.py
Normal file
22
backend/app/core/config.py
Normal file
@ -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)
|
||||||
45
backend/app/core/constants.py
Normal file
45
backend/app/core/constants.py
Normal file
@ -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"
|
||||||
21
backend/app/core/time.py
Normal file
21
backend/app/core/time.py
Normal file
@ -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]
|
||||||
1
backend/app/db/__init__.py
Normal file
1
backend/app/db/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
backend/app/db/base.py
Normal file
5
backend/app/db/base.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
28
backend/app/db/session.py
Normal file
28
backend/app/db/session.py
Normal file
@ -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()
|
||||||
65
backend/app/main.py
Normal file
65
backend/app/main.py
Normal file
@ -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)
|
||||||
1
backend/app/models/__init__.py
Normal file
1
backend/app/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
21
backend/app/models/approval.py
Normal file
21
backend/app/models/approval.py
Normal file
@ -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)
|
||||||
21
backend/app/models/audit_log.py
Normal file
21
backend/app/models/audit_log.py
Normal file
@ -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)
|
||||||
20
backend/app/models/edge_node.py
Normal file
20
backend/app/models/edge_node.py
Normal file
@ -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)
|
||||||
27
backend/app/models/edge_task.py
Normal file
27
backend/app/models/edge_task.py
Normal file
@ -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)
|
||||||
32
backend/app/models/task.py
Normal file
32
backend/app/models/task.py
Normal file
@ -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)
|
||||||
24
backend/app/models/tool_call.py
Normal file
24
backend/app/models/tool_call.py
Normal file
@ -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)
|
||||||
1
backend/app/repositories/__init__.py
Normal file
1
backend/app/repositories/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
38
backend/app/repositories/approval_repository.py
Normal file
38
backend/app/repositories/approval_repository.py
Normal file
@ -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]
|
||||||
21
backend/app/repositories/audit_repository.py
Normal file
21
backend/app/repositories/audit_repository.py
Normal file
@ -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())
|
||||||
65
backend/app/repositories/edge_repository.py
Normal file
65
backend/app/repositories/edge_repository.py
Normal file
@ -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())
|
||||||
27
backend/app/repositories/task_repository.py
Normal file
27
backend/app/repositories/task_repository.py
Normal file
@ -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()
|
||||||
21
backend/app/repositories/tool_call_repository.py
Normal file
21
backend/app/repositories/tool_call_repository.py
Normal file
@ -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())
|
||||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
49
backend/app/schemas/approval.py
Normal file
49
backend/app/schemas/approval.py
Normal file
@ -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]
|
||||||
25
backend/app/schemas/common.py
Normal file
25
backend/app/schemas/common.py
Normal file
@ -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
|
||||||
70
backend/app/schemas/edge.py
Normal file
70
backend/app/schemas/edge.py
Normal file
@ -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
|
||||||
39
backend/app/schemas/identity.py
Normal file
39
backend/app/schemas/identity.py
Normal file
@ -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
|
||||||
52
backend/app/schemas/software_a.py
Normal file
52
backend/app/schemas/software_a.py
Normal file
@ -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
|
||||||
142
backend/app/schemas/task.py
Normal file
142
backend/app/schemas/task.py
Normal file
@ -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]
|
||||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
164
backend/app/services/approval_service.py
Normal file
164
backend/app/services/approval_service.py
Normal file
@ -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)
|
||||||
279
backend/app/services/edge_service.py
Normal file
279
backend/app/services/edge_service.py
Normal file
@ -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)
|
||||||
56
backend/app/services/identity_service.py
Normal file
56
backend/app/services/identity_service.py
Normal file
@ -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"],
|
||||||
|
)
|
||||||
61
backend/app/services/intent_service.py
Normal file
61
backend/app/services/intent_service.py
Normal file
@ -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
|
||||||
17
backend/app/services/risk_service.py
Normal file
17
backend/app/services/risk_service.py
Normal file
@ -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
|
||||||
61
backend/app/services/software_a_service.py
Normal file
61
backend/app/services/software_a_service.py
Normal file
@ -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}"
|
||||||
395
backend/app/services/task_service.py
Normal file
395
backend/app/services/task_service.py
Normal file
@ -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)
|
||||||
20
backend/pyproject.toml
Normal file
20
backend/pyproject.toml
Normal file
@ -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"
|
||||||
601
backend/tests/test_task_api.py
Normal file
601
backend/tests/test_task_api.py
Normal file
@ -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
|
||||||
241
docs/智能化部署agent-demo最小DDL设计.md
Normal file
241
docs/智能化部署agent-demo最小DDL设计.md
Normal file
@ -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` 等表。
|
||||||
285
docs/智能化部署agent-demo首批OpenAPI.yaml
Normal file
285
docs/智能化部署agent-demo首批OpenAPI.yaml
Normal file
@ -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
|
||||||
@ -50,12 +50,15 @@ demo 阶段建议采用:
|
|||||||
|
|
||||||
## 2.2 部署单元建议
|
## 2.2 部署单元建议
|
||||||
|
|
||||||
demo 阶段建议至少包含 4 个运行单元:
|
demo 阶段建议至少包含 2 个运行单元:
|
||||||
|
|
||||||
1. `agent-backend`
|
1. `agent-backend`
|
||||||
2. `edge-agent`
|
2. `edge-agent`
|
||||||
3. `postgres`
|
|
||||||
4. `redis`
|
说明:
|
||||||
|
|
||||||
|
1. demo 阶段默认采用 `SQLite`,以本地文件方式随 `agent-backend` 一起运行,不额外部署数据库单元。
|
||||||
|
2. demo 阶段不引入 `Redis` 强依赖,任务轮询、简单队列和状态流转先通过数据库表和后台 worker 承接。
|
||||||
|
|
||||||
可选:
|
可选:
|
||||||
|
|
||||||
@ -77,9 +80,14 @@ demo 阶段建议至少包含 4 个运行单元:
|
|||||||
2. FastAPI
|
2. FastAPI
|
||||||
3. Pydantic
|
3. Pydantic
|
||||||
4. SQLAlchemy
|
4. SQLAlchemy
|
||||||
5. PostgreSQL
|
5. SQLite
|
||||||
6. Redis
|
6. LangGraph
|
||||||
7. LangGraph
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
1. demo 默认数据库为 `SQLite`。
|
||||||
|
2. Repository 和 ORM 层需预留切换 `PostgreSQL` 的能力。
|
||||||
|
3. demo 阶段不将 `Redis` 作为启动前置依赖。
|
||||||
|
|
||||||
## 3.2 选择理由
|
## 3.2 选择理由
|
||||||
|
|
||||||
@ -96,10 +104,12 @@ demo 阶段建议至少包含 4 个运行单元:
|
|||||||
2. Pydantic 模型与接口文档天然匹配。
|
2. Pydantic 模型与接口文档天然匹配。
|
||||||
3. 适合 demo 阶段快速落地。
|
3. 适合 demo 阶段快速落地。
|
||||||
|
|
||||||
### 3.2.3 为什么保留 PostgreSQL 和 Redis
|
### 3.2.3 为什么 demo 默认使用 SQLite 且不强依赖 Redis
|
||||||
|
|
||||||
1. PostgreSQL 用于结构化任务、审批、审计落库。
|
1. `SQLite` 安装成本最低,更适合 demo 阶段快速打通闭环。
|
||||||
2. Redis 用于任务队列、幂等键、短期上下文和轮询状态。
|
2. `SQLite` 足以支撑单体服务、低并发联调和最小可运行演示。
|
||||||
|
3. 当前主链路优先级高于基础设施完整性,队列能力可先通过数据库表和 worker 轮询承接。
|
||||||
|
4. `Redis` 可在后续进入多实例部署、强幂等或高并发调度阶段时再评估引入,优先评估 `Valkey` 或兼容方案。
|
||||||
|
|
||||||
## 3.3 如果团队必须走 Java
|
## 3.3 如果团队必须走 Java
|
||||||
|
|
||||||
@ -108,8 +118,7 @@ demo 阶段建议至少包含 4 个运行单元:
|
|||||||
1. Spring Boot
|
1. Spring Boot
|
||||||
2. Spring Web
|
2. Spring Web
|
||||||
3. JPA / MyBatis
|
3. JPA / MyBatis
|
||||||
4. PostgreSQL
|
4. SQLite / PostgreSQL
|
||||||
5. Redis
|
|
||||||
|
|
||||||
但 demo 阶段的 Agent 编排效率通常不如 Python 方案。
|
但 demo 阶段的 Agent 编排效率通常不如 Python 方案。
|
||||||
|
|
||||||
@ -594,6 +603,15 @@ edge-agent/
|
|||||||
4. 工具按操作系统分类适配。
|
4. 工具按操作系统分类适配。
|
||||||
5. 所有执行结果结构化回传。
|
5. 所有执行结果结构化回传。
|
||||||
|
|
||||||
|
## 9.3 本地 Agent 交付格式
|
||||||
|
|
||||||
|
demo 阶段正式确认:
|
||||||
|
|
||||||
|
1. Windows 使用 `zip` 便携包交付。
|
||||||
|
2. Linux 使用 `tar.gz` 自包含运行目录交付。
|
||||||
|
3. 两个平台均不依赖客户现场预装 Python。
|
||||||
|
4. 单文件可执行包不作为第一阶段默认方案。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 配置与环境变量建议
|
## 10. 配置与环境变量建议
|
||||||
@ -603,15 +621,20 @@ edge-agent/
|
|||||||
1. `APP_ENV`
|
1. `APP_ENV`
|
||||||
2. `APP_PORT`
|
2. `APP_PORT`
|
||||||
3. `DATABASE_URL`
|
3. `DATABASE_URL`
|
||||||
4. `REDIS_URL`
|
4. `LLM_BASE_URL`
|
||||||
5. `LLM_BASE_URL`
|
5. `LLM_API_KEY`
|
||||||
6. `LLM_API_KEY`
|
6. `SOFTWARE_A_BASE_URL`
|
||||||
7. `SOFTWARE_A_BASE_URL`
|
7. `SOFTWARE_A_TIMEOUT_MS`
|
||||||
8. `SOFTWARE_A_TIMEOUT_MS`
|
8. `IDENTITY_BASE_URL`
|
||||||
9. `IDENTITY_BASE_URL`
|
9. `APPROVAL_BASE_URL`
|
||||||
10. `APPROVAL_BASE_URL`
|
10. `EDGE_TOKEN_SECRET`
|
||||||
11. `EDGE_TOKEN_SECRET`
|
11. `DEFAULT_TIMEZONE`
|
||||||
12. `DEFAULT_TIMEZONE`
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
1. demo 阶段 `DATABASE_URL` 默认建议为 `sqlite:///./data/agent_demo.db`。
|
||||||
|
2. 如后续切换 `PostgreSQL`,优先只调整配置,不改动 service 层接口。
|
||||||
|
3. demo 阶段不要求 `REDIS_URL`。
|
||||||
|
|
||||||
本地 Agent 建议至少支持:
|
本地 Agent 建议至少支持:
|
||||||
|
|
||||||
@ -635,6 +658,8 @@ edge-agent/
|
|||||||
5. `POST /api/agent/tasks`
|
5. `POST /api/agent/tasks`
|
||||||
6. `POST /api/agent/tasks/{task_id}/confirm`
|
6. `POST /api/agent/tasks/{task_id}/confirm`
|
||||||
7. `GET /api/agent/tasks/{task_id}`
|
7. `GET /api/agent/tasks/{task_id}`
|
||||||
|
8. 最小 DDL 文档。
|
||||||
|
9. 首批 OpenAPI 草案。
|
||||||
|
|
||||||
## 11.2 第二批完成
|
## 11.2 第二批完成
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,20 @@
|
|||||||
3. 真实软件 A 适配细节。
|
3. 真实软件 A 适配细节。
|
||||||
4. 所有部署场景的扩展字段。
|
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. 总体约定
|
## 2. 总体约定
|
||||||
|
|||||||
@ -6,9 +6,11 @@
|
|||||||
|
|
||||||
当前阶段已完成从"需求方案"到"技术架构"再到"接口定义"和"demo 后端骨架"的文档化收敛,整体处于:
|
当前阶段已完成从"需求方案"到"技术架构"再到"接口定义"和"demo 后端骨架"的文档化收敛,整体处于:
|
||||||
|
|
||||||
**方案已成型、文档体系已建立、技术路线已基本明确、代码尚未开始实现**
|
**方案已成型、文档体系已建立、技术路线已基本明确、demo 后端代码骨架已开始实现**
|
||||||
|
|
||||||
当前产出重点仍然是文档设计,不是代码开发。
|
当前产出重点已经从纯文档设计切换为:
|
||||||
|
|
||||||
|
**文档收口 + demo 代码骨架落地 + 主链路验证**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -28,7 +30,13 @@
|
|||||||
4. `智能化部署agent-demo后端项目骨架设计.md`
|
4. `智能化部署agent-demo后端项目骨架设计.md`
|
||||||
用于描述 demo 后端的推荐技术栈、项目结构、模块职责、数据库表建议、代码落点和开发顺序。
|
用于描述 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 骨架建议。
|
6. 本地 Agent 骨架建议。
|
||||||
7. 开发顺序建议。
|
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. 当前已明确的核心技术结论
|
## 4. 当前已明确的核心技术结论
|
||||||
@ -155,7 +206,7 @@ demo 接口定义文档已覆盖:
|
|||||||
2. 如果以 demo 快速落地和减少安装成本为优先,可以先用 SQLite。
|
2. 如果以 demo 快速落地和减少安装成本为优先,可以先用 SQLite。
|
||||||
3. 后续试点或正式化阶段再切换 PostgreSQL。
|
3. 后续试点或正式化阶段再切换 PostgreSQL。
|
||||||
|
|
||||||
该结论属于当前建议,尚未完整回写到骨架设计文档中。
|
该结论已在本轮决策、最小 DDL 和当前后端实现中落地。
|
||||||
|
|
||||||
### 4.6 开源和商用许可判断
|
### 4.6 开源和商用许可判断
|
||||||
|
|
||||||
@ -165,71 +216,143 @@ demo 接口定义文档已覆盖:
|
|||||||
2. Redis 的许可证情况相对复杂,不建议在文档中简单视为"低风险宽松开源"。
|
2. Redis 的许可证情况相对复杂,不建议在文档中简单视为"低风险宽松开源"。
|
||||||
3. 如果确实需要 Redis 类组件,后续应评估 Valkey 或在 demo 阶段先不强依赖缓存中间件。
|
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 后端初始化代码。
|
1. 本地 `edge-agent` 初始化代码与打包脚本。
|
||||||
2. 本地 Agent 初始化代码。
|
2. 文件型 SQLite / PostgreSQL 实库运行验证。
|
||||||
3. 数据库建表脚本。
|
3. 身份 demo / 审批 demo 与任务主链路的权限、审批决策联动细化。
|
||||||
4. OpenAPI 文档生成。
|
4. `duration_ms` 等执行指标的真实计算与回填。
|
||||||
5. 软件 A demo 服务实现。
|
5. 更真实的验证插件实现。
|
||||||
6. 身份 demo 服务实现。
|
6. 部署脚本和运行脚本完善。
|
||||||
7. 审批 demo 服务实现。
|
7. OpenAPI 扩展到第二批接口。
|
||||||
8. 验证插件实现。
|
8. 更多测试用例与联调脚本。
|
||||||
9. 部署脚本和运行脚本。
|
|
||||||
10. 测试用例与联调脚本。
|
### 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。
|
1. 回填执行指标:
|
||||||
2. 是否正式确认 Redis 在 demo 阶段保留、替换还是去掉。
|
重点补 `duration_ms`、更完整的执行结果摘要与审计信息。
|
||||||
3. 是否正式确认用户端 Agent 的交付格式:
|
2. 增补失败路径与幂等性测试:
|
||||||
Windows zip 包、Linux tar.gz 包,或单文件可执行包。
|
重点补重复请求、重复回传、异常回滚等场景。
|
||||||
4. 是否继续补数据库 DDL 文档。
|
3. 继续丰富结果摘要与审计细节:
|
||||||
5. 是否继续补 OpenAPI 草案。
|
让失败原因在详情和报告里更直观可见。
|
||||||
6. 是否直接开始生成 demo 后端代码骨架。
|
4. 然后再继续:
|
||||||
|
本地 `edge-agent` 骨架、第二批 OpenAPI、更多联调能力。
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
|
||||||
|
**SQLite / 去 Redis / 最小 DDL / 首批 OpenAPI / FastAPI 骨架 / 三条主接口 / demo adapter / edge 接口,均已完成第一轮落地。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 建议的下一步
|
## 7. 建议的下一步
|
||||||
|
|
||||||
按当前进度,建议后续按以下顺序推进:
|
按当前进度,建议后续直接按以下顺序推进:
|
||||||
|
|
||||||
### 路线 A:继续补文档后再开发
|
1. 计算并持久化 `duration_ms`。
|
||||||
|
2. 增补状态冲突、失败回滚、重复上报等测试。
|
||||||
1. 补数据库 DDL 设计。
|
3. 丰富结果摘要与失败原因呈现。
|
||||||
2. 补 OpenAPI 草案。
|
4. 再进入本地 `edge-agent` 初始化代码和第二批 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. 通过代码反推细节问题。
|
|
||||||
|
|
||||||
当前更推荐:
|
当前更推荐:
|
||||||
|
|
||||||
**先补最小 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 与联调能力继续补齐**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user