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 部署单元建议
|
||||
|
||||
demo 阶段建议至少包含 4 个运行单元:
|
||||
demo 阶段建议至少包含 2 个运行单元:
|
||||
|
||||
1. `agent-backend`
|
||||
2. `edge-agent`
|
||||
3. `postgres`
|
||||
4. `redis`
|
||||
|
||||
说明:
|
||||
|
||||
1. demo 阶段默认采用 `SQLite`,以本地文件方式随 `agent-backend` 一起运行,不额外部署数据库单元。
|
||||
2. demo 阶段不引入 `Redis` 强依赖,任务轮询、简单队列和状态流转先通过数据库表和后台 worker 承接。
|
||||
|
||||
可选:
|
||||
|
||||
@ -77,9 +80,14 @@ demo 阶段建议至少包含 4 个运行单元:
|
||||
2. FastAPI
|
||||
3. Pydantic
|
||||
4. SQLAlchemy
|
||||
5. PostgreSQL
|
||||
6. Redis
|
||||
7. LangGraph
|
||||
5. SQLite
|
||||
6. LangGraph
|
||||
|
||||
补充说明:
|
||||
|
||||
1. demo 默认数据库为 `SQLite`。
|
||||
2. Repository 和 ORM 层需预留切换 `PostgreSQL` 的能力。
|
||||
3. demo 阶段不将 `Redis` 作为启动前置依赖。
|
||||
|
||||
## 3.2 选择理由
|
||||
|
||||
@ -96,10 +104,12 @@ demo 阶段建议至少包含 4 个运行单元:
|
||||
2. Pydantic 模型与接口文档天然匹配。
|
||||
3. 适合 demo 阶段快速落地。
|
||||
|
||||
### 3.2.3 为什么保留 PostgreSQL 和 Redis
|
||||
### 3.2.3 为什么 demo 默认使用 SQLite 且不强依赖 Redis
|
||||
|
||||
1. PostgreSQL 用于结构化任务、审批、审计落库。
|
||||
2. Redis 用于任务队列、幂等键、短期上下文和轮询状态。
|
||||
1. `SQLite` 安装成本最低,更适合 demo 阶段快速打通闭环。
|
||||
2. `SQLite` 足以支撑单体服务、低并发联调和最小可运行演示。
|
||||
3. 当前主链路优先级高于基础设施完整性,队列能力可先通过数据库表和 worker 轮询承接。
|
||||
4. `Redis` 可在后续进入多实例部署、强幂等或高并发调度阶段时再评估引入,优先评估 `Valkey` 或兼容方案。
|
||||
|
||||
## 3.3 如果团队必须走 Java
|
||||
|
||||
@ -108,8 +118,7 @@ demo 阶段建议至少包含 4 个运行单元:
|
||||
1. Spring Boot
|
||||
2. Spring Web
|
||||
3. JPA / MyBatis
|
||||
4. PostgreSQL
|
||||
5. Redis
|
||||
4. SQLite / PostgreSQL
|
||||
|
||||
但 demo 阶段的 Agent 编排效率通常不如 Python 方案。
|
||||
|
||||
@ -594,6 +603,15 @@ edge-agent/
|
||||
4. 工具按操作系统分类适配。
|
||||
5. 所有执行结果结构化回传。
|
||||
|
||||
## 9.3 本地 Agent 交付格式
|
||||
|
||||
demo 阶段正式确认:
|
||||
|
||||
1. Windows 使用 `zip` 便携包交付。
|
||||
2. Linux 使用 `tar.gz` 自包含运行目录交付。
|
||||
3. 两个平台均不依赖客户现场预装 Python。
|
||||
4. 单文件可执行包不作为第一阶段默认方案。
|
||||
|
||||
---
|
||||
|
||||
## 10. 配置与环境变量建议
|
||||
@ -603,15 +621,20 @@ edge-agent/
|
||||
1. `APP_ENV`
|
||||
2. `APP_PORT`
|
||||
3. `DATABASE_URL`
|
||||
4. `REDIS_URL`
|
||||
5. `LLM_BASE_URL`
|
||||
6. `LLM_API_KEY`
|
||||
7. `SOFTWARE_A_BASE_URL`
|
||||
8. `SOFTWARE_A_TIMEOUT_MS`
|
||||
9. `IDENTITY_BASE_URL`
|
||||
10. `APPROVAL_BASE_URL`
|
||||
11. `EDGE_TOKEN_SECRET`
|
||||
12. `DEFAULT_TIMEZONE`
|
||||
4. `LLM_BASE_URL`
|
||||
5. `LLM_API_KEY`
|
||||
6. `SOFTWARE_A_BASE_URL`
|
||||
7. `SOFTWARE_A_TIMEOUT_MS`
|
||||
8. `IDENTITY_BASE_URL`
|
||||
9. `APPROVAL_BASE_URL`
|
||||
10. `EDGE_TOKEN_SECRET`
|
||||
11. `DEFAULT_TIMEZONE`
|
||||
|
||||
说明:
|
||||
|
||||
1. demo 阶段 `DATABASE_URL` 默认建议为 `sqlite:///./data/agent_demo.db`。
|
||||
2. 如后续切换 `PostgreSQL`,优先只调整配置,不改动 service 层接口。
|
||||
3. demo 阶段不要求 `REDIS_URL`。
|
||||
|
||||
本地 Agent 建议至少支持:
|
||||
|
||||
@ -635,6 +658,8 @@ edge-agent/
|
||||
5. `POST /api/agent/tasks`
|
||||
6. `POST /api/agent/tasks/{task_id}/confirm`
|
||||
7. `GET /api/agent/tasks/{task_id}`
|
||||
8. 最小 DDL 文档。
|
||||
9. 首批 OpenAPI 草案。
|
||||
|
||||
## 11.2 第二批完成
|
||||
|
||||
|
||||
@ -30,6 +30,20 @@
|
||||
3. 真实软件 A 适配细节。
|
||||
4. 所有部署场景的扩展字段。
|
||||
|
||||
### 1.4 本轮首批 OpenAPI 落地范围
|
||||
|
||||
本轮 OpenAPI 草案仅覆盖第一批主链路接口:
|
||||
|
||||
1. `POST /api/agent/tasks`
|
||||
2. `POST /api/agent/tasks/{task_id}/confirm`
|
||||
3. `GET /api/agent/tasks/{task_id}`
|
||||
|
||||
说明:
|
||||
|
||||
1. 软件 A demo、身份 demo、审批 demo、edge 接口继续保留在本文档中作为后续实现输入。
|
||||
2. 首批代码骨架只要求先打通以上三条主接口。
|
||||
3. 后续扩展 OpenAPI 时,优先保持当前对象模型和错误码不变。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体约定
|
||||
|
||||
@ -6,9 +6,11 @@
|
||||
|
||||
当前阶段已完成从"需求方案"到"技术架构"再到"接口定义"和"demo 后端骨架"的文档化收敛,整体处于:
|
||||
|
||||
**方案已成型、文档体系已建立、技术路线已基本明确、代码尚未开始实现**
|
||||
**方案已成型、文档体系已建立、技术路线已基本明确、demo 后端代码骨架已开始实现**
|
||||
|
||||
当前产出重点仍然是文档设计,不是代码开发。
|
||||
当前产出重点已经从纯文档设计切换为:
|
||||
|
||||
**文档收口 + demo 代码骨架落地 + 主链路验证**
|
||||
|
||||
---
|
||||
|
||||
@ -28,7 +30,13 @@
|
||||
4. `智能化部署agent-demo后端项目骨架设计.md`
|
||||
用于描述 demo 后端的推荐技术栈、项目结构、模块职责、数据库表建议、代码落点和开发顺序。
|
||||
|
||||
5. `智能化部署agent-技术架构设计说明书.backup-20260408-141109.md`
|
||||
5. `docs/智能化部署agent-demo最小DDL设计.md`
|
||||
用于沉淀 demo 阶段最小可运行的数据表结构。
|
||||
|
||||
6. `docs/智能化部署agent-demo首批OpenAPI.yaml`
|
||||
用于沉淀第一批已收口接口的 OpenAPI 草案。
|
||||
|
||||
7. `智能化部署agent-技术架构设计说明书.backup-20260408-141109.md`
|
||||
为技术架构说明书备份文件。
|
||||
|
||||
---
|
||||
@ -106,6 +114,49 @@ demo 接口定义文档已覆盖:
|
||||
6. 本地 Agent 骨架建议。
|
||||
7. 开发顺序建议。
|
||||
|
||||
### 3.7 demo 后端初始化代码已开始落地
|
||||
|
||||
当前已完成以下代码层工作:
|
||||
|
||||
1. 已生成 FastAPI demo 后端项目基础目录。
|
||||
2. 已补充 `pyproject.toml`、基础 `README` 和 `.gitignore`。
|
||||
3. 已实现 `task`、`approval_request`、`tool_call`、`audit_log` 对应的最小模型和数据库初始化逻辑。
|
||||
4. 已打通三条主接口:
|
||||
`POST /api/agent/tasks`、`POST /api/agent/tasks/{task_id}/confirm`、`GET /api/agent/tasks/{task_id}`
|
||||
5. 已实现最小 `identity demo`、`approval demo`、`software-a demo` 接口。
|
||||
6. 已将高风险任务确认后的审批创建流程接入后端主链路。
|
||||
7. 已实现最小 `edge` 心跳、拉取任务、回传结果接口。
|
||||
8. 已将默认验证任务接入 edge 调度主链路。
|
||||
9. 已将 `software-a demo` 部署任务创建接入主执行链。
|
||||
10. 已将 `tool_call` 和 `audit_log` 接入主链路关键动作。
|
||||
11. 已实现任务报告接口,可返回审批、工具、验证、审计轨迹。
|
||||
12. 已实现任务取消接口,并将 `request_id`、`operator` 维度写入关键审计和工具调用记录。
|
||||
13. 已补充自动化测试,并基于内存 SQLite 完成首轮通过验证。
|
||||
14. 已完成任务状态机第一轮收紧,补上重复确认、审批后任务状态漂移、edge 重复回传等冲突校验。
|
||||
15. 已补上首轮失败分支细化,包括 software-a demo 执行失败、审批驳回、edge 验证失败三条主失败路径。
|
||||
|
||||
### 3.8 当前代码可运行范围
|
||||
|
||||
截至当前回合,后端代码已具备以下最小可运行范围:
|
||||
|
||||
1. 任务创建、确认、查询、取消。
|
||||
2. 高风险任务确认后自动创建审批单。
|
||||
3. 审批通过后进入执行链,审批驳回后进入取消态。
|
||||
4. 执行链包含:
|
||||
software-a 权限校验 -> software-a demo 部署任务创建 -> edge 默认验证任务创建 -> edge 拉取 -> edge 回传。
|
||||
5. 任务详情接口可返回:
|
||||
当前状态、software-a 状态、工具调用摘要、验证结果摘要。
|
||||
6. 任务报告接口可返回:
|
||||
`task_basic`、`intent_snapshot`、`approval_trace`、`tool_trace`、`verification_trace`、`result_summary`、`audit_trace`
|
||||
7. edge 侧已支持:
|
||||
心跳、拉取任务、回传结果、上报异常事件。
|
||||
|
||||
当前测试基线:
|
||||
|
||||
1. 共 14 条测试通过。
|
||||
2. 使用 `sqlite:///:memory:` 做回归验证。
|
||||
3. 当前主链路已不是“只有接口壳”,而是具备最小闭环行为。
|
||||
|
||||
---
|
||||
|
||||
## 4. 当前已明确的核心技术结论
|
||||
@ -155,7 +206,7 @@ demo 接口定义文档已覆盖:
|
||||
2. 如果以 demo 快速落地和减少安装成本为优先,可以先用 SQLite。
|
||||
3. 后续试点或正式化阶段再切换 PostgreSQL。
|
||||
|
||||
该结论属于当前建议,尚未完整回写到骨架设计文档中。
|
||||
该结论已在本轮决策、最小 DDL 和当前后端实现中落地。
|
||||
|
||||
### 4.6 开源和商用许可判断
|
||||
|
||||
@ -165,71 +216,143 @@ demo 接口定义文档已覆盖:
|
||||
2. Redis 的许可证情况相对复杂,不建议在文档中简单视为"低风险宽松开源"。
|
||||
3. 如果确实需要 Redis 类组件,后续应评估 Valkey 或在 demo 阶段先不强依赖缓存中间件。
|
||||
|
||||
该结论属于当前建议,尚未完整回写到骨架设计文档中。
|
||||
该结论已收口为当前 demo 阶段“不引入 Redis 强依赖”的正式实现策略。
|
||||
|
||||
### 4.7 本轮正式落地决策
|
||||
|
||||
本轮已正式确认以下落地决策,后续实现与文档以此为准:
|
||||
|
||||
1. demo 数据库默认采用 `SQLite`,后续试点和正式化阶段再切换 `PostgreSQL`。
|
||||
2. demo 阶段不引入 `Redis` 强依赖,缓存能力默认弱化,任务队列先采用数据库表 + 后台轮询方式承接。
|
||||
3. 用户端 `edge-agent` 交付格式正式确认为:
|
||||
Windows 使用 `zip` 便携包,Linux 使用 `tar.gz` 自包含运行目录。
|
||||
4. 文档补充策略正式确认为:
|
||||
只补最小 DDL 和首批 OpenAPI 草案,不一次性扩展到全部表和全部接口。
|
||||
5. 开发顺序正式确认为:
|
||||
先补最小 DDL 和首批 OpenAPI,再直接进入 FastAPI demo 后端骨架开发。
|
||||
|
||||
### 4.8 当前代码层关键实现约定
|
||||
|
||||
以下约定虽然部分未完整回写到全部设计文档,但当前代码实现已经以此为准:
|
||||
|
||||
1. 任务主状态机当前主要覆盖:
|
||||
`CREATED` -> `PENDING_CONFIRM` -> `RUNNING` -> `VERIFYING` -> `SUCCEEDED` / `FAILED` / `CANCELLED`
|
||||
2. 高风险任务路径为:
|
||||
`PENDING_CONFIRM` -> `PENDING_APPROVAL` -> `RUNNING`
|
||||
3. `software-a demo` 当前在任务详情查询时会同步刷新状态,因此:
|
||||
确认接口返回的 `software_a_task_status` 可能是 `RUNNING`,而后续查询任务详情时可能已变为 `SUCCEEDED`
|
||||
4. 当前 demo 中的 operator 默认使用:
|
||||
`alice(u1001)` 作为任务发起和执行方,`bob(u2001)` 作为审批人
|
||||
5. 当前 edge 默认验证工具为:
|
||||
`http_health_check`
|
||||
6. 当前默认 edge 节点 ID 为:
|
||||
`edge-shanghai-001`
|
||||
7. 当前任务报告中的 `tool_trace` 和 `audit_trace` 已包含 `request_id` 和 operator 信息,后续扩展应保持兼容。
|
||||
8. 当前已补上的状态约束包括:
|
||||
重复确认拦截、重复执行拦截、审批决策前必须仍处于 `PENDING_APPROVAL`、edge 重复回传拦截、非 `RUNNING` 任务不再下发 edge 执行。
|
||||
9. 当前 demo 已支持可控失败模拟:
|
||||
若 `app_code` 或 `version` 包含 `fail`,则 `software-a demo` 会返回失败任务,用于联调失败分支。
|
||||
|
||||
---
|
||||
|
||||
## 5. 当前尚未开始的部分
|
||||
## 5. 当前待补强的部分
|
||||
|
||||
目前尚未开始以下工作:
|
||||
当前还未收口,或仅实现了最小版本的工作包括:
|
||||
|
||||
1. demo 后端初始化代码。
|
||||
2. 本地 Agent 初始化代码。
|
||||
3. 数据库建表脚本。
|
||||
4. OpenAPI 文档生成。
|
||||
5. 软件 A demo 服务实现。
|
||||
6. 身份 demo 服务实现。
|
||||
7. 审批 demo 服务实现。
|
||||
8. 验证插件实现。
|
||||
9. 部署脚本和运行脚本。
|
||||
10. 测试用例与联调脚本。
|
||||
1. 本地 `edge-agent` 初始化代码与打包脚本。
|
||||
2. 文件型 SQLite / PostgreSQL 实库运行验证。
|
||||
3. 身份 demo / 审批 demo 与任务主链路的权限、审批决策联动细化。
|
||||
4. `duration_ms` 等执行指标的真实计算与回填。
|
||||
5. 更真实的验证插件实现。
|
||||
6. 部署脚本和运行脚本完善。
|
||||
7. OpenAPI 扩展到第二批接口。
|
||||
8. 更多测试用例与联调脚本。
|
||||
|
||||
### 5.1 当前已知环境限制
|
||||
|
||||
以下问题不是当前代码逻辑错误,而是当前运行环境限制:
|
||||
|
||||
1. 当前对话环境下,文件型 SQLite 落盘会出现 `disk I/O error`。
|
||||
2. 因此当前自动化验证统一采用:
|
||||
`DATABASE_URL=sqlite:///:memory:`
|
||||
3. 当前测试命令需禁用 pytest cache provider,否则可能因写缓存目录失败出现噪音告警。
|
||||
4. PowerShell 内联脚本在中文字符串场景下可能有编码干扰,因此测试样例优先使用 ASCII 文本。
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前存在的待落地事项
|
||||
## 6. 当前待落地重点
|
||||
|
||||
虽然整体文档体系已比较完整,但仍有几项内容尚未真正落到可运行层面:
|
||||
当前不是继续补基础文档,而是继续补强现有可运行链路。优先级建议收敛为:
|
||||
|
||||
1. 是否正式确认 demo 阶段使用 SQLite 还是 PostgreSQL。
|
||||
2. 是否正式确认 Redis 在 demo 阶段保留、替换还是去掉。
|
||||
3. 是否正式确认用户端 Agent 的交付格式:
|
||||
Windows zip 包、Linux tar.gz 包,或单文件可执行包。
|
||||
4. 是否继续补数据库 DDL 文档。
|
||||
5. 是否继续补 OpenAPI 草案。
|
||||
6. 是否直接开始生成 demo 后端代码骨架。
|
||||
1. 回填执行指标:
|
||||
重点补 `duration_ms`、更完整的执行结果摘要与审计信息。
|
||||
2. 增补失败路径与幂等性测试:
|
||||
重点补重复请求、重复回传、异常回滚等场景。
|
||||
3. 继续丰富结果摘要与审计细节:
|
||||
让失败原因在详情和报告里更直观可见。
|
||||
4. 然后再继续:
|
||||
本地 `edge-agent` 骨架、第二批 OpenAPI、更多联调能力。
|
||||
|
||||
当前状态:
|
||||
|
||||
**SQLite / 去 Redis / 最小 DDL / 首批 OpenAPI / FastAPI 骨架 / 三条主接口 / demo adapter / edge 接口,均已完成第一轮落地。**
|
||||
|
||||
---
|
||||
|
||||
## 7. 建议的下一步
|
||||
|
||||
按当前进度,建议后续按以下顺序推进:
|
||||
按当前进度,建议后续直接按以下顺序推进:
|
||||
|
||||
### 路线 A:继续补文档后再开发
|
||||
|
||||
1. 补数据库 DDL 设计。
|
||||
2. 补 OpenAPI 草案。
|
||||
3. 将 SQLite / PostgreSQL、Redis / Valkey、用户端 Python 便携运行方案正式回写到文档。
|
||||
|
||||
适合:
|
||||
|
||||
1. 先把设计收口到可以评审。
|
||||
2. 由多人协作开发前需要统一边界。
|
||||
|
||||
### 路线 B:直接进入 demo 代码骨架
|
||||
|
||||
1. 生成 FastAPI 后端项目初始化代码。
|
||||
2. 生成核心目录结构和空实现。
|
||||
3. 先打通 `POST /api/agent/tasks`、确认任务、查询任务三条主接口。
|
||||
4. 再补软件 A demo adapter、身份 demo adapter、审批 demo adapter。
|
||||
|
||||
适合:
|
||||
|
||||
1. 尽快进入实现阶段。
|
||||
2. 通过代码反推细节问题。
|
||||
1. 计算并持久化 `duration_ms`。
|
||||
2. 增补状态冲突、失败回滚、重复上报等测试。
|
||||
3. 丰富结果摘要与失败原因呈现。
|
||||
4. 再进入本地 `edge-agent` 初始化代码和第二批 OpenAPI。
|
||||
|
||||
当前更推荐:
|
||||
|
||||
**先补最小 DDL 和 OpenAPI 草案,然后直接进入 demo 后端代码骨架。**
|
||||
**继续迭代代码主链路,不再回到“大段补文档”的节奏。**
|
||||
|
||||
### 7.1 如果下一轮需要快速续接,优先做什么
|
||||
|
||||
如果后续上下文被裁剪,建议下一轮直接先读取本文件,然后按以下顺序继续:
|
||||
|
||||
1. 优先读取:
|
||||
`backend/README.md`
|
||||
2. 再读取关键代码入口:
|
||||
`backend/app/main.py`
|
||||
`backend/app/api/agent/tasks.py`
|
||||
`backend/app/services/task_service.py`
|
||||
`backend/app/services/approval_service.py`
|
||||
`backend/app/services/edge_service.py`
|
||||
3. 再读取测试:
|
||||
`backend/tests/test_task_api.py`
|
||||
|
||||
下一步推荐顺序:
|
||||
|
||||
1. 计算并回填 `duration_ms`。
|
||||
2. 再补失败路径和幂等性测试。
|
||||
3. 再补结果摘要和失败原因展示。
|
||||
4. 再补本地 Agent 初始化代码或第二批 OpenAPI。
|
||||
|
||||
### 7.2 如果上下文快满,有什么影响
|
||||
|
||||
主要影响是:
|
||||
|
||||
1. 对话里的临时上下文可能被裁剪。
|
||||
2. 已写入仓库的代码和文档不会受影响。
|
||||
3. 因此续接时优先读本文件和 `backend/README.md`,成本可控。
|
||||
|
||||
结论:
|
||||
|
||||
**上下文快满不会影响现有代码成果,只会增加下一轮续接时重新装载上下文的成本。**
|
||||
|
||||
当前推荐命令:
|
||||
|
||||
```bash
|
||||
set PYTHONPATH=backend
|
||||
set DATABASE_URL=sqlite:///:memory:
|
||||
.venv\Scripts\python -m pytest backend/tests -q -p no:cacheprovider
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -239,4 +362,6 @@ demo 接口定义文档已覆盖:
|
||||
|
||||
**方案文档 -> 技术架构 -> 接口定义 -> 后端骨架**
|
||||
|
||||
下一步已经可以从"写文档"切换到"写 demo 代码"。
|
||||
当前已经完成从"写文档"切换到"写 demo 代码"的第一步,下一步进入:
|
||||
|
||||
**duration_ms 回填 -> 失败结果呈现增强 -> 本地 Agent 与联调能力继续补齐**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user