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:
2521690 2026-04-09 14:10:13 +08:00
parent 591df2d18e
commit ce299cbb18
32 changed files with 1914 additions and 180 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
.venv/ .venv/
data/ data/
dist/ dist/
tmp-linux-runtime/
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
*.pyc *.pyc

View File

@ -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

View File

@ -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,

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

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

View File

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

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

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

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

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

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

View 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

View 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

View File

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

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

View File

@ -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,

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

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

View 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

View File

@ -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"])

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

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

View File

@ -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(),

View 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

View File

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

View File

@ -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

View File

@ -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"]

View 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 == {}

View File

@ -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 做产品化打磨