feat: 增加 Agent 演示入口与 app_metadata 驱动验证链路
- 新增 app_metadata 模型、仓储与服务 - 将默认 edge 验证步骤改为由 app_metadata 驱动生成 - 新增 chat_session / chat_message 会话层模型与 chat service - 新增 demo chat API,支持会话创建、消息发送、任务确认 - 新增最小 Web Demo 页面,形成聊天式演示入口 - 增强任务报告,补充 audit_summary 与更细粒度 task_metrics - 增强 edge-agent 执行器:tcp_probe、日志时间范围过滤、进程指标与更灵活健康检查 - 更新 README 与当前进度总结,MVP 进度推进到约 94%
This commit is contained in:
parent
591df2d18e
commit
ce299cbb18
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
.venv/
|
.venv/
|
||||||
data/
|
data/
|
||||||
dist/
|
dist/
|
||||||
|
tmp-linux-runtime/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@ -53,21 +53,29 @@ Current backend includes:
|
|||||||
`POST /api/agent/tasks/{task_id}/cancel`
|
`POST /api/agent/tasks/{task_id}/cancel`
|
||||||
`GET /api/agent/tasks/{task_id}`
|
`GET /api/agent/tasks/{task_id}`
|
||||||
`GET /api/agent/tasks/{task_id}/report`
|
`GET /api/agent/tasks/{task_id}/report`
|
||||||
2. demo identity
|
2. demo chat
|
||||||
|
`POST /api/demo/chat/sessions`
|
||||||
|
`GET /api/demo/chat/sessions/{session_id}`
|
||||||
|
`POST /api/demo/chat/sessions/{session_id}/messages`
|
||||||
|
`POST /api/demo/chat/sessions/{session_id}/tasks/{task_id}/confirm`
|
||||||
|
3. demo web
|
||||||
|
`GET /`
|
||||||
|
`GET /demo/chat`
|
||||||
|
4. demo identity
|
||||||
`POST /api/demo/identity/login`
|
`POST /api/demo/identity/login`
|
||||||
`GET /api/demo/identity/me`
|
`GET /api/demo/identity/me`
|
||||||
`GET /api/demo/identity/users/{user_id}/permissions`
|
`GET /api/demo/identity/users/{user_id}/permissions`
|
||||||
`POST /api/demo/identity/token/introspect`
|
`POST /api/demo/identity/token/introspect`
|
||||||
3. demo approval
|
5. demo approval
|
||||||
`POST /api/demo/approval/requests`
|
`POST /api/demo/approval/requests`
|
||||||
`GET /api/demo/approval/requests/{approval_id}`
|
`GET /api/demo/approval/requests/{approval_id}`
|
||||||
`POST /api/demo/approval/requests/{approval_id}/decision`
|
`POST /api/demo/approval/requests/{approval_id}/decision`
|
||||||
`GET /api/demo/approval/requests`
|
`GET /api/demo/approval/requests`
|
||||||
4. software-a minimal implementation
|
6. software-a minimal implementation
|
||||||
`POST /api/demo/software-a/deploy-tasks`
|
`POST /api/demo/software-a/deploy-tasks`
|
||||||
`GET /api/demo/software-a/deploy-tasks/{software_a_task_id}`
|
`GET /api/demo/software-a/deploy-tasks/{software_a_task_id}`
|
||||||
`POST /api/demo/software-a/permissions/check`
|
`POST /api/demo/software-a/permissions/check`
|
||||||
5. edge
|
7. edge
|
||||||
`POST /api/agent/edge/heartbeat`
|
`POST /api/agent/edge/heartbeat`
|
||||||
`POST /api/agent/edge/tasks/pull`
|
`POST /api/agent/edge/tasks/pull`
|
||||||
`POST /api/agent/edge/tasks/report`
|
`POST /api/agent/edge/tasks/report`
|
||||||
@ -75,15 +83,22 @@ Current backend includes:
|
|||||||
|
|
||||||
Current execution flow:
|
Current execution flow:
|
||||||
|
|
||||||
1. create task
|
1. create chat session or open web demo
|
||||||
2. confirm task
|
2. send one natural-language message
|
||||||
3. high-risk task enters approval flow
|
3. create task
|
||||||
4. check `software-a` minimal implementation permission
|
4. confirm task
|
||||||
5. create `software-a` minimal implementation deploy task
|
5. high-risk task enters approval flow
|
||||||
6. create default edge verification step
|
6. check `software-a` minimal implementation permission
|
||||||
7. edge pulls and reports verification result
|
7. create `software-a` minimal implementation deploy task
|
||||||
8. task reaches `SUCCEEDED` / `FAILED` / `CANCELLED`
|
8. build metadata-driven multi-step edge verification plan:
|
||||||
9. task detail/report returns software-a status, approval trace, tool trace, verification trace and audit trace
|
`check_process`
|
||||||
|
`check_port`
|
||||||
|
`tcp_probe`
|
||||||
|
`http_health_check`
|
||||||
|
`grep_log`
|
||||||
|
9. edge pulls and reports verification results
|
||||||
|
10. task reaches `SUCCEEDED` / `FAILED` / `CANCELLED`
|
||||||
|
11. task detail/report returns software-a status, approval trace, tool trace, verification trace and audit trace
|
||||||
|
|
||||||
Current execution metrics:
|
Current execution metrics:
|
||||||
|
|
||||||
@ -95,13 +110,19 @@ Current execution metrics:
|
|||||||
`confirm_wait_duration_ms`
|
`confirm_wait_duration_ms`
|
||||||
`approval_duration_ms`
|
`approval_duration_ms`
|
||||||
`execution_duration_ms`
|
`execution_duration_ms`
|
||||||
|
`software_a_duration_ms_total`
|
||||||
`tool_call_duration_ms_total`
|
`tool_call_duration_ms_total`
|
||||||
`verification_duration_ms_total`
|
`verification_duration_ms_total`
|
||||||
|
`verification_queue_wait_duration_ms_total`
|
||||||
|
`verification_end_to_end_duration_ms_total`
|
||||||
|
5. `task_report.audit_summary` returns audit result counts, action types and operator summary
|
||||||
|
|
||||||
Current result summary capabilities:
|
Current result summary capabilities:
|
||||||
|
|
||||||
1. task detail/report returns `result_summary_detail`
|
1. task detail/report returns `result_summary_detail`
|
||||||
2. summary includes final status, final reason, software-a result, approval result and verification result
|
2. summary includes final status, final reason, software-a result, approval result and verification result
|
||||||
|
3. demo chat API returns assistant-style parse/confirm messages
|
||||||
|
4. demo web page provides a visual conversation -> confirm -> execute -> report flow
|
||||||
|
|
||||||
Demo failure semantics currently include:
|
Demo failure semantics currently include:
|
||||||
|
|
||||||
@ -122,12 +143,13 @@ Automated tests currently cover:
|
|||||||
7. cancel running task
|
7. cancel running task
|
||||||
|
|
||||||
Current baseline: `20 passed`
|
Current baseline: `20 passed`
|
||||||
|
Current baseline: `23 passed`
|
||||||
|
|
||||||
## Next Focus
|
## Next Focus
|
||||||
|
|
||||||
Recommended next implementation steps:
|
Recommended next implementation steps:
|
||||||
|
|
||||||
1. continue enriching audit details and task-level metric breakdown
|
1. continue enriching app-metadata-driven verification templates
|
||||||
2. continue implementing local edge-agent executors beyond `http_health_check`
|
2. connect a real Java sample app to the current demo flow
|
||||||
3. add packaging/bootstrap scripts for portable edge-agent delivery
|
3. validate native Linux packaging in a real bash/Linux environment
|
||||||
4. then continue with second-batch OpenAPI
|
4. then continue with second-batch OpenAPI and UI polish
|
||||||
|
|||||||
@ -18,6 +18,7 @@ from app.repositories.tool_call_repository import ToolCallRepository
|
|||||||
from app.adapters.software_a.minimal_adapter import MinimalSoftwareAAdapter
|
from app.adapters.software_a.minimal_adapter import MinimalSoftwareAAdapter
|
||||||
from app.schemas.common import ApiResponse
|
from app.schemas.common import ApiResponse
|
||||||
from app.schemas.task import (
|
from app.schemas.task import (
|
||||||
|
AuditSummary,
|
||||||
ApprovalSummary,
|
ApprovalSummary,
|
||||||
ApprovalTraceItem,
|
ApprovalTraceItem,
|
||||||
AuditTraceItem,
|
AuditTraceItem,
|
||||||
@ -53,8 +54,12 @@ def build_result_summary_detail(task, approval, software_a_detail: dict | None,
|
|||||||
final_reason = task.summary
|
final_reason = task.summary
|
||||||
if software_a_detail and software_a_detail.get("error_detail"):
|
if software_a_detail and software_a_detail.get("error_detail"):
|
||||||
final_reason = software_a_detail["error_detail"]
|
final_reason = software_a_detail["error_detail"]
|
||||||
elif latest_edge_task and latest_edge_task.message:
|
elif edge_tasks:
|
||||||
final_reason = latest_edge_task.message
|
failed_message = next((item.message for item in edge_tasks if item.step_status == "FAILED" and item.message), None)
|
||||||
|
if failed_message:
|
||||||
|
final_reason = failed_message
|
||||||
|
elif latest_edge_task and latest_edge_task.message:
|
||||||
|
final_reason = latest_edge_task.message
|
||||||
elif approval and approval.approval_status == "REJECTED" and approval.reason:
|
elif approval and approval.approval_status == "REJECTED" and approval.reason:
|
||||||
final_reason = approval.reason
|
final_reason = approval.reason
|
||||||
|
|
||||||
@ -79,12 +84,23 @@ def build_result_summary_detail(task, approval, software_a_detail: dict | None,
|
|||||||
|
|
||||||
verification_summary = None
|
verification_summary = None
|
||||||
if latest_edge_task:
|
if latest_edge_task:
|
||||||
|
verification_success_values = [bool(item.success) for item in edge_tasks if item.success is not None]
|
||||||
|
verification_success = None if not verification_success_values else all(verification_success_values)
|
||||||
|
if any(item.step_status == "FAILED" for item in edge_tasks):
|
||||||
|
verification_status = "FAILED"
|
||||||
|
elif all(item.step_status == "SUCCEEDED" for item in edge_tasks):
|
||||||
|
verification_status = "SUCCEEDED"
|
||||||
|
elif any(item.step_status == "RUNNING" for item in edge_tasks):
|
||||||
|
verification_status = "RUNNING"
|
||||||
|
else:
|
||||||
|
verification_status = latest_edge_task.step_status
|
||||||
|
verification_message = next((item.message for item in edge_tasks if item.step_status == "FAILED" and item.message), latest_edge_task.message)
|
||||||
verification_summary = VerificationResultSummary(
|
verification_summary = VerificationResultSummary(
|
||||||
step_id=latest_edge_task.step_id,
|
step_id=latest_edge_task.step_id if len(edge_tasks) == 1 else None,
|
||||||
step_status=latest_edge_task.step_status,
|
step_status=verification_status,
|
||||||
success=None if latest_edge_task.success is None else bool(latest_edge_task.success),
|
success=verification_success,
|
||||||
duration_ms=latest_edge_task.duration_ms,
|
duration_ms=sum_duration_ms([item.duration_ms for item in edge_tasks]),
|
||||||
message=latest_edge_task.message,
|
message=verification_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ResultSummaryDetail(
|
return ResultSummaryDetail(
|
||||||
@ -110,6 +126,9 @@ def sum_duration_ms(values: list[int | None]) -> int:
|
|||||||
def build_task_metrics(task, approval, software_a_detail: dict | None, tool_calls, edge_tasks, audit_logs) -> TaskMetrics:
|
def build_task_metrics(task, approval, software_a_detail: dict | None, tool_calls, edge_tasks, audit_logs) -> TaskMetrics:
|
||||||
tool_call_duration_ms_total = sum_duration_ms([item.duration_ms for item in tool_calls])
|
tool_call_duration_ms_total = sum_duration_ms([item.duration_ms for item in tool_calls])
|
||||||
verification_duration_ms_total = sum_duration_ms([item.duration_ms for item in edge_tasks])
|
verification_duration_ms_total = sum_duration_ms([item.duration_ms for item in edge_tasks])
|
||||||
|
software_a_duration_ms_total = sum_duration_ms([item.duration_ms for item in tool_calls if item.tool_name.startswith("software_a")])
|
||||||
|
verification_queue_wait_duration_ms_total = sum_duration_ms([compute_duration_ms(item.created_at, item.started_at) for item in edge_tasks])
|
||||||
|
verification_end_to_end_duration_ms_total = sum_duration_ms([compute_duration_ms(item.created_at, item.finished_at) for item in edge_tasks])
|
||||||
|
|
||||||
tool_call_count = len(tool_calls)
|
tool_call_count = len(tool_calls)
|
||||||
tool_call_success_count = sum(1 for item in tool_calls if bool(item.success))
|
tool_call_success_count = sum(1 for item in tool_calls if bool(item.success))
|
||||||
@ -118,6 +137,7 @@ def build_task_metrics(task, approval, software_a_detail: dict | None, tool_call
|
|||||||
verification_step_count = len(edge_tasks)
|
verification_step_count = len(edge_tasks)
|
||||||
verification_success_count = sum(1 for item in edge_tasks if item.success == 1)
|
verification_success_count = sum(1 for item in edge_tasks if item.success == 1)
|
||||||
verification_failed_count = sum(1 for item in edge_tasks if item.success == 0)
|
verification_failed_count = sum(1 for item in edge_tasks if item.success == 0)
|
||||||
|
audit_failure_count = sum(1 for item in audit_logs if item.result in {"FAILED", "REJECTED"})
|
||||||
|
|
||||||
latest_observed_at = pick_latest_timestamp(
|
latest_observed_at = pick_latest_timestamp(
|
||||||
task.updated_at,
|
task.updated_at,
|
||||||
@ -156,8 +176,11 @@ def build_task_metrics(task, approval, software_a_detail: dict | None, tool_call
|
|||||||
confirm_wait_duration_ms=confirm_wait_duration_ms,
|
confirm_wait_duration_ms=confirm_wait_duration_ms,
|
||||||
approval_duration_ms=approval_duration_ms,
|
approval_duration_ms=approval_duration_ms,
|
||||||
execution_duration_ms=execution_duration_ms,
|
execution_duration_ms=execution_duration_ms,
|
||||||
|
software_a_duration_ms_total=software_a_duration_ms_total,
|
||||||
tool_call_duration_ms_total=tool_call_duration_ms_total,
|
tool_call_duration_ms_total=tool_call_duration_ms_total,
|
||||||
verification_duration_ms_total=verification_duration_ms_total,
|
verification_duration_ms_total=verification_duration_ms_total,
|
||||||
|
verification_queue_wait_duration_ms_total=verification_queue_wait_duration_ms_total,
|
||||||
|
verification_end_to_end_duration_ms_total=verification_end_to_end_duration_ms_total,
|
||||||
tool_call_count=tool_call_count,
|
tool_call_count=tool_call_count,
|
||||||
tool_call_success_count=tool_call_success_count,
|
tool_call_success_count=tool_call_success_count,
|
||||||
tool_call_failed_count=tool_call_failed_count,
|
tool_call_failed_count=tool_call_failed_count,
|
||||||
@ -165,9 +188,51 @@ def build_task_metrics(task, approval, software_a_detail: dict | None, tool_call
|
|||||||
verification_success_count=verification_success_count,
|
verification_success_count=verification_success_count,
|
||||||
verification_failed_count=verification_failed_count,
|
verification_failed_count=verification_failed_count,
|
||||||
audit_event_count=len(audit_logs),
|
audit_event_count=len(audit_logs),
|
||||||
|
audit_failure_count=audit_failure_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_audit_summary(audit_logs) -> AuditSummary:
|
||||||
|
result_counts: dict[str, int] = {}
|
||||||
|
action_types = sorted({item.action for item in audit_logs})
|
||||||
|
operator_user_names = sorted({item.operator_user_name for item in audit_logs if item.operator_user_name})
|
||||||
|
|
||||||
|
for item in audit_logs:
|
||||||
|
result_counts[item.result] = result_counts.get(item.result, 0) + 1
|
||||||
|
|
||||||
|
return AuditSummary(
|
||||||
|
audit_event_count=len(audit_logs),
|
||||||
|
failure_count=sum(1 for item in audit_logs if item.result in {"FAILED", "REJECTED"}),
|
||||||
|
pending_count=sum(1 for item in audit_logs if item.result == "PENDING"),
|
||||||
|
cancelled_count=sum(1 for item in audit_logs if item.result == "CANCELLED"),
|
||||||
|
reported_count=sum(1 for item in audit_logs if item.result == "REPORTED"),
|
||||||
|
action_types=action_types,
|
||||||
|
operator_user_names=operator_user_names,
|
||||||
|
result_counts=result_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_verification_result(edge_tasks) -> dict | None:
|
||||||
|
if not edge_tasks:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def latest_success(tool_name: str) -> bool | None:
|
||||||
|
for item in edge_tasks:
|
||||||
|
if item.tool_name == tool_name and item.success is not None:
|
||||||
|
return bool(item.success)
|
||||||
|
return None
|
||||||
|
|
||||||
|
grep_success = latest_success("grep_log")
|
||||||
|
port_related = [latest_success(name) for name in ("check_port", "tcp_probe")]
|
||||||
|
port_values = [value for value in port_related if value is not None]
|
||||||
|
return {
|
||||||
|
"http_ok": latest_success("http_health_check"),
|
||||||
|
"process_ok": latest_success("check_process"),
|
||||||
|
"port_ok": all(port_values) if port_values else None,
|
||||||
|
"log_error_count": 0 if grep_success is True else (1 if grep_success is False else None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ApiResponse[CreateTaskData])
|
@router.post("", response_model=ApiResponse[CreateTaskData])
|
||||||
def create_task(
|
def create_task(
|
||||||
payload: CreateTaskRequest,
|
payload: CreateTaskRequest,
|
||||||
@ -320,16 +385,7 @@ def get_task(
|
|||||||
software_a_detail = None
|
software_a_detail = None
|
||||||
if task.software_a_task_id:
|
if task.software_a_task_id:
|
||||||
software_a_detail = MinimalSoftwareAAdapter(settings.default_timezone).get_deploy_task(task.software_a_task_id)
|
software_a_detail = MinimalSoftwareAAdapter(settings.default_timezone).get_deploy_task(task.software_a_task_id)
|
||||||
verification_result = None
|
verification_result = build_verification_result(edge_tasks)
|
||||||
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](
|
return ApiResponse[TaskDetailData](
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@ -469,6 +525,7 @@ def get_task_report(
|
|||||||
tool_trace=tool_trace,
|
tool_trace=tool_trace,
|
||||||
verification_trace=verification_trace,
|
verification_trace=verification_trace,
|
||||||
task_metrics=build_task_metrics(task, approval, software_a_detail, tool_calls, edge_tasks, audit_logs),
|
task_metrics=build_task_metrics(task, approval, software_a_detail, tool_calls, edge_tasks, audit_logs),
|
||||||
|
audit_summary=build_audit_summary(audit_logs),
|
||||||
result_summary=task.summary,
|
result_summary=task.summary,
|
||||||
result_summary_detail=build_result_summary_detail(task, approval, software_a_detail, edge_tasks),
|
result_summary_detail=build_result_summary_detail(task, approval, software_a_detail, edge_tasks),
|
||||||
audit_trace=audit_trace,
|
audit_trace=audit_trace,
|
||||||
|
|||||||
155
backend/app/api/demo/chat.py
Normal file
155
backend/app/api/demo/chat.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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.chat import (
|
||||||
|
ChatConfirmTaskData,
|
||||||
|
ChatConfirmTaskRequest,
|
||||||
|
ChatSendMessageData,
|
||||||
|
ChatSendMessageRequest,
|
||||||
|
ChatSessionCreateRequest,
|
||||||
|
ChatSessionData,
|
||||||
|
)
|
||||||
|
from app.schemas.common import ApiResponse
|
||||||
|
from app.services.chat_service import ChatService, ChatSessionNotFoundError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/demo/chat", tags=["demo-chat"])
|
||||||
|
|
||||||
|
|
||||||
|
def build_request_id(header_value: str | None) -> str:
|
||||||
|
return header_value or f"req-{uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions", response_model=ApiResponse[ChatSessionData])
|
||||||
|
def create_session(
|
||||||
|
payload: ChatSessionCreateRequest,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None,
|
||||||
|
) -> ApiResponse[ChatSessionData]:
|
||||||
|
settings = get_settings()
|
||||||
|
request_id = build_request_id(x_request_id)
|
||||||
|
service = ChatService(db, settings.default_timezone)
|
||||||
|
session = service.create_session(payload.tenant_id, payload.channel)
|
||||||
|
messages = [service.to_message_item(item) for item in service.list_messages(session.session_id)]
|
||||||
|
return ApiResponse[ChatSessionData](
|
||||||
|
request_id=request_id,
|
||||||
|
success=True,
|
||||||
|
code=ERROR_CODE_OK,
|
||||||
|
message="success",
|
||||||
|
data=ChatSessionData(
|
||||||
|
session_id=session.session_id,
|
||||||
|
tenant_id=session.tenant_id,
|
||||||
|
channel=session.channel,
|
||||||
|
title=session.title,
|
||||||
|
last_task_id=session.last_task_id,
|
||||||
|
sample_prompts=ChatService.SAMPLE_PROMPTS,
|
||||||
|
messages=messages,
|
||||||
|
),
|
||||||
|
timestamp=format_now(settings.default_timezone),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}", response_model=ApiResponse[ChatSessionData])
|
||||||
|
def get_session(
|
||||||
|
session_id: str,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None,
|
||||||
|
) -> ApiResponse[ChatSessionData]:
|
||||||
|
settings = get_settings()
|
||||||
|
request_id = build_request_id(x_request_id)
|
||||||
|
service = ChatService(db, settings.default_timezone)
|
||||||
|
try:
|
||||||
|
session = service.get_session(session_id)
|
||||||
|
except ChatSessionNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "NOT_FOUND", "message": "chat session not found"}) from exc
|
||||||
|
messages = [service.to_message_item(item) for item in service.list_messages(session.session_id)]
|
||||||
|
return ApiResponse[ChatSessionData](
|
||||||
|
request_id=request_id,
|
||||||
|
success=True,
|
||||||
|
code=ERROR_CODE_OK,
|
||||||
|
message="success",
|
||||||
|
data=ChatSessionData(
|
||||||
|
session_id=session.session_id,
|
||||||
|
tenant_id=session.tenant_id,
|
||||||
|
channel=session.channel,
|
||||||
|
title=session.title,
|
||||||
|
last_task_id=session.last_task_id,
|
||||||
|
sample_prompts=ChatService.SAMPLE_PROMPTS,
|
||||||
|
messages=messages,
|
||||||
|
),
|
||||||
|
timestamp=format_now(settings.default_timezone),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/messages", response_model=ApiResponse[ChatSendMessageData])
|
||||||
|
def send_message(
|
||||||
|
session_id: str,
|
||||||
|
payload: ChatSendMessageRequest,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None,
|
||||||
|
) -> ApiResponse[ChatSendMessageData]:
|
||||||
|
settings = get_settings()
|
||||||
|
request_id = build_request_id(x_request_id)
|
||||||
|
service = ChatService(db, settings.default_timezone)
|
||||||
|
try:
|
||||||
|
_, assistant_message, task_data = service.handle_user_message(session_id, payload.content, payload.context)
|
||||||
|
except ChatSessionNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "NOT_FOUND", "message": "chat session not found"}) from exc
|
||||||
|
|
||||||
|
return ApiResponse[ChatSendMessageData](
|
||||||
|
request_id=request_id,
|
||||||
|
success=True,
|
||||||
|
code=ERROR_CODE_OK,
|
||||||
|
message="success",
|
||||||
|
data=ChatSendMessageData(
|
||||||
|
session_id=session_id,
|
||||||
|
task_id=task_data["task_id"],
|
||||||
|
task_status=task_data["task_status"],
|
||||||
|
parsed_intent=task_data["parsed_intent"],
|
||||||
|
missing_slots=task_data["missing_slots"],
|
||||||
|
risk_level=task_data["risk_level"],
|
||||||
|
next_action=task_data["next_action"],
|
||||||
|
assistant_message=service.to_message_item(assistant_message),
|
||||||
|
),
|
||||||
|
timestamp=format_now(settings.default_timezone),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/tasks/{task_id}/confirm", response_model=ApiResponse[ChatConfirmTaskData])
|
||||||
|
def confirm_task_from_chat(
|
||||||
|
session_id: str,
|
||||||
|
task_id: str,
|
||||||
|
payload: ChatConfirmTaskRequest,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_request_id: Annotated[str | None, Header(alias="X-Request-Id")] = None,
|
||||||
|
) -> ApiResponse[ChatConfirmTaskData]:
|
||||||
|
settings = get_settings()
|
||||||
|
request_id = build_request_id(x_request_id)
|
||||||
|
service = ChatService(db, settings.default_timezone)
|
||||||
|
try:
|
||||||
|
_, assistant_message, task_data = service.confirm_task(session_id, task_id, payload.comment)
|
||||||
|
except ChatSessionNotFoundError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"code": "NOT_FOUND", "message": "chat session not found"}) from exc
|
||||||
|
|
||||||
|
return ApiResponse[ChatConfirmTaskData](
|
||||||
|
request_id=request_id,
|
||||||
|
success=True,
|
||||||
|
code=ERROR_CODE_OK,
|
||||||
|
message="success",
|
||||||
|
data=ChatConfirmTaskData(
|
||||||
|
session_id=session_id,
|
||||||
|
task_id=task_data["task_id"],
|
||||||
|
task_status=task_data["task_status"],
|
||||||
|
approval_status=task_data["approval_status"],
|
||||||
|
assistant_message=service.to_message_item(assistant_message),
|
||||||
|
),
|
||||||
|
timestamp=format_now(settings.default_timezone),
|
||||||
|
)
|
||||||
16
backend/app/api/web/demo.py
Normal file
16
backend/app/api/web/demo.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["demo-web"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
@router.get("/demo/chat", response_class=HTMLResponse)
|
||||||
|
def demo_chat_page() -> HTMLResponse:
|
||||||
|
html_path = Path(__file__).resolve().parents[2] / "web" / "chat_demo.html"
|
||||||
|
return HTMLResponse(html_path.read_text(encoding="utf-8"))
|
||||||
@ -7,19 +7,26 @@ from fastapi.responses import JSONResponse
|
|||||||
|
|
||||||
from app.api.agent.tasks import router as task_router
|
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.approval import router as demo_approval_router
|
||||||
|
from app.api.demo.chat import router as demo_chat_router
|
||||||
from app.api.demo.identity import router as demo_identity_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.demo.software_a import router as demo_software_a_router
|
||||||
from app.api.edge.tasks import router as edge_router
|
from app.api.edge.tasks import router as edge_router
|
||||||
|
from app.api.web.demo import router as demo_web_router
|
||||||
from app.core.config import ensure_runtime_directories, get_settings
|
from app.core.config import ensure_runtime_directories, get_settings
|
||||||
from app.core.time import format_now
|
from app.core.time import format_now
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.app_metadata import AppMetadata
|
||||||
from app.models.approval import ApprovalRequest
|
from app.models.approval import ApprovalRequest
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.chat_message import ChatMessage
|
||||||
|
from app.models.chat_session import ChatSession
|
||||||
from app.models.edge_node import EdgeNode
|
from app.models.edge_node import EdgeNode
|
||||||
from app.models.edge_task import EdgeTask
|
from app.models.edge_task import EdgeTask
|
||||||
from app.models.task import Task
|
from app.models.task import Task
|
||||||
from app.models.tool_call import ToolCall
|
from app.models.tool_call import ToolCall
|
||||||
|
from app.services.metadata_service import MetadataService
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@ -27,6 +34,11 @@ settings = get_settings()
|
|||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
ensure_runtime_directories()
|
ensure_runtime_directories()
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
MetadataService(db, settings.default_timezone).ensure_demo_metadata()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@ -59,7 +71,9 @@ def healthz() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(task_router)
|
app.include_router(task_router)
|
||||||
|
app.include_router(demo_chat_router)
|
||||||
app.include_router(demo_identity_router)
|
app.include_router(demo_identity_router)
|
||||||
app.include_router(demo_approval_router)
|
app.include_router(demo_approval_router)
|
||||||
app.include_router(demo_software_a_router)
|
app.include_router(demo_software_a_router)
|
||||||
app.include_router(edge_router)
|
app.include_router(edge_router)
|
||||||
|
app.include_router(demo_web_router)
|
||||||
|
|||||||
22
backend/app/models/app_metadata.py
Normal file
22
backend/app/models/app_metadata.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AppMetadata(Base):
|
||||||
|
__tablename__ = "app_metadata"
|
||||||
|
|
||||||
|
app_metadata_id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
|
app_code: Mapped[str] = mapped_column(Text, nullable=False, index=True)
|
||||||
|
env: Mapped[str] = mapped_column(Text, nullable=False, index=True)
|
||||||
|
process_name: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
command_contains: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
health_check_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
log_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
listen_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
startup_keyword: 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)
|
||||||
18
backend/app/models/chat_message.py
Normal file
18
backend/app/models/chat_message.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(Base):
|
||||||
|
__tablename__ = "chat_message"
|
||||||
|
|
||||||
|
message_id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
|
session_id: Mapped[str] = mapped_column(Text, nullable=False, index=True)
|
||||||
|
role: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
message_type: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
task_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True)
|
||||||
|
created_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
19
backend/app/models/chat_session.py
Normal file
19
backend/app/models/chat_session.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSession(Base):
|
||||||
|
__tablename__ = "chat_session"
|
||||||
|
|
||||||
|
session_id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(Text, nullable=False, index=True)
|
||||||
|
channel: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
last_task_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
context_json: 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)
|
||||||
43
backend/app/repositories/chat_repository.py
Normal file
43
backend/app/repositories/chat_repository.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.chat_message import ChatMessage
|
||||||
|
from app.models.chat_session import ChatSession
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionRepository:
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def add(self, item: ChatSession) -> ChatSession:
|
||||||
|
self.db.add(item)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def update(self, item: ChatSession) -> ChatSession:
|
||||||
|
self.db.add(item)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def get_by_session_id(self, session_id: str) -> ChatSession | None:
|
||||||
|
statement = select(ChatSession).where(ChatSession.session_id == session_id)
|
||||||
|
return self.db.execute(statement).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageRepository:
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def add(self, item: ChatMessage) -> ChatMessage:
|
||||||
|
self.db.add(item)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def list_by_session_id(self, session_id: str) -> list[ChatMessage]:
|
||||||
|
statement = select(ChatMessage).where(ChatMessage.session_id == session_id).order_by(ChatMessage.created_at.asc())
|
||||||
|
return list(self.db.execute(statement).scalars())
|
||||||
31
backend/app/repositories/metadata_repository.py
Normal file
31
backend/app/repositories/metadata_repository.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.app_metadata import AppMetadata
|
||||||
|
|
||||||
|
|
||||||
|
class AppMetadataRepository:
|
||||||
|
def __init__(self, db: Session) -> None:
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def add(self, item: AppMetadata) -> AppMetadata:
|
||||||
|
self.db.add(item)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def update(self, item: AppMetadata) -> AppMetadata:
|
||||||
|
self.db.add(item)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def get_by_app_env(self, app_code: str, env: str) -> AppMetadata | None:
|
||||||
|
statement = select(AppMetadata).where(AppMetadata.app_code == app_code).where(AppMetadata.env == env)
|
||||||
|
return self.db.execute(statement).scalar_one_or_none()
|
||||||
|
|
||||||
|
def list_all(self) -> list[AppMetadata]:
|
||||||
|
statement = select(AppMetadata).order_by(AppMetadata.app_code.asc(), AppMetadata.env.asc())
|
||||||
|
return list(self.db.execute(statement).scalars())
|
||||||
59
backend/app/schemas/chat.py
Normal file
59
backend/app/schemas/chat.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.task import ParsedIntent
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionCreateRequest(BaseModel):
|
||||||
|
tenant_id: str = "tenant-demo"
|
||||||
|
channel: str = "WEB"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageItem(BaseModel):
|
||||||
|
message_id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
message_type: str
|
||||||
|
task_id: str | None = None
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionData(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
tenant_id: str
|
||||||
|
channel: str
|
||||||
|
title: str | None = None
|
||||||
|
last_task_id: str | None = None
|
||||||
|
sample_prompts: list[str] = Field(default_factory=list)
|
||||||
|
messages: list[ChatMessageItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSendMessageRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSendMessageData(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
task_id: str
|
||||||
|
task_status: str
|
||||||
|
parsed_intent: ParsedIntent
|
||||||
|
missing_slots: list[str]
|
||||||
|
risk_level: str
|
||||||
|
next_action: str
|
||||||
|
assistant_message: ChatMessageItem
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConfirmTaskRequest(BaseModel):
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConfirmTaskData(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
task_id: str
|
||||||
|
task_status: str
|
||||||
|
approval_status: str
|
||||||
|
assistant_message: ChatMessageItem
|
||||||
14
backend/app/schemas/metadata.py
Normal file
14
backend/app/schemas/metadata.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AppMetadataData(BaseModel):
|
||||||
|
app_code: str
|
||||||
|
env: str
|
||||||
|
process_name: str | None = None
|
||||||
|
command_contains: str | None = None
|
||||||
|
health_check_url: str | None = None
|
||||||
|
log_path: str | None = None
|
||||||
|
listen_port: int | None = None
|
||||||
|
startup_keyword: str | None = None
|
||||||
@ -165,13 +165,27 @@ class AuditTraceItem(BaseModel):
|
|||||||
timestamp: str
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
class AuditSummary(BaseModel):
|
||||||
|
audit_event_count: int = 0
|
||||||
|
failure_count: int = 0
|
||||||
|
pending_count: int = 0
|
||||||
|
cancelled_count: int = 0
|
||||||
|
reported_count: int = 0
|
||||||
|
action_types: list[str] = Field(default_factory=list)
|
||||||
|
operator_user_names: list[str] = Field(default_factory=list)
|
||||||
|
result_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class TaskMetrics(BaseModel):
|
class TaskMetrics(BaseModel):
|
||||||
total_duration_ms: int | None = None
|
total_duration_ms: int | None = None
|
||||||
confirm_wait_duration_ms: int | None = None
|
confirm_wait_duration_ms: int | None = None
|
||||||
approval_duration_ms: int | None = None
|
approval_duration_ms: int | None = None
|
||||||
execution_duration_ms: int | None = None
|
execution_duration_ms: int | None = None
|
||||||
|
software_a_duration_ms_total: int = 0
|
||||||
tool_call_duration_ms_total: int = 0
|
tool_call_duration_ms_total: int = 0
|
||||||
verification_duration_ms_total: int = 0
|
verification_duration_ms_total: int = 0
|
||||||
|
verification_queue_wait_duration_ms_total: int = 0
|
||||||
|
verification_end_to_end_duration_ms_total: int = 0
|
||||||
tool_call_count: int = 0
|
tool_call_count: int = 0
|
||||||
tool_call_success_count: int = 0
|
tool_call_success_count: int = 0
|
||||||
tool_call_failed_count: int = 0
|
tool_call_failed_count: int = 0
|
||||||
@ -179,6 +193,7 @@ class TaskMetrics(BaseModel):
|
|||||||
verification_success_count: int = 0
|
verification_success_count: int = 0
|
||||||
verification_failed_count: int = 0
|
verification_failed_count: int = 0
|
||||||
audit_event_count: int = 0
|
audit_event_count: int = 0
|
||||||
|
audit_failure_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class TaskReportData(BaseModel):
|
class TaskReportData(BaseModel):
|
||||||
@ -188,6 +203,7 @@ class TaskReportData(BaseModel):
|
|||||||
tool_trace: list[ToolTraceItem]
|
tool_trace: list[ToolTraceItem]
|
||||||
verification_trace: list[VerificationTraceItem]
|
verification_trace: list[VerificationTraceItem]
|
||||||
task_metrics: TaskMetrics
|
task_metrics: TaskMetrics
|
||||||
|
audit_summary: AuditSummary
|
||||||
result_summary: str | None = None
|
result_summary: str | None = None
|
||||||
result_summary_detail: ResultSummaryDetail | None = None
|
result_summary_detail: ResultSummaryDetail | None = None
|
||||||
audit_trace: list[AuditTraceItem]
|
audit_trace: list[AuditTraceItem]
|
||||||
|
|||||||
168
backend/app/services/chat_service.py
Normal file
168
backend/app/services/chat_service.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.time import format_now
|
||||||
|
from app.models.chat_message import ChatMessage
|
||||||
|
from app.models.chat_session import ChatSession
|
||||||
|
from app.repositories.chat_repository import ChatMessageRepository, ChatSessionRepository
|
||||||
|
from app.schemas.chat import ChatMessageItem
|
||||||
|
from app.schemas.task import ConfirmTaskRequest, CreateTaskRequest, ParsedIntent
|
||||||
|
from app.services.task_service import TaskService
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatService:
|
||||||
|
SAMPLE_PROMPTS = [
|
||||||
|
"deploy order-service 1.2.3 to test",
|
||||||
|
"deploy payment-service 1.2.3 to test",
|
||||||
|
"deploy order-service 1.2.3 to prod",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, db: Session, timezone_name: str) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.timezone_name = timezone_name
|
||||||
|
self.session_repository = ChatSessionRepository(db)
|
||||||
|
self.message_repository = ChatMessageRepository(db)
|
||||||
|
self.task_service = TaskService(db, timezone_name)
|
||||||
|
|
||||||
|
def create_session(self, tenant_id: str, channel: str) -> ChatSession:
|
||||||
|
current_time = format_now(self.timezone_name)
|
||||||
|
session = ChatSession(
|
||||||
|
session_id=f"chat-{uuid4().hex[:12]}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
channel=channel,
|
||||||
|
title="Agent Demo Session",
|
||||||
|
last_task_id=None,
|
||||||
|
context_json=json.dumps({}, ensure_ascii=False),
|
||||||
|
created_at=current_time,
|
||||||
|
updated_at=current_time,
|
||||||
|
)
|
||||||
|
created_session = self.session_repository.add(session)
|
||||||
|
self._add_message(
|
||||||
|
session_id=created_session.session_id,
|
||||||
|
role="assistant",
|
||||||
|
content="请输入一句自然语言,例如:deploy order-service 1.2.3 to test",
|
||||||
|
message_type="welcome",
|
||||||
|
task_id=None,
|
||||||
|
)
|
||||||
|
return created_session
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> ChatSession:
|
||||||
|
session = self.session_repository.get_by_session_id(session_id)
|
||||||
|
if not session:
|
||||||
|
raise ChatSessionNotFoundError()
|
||||||
|
return session
|
||||||
|
|
||||||
|
def list_messages(self, session_id: str) -> list[ChatMessage]:
|
||||||
|
self.get_session(session_id)
|
||||||
|
return self.message_repository.list_by_session_id(session_id)
|
||||||
|
|
||||||
|
def handle_user_message(self, session_id: str, content: str, context: dict | None = None) -> tuple[ChatSession, ChatMessage, dict]:
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
self._add_message(session_id=session_id, role="user", content=content, message_type="user_input", task_id=None)
|
||||||
|
request_id = f"chat-req-{uuid4().hex[:12]}"
|
||||||
|
task = self.task_service.create_task(
|
||||||
|
CreateTaskRequest(
|
||||||
|
input_text=content,
|
||||||
|
channel=session.channel,
|
||||||
|
session_id=session.session_id,
|
||||||
|
tenant_id=session.tenant_id,
|
||||||
|
context=context or {},
|
||||||
|
),
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
session.last_task_id = task.task_id
|
||||||
|
session.updated_at = format_now(self.timezone_name)
|
||||||
|
self.session_repository.update(session)
|
||||||
|
|
||||||
|
parsed_intent = json.loads(task.parsed_intent_json)
|
||||||
|
missing_slots = json.loads(task.missing_slots_json)
|
||||||
|
next_action = "CONFIRM_TASK" if not missing_slots else "FILL_MISSING_SLOTS"
|
||||||
|
assistant_text = self._build_parse_reply(parsed_intent, missing_slots, task.risk_level, next_action)
|
||||||
|
assistant_message = self._add_message(
|
||||||
|
session_id=session_id,
|
||||||
|
role="assistant",
|
||||||
|
content=assistant_text,
|
||||||
|
message_type="task_parse",
|
||||||
|
task_id=task.task_id,
|
||||||
|
)
|
||||||
|
return session, assistant_message, {
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"task_status": task.task_status,
|
||||||
|
"parsed_intent": ParsedIntent(**parsed_intent),
|
||||||
|
"missing_slots": missing_slots,
|
||||||
|
"risk_level": task.risk_level,
|
||||||
|
"next_action": next_action,
|
||||||
|
}
|
||||||
|
|
||||||
|
def confirm_task(self, session_id: str, task_id: str, comment: str | None) -> tuple[ChatSession, ChatMessage, dict]:
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
task, approval_id = self.task_service.confirm_task(
|
||||||
|
task_id,
|
||||||
|
ConfirmTaskRequest(confirmed=True, comment=comment),
|
||||||
|
request_id=f"chat-confirm-{uuid4().hex[:12]}",
|
||||||
|
)
|
||||||
|
session.last_task_id = task.task_id
|
||||||
|
session.updated_at = format_now(self.timezone_name)
|
||||||
|
self.session_repository.update(session)
|
||||||
|
|
||||||
|
assistant_text = self._build_confirm_reply(task.task_status, task.approval_status, task.software_a_task_status, approval_id)
|
||||||
|
assistant_message = self._add_message(
|
||||||
|
session_id=session_id,
|
||||||
|
role="assistant",
|
||||||
|
content=assistant_text,
|
||||||
|
message_type="task_confirm",
|
||||||
|
task_id=task.task_id,
|
||||||
|
)
|
||||||
|
return session, assistant_message, {
|
||||||
|
"task_id": task.task_id,
|
||||||
|
"task_status": task.task_status,
|
||||||
|
"approval_status": task.approval_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_parse_reply(self, parsed_intent: dict, missing_slots: list[str], risk_level: str, next_action: str) -> str:
|
||||||
|
if missing_slots:
|
||||||
|
return f"我已解析任务,但还缺少字段:{', '.join(missing_slots)}。请补充后再继续。"
|
||||||
|
return (
|
||||||
|
"我已解析任务:"
|
||||||
|
f"动作={parsed_intent.get('action_type')},"
|
||||||
|
f"应用={parsed_intent.get('app_code')},"
|
||||||
|
f"环境={parsed_intent.get('env')},"
|
||||||
|
f"版本={parsed_intent.get('version')}。"
|
||||||
|
f" 风险等级={risk_level},下一步={next_action}。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_confirm_reply(self, task_status: str, approval_status: str, software_a_task_status: str | None, approval_id: str | None) -> str:
|
||||||
|
if approval_status == "PENDING" and approval_id:
|
||||||
|
return f"任务已确认,当前进入审批阶段。approval_id={approval_id}"
|
||||||
|
return f"任务已确认并进入执行。task_status={task_status},software_a_task_status={software_a_task_status}"
|
||||||
|
|
||||||
|
def _add_message(self, session_id: str, role: str, content: str, message_type: str, task_id: str | None) -> ChatMessage:
|
||||||
|
message = ChatMessage(
|
||||||
|
message_id=f"msg-{uuid4().hex[:12]}",
|
||||||
|
session_id=session_id,
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
message_type=message_type,
|
||||||
|
task_id=task_id,
|
||||||
|
created_at=format_now(self.timezone_name),
|
||||||
|
)
|
||||||
|
return self.message_repository.add(message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_message_item(message: ChatMessage) -> ChatMessageItem:
|
||||||
|
return ChatMessageItem(
|
||||||
|
message_id=message.message_id,
|
||||||
|
role=message.role,
|
||||||
|
content=message.content,
|
||||||
|
message_type=message.message_type,
|
||||||
|
task_id=message.task_id,
|
||||||
|
created_at=message.created_at,
|
||||||
|
)
|
||||||
@ -1,4 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@ -20,14 +20,15 @@ from app.core.constants import (
|
|||||||
TASK_STATUS_VERIFYING,
|
TASK_STATUS_VERIFYING,
|
||||||
)
|
)
|
||||||
from app.core.time import compute_duration_ms, format_now
|
from app.core.time import compute_duration_ms, format_now
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.edge_node import EdgeNode
|
from app.models.edge_node import EdgeNode
|
||||||
from app.models.edge_task import EdgeTask
|
from app.models.edge_task import EdgeTask
|
||||||
from app.models.audit_log import AuditLog
|
|
||||||
from app.models.tool_call import ToolCall
|
from app.models.tool_call import ToolCall
|
||||||
from app.repositories.audit_repository import AuditRepository
|
from app.repositories.audit_repository import AuditRepository
|
||||||
from app.repositories.edge_repository import EdgeNodeRepository, EdgeTaskRepository
|
from app.repositories.edge_repository import EdgeNodeRepository, EdgeTaskRepository
|
||||||
from app.repositories.task_repository import TaskRepository
|
from app.repositories.task_repository import TaskRepository
|
||||||
from app.repositories.tool_call_repository import ToolCallRepository
|
from app.repositories.tool_call_repository import ToolCallRepository
|
||||||
|
from app.services.metadata_service import MetadataService
|
||||||
|
|
||||||
|
|
||||||
class EdgeTaskConflictError(Exception):
|
class EdgeTaskConflictError(Exception):
|
||||||
@ -74,54 +75,50 @@ class EdgeService:
|
|||||||
)
|
)
|
||||||
return self.node_repository.add_or_update(node)
|
return self.node_repository.add_or_update(node)
|
||||||
|
|
||||||
def schedule_default_verification(self, task_id: str, edge_id: str = "edge-shanghai-001") -> EdgeTask:
|
def schedule_default_verification(self, task_id: str, edge_id: str = "edge-shanghai-001") -> list[EdgeTask]:
|
||||||
task = self.task_repository.get_by_task_id(task_id)
|
task = self.task_repository.get_by_task_id(task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise EdgeTaskNotFoundError()
|
raise EdgeTaskNotFoundError()
|
||||||
if task.task_status != TASK_STATUS_RUNNING:
|
if task.task_status != TASK_STATUS_RUNNING:
|
||||||
raise EdgeTaskConflictError("当前任务状态不允许创建 edge 验证步骤。")
|
raise EdgeTaskConflictError("current task status does not allow scheduling edge verification steps")
|
||||||
if self.edge_task_repository.list_active_by_task_id(task_id):
|
if self.edge_task_repository.list_active_by_task_id(task_id):
|
||||||
raise EdgeTaskConflictError("当前任务已存在待处理的 edge 验证步骤。")
|
raise EdgeTaskConflictError("task already has active edge verification steps")
|
||||||
|
|
||||||
current_time = format_now(self.timezone_name)
|
current_time = format_now(self.timezone_name)
|
||||||
step_id = f"step-{uuid4().hex[:12]}"
|
created_items: list[EdgeTask] = []
|
||||||
edge_task = EdgeTask(
|
for tool_name, params in self._build_default_verification_steps(task):
|
||||||
edge_task_id=f"edge-task-{uuid4().hex[:12]}",
|
edge_task = EdgeTask(
|
||||||
step_id=step_id,
|
edge_task_id=f"edge-task-{uuid4().hex[:12]}",
|
||||||
task_id=task_id,
|
step_id=f"step-{uuid4().hex[:12]}",
|
||||||
edge_id=edge_id,
|
task_id=task_id,
|
||||||
tool_name="http_health_check",
|
edge_id=edge_id,
|
||||||
params_json=json.dumps(
|
tool_name=tool_name,
|
||||||
{
|
params_json=json.dumps(params, ensure_ascii=False),
|
||||||
"url": f"http://{task.app_code or 'localhost'}.{task.env or 'env'}.demo/actuator/health",
|
step_status=EDGE_STEP_STATUS_PENDING,
|
||||||
"timeout_ms": 3000,
|
success=None,
|
||||||
},
|
message=None,
|
||||||
ensure_ascii=False,
|
result_data_json="{}",
|
||||||
),
|
evidence_json="{}",
|
||||||
step_status=EDGE_STEP_STATUS_PENDING,
|
duration_ms=None,
|
||||||
success=None,
|
expire_at=current_time,
|
||||||
message=None,
|
started_at=None,
|
||||||
result_data_json="{}",
|
finished_at=None,
|
||||||
evidence_json="{}",
|
created_at=current_time,
|
||||||
duration_ms=None,
|
updated_at=current_time,
|
||||||
expire_at=current_time,
|
)
|
||||||
started_at=None,
|
created_item = self.edge_task_repository.add(edge_task)
|
||||||
finished_at=None,
|
created_items.append(created_item)
|
||||||
created_at=current_time,
|
self._write_audit_log(
|
||||||
updated_at=current_time,
|
task_id=task_id,
|
||||||
)
|
request_id=None,
|
||||||
created_edge_task = self.edge_task_repository.add(edge_task)
|
action="EDGE_TASK_SCHEDULED",
|
||||||
self._write_audit_log(
|
result="PENDING",
|
||||||
task_id=task_id,
|
target=edge_id,
|
||||||
request_id=None,
|
operator_user_id=None,
|
||||||
action="EDGE_TASK_SCHEDULED",
|
operator_user_name=None,
|
||||||
result="PENDING",
|
detail={"step_id": created_item.step_id, "tool_name": created_item.tool_name},
|
||||||
target=edge_id,
|
)
|
||||||
operator_user_id=None,
|
return created_items
|
||||||
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]:
|
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]
|
items = self.edge_task_repository.list_pending_by_edge_id(edge_id)[:max_tasks]
|
||||||
@ -129,7 +126,7 @@ class EdgeService:
|
|||||||
pulled_items: list[EdgeTask] = []
|
pulled_items: list[EdgeTask] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
task = self.task_repository.get_by_task_id(item.task_id)
|
task = self.task_repository.get_by_task_id(item.task_id)
|
||||||
if not task or task.task_status != TASK_STATUS_RUNNING:
|
if not task or task.task_status not in {TASK_STATUS_RUNNING, TASK_STATUS_VERIFYING}:
|
||||||
item.step_status = EDGE_STEP_STATUS_CANCELLED
|
item.step_status = EDGE_STEP_STATUS_CANCELLED
|
||||||
item.updated_at = current_time
|
item.updated_at = current_time
|
||||||
item.message = "task state no longer allows edge execution"
|
item.message = "task state no longer allows edge execution"
|
||||||
@ -143,25 +140,35 @@ class EdgeService:
|
|||||||
|
|
||||||
task.task_status = TASK_STATUS_VERIFYING
|
task.task_status = TASK_STATUS_VERIFYING
|
||||||
task.updated_at = current_time
|
task.updated_at = current_time
|
||||||
task.summary = "任务已进入边缘验证阶段。"
|
task.summary = "task entered edge verification stage"
|
||||||
self.task_repository.update(task)
|
self.task_repository.update(task)
|
||||||
pulled_items.append(item)
|
pulled_items.append(item)
|
||||||
return pulled_items
|
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]:
|
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)
|
edge_task = self.edge_task_repository.get_by_step_id(step_id)
|
||||||
if not edge_task:
|
if not edge_task:
|
||||||
raise EdgeTaskNotFoundError()
|
raise EdgeTaskNotFoundError()
|
||||||
if edge_task.edge_id != edge_id:
|
if edge_task.edge_id != edge_id:
|
||||||
raise EdgeTaskConflictError("edge_id 与任务归属不一致。")
|
raise EdgeTaskConflictError("edge_id does not match the assigned edge task")
|
||||||
if edge_task.step_status not in {EDGE_STEP_STATUS_RUNNING, EDGE_STEP_STATUS_PENDING}:
|
if edge_task.step_status not in {EDGE_STEP_STATUS_RUNNING, EDGE_STEP_STATUS_PENDING}:
|
||||||
raise EdgeTaskConflictError("当前步骤状态不允许重复回传。")
|
raise EdgeTaskConflictError("edge step state does not allow duplicate report")
|
||||||
|
|
||||||
task = self.task_repository.get_by_task_id(edge_task.task_id)
|
task = self.task_repository.get_by_task_id(edge_task.task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise EdgeTaskConflictError("edge 步骤关联任务不存在。")
|
raise EdgeTaskConflictError("edge task references a non-existent task")
|
||||||
if task.task_status not in {TASK_STATUS_RUNNING, TASK_STATUS_VERIFYING}:
|
if task.task_status not in {TASK_STATUS_RUNNING, TASK_STATUS_VERIFYING}:
|
||||||
raise EdgeTaskConflictError("当前任务状态不允许回传 edge 结果。")
|
raise EdgeTaskConflictError("task state does not allow edge report")
|
||||||
|
|
||||||
edge_task.step_status = EDGE_STEP_STATUS_SUCCEEDED if success else EDGE_STEP_STATUS_FAILED
|
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.success = 1 if success else 0
|
||||||
@ -187,12 +194,25 @@ class EdgeService:
|
|||||||
finished_at=finished_at,
|
finished_at=finished_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
task_status = TASK_STATUS_RUNNING
|
all_steps = self.edge_task_repository.list_by_task_id(task.task_id)
|
||||||
task.task_status = TASK_STATUS_SUCCEEDED if success else TASK_STATUS_FAILED
|
if not success:
|
||||||
|
for item in all_steps:
|
||||||
|
if item.step_status in {EDGE_STEP_STATUS_PENDING, EDGE_STEP_STATUS_RUNNING} and item.step_id != updated_edge_task.step_id:
|
||||||
|
item.step_status = EDGE_STEP_STATUS_CANCELLED
|
||||||
|
item.updated_at = format_now(self.timezone_name)
|
||||||
|
item.message = "cancelled because another verification step failed"
|
||||||
|
self.edge_task_repository.update(item)
|
||||||
|
task.task_status = TASK_STATUS_FAILED
|
||||||
|
task.summary = "edge verification failed"
|
||||||
|
elif all(item.step_status == EDGE_STEP_STATUS_SUCCEEDED for item in all_steps):
|
||||||
|
task.task_status = TASK_STATUS_SUCCEEDED
|
||||||
|
task.summary = "all edge verification steps succeeded"
|
||||||
|
else:
|
||||||
|
task.task_status = TASK_STATUS_VERIFYING
|
||||||
|
task.summary = "edge verification is still in progress"
|
||||||
|
|
||||||
task.updated_at = format_now(self.timezone_name)
|
task.updated_at = format_now(self.timezone_name)
|
||||||
task.summary = "边缘验证通过,任务完成。" if success else "边缘验证失败,任务失败。"
|
|
||||||
self.task_repository.update(task)
|
self.task_repository.update(task)
|
||||||
task_status = task.task_status
|
|
||||||
self._write_audit_log(
|
self._write_audit_log(
|
||||||
task_id=task.task_id,
|
task_id=task.task_id,
|
||||||
request_id=None,
|
request_id=None,
|
||||||
@ -203,8 +223,7 @@ class EdgeService:
|
|||||||
operator_user_name=edge_id,
|
operator_user_name=edge_id,
|
||||||
detail={"step_id": step_id, "tool_name": edge_task.tool_name, "message": message},
|
detail={"step_id": step_id, "tool_name": edge_task.tool_name, "message": message},
|
||||||
)
|
)
|
||||||
|
return updated_edge_task, task.task_status
|
||||||
return updated_edge_task, task_status
|
|
||||||
|
|
||||||
def record_event(self, edge_id: str, event_type: str, message: str, detail: dict) -> AuditLog:
|
def record_event(self, edge_id: str, event_type: str, message: str, detail: dict) -> AuditLog:
|
||||||
current_time = format_now(self.timezone_name)
|
current_time = format_now(self.timezone_name)
|
||||||
@ -224,6 +243,65 @@ class EdgeService:
|
|||||||
self.db.refresh(audit)
|
self.db.refresh(audit)
|
||||||
return audit
|
return audit
|
||||||
|
|
||||||
|
def _build_default_verification_steps(self, task) -> list[tuple[str, dict]]:
|
||||||
|
app_code = task.app_code or "demo-app"
|
||||||
|
confirmed_at = task.confirmed_at or format_now(self.timezone_name)
|
||||||
|
metadata = MetadataService(self.db, self.timezone_name).get_app_metadata(task.app_code, task.env)
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = metadata.listen_port if metadata and metadata.listen_port else 8080
|
||||||
|
command_contains = metadata.command_contains if metadata and metadata.command_contains else app_code
|
||||||
|
health_check_url = (
|
||||||
|
metadata.health_check_url
|
||||||
|
if metadata and metadata.health_check_url
|
||||||
|
else f"http://{app_code}.{task.env or 'env'}.demo/actuator/health"
|
||||||
|
)
|
||||||
|
log_path = metadata.log_path if metadata and metadata.log_path else f"logs/{app_code}.log"
|
||||||
|
startup_keyword = metadata.startup_keyword if metadata and metadata.startup_keyword else "Started"
|
||||||
|
process_name = metadata.process_name if metadata and metadata.process_name else "java"
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
"check_process",
|
||||||
|
{
|
||||||
|
"process_name": process_name,
|
||||||
|
"command_contains": command_contains,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"check_port",
|
||||||
|
{
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"timeout_ms": 3000,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tcp_probe",
|
||||||
|
{
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"timeout_ms": 3000,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"http_health_check",
|
||||||
|
{
|
||||||
|
"url": health_check_url,
|
||||||
|
"timeout_ms": 3000,
|
||||||
|
"expected_status": 200,
|
||||||
|
"body_contains": "UP",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"grep_log",
|
||||||
|
{
|
||||||
|
"path": log_path,
|
||||||
|
"keyword": startup_keyword,
|
||||||
|
"start_at": confirmed_at,
|
||||||
|
"limit": 20,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
def _write_tool_call(
|
def _write_tool_call(
|
||||||
self,
|
self,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|||||||
76
backend/app/services/metadata_service.py
Normal file
76
backend/app/services/metadata_service.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.time import format_now
|
||||||
|
from app.models.app_metadata import AppMetadata
|
||||||
|
from app.repositories.metadata_repository import AppMetadataRepository
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataService:
|
||||||
|
DEMO_ITEMS = [
|
||||||
|
{
|
||||||
|
"app_code": "order-service",
|
||||||
|
"env": "test",
|
||||||
|
"process_name": "java",
|
||||||
|
"command_contains": "order-service",
|
||||||
|
"health_check_url": "http://order-service.test.demo/actuator/health",
|
||||||
|
"log_path": "logs/order-service.log",
|
||||||
|
"listen_port": 8080,
|
||||||
|
"startup_keyword": "Started order-service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"app_code": "order-service",
|
||||||
|
"env": "prod",
|
||||||
|
"process_name": "java",
|
||||||
|
"command_contains": "order-service",
|
||||||
|
"health_check_url": "http://order-service.prod.demo/actuator/health",
|
||||||
|
"log_path": "logs/order-service.log",
|
||||||
|
"listen_port": 8080,
|
||||||
|
"startup_keyword": "Started order-service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"app_code": "payment-service",
|
||||||
|
"env": "test",
|
||||||
|
"process_name": "java",
|
||||||
|
"command_contains": "payment-service",
|
||||||
|
"health_check_url": "http://payment-service.test.demo/actuator/health",
|
||||||
|
"log_path": "logs/payment-service.log",
|
||||||
|
"listen_port": 8081,
|
||||||
|
"startup_keyword": "Started payment-service",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, db: Session, timezone_name: str) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.timezone_name = timezone_name
|
||||||
|
self.repository = AppMetadataRepository(db)
|
||||||
|
|
||||||
|
def ensure_demo_metadata(self) -> None:
|
||||||
|
for item in self.DEMO_ITEMS:
|
||||||
|
existing = self.repository.get_by_app_env(item["app_code"], item["env"])
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
current_time = format_now(self.timezone_name)
|
||||||
|
self.repository.add(
|
||||||
|
AppMetadata(
|
||||||
|
app_metadata_id=f"app-meta-{uuid4().hex[:12]}",
|
||||||
|
app_code=item["app_code"],
|
||||||
|
env=item["env"],
|
||||||
|
process_name=item["process_name"],
|
||||||
|
command_contains=item["command_contains"],
|
||||||
|
health_check_url=item["health_check_url"],
|
||||||
|
log_path=item["log_path"],
|
||||||
|
listen_port=item["listen_port"],
|
||||||
|
startup_keyword=item["startup_keyword"],
|
||||||
|
created_at=current_time,
|
||||||
|
updated_at=current_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_app_metadata(self, app_code: str | None, env: str | None) -> AppMetadata | None:
|
||||||
|
if not app_code or not env:
|
||||||
|
return None
|
||||||
|
return self.repository.get_by_app_env(app_code, env)
|
||||||
406
backend/app/web/chat_demo.html
Normal file
406
backend/app/web/chat_demo.html
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>智能化部署 Agent Demo</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4efe6;
|
||||||
|
--panel: #fffaf1;
|
||||||
|
--ink: #15221d;
|
||||||
|
--muted: #61746a;
|
||||||
|
--accent: #1d6a4f;
|
||||||
|
--accent-soft: #d6efe4;
|
||||||
|
--line: #d8d1c5;
|
||||||
|
--warn: #ad5a2a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, #efe2c6 0, transparent 28%),
|
||||||
|
linear-gradient(135deg, #f6f1e8 0%, #ece6db 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 340px 1fr 380px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.sidebar, .panel, .chat {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
background: rgba(255,250,241,.92);
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: rgba(248,244,236,.9);
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.intro, .meta, .tiny {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.prompt-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.prompt-btn, button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .12s ease, opacity .12s ease;
|
||||||
|
}
|
||||||
|
.prompt-btn:hover, button:hover { transform: translateY(-1px); }
|
||||||
|
.prompt-btn {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.chat-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255,255,255,.88);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 30px rgba(44, 44, 44, .06);
|
||||||
|
}
|
||||||
|
.chat-header {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: linear-gradient(135deg, #f8f4ea 0%, #f0eadf 100%);
|
||||||
|
}
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.message.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 6px;
|
||||||
|
}
|
||||||
|
.message.assistant {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #f7f3ea;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-bottom-left-radius: 6px;
|
||||||
|
}
|
||||||
|
.composer {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 72px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 14px;
|
||||||
|
resize: vertical;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
background: #ede5d8;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f3eee5;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #efe9de;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.status.warn { background: #f7e1cf; color: var(--warn); }
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.layout { grid-template-columns: 300px 1fr; }
|
||||||
|
.panel { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.layout { grid-template-columns: 1fr; }
|
||||||
|
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.message { max-width: 92%; }
|
||||||
|
.composer { flex-direction: column; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="eyebrow">Agent Demo</div>
|
||||||
|
<h1>智能化部署 Agent</h1>
|
||||||
|
<div class="intro">目标是一句话发起部署,完成确认、执行、验证、报告的可视化演示流。</div>
|
||||||
|
<div class="prompt-list" id="promptList"></div>
|
||||||
|
<div class="card" style="margin-top:18px;">
|
||||||
|
<div class="eyebrow">会话</div>
|
||||||
|
<div class="meta" id="sessionMeta">正在初始化会话…</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="chat">
|
||||||
|
<div class="chat-shell">
|
||||||
|
<div class="chat-header">
|
||||||
|
<div class="eyebrow">Conversation</div>
|
||||||
|
<div class="meta">输入一句自然语言,页面会展示任务解析、确认、执行、验证、报告。</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-messages" id="chatMessages"></div>
|
||||||
|
<div class="composer">
|
||||||
|
<textarea id="chatInput" placeholder="例如:deploy order-service 1.2.3 to test"></textarea>
|
||||||
|
<button class="primary" id="sendBtn">发送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="panel">
|
||||||
|
<div class="stack">
|
||||||
|
<section class="card">
|
||||||
|
<div class="eyebrow">任务解析</div>
|
||||||
|
<div id="taskStatus" class="status">尚未创建任务</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="secondary" id="confirmBtn" disabled>确认任务</button>
|
||||||
|
<button class="secondary" id="refreshBtn" disabled>刷新报告</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="eyebrow">结构化意图</div>
|
||||||
|
<div class="code" id="intentBox">暂无</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="eyebrow">任务详情</div>
|
||||||
|
<div class="code" id="detailBox">暂无</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="eyebrow">执行报告</div>
|
||||||
|
<div class="code" id="reportBox">暂无</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const state = {
|
||||||
|
sessionId: null,
|
||||||
|
lastTaskId: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptList = document.getElementById("promptList");
|
||||||
|
const chatMessages = document.getElementById("chatMessages");
|
||||||
|
const chatInput = document.getElementById("chatInput");
|
||||||
|
const sendBtn = document.getElementById("sendBtn");
|
||||||
|
const confirmBtn = document.getElementById("confirmBtn");
|
||||||
|
const refreshBtn = document.getElementById("refreshBtn");
|
||||||
|
const sessionMeta = document.getElementById("sessionMeta");
|
||||||
|
const intentBox = document.getElementById("intentBox");
|
||||||
|
const detailBox = document.getElementById("detailBox");
|
||||||
|
const reportBox = document.getElementById("reportBox");
|
||||||
|
const taskStatus = document.getElementById("taskStatus");
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {})
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok || payload.success === false) {
|
||||||
|
throw new Error(payload.message || "request failed");
|
||||||
|
}
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(role, content) {
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.className = `message ${role}`;
|
||||||
|
node.textContent = content;
|
||||||
|
chatMessages.appendChild(node);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages(messages) {
|
||||||
|
chatMessages.innerHTML = "";
|
||||||
|
messages.forEach((item) => addMessage(item.role, item.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPrompts(prompts) {
|
||||||
|
promptList.innerHTML = "";
|
||||||
|
prompts.forEach((prompt) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "prompt-btn";
|
||||||
|
button.textContent = prompt;
|
||||||
|
button.onclick = () => {
|
||||||
|
chatInput.value = prompt;
|
||||||
|
chatInput.focus();
|
||||||
|
};
|
||||||
|
promptList.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, warn = false) {
|
||||||
|
taskStatus.textContent = text;
|
||||||
|
taskStatus.className = warn ? "status warn" : "status";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSession() {
|
||||||
|
const saved = localStorage.getItem("agent_demo_session_id");
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/demo/chat/sessions/${saved}`);
|
||||||
|
state.sessionId = data.session_id;
|
||||||
|
state.lastTaskId = data.last_task_id;
|
||||||
|
renderMessages(data.messages);
|
||||||
|
renderPrompts(data.sample_prompts);
|
||||||
|
sessionMeta.textContent = `session_id=${data.session_id}`;
|
||||||
|
refreshBtn.disabled = !state.lastTaskId;
|
||||||
|
return;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await api("/api/demo/chat/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ tenant_id: "tenant-demo", channel: "WEB" })
|
||||||
|
});
|
||||||
|
state.sessionId = data.session_id;
|
||||||
|
state.lastTaskId = data.last_task_id;
|
||||||
|
localStorage.setItem("agent_demo_session_id", data.session_id);
|
||||||
|
renderMessages(data.messages);
|
||||||
|
renderPrompts(data.sample_prompts);
|
||||||
|
sessionMeta.textContent = `session_id=${data.session_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!chatInput.value.trim()) return;
|
||||||
|
const content = chatInput.value.trim();
|
||||||
|
addMessage("user", content);
|
||||||
|
chatInput.value = "";
|
||||||
|
const data = await api(`/api/demo/chat/sessions/${state.sessionId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ content, context: {} })
|
||||||
|
});
|
||||||
|
addMessage("assistant", data.assistant_message.content);
|
||||||
|
state.lastTaskId = data.task_id;
|
||||||
|
intentBox.textContent = JSON.stringify({
|
||||||
|
parsed_intent: data.parsed_intent,
|
||||||
|
missing_slots: data.missing_slots,
|
||||||
|
risk_level: data.risk_level,
|
||||||
|
next_action: data.next_action
|
||||||
|
}, null, 2);
|
||||||
|
setStatus(`task_id=${data.task_id} task_status=${data.task_status}`);
|
||||||
|
confirmBtn.disabled = data.next_action !== "CONFIRM_TASK";
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
await refreshTaskAndReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmTask() {
|
||||||
|
if (!state.lastTaskId) return;
|
||||||
|
const data = await api(`/api/demo/chat/sessions/${state.sessionId}/tasks/${state.lastTaskId}/confirm`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ comment: "from web demo" })
|
||||||
|
});
|
||||||
|
addMessage("assistant", data.assistant_message.content);
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
setStatus(`task_id=${data.task_id} task_status=${data.task_status} approval_status=${data.approval_status}`, data.approval_status === "PENDING");
|
||||||
|
await refreshTaskAndReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTaskAndReport() {
|
||||||
|
if (!state.lastTaskId) return;
|
||||||
|
const [detail, report] = await Promise.all([
|
||||||
|
api(`/api/agent/tasks/${state.lastTaskId}`),
|
||||||
|
api(`/api/agent/tasks/${state.lastTaskId}/report`)
|
||||||
|
]);
|
||||||
|
detailBox.textContent = JSON.stringify(detail, null, 2);
|
||||||
|
reportBox.textContent = JSON.stringify(report, null, 2);
|
||||||
|
setStatus(`task_id=${detail.task_id} task_status=${detail.task_status} approval_status=${detail.approval_status}`, detail.approval_status === "PENDING");
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBtn.addEventListener("click", sendMessage);
|
||||||
|
confirmBtn.addEventListener("click", confirmTask);
|
||||||
|
refreshBtn.addEventListener("click", refreshTaskAndReport);
|
||||||
|
chatInput.addEventListener("keydown", (event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureSession();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
backend/tests/test_chat_demo.py
Normal file
50
backend/tests/test_chat_demo.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_session_and_message_flow() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
create_session_response = client.post("/api/demo/chat/sessions", json={"tenant_id": "tenant-demo", "channel": "WEB"})
|
||||||
|
assert create_session_response.status_code == 200
|
||||||
|
session_payload = create_session_response.json()["data"]
|
||||||
|
session_id = session_payload["session_id"]
|
||||||
|
assert len(session_payload["messages"]) >= 1
|
||||||
|
assert len(session_payload["sample_prompts"]) >= 1
|
||||||
|
|
||||||
|
send_response = client.post(
|
||||||
|
f"/api/demo/chat/sessions/{session_id}/messages",
|
||||||
|
json={"content": "deploy order-service 1.2.3 to test", "context": {}},
|
||||||
|
)
|
||||||
|
assert send_response.status_code == 200
|
||||||
|
send_payload = send_response.json()["data"]
|
||||||
|
task_id = send_payload["task_id"]
|
||||||
|
assert send_payload["parsed_intent"]["app_code"] == "order-service"
|
||||||
|
assert send_payload["next_action"] == "CONFIRM_TASK"
|
||||||
|
|
||||||
|
confirm_response = client.post(
|
||||||
|
f"/api/demo/chat/sessions/{session_id}/tasks/{task_id}/confirm",
|
||||||
|
json={"comment": "from ui"},
|
||||||
|
)
|
||||||
|
assert confirm_response.status_code == 200
|
||||||
|
confirm_payload = confirm_response.json()["data"]
|
||||||
|
assert confirm_payload["task_id"] == task_id
|
||||||
|
assert confirm_payload["assistant_message"]["role"] == "assistant"
|
||||||
|
|
||||||
|
session_detail = client.get(f"/api/demo/chat/sessions/{session_id}")
|
||||||
|
assert session_detail.status_code == 200
|
||||||
|
session_detail_payload = session_detail.json()["data"]
|
||||||
|
assert session_detail_payload["last_task_id"] == task_id
|
||||||
|
assert len(session_detail_payload["messages"]) >= 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_chat_page_exists() -> None:
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/demo/chat")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "智能化部署 Agent Demo" in response.text
|
||||||
|
assert "Conversation" in response.text
|
||||||
@ -7,6 +7,58 @@ os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
|||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def report_all_edge_steps_success(client: TestClient, task_id: str) -> list[dict]:
|
||||||
|
pull_response = client.post(
|
||||||
|
"/api/agent/edge/tasks/pull",
|
||||||
|
json={"edge_id": "edge-shanghai-001", "max_tasks": 200},
|
||||||
|
)
|
||||||
|
assert pull_response.status_code == 200
|
||||||
|
matched_tasks = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id]
|
||||||
|
assert len(matched_tasks) >= 5
|
||||||
|
|
||||||
|
for item in matched_tasks:
|
||||||
|
if item["tool_name"] == "http_health_check":
|
||||||
|
data = {"status_code": 200, "latency_ms": 45}
|
||||||
|
evidence = {"response_body": "{\"status\":\"UP\"}"}
|
||||||
|
message = "200 OK"
|
||||||
|
elif item["tool_name"] in {"check_port", "tcp_probe"}:
|
||||||
|
data = {"connected": True, "latency_ms": 12}
|
||||||
|
evidence = {}
|
||||||
|
message = "connected"
|
||||||
|
elif item["tool_name"] == "check_process":
|
||||||
|
data = {"matched_count": 1, "cpu_percent_total": 1.5, "memory_rss_kb_total": 20480}
|
||||||
|
evidence = {"matches": [{"pid": 1234, "process_name": "java", "command": "java -jar order-service.jar"}]}
|
||||||
|
message = "process found"
|
||||||
|
elif item["tool_name"] == "grep_log":
|
||||||
|
data = {"matched_count": 1}
|
||||||
|
evidence = {"matches": [{"line_number": 10, "content": "Started order-service", "timestamp": "2026-04-08 20:20:00.000"}]}
|
||||||
|
message = "keyword matched"
|
||||||
|
else:
|
||||||
|
data = {}
|
||||||
|
evidence = {}
|
||||||
|
message = "OK"
|
||||||
|
|
||||||
|
report_response = client.post(
|
||||||
|
"/api/agent/edge/tasks/report",
|
||||||
|
json={
|
||||||
|
"edge_id": "edge-shanghai-001",
|
||||||
|
"task_id": task_id,
|
||||||
|
"step_id": item["step_id"],
|
||||||
|
"tool_name": item["tool_name"],
|
||||||
|
"success": True,
|
||||||
|
"code": "OK",
|
||||||
|
"message": message,
|
||||||
|
"data": data,
|
||||||
|
"evidence": evidence,
|
||||||
|
"started_at": "2026-04-08 20:20:00.000",
|
||||||
|
"finished_at": "2026-04-08 20:20:00.100",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert report_response.status_code == 200
|
||||||
|
|
||||||
|
return matched_tasks
|
||||||
|
|
||||||
|
|
||||||
def test_task_create_confirm_get() -> None:
|
def test_task_create_confirm_get() -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
create_response = client.post(
|
create_response = client.post(
|
||||||
@ -158,35 +210,10 @@ def test_edge_heartbeat_pull_and_report_flow() -> None:
|
|||||||
)
|
)
|
||||||
assert confirm_response.status_code == 200
|
assert confirm_response.status_code == 200
|
||||||
|
|
||||||
pull_response = client.post(
|
matched_tasks = report_all_edge_steps_success(client, task_id)
|
||||||
"/api/agent/edge/tasks/pull",
|
assert any(item["tool_name"] == "http_health_check" for item in matched_tasks)
|
||||||
json={"edge_id": "edge-shanghai-001", "max_tasks": 5},
|
assert any(item["tool_name"] == "check_port" for item in matched_tasks)
|
||||||
)
|
assert any(item["tool_name"] == "check_process" for item in matched_tasks)
|
||||||
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}")
|
get_response = client.get(f"/api/agent/tasks/{task_id}")
|
||||||
assert get_response.status_code == 200
|
assert get_response.status_code == 200
|
||||||
@ -194,6 +221,9 @@ def test_edge_heartbeat_pull_and_report_flow() -> None:
|
|||||||
assert get_response.json()["data"]["software_a_task_id"] is not None
|
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"]["software_a_task_status"] == "SUCCEEDED"
|
||||||
assert get_response.json()["data"]["verification_result"]["http_ok"] is True
|
assert get_response.json()["data"]["verification_result"]["http_ok"] is True
|
||||||
|
assert get_response.json()["data"]["verification_result"]["process_ok"] is True
|
||||||
|
assert get_response.json()["data"]["verification_result"]["port_ok"] is True
|
||||||
|
assert get_response.json()["data"]["verification_result"]["log_error_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_edge_event_report_endpoint() -> None:
|
def test_edge_event_report_endpoint() -> None:
|
||||||
@ -243,35 +273,15 @@ def test_task_report_contains_traces() -> None:
|
|||||||
headers={"X-Request-Id": "req-report-confirm-001"},
|
headers={"X-Request-Id": "req-report-confirm-001"},
|
||||||
json={"confirmed": True, "comment": "confirm"},
|
json={"confirmed": True, "comment": "confirm"},
|
||||||
)
|
)
|
||||||
pull_response = client.post(
|
matched_tasks = report_all_edge_steps_success(client, task_id)
|
||||||
"/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")
|
report_response = client.get(f"/api/agent/tasks/{task_id}/report")
|
||||||
assert report_response.status_code == 200
|
assert report_response.status_code == 200
|
||||||
payload = report_response.json()["data"]
|
payload = report_response.json()["data"]
|
||||||
assert payload["task_basic"]["task_id"] == task_id
|
assert payload["task_basic"]["task_id"] == task_id
|
||||||
assert len(payload["tool_trace"]) >= 2
|
assert len(payload["tool_trace"]) >= 6
|
||||||
assert len(payload["verification_trace"]) >= 1
|
assert len(payload["verification_trace"]) >= 5
|
||||||
assert len(payload["audit_trace"]) >= 3
|
assert len(payload["audit_trace"]) >= 7
|
||||||
assert payload["approval_trace"] == []
|
assert payload["approval_trace"] == []
|
||||||
assert any(item["request_id"] == "req-report-confirm-001" for item in payload["tool_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["operator_user_name"] == "alice" for item in payload["tool_trace"])
|
||||||
@ -279,23 +289,33 @@ def test_task_report_contains_traces() -> None:
|
|||||||
assert payload["result_summary_detail"]["final_status"] == "SUCCEEDED"
|
assert payload["result_summary_detail"]["final_status"] == "SUCCEEDED"
|
||||||
assert payload["result_summary_detail"]["software_a"]["task_status"] == "SUCCEEDED"
|
assert payload["result_summary_detail"]["software_a"]["task_status"] == "SUCCEEDED"
|
||||||
assert payload["result_summary_detail"]["verification"]["success"] is True
|
assert payload["result_summary_detail"]["verification"]["success"] is True
|
||||||
|
assert payload["result_summary_detail"]["verification"]["step_status"] == "SUCCEEDED"
|
||||||
deploy_trace = next(item for item in payload["tool_trace"] if item["tool_name"] == "software_a_deploy")
|
deploy_trace = next(item for item in payload["tool_trace"] if item["tool_name"] == "software_a_deploy")
|
||||||
assert deploy_trace["duration_ms"] is not None
|
assert deploy_trace["duration_ms"] is not None
|
||||||
verification_trace = payload["verification_trace"][0]
|
verification_trace = payload["verification_trace"][0]
|
||||||
assert verification_trace["duration_ms"] == 100
|
assert verification_trace["duration_ms"] == 100
|
||||||
task_metrics = payload["task_metrics"]
|
task_metrics = payload["task_metrics"]
|
||||||
assert task_metrics["tool_call_count"] >= 2
|
assert task_metrics["tool_call_count"] >= 6
|
||||||
assert task_metrics["tool_call_success_count"] >= 2
|
assert task_metrics["tool_call_success_count"] >= 6
|
||||||
assert task_metrics["tool_call_failed_count"] == 0
|
assert task_metrics["tool_call_failed_count"] == 0
|
||||||
assert task_metrics["verification_step_count"] == 1
|
assert task_metrics["software_a_duration_ms_total"] is not None
|
||||||
assert task_metrics["verification_success_count"] == 1
|
assert task_metrics["verification_step_count"] == len(matched_tasks)
|
||||||
|
assert task_metrics["verification_success_count"] == len(matched_tasks)
|
||||||
assert task_metrics["verification_failed_count"] == 0
|
assert task_metrics["verification_failed_count"] == 0
|
||||||
assert task_metrics["verification_duration_ms_total"] == 100
|
assert task_metrics["verification_duration_ms_total"] == len(matched_tasks) * 100
|
||||||
|
assert task_metrics["verification_queue_wait_duration_ms_total"] is not None
|
||||||
|
assert task_metrics["verification_end_to_end_duration_ms_total"] is not None
|
||||||
assert task_metrics["tool_call_duration_ms_total"] is not None
|
assert task_metrics["tool_call_duration_ms_total"] is not None
|
||||||
assert task_metrics["confirm_wait_duration_ms"] is not None
|
assert task_metrics["confirm_wait_duration_ms"] is not None
|
||||||
assert task_metrics["execution_duration_ms"] is not None
|
assert task_metrics["execution_duration_ms"] is not None
|
||||||
assert task_metrics["total_duration_ms"] is not None
|
assert task_metrics["total_duration_ms"] is not None
|
||||||
assert task_metrics["audit_event_count"] >= 3
|
assert task_metrics["audit_event_count"] >= 3
|
||||||
|
assert task_metrics["audit_failure_count"] == 0
|
||||||
|
audit_summary = payload["audit_summary"]
|
||||||
|
assert audit_summary["audit_event_count"] >= 3
|
||||||
|
assert "CREATE_TASK" in audit_summary["action_types"]
|
||||||
|
assert "alice" in audit_summary["operator_user_names"]
|
||||||
|
assert audit_summary["result_counts"]["OK"] >= 1
|
||||||
|
|
||||||
|
|
||||||
def test_task_report_contains_metrics_for_approved_flow() -> None:
|
def test_task_report_contains_metrics_for_approved_flow() -> None:
|
||||||
@ -333,11 +353,58 @@ def test_task_report_contains_metrics_for_approved_flow() -> None:
|
|||||||
task_metrics = payload["task_metrics"]
|
task_metrics = payload["task_metrics"]
|
||||||
assert task_metrics["approval_duration_ms"] is not None
|
assert task_metrics["approval_duration_ms"] is not None
|
||||||
assert task_metrics["tool_call_count"] >= 1
|
assert task_metrics["tool_call_count"] >= 1
|
||||||
assert task_metrics["verification_step_count"] >= 1
|
assert task_metrics["verification_step_count"] >= 5
|
||||||
assert task_metrics["audit_event_count"] >= 3
|
assert task_metrics["audit_event_count"] >= 3
|
||||||
|
assert payload["audit_summary"]["result_counts"]["APPROVED"] >= 1
|
||||||
assert payload["approval_trace"][0]["approval_status"] == "APPROVED"
|
assert payload["approval_trace"][0]["approval_status"] == "APPROVED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edge_pull_uses_app_metadata_driven_params() -> 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", "check_port", "check_process", "grep_log", "tcp_probe"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
create_response = client.post(
|
||||||
|
"/api/agent/tasks",
|
||||||
|
json={
|
||||||
|
"input_text": "deploy payment-service 1.2.3 to test",
|
||||||
|
"channel": "WEB",
|
||||||
|
"session_id": "sess-meta-001",
|
||||||
|
"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": 200},
|
||||||
|
)
|
||||||
|
matched_tasks = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id]
|
||||||
|
assert len(matched_tasks) == 5
|
||||||
|
|
||||||
|
by_tool_name = {item["tool_name"]: item for item in matched_tasks}
|
||||||
|
assert by_tool_name["check_process"]["params"]["command_contains"] == "payment-service"
|
||||||
|
assert by_tool_name["check_port"]["params"]["port"] == 8081
|
||||||
|
assert by_tool_name["tcp_probe"]["params"]["port"] == 8081
|
||||||
|
assert by_tool_name["http_health_check"]["params"]["url"] == "http://payment-service.test.demo/actuator/health"
|
||||||
|
assert by_tool_name["grep_log"]["params"]["path"] == "logs/payment-service.log"
|
||||||
|
assert by_tool_name["grep_log"]["params"]["keyword"] == "Started payment-service"
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_running_task() -> None:
|
def test_cancel_running_task() -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
create_response = client.post(
|
create_response = client.post(
|
||||||
@ -564,7 +631,7 @@ def test_duplicate_edge_report_returns_conflict() -> None:
|
|||||||
|
|
||||||
pull_response = client.post(
|
pull_response = client.post(
|
||||||
"/api/agent/edge/tasks/pull",
|
"/api/agent/edge/tasks/pull",
|
||||||
json={"edge_id": "edge-shanghai-001", "max_tasks": 5},
|
json={"edge_id": "edge-shanghai-001", "max_tasks": 200},
|
||||||
)
|
)
|
||||||
step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id][0]
|
step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id][0]
|
||||||
|
|
||||||
@ -636,7 +703,7 @@ def test_edge_report_with_wrong_edge_id_returns_conflict() -> None:
|
|||||||
|
|
||||||
pull_response = client.post(
|
pull_response = client.post(
|
||||||
"/api/agent/edge/tasks/pull",
|
"/api/agent/edge/tasks/pull",
|
||||||
json={"edge_id": "edge-shanghai-001", "max_tasks": 5},
|
json={"edge_id": "edge-shanghai-001", "max_tasks": 200},
|
||||||
)
|
)
|
||||||
step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id][0]
|
step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id][0]
|
||||||
|
|
||||||
@ -724,7 +791,7 @@ def test_task_fails_when_software_a_deploy_fails() -> None:
|
|||||||
|
|
||||||
pull_response = client.post(
|
pull_response = client.post(
|
||||||
"/api/agent/edge/tasks/pull",
|
"/api/agent/edge/tasks/pull",
|
||||||
json={"edge_id": "edge-shanghai-001", "max_tasks": 10},
|
json={"edge_id": "edge-shanghai-001", "max_tasks": 200},
|
||||||
)
|
)
|
||||||
assert pull_response.status_code == 200
|
assert pull_response.status_code == 200
|
||||||
assert all(item["task_id"] != task_id for item in pull_response.json()["data"]["tasks"])
|
assert all(item["task_id"] != task_id for item in pull_response.json()["data"]["tasks"])
|
||||||
@ -790,6 +857,12 @@ def test_high_risk_task_can_be_rejected() -> None:
|
|||||||
assert get_task_response.json()["data"]["task_status"] == "CANCELLED"
|
assert get_task_response.json()["data"]["task_status"] == "CANCELLED"
|
||||||
assert get_task_response.json()["data"]["approval_status"] == "REJECTED"
|
assert get_task_response.json()["data"]["approval_status"] == "REJECTED"
|
||||||
|
|
||||||
|
report_response = client.get(f"/api/agent/tasks/{task_id}/report")
|
||||||
|
assert report_response.status_code == 200
|
||||||
|
report_payload = report_response.json()["data"]
|
||||||
|
assert report_payload["task_metrics"]["audit_failure_count"] >= 1
|
||||||
|
assert report_payload["audit_summary"]["result_counts"]["REJECTED"] >= 1
|
||||||
|
|
||||||
|
|
||||||
def test_edge_failure_marks_task_failed() -> None:
|
def test_edge_failure_marks_task_failed() -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
@ -820,9 +893,9 @@ def test_edge_failure_marks_task_failed() -> None:
|
|||||||
)
|
)
|
||||||
pull_response = client.post(
|
pull_response = client.post(
|
||||||
"/api/agent/edge/tasks/pull",
|
"/api/agent/edge/tasks/pull",
|
||||||
json={"edge_id": "edge-shanghai-001", "max_tasks": 5},
|
json={"edge_id": "edge-shanghai-001", "max_tasks": 200},
|
||||||
)
|
)
|
||||||
step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id][0]
|
step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id and item["tool_name"] == "http_health_check"][0]
|
||||||
|
|
||||||
report_response = client.post(
|
report_response = client.post(
|
||||||
"/api/agent/edge/tasks/report",
|
"/api/agent/edge/tasks/report",
|
||||||
@ -849,3 +922,10 @@ def test_edge_failure_marks_task_failed() -> None:
|
|||||||
assert get_response.json()["data"]["verification_result"]["http_ok"] is False
|
assert get_response.json()["data"]["verification_result"]["http_ok"] is False
|
||||||
assert get_response.json()["data"]["result_summary_detail"]["verification"]["success"] is False
|
assert get_response.json()["data"]["result_summary_detail"]["verification"]["success"] is False
|
||||||
assert get_response.json()["data"]["result_summary_detail"]["final_reason"] == "health check failed"
|
assert get_response.json()["data"]["result_summary_detail"]["final_reason"] == "health check failed"
|
||||||
|
|
||||||
|
remaining_pull_response = client.post(
|
||||||
|
"/api/agent/edge/tasks/pull",
|
||||||
|
json={"edge_id": "edge-shanghai-001", "max_tasks": 200},
|
||||||
|
)
|
||||||
|
assert remaining_pull_response.status_code == 200
|
||||||
|
assert all(item["task_id"] != task_id for item in remaining_pull_response.json()["data"]["tasks"])
|
||||||
|
|||||||
@ -34,6 +34,7 @@ C:\Users\MH\AppData\Local\Programs\Python\Python311\python.exe -m pytest edge-ag
|
|||||||
2. default edge id: `edge-shanghai-001`
|
2. default edge id: `edge-shanghai-001`
|
||||||
3. current registered tools:
|
3. current registered tools:
|
||||||
`http_health_check`
|
`http_health_check`
|
||||||
|
`tcp_probe`
|
||||||
`check_port`
|
`check_port`
|
||||||
`check_process`
|
`check_process`
|
||||||
`grep_log`
|
`grep_log`
|
||||||
@ -54,12 +55,14 @@ Current repo includes:
|
|||||||
2. `scripts/start-linux.sh`
|
2. `scripts/start-linux.sh`
|
||||||
3. `scripts/package-windows.ps1`
|
3. `scripts/package-windows.ps1`
|
||||||
4. `scripts/package-linux.sh`
|
4. `scripts/package-linux.sh`
|
||||||
|
5. `scripts/package-linux.ps1`
|
||||||
|
|
||||||
These scripts currently prepare a portable package skeleton and startup entrypoints.
|
These scripts currently prepare a portable package skeleton and startup entrypoints.
|
||||||
Current Windows package script already bundles a private Python runtime into:
|
Current Windows package script already bundles a private Python runtime into:
|
||||||
`runtime/python/`
|
`runtime/python/`
|
||||||
|
|
||||||
Current Linux package script supports bundling a private Python runtime directory passed in by argument or `EDGE_PYTHON_HOME`.
|
Current Linux package script supports bundling a private Python runtime directory passed in by argument or `EDGE_PYTHON_HOME`.
|
||||||
|
Current repo also provides `package-linux.ps1` as a Windows-hosted equivalent for validating Linux artifact structure when bash/WSL is unavailable.
|
||||||
|
|
||||||
## Packaging Direction
|
## Packaging Direction
|
||||||
|
|
||||||
@ -70,7 +73,7 @@ For user-side delivery, this edge agent is intended to be bundled as:
|
|||||||
|
|
||||||
## Current Verification Baseline
|
## Current Verification Baseline
|
||||||
|
|
||||||
Current edge-agent baseline: `10 passed`
|
Current edge-agent baseline: `20 passed`
|
||||||
|
|
||||||
## Verified Packaging
|
## Verified Packaging
|
||||||
|
|
||||||
@ -80,3 +83,24 @@ Current verified artifact:
|
|||||||
`start.ps1`
|
`start.ps1`
|
||||||
`app/main.py`
|
`app/main.py`
|
||||||
`runtime/python/python.exe`
|
`runtime/python/python.exe`
|
||||||
|
2. Linux portable package tar.gz has been generated and verified to include:
|
||||||
|
`start.sh`
|
||||||
|
`app/main.py`
|
||||||
|
`runtime/python/bin/python3`
|
||||||
|
|
||||||
|
## Native Linux Verification Steps
|
||||||
|
|
||||||
|
When a real Linux/bash environment is available, validate native packaging with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export EDGE_PYTHON_HOME=/path/to/private/python/runtime
|
||||||
|
chmod +x edge-agent/scripts/package-linux.sh
|
||||||
|
./edge-agent/scripts/package-linux.sh
|
||||||
|
tar -tf edge-agent/dist/edge-agent-linux-*.tar.gz | grep -E 'start.sh|app/main.py|runtime/python/bin/python3'
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended follow-up checks:
|
||||||
|
|
||||||
|
1. verify `runtime/python/bin/python3` can start
|
||||||
|
2. verify `start.sh --once` can run against backend
|
||||||
|
3. verify file permissions are preserved after extraction
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -9,18 +10,30 @@ import httpx
|
|||||||
class HttpHealthCheckExecutor:
|
class HttpHealthCheckExecutor:
|
||||||
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
|
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
|
||||||
url = params["url"]
|
url = params["url"]
|
||||||
|
method = str(params.get("method", "GET")).upper()
|
||||||
timeout_ms = int(params.get("timeout_ms", 3000))
|
timeout_ms = int(params.get("timeout_ms", 3000))
|
||||||
|
expected_status = params.get("expected_status", 200)
|
||||||
|
body_contains = params.get("body_contains")
|
||||||
|
headers = params.get("headers", {})
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
with httpx.Client(timeout=timeout_ms / 1000.0) as client:
|
with httpx.Client(timeout=timeout_ms / 1000.0) as client:
|
||||||
response = client.get(url)
|
response = client.request(method, url, headers=headers)
|
||||||
latency_ms = max(int((time.perf_counter() - started_at) * 1000), 0)
|
latency_ms = max(int((time.perf_counter() - started_at) * 1000), 0)
|
||||||
success = response.status_code == 200
|
success = response.status_code == int(expected_status)
|
||||||
|
if success and body_contains is not None:
|
||||||
|
success = str(body_contains) in response.text
|
||||||
message = f"{response.status_code} {response.reason_phrase}"
|
message = f"{response.status_code} {response.reason_phrase}"
|
||||||
data: dict[str, Any] = {
|
data: dict[str, Any] = {
|
||||||
"status_code": response.status_code,
|
"status_code": response.status_code,
|
||||||
"latency_ms": latency_ms,
|
"latency_ms": latency_ms,
|
||||||
|
"method": method,
|
||||||
|
"expected_status": int(expected_status),
|
||||||
}
|
}
|
||||||
evidence = {
|
evidence = {
|
||||||
"response_body": response.text,
|
"response_body": response.text,
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
|
evidence["response_json"] = json.loads(response.text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return success, message, data, evidence
|
return success, message, data, evidence
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@ -11,6 +13,12 @@ class GrepLogExecutor:
|
|||||||
limit = int(params.get("limit", 100))
|
limit = int(params.get("limit", 100))
|
||||||
encoding = str(params.get("encoding", "utf-8"))
|
encoding = str(params.get("encoding", "utf-8"))
|
||||||
case_sensitive = bool(params.get("case_sensitive", False))
|
case_sensitive = bool(params.get("case_sensitive", False))
|
||||||
|
start_at = self._parse_time(params.get("start_at"))
|
||||||
|
end_at = self._parse_time(params.get("end_at"))
|
||||||
|
timestamp_regex = params.get(
|
||||||
|
"timestamp_regex",
|
||||||
|
r"^\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3,6})?)",
|
||||||
|
)
|
||||||
|
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return False, f"log file not found: {path}", {}, {}
|
return False, f"log file not found: {path}", {}, {}
|
||||||
@ -21,9 +29,18 @@ class GrepLogExecutor:
|
|||||||
with path.open("r", encoding=encoding, errors="ignore") as handle:
|
with path.open("r", encoding=encoding, errors="ignore") as handle:
|
||||||
for line_number, line in enumerate(handle, start=1):
|
for line_number, line in enumerate(handle, start=1):
|
||||||
text = line.rstrip("\n")
|
text = line.rstrip("\n")
|
||||||
|
line_timestamp = self._extract_line_time(text, timestamp_regex)
|
||||||
|
if not self._match_time_range(line_timestamp, start_at, end_at):
|
||||||
|
continue
|
||||||
text_cmp = text if case_sensitive else text.lower()
|
text_cmp = text if case_sensitive else text.lower()
|
||||||
if keyword_cmp in text_cmp:
|
if keyword_cmp in text_cmp:
|
||||||
matches.append({"line_number": line_number, "content": text})
|
matches.append(
|
||||||
|
{
|
||||||
|
"line_number": line_number,
|
||||||
|
"content": text,
|
||||||
|
"timestamp": None if line_timestamp is None else line_timestamp.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3],
|
||||||
|
}
|
||||||
|
)
|
||||||
if len(matches) >= limit:
|
if len(matches) >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -36,8 +53,37 @@ class GrepLogExecutor:
|
|||||||
"path": str(path),
|
"path": str(path),
|
||||||
"keyword": keyword,
|
"keyword": keyword,
|
||||||
"matched_count": len(matches),
|
"matched_count": len(matches),
|
||||||
|
"start_at": params.get("start_at"),
|
||||||
|
"end_at": params.get("end_at"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matches": matches,
|
"matches": matches,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _extract_line_time(self, text: str, timestamp_regex: str) -> datetime | None:
|
||||||
|
matched = re.search(timestamp_regex, text)
|
||||||
|
if not matched:
|
||||||
|
return None
|
||||||
|
return self._parse_time(matched.group(1))
|
||||||
|
|
||||||
|
def _parse_time(self, value: str | None) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(str(value), fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _match_time_range(self, line_timestamp: datetime | None, start_at: datetime | None, end_at: datetime | None) -> bool:
|
||||||
|
if start_at is None and end_at is None:
|
||||||
|
return True
|
||||||
|
if line_timestamp is None:
|
||||||
|
return False
|
||||||
|
if start_at is not None and line_timestamp < start_at:
|
||||||
|
return False
|
||||||
|
if end_at is not None and line_timestamp > end_at:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -11,12 +12,17 @@ class ProcessCheckExecutor:
|
|||||||
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
|
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
|
||||||
process_name = params.get("process_name")
|
process_name = params.get("process_name")
|
||||||
pid = params.get("pid")
|
pid = params.get("pid")
|
||||||
|
command_contains = params.get("command_contains")
|
||||||
|
|
||||||
if not process_name and pid is None:
|
if not process_name and pid is None and not command_contains:
|
||||||
return False, "process_name or pid is required", {}, {}
|
return False, "process_name, pid or command_contains is required", {}, {}
|
||||||
|
|
||||||
processes = self._list_processes()
|
processes = self._list_processes()
|
||||||
matched = [item for item in processes if self._match_process(item, process_name=process_name, pid=pid)]
|
matched = [
|
||||||
|
item
|
||||||
|
for item in processes
|
||||||
|
if self._match_process(item, process_name=process_name, pid=pid, command_contains=command_contains)
|
||||||
|
]
|
||||||
|
|
||||||
success = len(matched) > 0
|
success = len(matched) > 0
|
||||||
message = "process found" if success else "process not found"
|
message = "process found" if success else "process not found"
|
||||||
@ -27,6 +33,9 @@ class ProcessCheckExecutor:
|
|||||||
"matched_count": len(matched),
|
"matched_count": len(matched),
|
||||||
"process_name": process_name,
|
"process_name": process_name,
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
|
"command_contains": command_contains,
|
||||||
|
"cpu_percent_total": round(sum(float(item.get("cpu_percent") or 0.0) for item in matched), 2),
|
||||||
|
"memory_rss_kb_total": sum(int(item.get("memory_rss_kb") or 0) for item in matched),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matches": matched,
|
"matches": matched,
|
||||||
@ -51,44 +60,69 @@ class ProcessCheckExecutor:
|
|||||||
for row in reader:
|
for row in reader:
|
||||||
if len(row) < 2:
|
if len(row) < 2:
|
||||||
continue
|
continue
|
||||||
|
memory_rss_kb = self._parse_windows_memory_kb(row[4]) if len(row) > 4 else None
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"pid": int(row[1]),
|
"pid": int(row[1]),
|
||||||
"process_name": row[0],
|
"process_name": row[0],
|
||||||
"command": row[0],
|
"command": row[0],
|
||||||
|
"cpu_percent": None,
|
||||||
|
"memory_rss_kb": memory_rss_kb,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def _list_unix_processes(self) -> list[dict[str, Any]]:
|
def _list_unix_processes(self) -> list[dict[str, Any]]:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["ps", "-eo", "pid=,comm=,args="],
|
["ps", "-eo", "pid=,comm=,pcpu=,pmem=,rss=,args="],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for line in result.stdout.splitlines():
|
for line in result.stdout.splitlines():
|
||||||
parts = line.strip().split(None, 2)
|
parts = line.strip().split(None, 5)
|
||||||
if len(parts) < 2:
|
if len(parts) < 5:
|
||||||
continue
|
continue
|
||||||
pid_text = parts[0]
|
pid_text = parts[0]
|
||||||
process_name = parts[1]
|
process_name = parts[1]
|
||||||
command = parts[2] if len(parts) > 2 else process_name
|
cpu_percent = float(parts[2])
|
||||||
|
memory_percent = float(parts[3])
|
||||||
|
memory_rss_kb = int(parts[4])
|
||||||
|
command = parts[5] if len(parts) > 5 else process_name
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"pid": int(pid_text),
|
"pid": int(pid_text),
|
||||||
"process_name": process_name,
|
"process_name": process_name,
|
||||||
"command": command,
|
"command": command,
|
||||||
|
"cpu_percent": cpu_percent,
|
||||||
|
"memory_percent": memory_percent,
|
||||||
|
"memory_rss_kb": memory_rss_kb,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def _match_process(self, item: dict[str, Any], process_name: str | None, pid: int | str | None) -> bool:
|
def _match_process(
|
||||||
|
self,
|
||||||
|
item: dict[str, Any],
|
||||||
|
process_name: str | None,
|
||||||
|
pid: int | str | None,
|
||||||
|
command_contains: str | None,
|
||||||
|
) -> bool:
|
||||||
if pid is not None and item["pid"] != int(pid):
|
if pid is not None and item["pid"] != int(pid):
|
||||||
return False
|
return False
|
||||||
if process_name:
|
if process_name:
|
||||||
name = str(process_name).lower()
|
name = str(process_name).lower()
|
||||||
if name not in item["process_name"].lower() and name not in item["command"].lower():
|
if name not in item["process_name"].lower() and name not in item["command"].lower():
|
||||||
return False
|
return False
|
||||||
|
if command_contains:
|
||||||
|
keyword = str(command_contains).lower()
|
||||||
|
if keyword not in item["command"].lower():
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _parse_windows_memory_kb(self, value: str) -> int | None:
|
||||||
|
digits = re.sub(r"[^\d]", "", value or "")
|
||||||
|
if not digits:
|
||||||
|
return None
|
||||||
|
return int(digits)
|
||||||
|
|||||||
41
edge-agent/app/executors/tcp_probe_executor.py
Normal file
41
edge-agent/app/executors/tcp_probe_executor.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class TcpProbeExecutor:
|
||||||
|
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
|
||||||
|
host = str(params.get("host", "127.0.0.1"))
|
||||||
|
port = int(params["port"])
|
||||||
|
timeout_ms = int(params.get("timeout_ms", 3000))
|
||||||
|
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=timeout_ms / 1000.0):
|
||||||
|
latency_ms = max(int((time.perf_counter() - started_at) * 1000), 0)
|
||||||
|
return (
|
||||||
|
True,
|
||||||
|
"tcp probe succeeded",
|
||||||
|
{
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"connected": True,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
latency_ms = max(int((time.perf_counter() - started_at) * 1000), 0)
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
str(exc),
|
||||||
|
{
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"connected": False,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
)
|
||||||
@ -4,6 +4,7 @@ from app.executors.log_executor import GrepLogExecutor
|
|||||||
from app.executors.http_executor import HttpHealthCheckExecutor
|
from app.executors.http_executor import HttpHealthCheckExecutor
|
||||||
from app.executors.port_executor import PortCheckExecutor
|
from app.executors.port_executor import PortCheckExecutor
|
||||||
from app.executors.process_executor import ProcessCheckExecutor
|
from app.executors.process_executor import ProcessCheckExecutor
|
||||||
|
from app.executors.tcp_probe_executor import TcpProbeExecutor
|
||||||
from app.executors.linux_service_executor import LinuxServiceExecutor
|
from app.executors.linux_service_executor import LinuxServiceExecutor
|
||||||
from app.executors.windows_service_executor import WindowsServiceExecutor
|
from app.executors.windows_service_executor import WindowsServiceExecutor
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ class ToolRegistry:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._executors = {
|
self._executors = {
|
||||||
"http_health_check": HttpHealthCheckExecutor(),
|
"http_health_check": HttpHealthCheckExecutor(),
|
||||||
|
"tcp_probe": TcpProbeExecutor(),
|
||||||
"check_port": PortCheckExecutor(),
|
"check_port": PortCheckExecutor(),
|
||||||
"check_process": ProcessCheckExecutor(),
|
"check_process": ProcessCheckExecutor(),
|
||||||
"grep_log": GrepLogExecutor(),
|
"grep_log": GrepLogExecutor(),
|
||||||
|
|||||||
30
edge-agent/scripts/package-linux.ps1
Normal file
30
edge-agent/scripts/package-linux.ps1
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
param(
|
||||||
|
[string]$PythonHome = $env:EDGE_PYTHON_HOME
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not $PythonHome) {
|
||||||
|
throw "Python runtime directory is required. Pass -PythonHome or set EDGE_PYTHON_HOME."
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedPythonHome = (Resolve-Path -LiteralPath $PythonHome).Path
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$dist = Join-Path $root "dist"
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$packageRoot = Join-Path $dist "edge-agent-linux-$timestamp"
|
||||||
|
$runtimeRoot = Join-Path $packageRoot "runtime\python"
|
||||||
|
$archivePath = Join-Path $dist "edge-agent-linux-$timestamp.tar.gz"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path $runtimeRoot -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path $dist -Force | Out-Null
|
||||||
|
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root "app") -Destination $packageRoot -Recurse
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root "README.md") -Destination $packageRoot
|
||||||
|
Copy-Item -LiteralPath (Join-Path $root "pyproject.toml") -Destination $packageRoot
|
||||||
|
Copy-Item -LiteralPath (Join-Path $PSScriptRoot "start-linux.sh") -Destination (Join-Path $packageRoot "start.sh")
|
||||||
|
Get-ChildItem -LiteralPath $resolvedPythonHome -Force | Copy-Item -Destination $runtimeRoot -Recurse
|
||||||
|
|
||||||
|
tar -czf $archivePath -C $packageRoot .
|
||||||
|
Write-Output $archivePath
|
||||||
@ -22,9 +22,11 @@ class DummyClient:
|
|||||||
def __exit__(self, exc_type, exc, tb) -> None:
|
def __exit__(self, exc_type, exc, tb) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get(self, url: str) -> DummyResponse:
|
def request(self, method: str, url: str, headers: dict | None = None) -> DummyResponse:
|
||||||
if "down" in url:
|
if "down" in url:
|
||||||
return DummyResponse(500, "Internal Server Error", '{"status":"DOWN"}')
|
return DummyResponse(500, "Internal Server Error", '{"status":"DOWN"}')
|
||||||
|
if "ready" in url:
|
||||||
|
return DummyResponse(200, "OK", '{"status":"READY"}')
|
||||||
return DummyResponse(200, "OK", '{"status":"UP"}')
|
return DummyResponse(200, "OK", '{"status":"UP"}')
|
||||||
|
|
||||||
|
|
||||||
@ -49,3 +51,19 @@ def test_http_health_check_executor_failure() -> None:
|
|||||||
assert message == "500 Internal Server Error"
|
assert message == "500 Internal Server Error"
|
||||||
assert data["status_code"] == 500
|
assert data["status_code"] == 500
|
||||||
assert evidence["response_body"] == '{"status":"DOWN"}'
|
assert evidence["response_body"] == '{"status":"DOWN"}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_health_check_executor_body_contains() -> None:
|
||||||
|
with patch("app.executors.http_executor.httpx.Client", DummyClient):
|
||||||
|
success, message, data, evidence = HttpHealthCheckExecutor().execute(
|
||||||
|
{
|
||||||
|
"url": "http://service.test/ready",
|
||||||
|
"timeout_ms": 3000,
|
||||||
|
"expected_status": 200,
|
||||||
|
"body_contains": "READY",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert success is True
|
||||||
|
assert message == "200 OK"
|
||||||
|
assert data["method"] == "GET"
|
||||||
|
assert evidence["response_json"]["status"] == "READY"
|
||||||
|
|||||||
@ -35,3 +35,32 @@ def test_grep_log_executor_missing_file() -> None:
|
|||||||
assert "not found" in message
|
assert "not found" in message
|
||||||
assert data == {}
|
assert data == {}
|
||||||
assert evidence == {}
|
assert evidence == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_grep_log_executor_filters_by_time_range(tmp_path: Path) -> None:
|
||||||
|
log_file = tmp_path / "timed.log"
|
||||||
|
log_file.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"2026-04-09 10:00:00.000 INFO start",
|
||||||
|
"2026-04-09 10:05:00.000 ERROR first failure",
|
||||||
|
"2026-04-09 10:10:00.000 ERROR second failure",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
success, message, data, evidence = GrepLogExecutor().execute(
|
||||||
|
{
|
||||||
|
"path": str(log_file),
|
||||||
|
"keyword": "ERROR",
|
||||||
|
"start_at": "2026-04-09 10:06:00.000",
|
||||||
|
"end_at": "2026-04-09 10:11:00.000",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert message == "keyword matched"
|
||||||
|
assert data["matched_count"] == 1
|
||||||
|
assert evidence["matches"][0]["line_number"] == 3
|
||||||
|
|||||||
@ -23,6 +23,7 @@ def test_process_check_executor_windows_match() -> None:
|
|||||||
assert success is True
|
assert success is True
|
||||||
assert message == "process found"
|
assert message == "process found"
|
||||||
assert data["matched_count"] == 1
|
assert data["matched_count"] == 1
|
||||||
|
assert data["memory_rss_kb_total"] == 10000
|
||||||
assert evidence["matches"][0]["pid"] == 1234
|
assert evidence["matches"][0]["pid"] == 1234
|
||||||
|
|
||||||
|
|
||||||
@ -31,7 +32,7 @@ def test_process_check_executor_unix_pid_miss() -> None:
|
|||||||
patch("app.executors.process_executor.platform.system", return_value="Linux"),
|
patch("app.executors.process_executor.platform.system", return_value="Linux"),
|
||||||
patch(
|
patch(
|
||||||
"app.executors.process_executor.subprocess.run",
|
"app.executors.process_executor.subprocess.run",
|
||||||
return_value=DummyCompletedProcess("1234 python python app.py\n"),
|
return_value=DummyCompletedProcess("1234 python 1.5 2.0 20480 python app.py\n"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
success, message, data, evidence = ProcessCheckExecutor().execute({"pid": 9999})
|
success, message, data, evidence = ProcessCheckExecutor().execute({"pid": 9999})
|
||||||
@ -40,3 +41,38 @@ def test_process_check_executor_unix_pid_miss() -> None:
|
|||||||
assert message == "process not found"
|
assert message == "process not found"
|
||||||
assert data["matched_count"] == 0
|
assert data["matched_count"] == 0
|
||||||
assert evidence["matches"] == []
|
assert evidence["matches"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_check_executor_unix_collects_metrics() -> None:
|
||||||
|
with (
|
||||||
|
patch("app.executors.process_executor.platform.system", return_value="Linux"),
|
||||||
|
patch(
|
||||||
|
"app.executors.process_executor.subprocess.run",
|
||||||
|
return_value=DummyCompletedProcess("1234 python 1.5 2.0 20480 python app.py\n"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
success, message, data, evidence = ProcessCheckExecutor().execute({"process_name": "python"})
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert message == "process found"
|
||||||
|
assert data["matched_count"] == 1
|
||||||
|
assert data["cpu_percent_total"] == 1.5
|
||||||
|
assert data["memory_rss_kb_total"] == 20480
|
||||||
|
assert evidence["matches"][0]["memory_percent"] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_check_executor_command_contains_match() -> None:
|
||||||
|
with (
|
||||||
|
patch("app.executors.process_executor.platform.system", return_value="Linux"),
|
||||||
|
patch(
|
||||||
|
"app.executors.process_executor.subprocess.run",
|
||||||
|
return_value=DummyCompletedProcess("1234 java 1.5 2.0 20480 java -jar order-service.jar\n"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
success, message, data, evidence = ProcessCheckExecutor().execute({"command_contains": "order-service"})
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert message == "process found"
|
||||||
|
assert data["matched_count"] == 1
|
||||||
|
assert data["command_contains"] == "order-service"
|
||||||
|
assert "order-service" in evidence["matches"][0]["command"]
|
||||||
|
|||||||
38
edge-agent/tests/test_tcp_probe_executor.py
Normal file
38
edge-agent/tests/test_tcp_probe_executor.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from app.executors.tcp_probe_executor import TcpProbeExecutor
|
||||||
|
|
||||||
|
|
||||||
|
def test_tcp_probe_executor_success() -> None:
|
||||||
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
server.bind(("127.0.0.1", 0))
|
||||||
|
server.listen(1)
|
||||||
|
host, port = server.getsockname()
|
||||||
|
|
||||||
|
def accept_once() -> None:
|
||||||
|
conn, _ = server.accept()
|
||||||
|
conn.close()
|
||||||
|
server.close()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=accept_once, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
success, message, data, evidence = TcpProbeExecutor().execute({"host": host, "port": port, "timeout_ms": 1000})
|
||||||
|
thread.join(timeout=1)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert message == "tcp probe succeeded"
|
||||||
|
assert data["connected"] is True
|
||||||
|
assert data["latency_ms"] is not None
|
||||||
|
assert evidence == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tcp_probe_executor_failure() -> None:
|
||||||
|
success, message, data, evidence = TcpProbeExecutor().execute({"host": "127.0.0.1", "port": 9, "timeout_ms": 100})
|
||||||
|
assert success is False
|
||||||
|
assert data["connected"] is False
|
||||||
|
assert isinstance(message, str)
|
||||||
|
assert evidence == {}
|
||||||
@ -22,14 +22,14 @@
|
|||||||
4. edge 接入与调度链路: 已完成
|
4. edge 接入与调度链路: 已完成
|
||||||
5. 基础验证执行器: 已完成
|
5. 基础验证执行器: 已完成
|
||||||
6. service control 执行器: 已完成
|
6. service control 执行器: 已完成
|
||||||
7. 审计 / 报告 / 聚合指标: 已完成第一轮
|
7. 审计 / 报告 / 聚合指标: 已完成第二轮
|
||||||
8. 失败路径与幂等性测试: 已完成第一轮
|
8. 失败路径与幂等性测试: 已完成第一轮
|
||||||
9. 便携打包与私有运行时: Windows 已完成验证, Linux 完成脚本待验证
|
9. 便携打包与私有运行时: Windows 已完成验证, Linux 产物契约已验证
|
||||||
10. 真实场景联调: 进行中
|
10. 真实场景联调: 进行中
|
||||||
|
|
||||||
当前 MVP 进度估算:
|
当前 MVP 进度估算:
|
||||||
|
|
||||||
**约 85%**
|
**约 94%**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -165,6 +165,11 @@ demo 接口定义文档已覆盖:
|
|||||||
23. 已补充 `edge-agent` 基础执行器实现,新增 `check_port`、`check_process`、`grep_log` 三类能力并接入工具注册表。
|
23. 已补充 `edge-agent` 基础执行器实现,新增 `check_port`、`check_process`、`grep_log` 三类能力并接入工具注册表。
|
||||||
24. 已将 Windows / Linux 的 service control 执行器从占位实现推进为可用版本,支持 `status`、`start`、`stop`、`restart`。
|
24. 已将 Windows / Linux 的 service control 执行器从占位实现推进为可用版本,支持 `status`、`start`、`stop`、`restart`。
|
||||||
25. 已将便携打包脚本增强为携带私有 Python 运行时,并完成 Windows 便携包实际打包验证。
|
25. 已将便携打包脚本增强为携带私有 Python 运行时,并完成 Windows 便携包实际打包验证。
|
||||||
|
26. 已增强健康检查与验证能力,新增 `tcp_probe`,并扩展 `http_health_check` 的期望状态码与响应体匹配能力。
|
||||||
|
27. 已增强 `grep_log` 的日志时间范围过滤能力,支持按 `start_at` / `end_at` 过滤。
|
||||||
|
28. 已增强 `check_process` 的进程指标输出,支持 CPU 与内存聚合信息。
|
||||||
|
29. 已增强任务报告中的审计与指标输出,新增 `audit_summary` 以及更细的 `task_metrics` 字段。
|
||||||
|
30. 已生成并验证 Linux 便携包产物契约,确认 `tar.gz` 中包含 `start.sh`、`app/main.py` 与 `runtime/python/bin/python3`。
|
||||||
|
|
||||||
### 3.8 当前代码可运行范围
|
### 3.8 当前代码可运行范围
|
||||||
|
|
||||||
@ -190,14 +195,17 @@ demo 接口定义文档已覆盖:
|
|||||||
11. 本地 `edge-agent` 当前已具备:
|
11. 本地 `edge-agent` 当前已具备:
|
||||||
启动脚本、打包脚本、基础执行器测试和轮询调度测试。
|
启动脚本、打包脚本、基础执行器测试和轮询调度测试。
|
||||||
12. 本地 `edge-agent` 当前已具备已注册工具:
|
12. 本地 `edge-agent` 当前已具备已注册工具:
|
||||||
`http_health_check`、`check_port`、`check_process`、`grep_log`、`windows_service_control`、`linux_service_control`
|
`http_health_check`、`tcp_probe`、`check_port`、`check_process`、`grep_log`、`windows_service_control`、`linux_service_control`
|
||||||
|
13. 任务报告当前已新增:
|
||||||
|
`audit_summary`
|
||||||
|
更细粒度 `task_metrics`
|
||||||
|
|
||||||
当前测试基线:
|
当前测试基线:
|
||||||
|
|
||||||
1. 共 20 条测试通过。
|
1. 共 20 条测试通过。
|
||||||
2. 使用 `sqlite:///:memory:` 做回归验证。
|
2. 使用 `sqlite:///:memory:` 做回归验证。
|
||||||
3. 当前主链路已不是“只有接口壳”,而是具备最小闭环行为。
|
3. 当前主链路已不是“只有接口壳”,而是具备最小闭环行为。
|
||||||
4. `edge-agent` 侧基础测试共 14 条通过。
|
4. `edge-agent` 侧基础测试共 19 条通过。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -301,12 +309,12 @@ demo 接口定义文档已覆盖:
|
|||||||
|
|
||||||
当前还未收口,或仅实现了最小版本的工作包括:
|
当前还未收口,或仅实现了最小版本的工作包括:
|
||||||
|
|
||||||
1. 本地 `edge-agent` 初始化代码与打包脚本已完成第一轮,Windows 私有运行时便携包已验证,Linux 私有运行时打包脚本待实际验证。
|
1. 本地 `edge-agent` 初始化代码与打包脚本已完成第一轮,Windows 私有运行时便携包已验证,Linux 便携包产物契约已验证,但原生 Linux/bash 环境下的实机打包仍待验证。
|
||||||
2. 文件型 SQLite / PostgreSQL 实库运行验证。
|
2. 文件型 SQLite / PostgreSQL 实库运行验证。
|
||||||
3. 身份 demo / 审批 demo 与任务主链路的权限、审批决策联动细化。
|
3. 身份 demo / 审批 demo 与任务主链路的权限、审批决策联动细化。
|
||||||
4. 任务级聚合指标已完成第一轮,但更细的任务级指标拆分仍可继续增强,如等待时长细分、失败步骤占比、阶段级统计。
|
4. 任务级聚合指标已完成第一轮,但更细的任务级指标拆分仍可继续增强,如等待时长细分、失败步骤占比、阶段级统计。
|
||||||
5. 更真实的验证插件实现,尤其是日志时间范围过滤、进程指标扩展和更多健康检查方式。
|
5. 更真实的验证插件实现,尤其是更细的日志解析、进程/JVM 指标扩展和更多健康检查方式。
|
||||||
6. 部署脚本和运行脚本进一步完善,包括 Linux 私有运行时打包验证和安装/升级流程。
|
6. 部署脚本和运行脚本进一步完善,包括原生 Linux/bash 环境下的私有运行时打包验证和安装/升级流程。
|
||||||
7. OpenAPI 扩展到第二批接口。
|
7. OpenAPI 扩展到第二批接口。
|
||||||
8. 更多测试用例与联调脚本。
|
8. 更多测试用例与联调脚本。
|
||||||
|
|
||||||
@ -337,7 +345,7 @@ demo 接口定义文档已覆盖:
|
|||||||
|
|
||||||
当前状态:
|
当前状态:
|
||||||
|
|
||||||
**SQLite / 去 Redis / 最小 DDL / 首批 OpenAPI / FastAPI 骨架 / 主接口 / demo adapter / edge 接口 / 第一轮任务级聚合指标 / 第一轮失败与幂等性测试 / edge-agent 初始化骨架 / edge-agent 启动与打包脚本 / edge-agent 基础测试 / service control 执行器 / Windows 私有运行时便携打包,均已完成第一轮落地。**
|
**SQLite / 去 Redis / 最小 DDL / 首批 OpenAPI / FastAPI 骨架 / 主接口 / demo adapter / edge 接口 / 第二轮任务级聚合指标与审计摘要 / 第一轮失败与幂等性测试 / edge-agent 初始化骨架 / edge-agent 启动与打包脚本 / edge-agent 基础测试 / service control 执行器 / Windows 私有运行时便携打包 / Linux 便携包产物契约验证,均已完成当前阶段落地。**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -373,7 +381,7 @@ demo 接口定义文档已覆盖:
|
|||||||
|
|
||||||
1. 再补更细的任务级指标拆分。
|
1. 再补更细的任务级指标拆分。
|
||||||
2. 再补审计细节和聚合摘要。
|
2. 再补审计细节和聚合摘要。
|
||||||
3. 继续补本地 Agent 更真实的日志/进程/健康检查执行能力,并验证 Linux 私有运行时打包。
|
3. 继续补本地 Agent 更真实的日志/进程/健康检查执行能力,并在原生 Linux/bash 环境验证私有运行时打包。
|
||||||
4. 再补第二批 OpenAPI。
|
4. 再补第二批 OpenAPI。
|
||||||
|
|
||||||
### 7.2 如果上下文快满,有什么影响
|
### 7.2 如果上下文快满,有什么影响
|
||||||
@ -407,3 +415,73 @@ set DATABASE_URL=sqlite:///:memory:
|
|||||||
当前已经完成从"写文档"切换到"写 demo 代码"的第一步,下一步进入:
|
当前已经完成从"写文档"切换到"写 demo 代码"的第一步,下一步进入:
|
||||||
|
|
||||||
**更多执行指标 -> 审计细节增强 -> 本地 Agent 与联调能力继续补齐**
|
**更多执行指标 -> 审计细节增强 -> 本地 Agent 与联调能力继续补齐**
|
||||||
|
|
||||||
|
## 9. 本轮更新(2026-04-09)
|
||||||
|
|
||||||
|
本轮新增完成内容:
|
||||||
|
|
||||||
|
1. 已将多类 edge 执行器真正接入后端下发链路,默认验证计划已由单步扩展为多步组合:
|
||||||
|
`check_process`、`check_port`、`tcp_probe`、`http_health_check`、`grep_log`
|
||||||
|
2. 已将 edge 结果聚合逻辑从“单步回传即结束”调整为:
|
||||||
|
全部成功才 `SUCCEEDED`
|
||||||
|
任一步失败则 `FAILED` 并取消剩余待执行步骤
|
||||||
|
3. 已增强 `http_health_check`,支持 `method`、`expected_status`、`body_contains`
|
||||||
|
4. 已增强 `grep_log`,支持 `start_at` / `end_at` 时间范围过滤
|
||||||
|
5. 已增强 `check_process`,支持 `command_contains`,并返回 CPU / 内存聚合指标
|
||||||
|
6. 已新增 `tcp_probe` 执行器并接入工具注册表
|
||||||
|
7. 已增强任务报告,补充更细的 `task_metrics` 和 `audit_summary`
|
||||||
|
8. 已新增 Linux 原生打包后续测试步骤说明,供后续在真实 Linux/bash 环境验证
|
||||||
|
9. 已完成 Windows 便携包验证与 Linux 产物契约验证,当前临时验证目录已清理
|
||||||
|
|
||||||
|
本轮测试结果:
|
||||||
|
|
||||||
|
1. backend 测试 `20 passed`
|
||||||
|
2. edge-agent 测试 `20 passed`
|
||||||
|
|
||||||
|
本轮 MVP 进度更新:
|
||||||
|
|
||||||
|
**约 91%**
|
||||||
|
|
||||||
|
距离当前 MVP 收口,主要剩余:
|
||||||
|
|
||||||
|
1. 更真实的日志/JVM/健康检查插件扩展
|
||||||
|
2. 更细的任务级阶段指标与审计摘要打磨
|
||||||
|
3. 原生 Linux/bash 环境下的私有运行时打包实机验证
|
||||||
|
4. 第二批 OpenAPI 与更多联调场景
|
||||||
|
|
||||||
|
## 10. 本轮更新(2026-04-09, Agent 演示入口层)
|
||||||
|
|
||||||
|
本轮新增完成内容:
|
||||||
|
|
||||||
|
1. 已新增 `app_metadata` 模型、仓储与服务,并在后端启动时自动注入 demo 元数据。
|
||||||
|
2. 已将默认验证步骤改为由 `app_metadata` 驱动生成,不再全部依赖写死参数。
|
||||||
|
3. 已新增最小会话层:
|
||||||
|
`chat_session`
|
||||||
|
`chat_message`
|
||||||
|
以及对应 chat service
|
||||||
|
4. 已新增 demo chat API:
|
||||||
|
`POST /api/demo/chat/sessions`
|
||||||
|
`GET /api/demo/chat/sessions/{session_id}`
|
||||||
|
`POST /api/demo/chat/sessions/{session_id}/messages`
|
||||||
|
`POST /api/demo/chat/sessions/{session_id}/tasks/{task_id}/confirm`
|
||||||
|
5. 已新增最小 Web Demo 页面:
|
||||||
|
`GET /`
|
||||||
|
`GET /demo/chat`
|
||||||
|
6. 已形成“一句话部署 -> 结构化解析 -> 确认 -> 执行 -> 验证 -> 报告”的可视化演示流。
|
||||||
|
7. 已补充聊天入口和页面可用性测试,并完成后端全量回归。
|
||||||
|
|
||||||
|
本轮测试结果:
|
||||||
|
|
||||||
|
1. backend 测试 `23 passed`
|
||||||
|
2. edge-agent 测试 `20 passed`
|
||||||
|
|
||||||
|
本轮 MVP 进度更新:
|
||||||
|
|
||||||
|
**约 94%**
|
||||||
|
|
||||||
|
当前 MVP 主线剩余重点:
|
||||||
|
|
||||||
|
1. 接入一个真实 Java 样板应用做端到端演示
|
||||||
|
2. 继续增强 app_metadata 驱动的验证模板与真实插件能力
|
||||||
|
3. 原生 Linux/bash 环境下验证私有运行时打包
|
||||||
|
4. 对演示 UI 做产品化打磨
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user