feat: scaffold demo backend and task workflow

This commit is contained in:
redbotu 2026-04-08 21:42:43 +08:00
parent 37e0572b1a
commit 62186e7994
65 changed files with 4328 additions and 71 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.venv/
data/
__pycache__/
.pytest_cache/
*.pyc
*.egg-info/

115
backend/README.md Normal file
View 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
View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View 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

View 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)

View File

@ -0,0 +1 @@

View 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

View 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)

View File

@ -0,0 +1 @@

View 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

View 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)

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View 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),
)

View File

@ -0,0 +1 @@

View 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),
)

View 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"),
)

View 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"),
)

View File

@ -0,0 +1 @@

View 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),
)

View File

@ -0,0 +1 @@

View 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)

View 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
View 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]

View File

@ -0,0 +1 @@

5
backend/app/db/base.py Normal file
View File

@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

28
backend/app/db/session.py Normal file
View 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
View 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)

View File

@ -0,0 +1 @@

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View File

@ -0,0 +1 @@

View 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]

View 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())

View 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())

View 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()

View 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())

View File

@ -0,0 +1 @@

View 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]

View 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

View 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

View 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

View 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
View 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]

View File

@ -0,0 +1 @@

View 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)

View 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)

View 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"],
)

View 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

View 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

View 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}"

View 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
View 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"

View 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

View 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` 等表。

View 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

View File

@ -50,12 +50,15 @@ demo 阶段建议采用:
## 2.2 部署单元建议 ## 2.2 部署单元建议
demo 阶段建议至少包含 4 个运行单元: demo 阶段建议至少包含 2 个运行单元:
1. `agent-backend` 1. `agent-backend`
2. `edge-agent` 2. `edge-agent`
3. `postgres`
4. `redis` 说明:
1. demo 阶段默认采用 `SQLite`,以本地文件方式随 `agent-backend` 一起运行,不额外部署数据库单元。
2. demo 阶段不引入 `Redis` 强依赖,任务轮询、简单队列和状态流转先通过数据库表和后台 worker 承接。
可选: 可选:
@ -77,9 +80,14 @@ demo 阶段建议至少包含 4 个运行单元:
2. FastAPI 2. FastAPI
3. Pydantic 3. Pydantic
4. SQLAlchemy 4. SQLAlchemy
5. PostgreSQL 5. SQLite
6. Redis 6. LangGraph
7. LangGraph
补充说明:
1. demo 默认数据库为 `SQLite`
2. Repository 和 ORM 层需预留切换 `PostgreSQL` 的能力。
3. demo 阶段不将 `Redis` 作为启动前置依赖。
## 3.2 选择理由 ## 3.2 选择理由
@ -96,10 +104,12 @@ demo 阶段建议至少包含 4 个运行单元:
2. Pydantic 模型与接口文档天然匹配。 2. Pydantic 模型与接口文档天然匹配。
3. 适合 demo 阶段快速落地。 3. 适合 demo 阶段快速落地。
### 3.2.3 为什么保留 PostgreSQL 和 Redis ### 3.2.3 为什么 demo 默认使用 SQLite 且不强依赖 Redis
1. PostgreSQL 用于结构化任务、审批、审计落库。 1. `SQLite` 安装成本最低,更适合 demo 阶段快速打通闭环。
2. Redis 用于任务队列、幂等键、短期上下文和轮询状态。 2. `SQLite` 足以支撑单体服务、低并发联调和最小可运行演示。
3. 当前主链路优先级高于基础设施完整性,队列能力可先通过数据库表和 worker 轮询承接。
4. `Redis` 可在后续进入多实例部署、强幂等或高并发调度阶段时再评估引入,优先评估 `Valkey` 或兼容方案。
## 3.3 如果团队必须走 Java ## 3.3 如果团队必须走 Java
@ -108,8 +118,7 @@ demo 阶段建议至少包含 4 个运行单元:
1. Spring Boot 1. Spring Boot
2. Spring Web 2. Spring Web
3. JPA / MyBatis 3. JPA / MyBatis
4. PostgreSQL 4. SQLite / PostgreSQL
5. Redis
但 demo 阶段的 Agent 编排效率通常不如 Python 方案。 但 demo 阶段的 Agent 编排效率通常不如 Python 方案。
@ -594,6 +603,15 @@ edge-agent/
4. 工具按操作系统分类适配。 4. 工具按操作系统分类适配。
5. 所有执行结果结构化回传。 5. 所有执行结果结构化回传。
## 9.3 本地 Agent 交付格式
demo 阶段正式确认:
1. Windows 使用 `zip` 便携包交付。
2. Linux 使用 `tar.gz` 自包含运行目录交付。
3. 两个平台均不依赖客户现场预装 Python。
4. 单文件可执行包不作为第一阶段默认方案。
--- ---
## 10. 配置与环境变量建议 ## 10. 配置与环境变量建议
@ -603,15 +621,20 @@ edge-agent/
1. `APP_ENV` 1. `APP_ENV`
2. `APP_PORT` 2. `APP_PORT`
3. `DATABASE_URL` 3. `DATABASE_URL`
4. `REDIS_URL` 4. `LLM_BASE_URL`
5. `LLM_BASE_URL` 5. `LLM_API_KEY`
6. `LLM_API_KEY` 6. `SOFTWARE_A_BASE_URL`
7. `SOFTWARE_A_BASE_URL` 7. `SOFTWARE_A_TIMEOUT_MS`
8. `SOFTWARE_A_TIMEOUT_MS` 8. `IDENTITY_BASE_URL`
9. `IDENTITY_BASE_URL` 9. `APPROVAL_BASE_URL`
10. `APPROVAL_BASE_URL` 10. `EDGE_TOKEN_SECRET`
11. `EDGE_TOKEN_SECRET` 11. `DEFAULT_TIMEZONE`
12. `DEFAULT_TIMEZONE`
说明:
1. demo 阶段 `DATABASE_URL` 默认建议为 `sqlite:///./data/agent_demo.db`
2. 如后续切换 `PostgreSQL`,优先只调整配置,不改动 service 层接口。
3. demo 阶段不要求 `REDIS_URL`
本地 Agent 建议至少支持: 本地 Agent 建议至少支持:
@ -635,6 +658,8 @@ edge-agent/
5. `POST /api/agent/tasks` 5. `POST /api/agent/tasks`
6. `POST /api/agent/tasks/{task_id}/confirm` 6. `POST /api/agent/tasks/{task_id}/confirm`
7. `GET /api/agent/tasks/{task_id}` 7. `GET /api/agent/tasks/{task_id}`
8. 最小 DDL 文档。
9. 首批 OpenAPI 草案。
## 11.2 第二批完成 ## 11.2 第二批完成

View File

@ -30,6 +30,20 @@
3. 真实软件 A 适配细节。 3. 真实软件 A 适配细节。
4. 所有部署场景的扩展字段。 4. 所有部署场景的扩展字段。
### 1.4 本轮首批 OpenAPI 落地范围
本轮 OpenAPI 草案仅覆盖第一批主链路接口:
1. `POST /api/agent/tasks`
2. `POST /api/agent/tasks/{task_id}/confirm`
3. `GET /api/agent/tasks/{task_id}`
说明:
1. 软件 A demo、身份 demo、审批 demo、edge 接口继续保留在本文档中作为后续实现输入。
2. 首批代码骨架只要求先打通以上三条主接口。
3. 后续扩展 OpenAPI 时,优先保持当前对象模型和错误码不变。
--- ---
## 2. 总体约定 ## 2. 总体约定

View File

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