feat: 补强 demo 后端任务指标与 edge-agent 执行骨架

- 新增 task_report 任务级聚合指标 task_metrics
- 补充创建任务幂等与失败路径/冲突测试
- 将后端测试基线提升到 20 passed
- 新增 edge-agent 初始化代码、启动脚本与打包脚本
- 新增 http_health_check、check_port、check_process、grep_log 执行器
- 补充 edge-agent 基础测试并提升基线到 10 passed
- 同步更新 backend README 与当前进度总结
This commit is contained in:
2521690 2026-04-09 10:51:19 +08:00
parent 5021c8c2ea
commit 2c7714268f
39 changed files with 1375 additions and 24 deletions

View File

@ -89,6 +89,14 @@ Current execution metrics:
1. `tool_call.duration_ms` is persisted from `started_at` / `finished_at` 1. `tool_call.duration_ms` is persisted from `started_at` / `finished_at`
2. `verification_trace.duration_ms` is persisted for edge task reports 2. `verification_trace.duration_ms` is persisted for edge task reports
3. `task_report.task_metrics` returns task-level aggregate durations and counts
4. current aggregate metrics include:
`total_duration_ms`
`confirm_wait_duration_ms`
`approval_duration_ms`
`execution_duration_ms`
`tool_call_duration_ms_total`
`verification_duration_ms_total`
Current result summary capabilities: Current result summary capabilities:
@ -113,13 +121,13 @@ Automated tests currently cover:
6. task report trace aggregation 6. task report trace aggregation
7. cancel running task 7. cancel running task
Current baseline: `14 passed` Current baseline: `20 passed`
## Next Focus ## Next Focus
Recommended next implementation steps: Recommended next implementation steps:
1. add more idempotency and rollback tests 1. continue enriching audit details and task-level metric breakdown
2. continue enriching audit details and task-level aggregate metrics 2. continue implementing local edge-agent executors beyond `http_health_check`
3. continue toward local edge-agent bootstrap 3. add packaging/bootstrap scripts for portable edge-agent delivery
4. then continue with second-batch OpenAPI 4. then continue with second-batch OpenAPI

View File

@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.core.constants import ERROR_CODE_OK from app.core.constants import ERROR_CODE_OK
from app.core.time import format_now from app.core.time import compute_duration_ms, format_now, parse_timestamp
from app.db.session import get_db from app.db.session import get_db
from app.repositories.approval_repository import ApprovalRepository from app.repositories.approval_repository import ApprovalRepository
from app.repositories.audit_repository import AuditRepository from app.repositories.audit_repository import AuditRepository
@ -31,6 +31,7 @@ from app.schemas.task import (
SoftwareAResultSummary, SoftwareAResultSummary,
TaskBasic, TaskBasic,
TaskDetailData, TaskDetailData,
TaskMetrics,
TaskReportData, TaskReportData,
ToolTraceItem, ToolTraceItem,
ToolCallItem, ToolCallItem,
@ -95,6 +96,78 @@ def build_result_summary_detail(task, approval, software_a_detail: dict | None,
) )
def pick_latest_timestamp(*values: str | None) -> str | None:
candidates = [value for value in values if value and parse_timestamp(value)]
if not candidates:
return None
return max(candidates, key=lambda value: parse_timestamp(value))
def sum_duration_ms(values: list[int | None]) -> int:
return sum(value for value in values if value is not None)
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])
verification_duration_ms_total = sum_duration_ms([item.duration_ms for item in edge_tasks])
tool_call_count = len(tool_calls)
tool_call_success_count = sum(1 for item in tool_calls if bool(item.success))
tool_call_failed_count = sum(1 for item in tool_calls if not bool(item.success))
verification_step_count = len(edge_tasks)
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)
latest_observed_at = pick_latest_timestamp(
task.updated_at,
approval.updated_at if approval else None,
*(item.finished_at for item in tool_calls),
*(item.finished_at for item in edge_tasks),
software_a_detail.get("finished_at") if software_a_detail else None,
)
total_duration_ms = compute_duration_ms(task.created_at, latest_observed_at)
confirm_wait_duration_ms = compute_duration_ms(task.created_at, task.confirmed_at)
approval_duration_ms = None
if approval:
approval_finished_at = approval.updated_at
if approval.approval_status == "PENDING":
approval_finished_at = latest_observed_at
approval_duration_ms = compute_duration_ms(approval.created_at, approval_finished_at)
execution_started_at = task.confirmed_at
if approval and approval.approval_status == "APPROVED":
execution_started_at = approval.updated_at
execution_duration_ms = None
if execution_started_at:
execution_finished_at = pick_latest_timestamp(
*(item.finished_at for item in tool_calls),
*(item.finished_at for item in edge_tasks),
software_a_detail.get("finished_at") if software_a_detail else None,
task.updated_at,
)
execution_duration_ms = compute_duration_ms(execution_started_at, execution_finished_at)
return TaskMetrics(
total_duration_ms=total_duration_ms,
confirm_wait_duration_ms=confirm_wait_duration_ms,
approval_duration_ms=approval_duration_ms,
execution_duration_ms=execution_duration_ms,
tool_call_duration_ms_total=tool_call_duration_ms_total,
verification_duration_ms_total=verification_duration_ms_total,
tool_call_count=tool_call_count,
tool_call_success_count=tool_call_success_count,
tool_call_failed_count=tool_call_failed_count,
verification_step_count=verification_step_count,
verification_success_count=verification_success_count,
verification_failed_count=verification_failed_count,
audit_event_count=len(audit_logs),
)
@router.post("", response_model=ApiResponse[CreateTaskData]) @router.post("", response_model=ApiResponse[CreateTaskData])
def create_task( def create_task(
payload: CreateTaskRequest, payload: CreateTaskRequest,
@ -104,7 +177,14 @@ def create_task(
settings = get_settings() settings = get_settings()
request_id = build_request_id(x_request_id) request_id = build_request_id(x_request_id)
service = TaskService(db, settings.default_timezone) service = TaskService(db, settings.default_timezone)
task = service.create_task(payload, request_id) try:
task = service.create_task(payload, request_id)
except TaskConflictError as exc:
message = exc.args[0] if exc.args else "task state conflict"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": exc.code, "message": message},
) from exc
missing_slots = json.loads(task.missing_slots_json) missing_slots = json.loads(task.missing_slots_json)
next_action = "CONFIRM_TASK" if not missing_slots else "FILL_MISSING_SLOTS" next_action = "CONFIRM_TASK" if not missing_slots else "FILL_MISSING_SLOTS"
@ -388,6 +468,7 @@ def get_task_report(
approval_trace=approval_trace, approval_trace=approval_trace,
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),
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

@ -25,3 +25,7 @@ class TaskRepository:
def get_by_task_id(self, task_id: str) -> Task | None: def get_by_task_id(self, task_id: str) -> Task | None:
statement = select(Task).where(Task.task_id == task_id) statement = select(Task).where(Task.task_id == task_id)
return self.db.execute(statement).scalar_one_or_none() return self.db.execute(statement).scalar_one_or_none()
def get_by_request_id(self, request_id: str) -> Task | None:
statement = select(Task).where(Task.request_id == request_id)
return self.db.execute(statement).scalar_one_or_none()

View File

@ -165,12 +165,29 @@ class AuditTraceItem(BaseModel):
timestamp: str timestamp: str
class TaskMetrics(BaseModel):
total_duration_ms: int | None = None
confirm_wait_duration_ms: int | None = None
approval_duration_ms: int | None = None
execution_duration_ms: int | None = None
tool_call_duration_ms_total: int = 0
verification_duration_ms_total: int = 0
tool_call_count: int = 0
tool_call_success_count: int = 0
tool_call_failed_count: int = 0
verification_step_count: int = 0
verification_success_count: int = 0
verification_failed_count: int = 0
audit_event_count: int = 0
class TaskReportData(BaseModel): class TaskReportData(BaseModel):
task_basic: TaskBasic task_basic: TaskBasic
intent_snapshot: ParsedIntent intent_snapshot: ParsedIntent
approval_trace: list[ApprovalTraceItem] approval_trace: list[ApprovalTraceItem]
tool_trace: list[ToolTraceItem] tool_trace: list[ToolTraceItem]
verification_trace: list[VerificationTraceItem] verification_trace: list[VerificationTraceItem]
task_metrics: TaskMetrics
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

@ -72,6 +72,18 @@ class TaskService:
raise TaskConflictError(f"当前任务状态不允许执行 {action},期望状态: {allowed_text},当前状态: {task.task_status}") raise TaskConflictError(f"当前任务状态不允许执行 {action},期望状态: {allowed_text},当前状态: {task.task_status}")
def create_task(self, payload: CreateTaskRequest, request_id: str | None) -> Task: def create_task(self, payload: CreateTaskRequest, request_id: str | None) -> Task:
if request_id:
existing_task = self.repository.get_by_request_id(request_id)
if existing_task:
if (
existing_task.input_text != payload.input_text
or existing_task.channel != payload.channel
or existing_task.session_id != payload.session_id
or existing_task.tenant_id != payload.tenant_id
):
raise TaskConflictError("相同 request_id 已用于其他创建任务请求,禁止复用。")
return existing_task
parsed_intent, missing_slots = self.intent_service.parse(payload.input_text) parsed_intent, missing_slots = self.intent_service.parse(payload.input_text)
risk_level = self.risk_service.evaluate(parsed_intent) risk_level = self.risk_service.evaluate(parsed_intent)
current_time = format_now(self.timezone_name) current_time = format_now(self.timezone_name)

View File

@ -283,6 +283,59 @@ def test_task_report_contains_traces() -> None:
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"]
assert task_metrics["tool_call_count"] >= 2
assert task_metrics["tool_call_success_count"] >= 2
assert task_metrics["tool_call_failed_count"] == 0
assert task_metrics["verification_step_count"] == 1
assert task_metrics["verification_success_count"] == 1
assert task_metrics["verification_failed_count"] == 0
assert task_metrics["verification_duration_ms_total"] == 100
assert task_metrics["tool_call_duration_ms_total"] 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["total_duration_ms"] is not None
assert task_metrics["audit_event_count"] >= 3
def test_task_report_contains_metrics_for_approved_flow() -> None:
with TestClient(app) as client:
create_response = client.post(
"/api/agent/tasks",
json={
"input_text": "deploy order-service 1.2.3 to prod",
"channel": "WEB",
"session_id": "sess-004a",
"tenant_id": "tenant-demo",
"context": {},
},
)
task_id = create_response.json()["data"]["task_id"]
confirm_response = client.post(
f"/api/agent/tasks/{task_id}/confirm",
json={"confirmed": True, "comment": "need approval"},
)
approval_id = confirm_response.json()["data"]["approval_id"]
client.post(
f"/api/demo/approval/requests/{approval_id}/decision",
json={
"decision": "APPROVED",
"comment": "approved",
"operator": {"user_id": "u2001", "user_name": "bob"},
},
)
report_response = client.get(f"/api/agent/tasks/{task_id}/report")
assert report_response.status_code == 200
payload = report_response.json()["data"]
task_metrics = payload["task_metrics"]
assert task_metrics["approval_duration_ms"] is not None
assert task_metrics["tool_call_count"] >= 1
assert task_metrics["verification_step_count"] >= 1
assert task_metrics["audit_event_count"] >= 3
assert payload["approval_trace"][0]["approval_status"] == "APPROVED"
def test_cancel_running_task() -> None: def test_cancel_running_task() -> None:
@ -344,6 +397,62 @@ def test_confirm_twice_returns_conflict() -> None:
assert second_confirm.json()["code"] == "CONFLICT" assert second_confirm.json()["code"] == "CONFLICT"
def test_create_task_is_idempotent_for_same_request_id() -> None:
with TestClient(app) as client:
payload = {
"input_text": "deploy order-service 1.2.3 to test",
"channel": "WEB",
"session_id": "sess-006a",
"tenant_id": "tenant-demo",
"context": {},
}
first_response = client.post(
"/api/agent/tasks",
headers={"X-Request-Id": "req-idempotent-create-001"},
json=payload,
)
assert first_response.status_code == 200
first_task_id = first_response.json()["data"]["task_id"]
second_response = client.post(
"/api/agent/tasks",
headers={"X-Request-Id": "req-idempotent-create-001"},
json=payload,
)
assert second_response.status_code == 200
assert second_response.json()["data"]["task_id"] == first_task_id
def test_create_task_conflicts_when_same_request_id_has_different_payload() -> None:
with TestClient(app) as client:
first_response = client.post(
"/api/agent/tasks",
headers={"X-Request-Id": "req-idempotent-create-002"},
json={
"input_text": "deploy order-service 1.2.3 to test",
"channel": "WEB",
"session_id": "sess-006b",
"tenant_id": "tenant-demo",
"context": {},
},
)
assert first_response.status_code == 200
second_response = client.post(
"/api/agent/tasks",
headers={"X-Request-Id": "req-idempotent-create-002"},
json={
"input_text": "deploy payment-service 1.2.3 to test",
"channel": "WEB",
"session_id": "sess-006b",
"tenant_id": "tenant-demo",
"context": {},
},
)
assert second_response.status_code == 409
assert second_response.json()["code"] == "CONFLICT"
def test_approval_decision_conflicts_after_task_cancelled() -> None: def test_approval_decision_conflicts_after_task_cancelled() -> None:
with TestClient(app) as client: with TestClient(app) as client:
create_response = client.post( create_response = client.post(
@ -383,6 +492,48 @@ def test_approval_decision_conflicts_after_task_cancelled() -> None:
assert decision_response.json()["code"] == "CONFLICT" assert decision_response.json()["code"] == "CONFLICT"
def test_approval_decision_twice_returns_conflict() -> None:
with TestClient(app) as client:
create_response = client.post(
"/api/agent/tasks",
json={
"input_text": "deploy order-service 1.2.3 to prod",
"channel": "WEB",
"session_id": "sess-007a",
"tenant_id": "tenant-demo",
"context": {},
},
)
task_id = create_response.json()["data"]["task_id"]
confirm_response = client.post(
f"/api/agent/tasks/{task_id}/confirm",
json={"confirmed": True, "comment": "need approval"},
)
approval_id = confirm_response.json()["data"]["approval_id"]
first_decision = client.post(
f"/api/demo/approval/requests/{approval_id}/decision",
json={
"decision": "APPROVED",
"comment": "approved",
"operator": {"user_id": "u2001", "user_name": "bob"},
},
)
assert first_decision.status_code == 200
second_decision = client.post(
f"/api/demo/approval/requests/{approval_id}/decision",
json={
"decision": "APPROVED",
"comment": "approved twice",
"operator": {"user_id": "u2001", "user_name": "bob"},
},
)
assert second_decision.status_code == 409
assert second_decision.json()["code"] == "CONFLICT"
def test_duplicate_edge_report_returns_conflict() -> None: def test_duplicate_edge_report_returns_conflict() -> None:
with TestClient(app) as client: with TestClient(app) as client:
client.post( client.post(
@ -455,6 +606,92 @@ def test_duplicate_edge_report_returns_conflict() -> None:
assert second_report.json()["code"] == "CONFLICT" assert second_report.json()["code"] == "CONFLICT"
def test_edge_report_with_wrong_edge_id_returns_conflict() -> None:
with TestClient(app) as client:
client.post(
"/api/agent/edge/heartbeat",
json={
"edge_id": "edge-shanghai-001",
"hostname": "customer-host-01",
"os_type": "WINDOWS",
"agent_version": "0.1.0",
"capabilities": ["http_health_check"],
},
)
create_response = client.post(
"/api/agent/tasks",
json={
"input_text": "deploy order-service 1.2.3 to test",
"channel": "WEB",
"session_id": "sess-008a",
"tenant_id": "tenant-demo",
"context": {},
},
)
task_id = create_response.json()["data"]["task_id"]
client.post(
f"/api/agent/tasks/{task_id}/confirm",
json={"confirmed": True, "comment": "confirm"},
)
pull_response = client.post(
"/api/agent/edge/tasks/pull",
json={"edge_id": "edge-shanghai-001", "max_tasks": 5},
)
step = [item for item in pull_response.json()["data"]["tasks"] if item["task_id"] == task_id][0]
wrong_report = client.post(
"/api/agent/edge/tasks/report",
json={
"edge_id": "edge-beijing-999",
"task_id": task_id,
"step_id": step["step_id"],
"tool_name": step["tool_name"],
"success": True,
"code": "OK",
"message": "200 OK",
"data": {"status_code": 200},
"evidence": {"response_body": "{\"status\":\"UP\"}"},
"started_at": "2026-04-08 20:20:00.000",
"finished_at": "2026-04-08 20:20:00.100",
},
)
assert wrong_report.status_code == 409
assert wrong_report.json()["code"] == "CONFLICT"
def test_cancel_twice_returns_conflict() -> None:
with TestClient(app) as client:
create_response = client.post(
"/api/agent/tasks",
json={
"input_text": "deploy order-service 1.2.3 to test",
"channel": "WEB",
"session_id": "sess-008b",
"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"},
)
first_cancel = client.post(
f"/api/agent/tasks/{task_id}/cancel",
json={"reason": "manual stop"},
)
assert first_cancel.status_code == 200
second_cancel = client.post(
f"/api/agent/tasks/{task_id}/cancel",
json={"reason": "manual stop again"},
)
assert second_cancel.status_code == 409
assert second_cancel.json()["code"] == "CONFLICT"
def test_task_fails_when_software_a_deploy_fails() -> None: def test_task_fails_when_software_a_deploy_fails() -> None:
with TestClient(app) as client: with TestClient(app) as client:
create_response = client.post( create_response = client.post(

68
edge-agent/README.md Normal file
View File

@ -0,0 +1,68 @@
# Smart Deploy Agent Demo Edge Agent
## Setup
```bash
python -m venv .venv
.venv\Scripts\python -m pip install -e edge-agent
```
## Run Once
```bash
set PYTHONPATH=edge-agent
.venv\Scripts\python -m app.main --once
```
## Run Loop
```bash
set PYTHONPATH=edge-agent
.venv\Scripts\python -m app.main
```
## Test
```bash
set PYTHONPATH=edge-agent
C:\Users\MH\AppData\Local\Programs\Python\Python311\python.exe -m pytest edge-agent/tests -q -p no:cacheprovider
```
## Default Runtime Notes
1. default backend url: `http://127.0.0.1:8000`
2. default edge id: `edge-shanghai-001`
3. current registered tools:
`http_health_check`
`check_port`
`check_process`
`grep_log`
4. current bootstrap implements:
heartbeat
pull task
execute registered tools
report result
report event
## Package Scripts
Current repo includes:
1. `scripts/start-windows.ps1`
2. `scripts/start-linux.sh`
3. `scripts/package-windows.ps1`
4. `scripts/package-linux.sh`
These scripts currently prepare a portable package skeleton and startup entrypoints.
They do not yet bundle a private Python runtime.
## Packaging Direction
For user-side delivery, this edge agent is intended to be bundled as:
1. Windows: `zip` portable package
2. Linux: `tar.gz` self-contained runtime directory
## Current Verification Baseline
Current edge-agent baseline: `10 passed`

View File

@ -0,0 +1 @@
from __future__ import annotations

View File

@ -0,0 +1 @@
from __future__ import annotations

View File

@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from uuid import uuid4
import httpx
from app.core.config import Settings
from app.core.security import build_auth_headers
class BackendClient:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.client = httpx.Client(timeout=settings.request_timeout_ms / 1000.0)
def _headers(self) -> dict[str, str]:
headers = build_auth_headers(self.settings)
headers["X-Request-Id"] = f"edge-req-{uuid4().hex[:12]}"
headers["X-Edge-Id"] = self.settings.edge_id
return headers
def heartbeat(self, capabilities: list[str]) -> dict[str, Any]:
response = self.client.post(
f"{self.settings.backend_base_url}/api/agent/edge/heartbeat",
headers=self._headers(),
json={
"edge_id": self.settings.edge_id,
"hostname": self.settings.edge_hostname,
"os_type": self.settings.edge_os_type,
"agent_version": self.settings.edge_agent_version,
"capabilities": capabilities,
},
)
response.raise_for_status()
return response.json()
def pull_tasks(self, max_tasks: int = 5) -> list[dict[str, Any]]:
response = self.client.post(
f"{self.settings.backend_base_url}/api/agent/edge/tasks/pull",
headers=self._headers(),
json={
"edge_id": self.settings.edge_id,
"max_tasks": max_tasks,
},
)
response.raise_for_status()
return response.json()["data"]["tasks"]
def report_task(self, payload: dict[str, Any]) -> dict[str, Any]:
response = self.client.post(
f"{self.settings.backend_base_url}/api/agent/edge/tasks/report",
headers=self._headers(),
json=payload,
)
response.raise_for_status()
return response.json()
def report_event(self, event_type: str, message: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
response = self.client.post(
f"{self.settings.backend_base_url}/api/agent/edge/events",
headers=self._headers(),
json={
"edge_id": self.settings.edge_id,
"event_type": event_type,
"message": message,
"detail": detail or {},
},
)
response.raise_for_status()
return response.json()
def close(self) -> None:
self.client.close()

View File

@ -0,0 +1 @@
from __future__ import annotations

View File

@ -0,0 +1,45 @@
from __future__ import annotations
import os
import platform
from dataclasses import dataclass
def detect_os_type() -> str:
system_name = platform.system().upper()
if system_name.startswith("WIN"):
return "WINDOWS"
if system_name.startswith("LINUX"):
return "LINUX"
return system_name or "UNKNOWN"
@dataclass(slots=True)
class Settings:
backend_base_url: str
edge_id: str
edge_name: str
edge_hostname: str
edge_os_type: str
edge_agent_version: str
edge_access_token: str | None
poll_interval_ms: int
heartbeat_interval_ms: int
request_timeout_ms: int
default_health_check_timeout_ms: int
def get_settings() -> Settings:
return Settings(
backend_base_url=os.getenv("BACKEND_BASE_URL", "http://127.0.0.1:8000").rstrip("/"),
edge_id=os.getenv("EDGE_ID", "edge-shanghai-001"),
edge_name=os.getenv("EDGE_NAME", "edge-agent-demo"),
edge_hostname=os.getenv("EDGE_HOSTNAME", platform.node() or "localhost"),
edge_os_type=os.getenv("EDGE_OS_TYPE", detect_os_type()),
edge_agent_version=os.getenv("EDGE_AGENT_VERSION", "0.1.0"),
edge_access_token=os.getenv("EDGE_ACCESS_TOKEN"),
poll_interval_ms=int(os.getenv("POLL_INTERVAL_MS", "3000")),
heartbeat_interval_ms=int(os.getenv("HEARTBEAT_INTERVAL_MS", "10000")),
request_timeout_ms=int(os.getenv("REQUEST_TIMEOUT_MS", "5000")),
default_health_check_timeout_ms=int(os.getenv("DEFAULT_HEALTH_CHECK_TIMEOUT_MS", "3000")),
)

View File

@ -0,0 +1,10 @@
from __future__ import annotations
import logging
def setup_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)

View File

@ -0,0 +1,10 @@
from __future__ import annotations
from app.core.config import Settings
def build_auth_headers(settings: Settings) -> dict[str, str]:
headers: dict[str, str] = {}
if settings.edge_access_token:
headers["Authorization"] = f"Bearer {settings.edge_access_token}"
return headers

View File

@ -0,0 +1,10 @@
from __future__ import annotations
from datetime import datetime
TIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f"
def format_now() -> str:
return datetime.now().strftime(TIME_FORMAT)[:-3]

View File

@ -0,0 +1 @@
from __future__ import annotations

View File

@ -0,0 +1,8 @@
from __future__ import annotations
from typing import Any, Protocol
class ToolExecutor(Protocol):
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
...

View File

@ -0,0 +1,26 @@
from __future__ import annotations
import time
from typing import Any
import httpx
class HttpHealthCheckExecutor:
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
url = params["url"]
timeout_ms = int(params.get("timeout_ms", 3000))
started_at = time.perf_counter()
with httpx.Client(timeout=timeout_ms / 1000.0) as client:
response = client.get(url)
latency_ms = max(int((time.perf_counter() - started_at) * 1000), 0)
success = response.status_code == 200
message = f"{response.status_code} {response.reason_phrase}"
data: dict[str, Any] = {
"status_code": response.status_code,
"latency_ms": latency_ms,
}
evidence = {
"response_body": response.text,
}
return success, message, data, evidence

View File

@ -0,0 +1,8 @@
from __future__ import annotations
from typing import Any
class LinuxServiceExecutor:
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
return False, "linux service executor not implemented", {"params": params}, {}

View File

@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
class GrepLogExecutor:
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
path = Path(str(params["path"]))
keyword = str(params["keyword"])
limit = int(params.get("limit", 100))
encoding = str(params.get("encoding", "utf-8"))
case_sensitive = bool(params.get("case_sensitive", False))
if not path.exists():
return False, f"log file not found: {path}", {}, {}
keyword_cmp = keyword if case_sensitive else keyword.lower()
matches: list[dict[str, Any]] = []
with path.open("r", encoding=encoding, errors="ignore") as handle:
for line_number, line in enumerate(handle, start=1):
text = line.rstrip("\n")
text_cmp = text if case_sensitive else text.lower()
if keyword_cmp in text_cmp:
matches.append({"line_number": line_number, "content": text})
if len(matches) >= limit:
break
success = len(matches) > 0
message = "keyword matched" if success else "keyword not found"
return (
success,
message,
{
"path": str(path),
"keyword": keyword,
"matched_count": len(matches),
},
{
"matches": matches,
},
)

View File

@ -0,0 +1,41 @@
from __future__ import annotations
import socket
import time
from typing import Any
class PortCheckExecutor:
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,
f"connected to {host}:{port}",
{
"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

@ -0,0 +1,94 @@
from __future__ import annotations
import csv
import platform
import subprocess
from io import StringIO
from typing import Any
class ProcessCheckExecutor:
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
process_name = params.get("process_name")
pid = params.get("pid")
if not process_name and pid is None:
return False, "process_name or pid is required", {}, {}
processes = self._list_processes()
matched = [item for item in processes if self._match_process(item, process_name=process_name, pid=pid)]
success = len(matched) > 0
message = "process found" if success else "process not found"
return (
success,
message,
{
"matched_count": len(matched),
"process_name": process_name,
"pid": pid,
},
{
"matches": matched,
},
)
def _list_processes(self) -> list[dict[str, Any]]:
system_name = platform.system().upper()
if system_name.startswith("WIN"):
return self._list_windows_processes()
return self._list_unix_processes()
def _list_windows_processes(self) -> list[dict[str, Any]]:
result = subprocess.run(
["tasklist", "/FO", "CSV", "/NH"],
capture_output=True,
text=True,
check=True,
)
reader = csv.reader(StringIO(result.stdout))
rows: list[dict[str, Any]] = []
for row in reader:
if len(row) < 2:
continue
rows.append(
{
"pid": int(row[1]),
"process_name": row[0],
"command": row[0],
}
)
return rows
def _list_unix_processes(self) -> list[dict[str, Any]]:
result = subprocess.run(
["ps", "-eo", "pid=,comm=,args="],
capture_output=True,
text=True,
check=True,
)
rows: list[dict[str, Any]] = []
for line in result.stdout.splitlines():
parts = line.strip().split(None, 2)
if len(parts) < 2:
continue
pid_text = parts[0]
process_name = parts[1]
command = parts[2] if len(parts) > 2 else process_name
rows.append(
{
"pid": int(pid_text),
"process_name": process_name,
"command": command,
}
)
return rows
def _match_process(self, item: dict[str, Any], process_name: str | None, pid: int | str | None) -> bool:
if pid is not None and item["pid"] != int(pid):
return False
if process_name:
name = str(process_name).lower()
if name not in item["process_name"].lower() and name not in item["command"].lower():
return False
return True

View File

@ -0,0 +1,8 @@
from __future__ import annotations
from typing import Any
class WindowsServiceExecutor:
def execute(self, params: dict[str, Any]) -> tuple[bool, str, dict[str, Any], dict[str, Any]]:
return False, "windows service executor not implemented", {"params": params}, {}

36
edge-agent/app/main.py Normal file
View File

@ -0,0 +1,36 @@
from __future__ import annotations
import argparse
import logging
from app.core.config import get_settings
from app.core.logging import setup_logging
from app.scheduler.polling_runner import PollingRunner
logger = logging.getLogger(__name__)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Smart Deploy Agent demo edge agent")
parser.add_argument("--once", action="store_true", help="run one heartbeat + pull + execute cycle")
return parser.parse_args()
def main() -> None:
setup_logging()
args = parse_args()
settings = get_settings()
runner = PollingRunner(settings)
try:
if args.once:
runner.run_once()
else:
runner.run_forever()
finally:
runner.close()
logger.info("edge agent stopped")
if __name__ == "__main__":
main()

View File

@ -0,0 +1 @@
from __future__ import annotations

View File

@ -0,0 +1,26 @@
from __future__ import annotations
from app.executors.log_executor import GrepLogExecutor
from app.executors.http_executor import HttpHealthCheckExecutor
from app.executors.port_executor import PortCheckExecutor
from app.executors.process_executor import ProcessCheckExecutor
from app.executors.linux_service_executor import LinuxServiceExecutor
from app.executors.windows_service_executor import WindowsServiceExecutor
class ToolRegistry:
def __init__(self) -> None:
self._executors = {
"http_health_check": HttpHealthCheckExecutor(),
"check_port": PortCheckExecutor(),
"check_process": ProcessCheckExecutor(),
"grep_log": GrepLogExecutor(),
"linux_service_control": LinuxServiceExecutor(),
"windows_service_control": WindowsServiceExecutor(),
}
def capabilities(self) -> list[str]:
return sorted(self._executors.keys())
def get(self, tool_name: str):
return self._executors.get(tool_name)

View File

@ -0,0 +1 @@
from __future__ import annotations

View File

@ -0,0 +1,114 @@
from __future__ import annotations
import logging
import time
import httpx
from app.client.backend_client import BackendClient
from app.core.config import Settings
from app.core.time import format_now
from app.registry.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
class PollingRunner:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.registry = ToolRegistry()
self.backend_client = BackendClient(settings)
self._last_heartbeat_at: float = 0.0
def run_once(self) -> None:
self._heartbeat_if_needed(force=True)
tasks = self.backend_client.pull_tasks()
for task in tasks:
self._execute_task(task)
def run_forever(self) -> None:
while True:
self._heartbeat_if_needed(force=False)
tasks = self.backend_client.pull_tasks()
for task in tasks:
self._execute_task(task)
time.sleep(self.settings.poll_interval_ms / 1000.0)
def close(self) -> None:
self.backend_client.close()
def _heartbeat_if_needed(self, force: bool) -> None:
current = time.time()
if not force and current - self._last_heartbeat_at < self.settings.heartbeat_interval_ms / 1000.0:
return
self.backend_client.heartbeat(self.registry.capabilities())
self._last_heartbeat_at = current
def _execute_task(self, task: dict) -> None:
executor = self.registry.get(task["tool_name"])
started_at = format_now()
if executor is None:
self.backend_client.report_event(
event_type="UNSUPPORTED_TOOL",
message=f"unsupported tool: {task['tool_name']}",
detail={"task_id": task["task_id"], "step_id": task["step_id"]},
)
self.backend_client.report_task(
{
"edge_id": self.settings.edge_id,
"task_id": task["task_id"],
"step_id": task["step_id"],
"tool_name": task["tool_name"],
"success": False,
"code": "UNSUPPORTED_TOOL",
"message": f"unsupported tool: {task['tool_name']}",
"data": {},
"evidence": {},
"started_at": started_at,
"finished_at": format_now(),
}
)
return
try:
success, message, data, evidence = executor.execute(task.get("params", {}))
code = "OK" if success else "EXECUTION_FAILED"
except httpx.HTTPError as exc:
success = False
code = "HTTP_ERROR"
message = str(exc)
data = {}
evidence = {}
self.backend_client.report_event(
event_type="HTTP_EXECUTOR_EXCEPTION",
message=str(exc),
detail={"task_id": task["task_id"], "step_id": task["step_id"], "tool_name": task["tool_name"]},
)
except Exception as exc: # pragma: no cover - defensive path
success = False
code = "EXECUTION_EXCEPTION"
message = str(exc)
data = {}
evidence = {}
self.backend_client.report_event(
event_type="AGENT_EXCEPTION",
message=str(exc),
detail={"task_id": task["task_id"], "step_id": task["step_id"], "tool_name": task["tool_name"]},
)
payload = {
"edge_id": self.settings.edge_id,
"task_id": task["task_id"],
"step_id": task["step_id"],
"tool_name": task["tool_name"],
"success": success,
"code": code,
"message": message,
"data": data,
"evidence": evidence,
"started_at": started_at,
"finished_at": format_now(),
}
logger.info("report edge step result task_id=%s step_id=%s success=%s", task["task_id"], task["step_id"], success)
self.backend_client.report_task(payload)

17
edge-agent/pyproject.toml Normal file
View File

@ -0,0 +1,17 @@
[project]
name = "smart-deploy-agent-demo-edge"
version = "0.1.0"
description = "Smart deploy agent demo edge agent"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.28.0,<1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0,<9.0.0",
]
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST_DIR="$ROOT_DIR/dist"
PACKAGE_ROOT="$DIST_DIR/edge-agent-linux"
ARCHIVE_PATH="$DIST_DIR/edge-agent-linux.tar.gz"
rm -rf "$PACKAGE_ROOT"
mkdir -p "$PACKAGE_ROOT"
mkdir -p "$DIST_DIR"
cp -r "$ROOT_DIR/app" "$PACKAGE_ROOT/"
cp "$ROOT_DIR/README.md" "$PACKAGE_ROOT/"
cp "$ROOT_DIR/pyproject.toml" "$PACKAGE_ROOT/"
cp "$ROOT_DIR/scripts/start-linux.sh" "$PACKAGE_ROOT/"
tar -czf "$ARCHIVE_PATH" -C "$PACKAGE_ROOT" .
echo "$ARCHIVE_PATH"

View File

@ -0,0 +1,24 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$dist = Join-Path $root "dist"
$packageRoot = Join-Path $dist "edge-agent-windows"
$zipPath = Join-Path $dist "edge-agent-windows.zip"
if (Test-Path $packageRoot) {
Remove-Item -LiteralPath $packageRoot -Recurse -Force
}
if (Test-Path $zipPath) {
Remove-Item -LiteralPath $zipPath -Force
}
New-Item -ItemType Directory -Path $packageRoot | 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-windows.ps1") -Destination $packageRoot
Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $zipPath -Force
Write-Output $zipPath

View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_BIN="$ROOT_DIR/.venv/bin/python"
if [[ ! -x "$PYTHON_BIN" ]]; then
echo "Python runtime not found at $PYTHON_BIN" >&2
exit 1
fi
export PYTHONPATH="$ROOT_DIR"
exec "$PYTHON_BIN" -m app.main "$@"

View File

@ -0,0 +1,11 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$python = Join-Path $root ".venv\Scripts\python.exe"
if (-not (Test-Path $python)) {
throw "Python runtime not found at $python"
}
$env:PYTHONPATH = $root
& $python -m app.main @args

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from unittest.mock import patch
from app.executors.http_executor import HttpHealthCheckExecutor
class DummyResponse:
def __init__(self, status_code: int, reason_phrase: str, text: str) -> None:
self.status_code = status_code
self.reason_phrase = reason_phrase
self.text = text
class DummyClient:
def __init__(self, *args, **kwargs) -> None:
self.kwargs = kwargs
def __enter__(self) -> "DummyClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
def get(self, url: str) -> DummyResponse:
if "down" in url:
return DummyResponse(500, "Internal Server Error", '{"status":"DOWN"}')
return DummyResponse(200, "OK", '{"status":"UP"}')
def test_http_health_check_executor_success() -> None:
with patch("app.executors.http_executor.httpx.Client", DummyClient):
success, message, data, evidence = HttpHealthCheckExecutor().execute(
{"url": "http://service.test/health", "timeout_ms": 3000}
)
assert success is True
assert message == "200 OK"
assert data["status_code"] == 200
assert data["latency_ms"] is not None
assert evidence["response_body"] == '{"status":"UP"}'
def test_http_health_check_executor_failure() -> None:
with patch("app.executors.http_executor.httpx.Client", DummyClient):
success, message, data, evidence = HttpHealthCheckExecutor().execute(
{"url": "http://service.test/down", "timeout_ms": 3000}
)
assert success is False
assert message == "500 Internal Server Error"
assert data["status_code"] == 500
assert evidence["response_body"] == '{"status":"DOWN"}'

View File

@ -0,0 +1,37 @@
from __future__ import annotations
from pathlib import Path
from app.executors.log_executor import GrepLogExecutor
def test_grep_log_executor_matches_keyword(tmp_path: Path) -> None:
log_file = tmp_path / "app.log"
log_file.write_text("INFO start\nERROR failed to boot\nINFO done\n", encoding="utf-8")
success, message, data, evidence = GrepLogExecutor().execute(
{
"path": str(log_file),
"keyword": "ERROR",
"limit": 10,
}
)
assert success is True
assert message == "keyword matched"
assert data["matched_count"] == 1
assert evidence["matches"][0]["line_number"] == 2
def test_grep_log_executor_missing_file() -> None:
success, message, data, evidence = GrepLogExecutor().execute(
{
"path": "not-exists.log",
"keyword": "ERROR",
}
)
assert success is False
assert "not found" in message
assert data == {}
assert evidence == {}

View File

@ -0,0 +1,97 @@
from __future__ import annotations
from app.core.config import Settings
from app.scheduler.polling_runner import PollingRunner
class StubBackendClient:
def __init__(self) -> None:
self.heartbeats: list[dict] = []
self.task_reports: list[dict] = []
self.event_reports: list[dict] = []
self._tasks: list[dict] = []
def heartbeat(self, capabilities: list[str]) -> dict:
self.heartbeats.append({"capabilities": capabilities})
return {"success": True}
def pull_tasks(self, max_tasks: int = 5) -> list[dict]:
return self._tasks[:max_tasks]
def report_task(self, payload: dict) -> dict:
self.task_reports.append(payload)
return {"success": True}
def report_event(self, event_type: str, message: str, detail: dict | None = None) -> dict:
self.event_reports.append({"event_type": event_type, "message": message, "detail": detail or {}})
return {"success": True}
def close(self) -> None:
return None
class StubExecutor:
def execute(self, params: dict) -> tuple[bool, str, dict, dict]:
return True, "200 OK", {"status_code": 200, "latency_ms": 12}, {"response_body": '{"status":"UP"}'}
def build_settings() -> Settings:
return Settings(
backend_base_url="http://127.0.0.1:8000",
edge_id="edge-shanghai-001",
edge_name="edge-agent-demo",
edge_hostname="customer-host-01",
edge_os_type="WINDOWS",
edge_agent_version="0.1.0",
edge_access_token=None,
poll_interval_ms=1000,
heartbeat_interval_ms=1000,
request_timeout_ms=5000,
default_health_check_timeout_ms=3000,
)
def test_polling_runner_reports_unsupported_tool() -> None:
runner = PollingRunner(build_settings())
runner.backend_client = StubBackendClient()
runner.backend_client._tasks = [
{
"task_id": "task-001",
"step_id": "step-001",
"tool_name": "unknown_tool",
"params": {},
"expire_at": "2026-04-09 10:00:00.000",
}
]
runner.run_once()
assert len(runner.backend_client.event_reports) == 1
assert runner.backend_client.event_reports[0]["event_type"] == "UNSUPPORTED_TOOL"
assert len(runner.backend_client.task_reports) == 1
assert runner.backend_client.task_reports[0]["success"] is False
assert runner.backend_client.task_reports[0]["code"] == "UNSUPPORTED_TOOL"
def test_polling_runner_executes_registered_tool() -> None:
runner = PollingRunner(build_settings())
runner.backend_client = StubBackendClient()
runner.registry._executors["http_health_check"] = StubExecutor()
runner.backend_client._tasks = [
{
"task_id": "task-002",
"step_id": "step-002",
"tool_name": "http_health_check",
"params": {"url": "http://service.test/health"},
"expire_at": "2026-04-09 10:00:00.000",
}
]
runner.run_once()
assert len(runner.backend_client.task_reports) == 1
report = runner.backend_client.task_reports[0]
assert report["success"] is True
assert report["code"] == "OK"
assert report["data"]["status_code"] == 200
assert report["evidence"]["response_body"] == '{"status":"UP"}'

View File

@ -0,0 +1,39 @@
from __future__ import annotations
import socket
import threading
from app.executors.port_executor import PortCheckExecutor
def test_port_check_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 = PortCheckExecutor().execute({"host": host, "port": port, "timeout_ms": 1000})
thread.join(timeout=1)
assert success is True
assert "connected" in message
assert data["connected"] is True
assert data["port"] == port
assert evidence == {}
def test_port_check_executor_failure() -> None:
success, message, data, evidence = PortCheckExecutor().execute({"host": "127.0.0.1", "port": 9, "timeout_ms": 100})
assert success is False
assert data["connected"] is False
assert data["port"] == 9
assert isinstance(message, str)
assert evidence == {}

View File

@ -0,0 +1,42 @@
from __future__ import annotations
from unittest.mock import patch
from app.executors.process_executor import ProcessCheckExecutor
class DummyCompletedProcess:
def __init__(self, stdout: str) -> None:
self.stdout = stdout
def test_process_check_executor_windows_match() -> None:
with (
patch("app.executors.process_executor.platform.system", return_value="Windows"),
patch(
"app.executors.process_executor.subprocess.run",
return_value=DummyCompletedProcess('"python.exe","1234","Console","1","10,000 K"\n'),
),
):
success, message, data, evidence = ProcessCheckExecutor().execute({"process_name": "python"})
assert success is True
assert message == "process found"
assert data["matched_count"] == 1
assert evidence["matches"][0]["pid"] == 1234
def test_process_check_executor_unix_pid_miss() -> None:
with (
patch("app.executors.process_executor.platform.system", return_value="Linux"),
patch(
"app.executors.process_executor.subprocess.run",
return_value=DummyCompletedProcess("1234 python python app.py\n"),
),
):
success, message, data, evidence = ProcessCheckExecutor().execute({"pid": 9999})
assert success is False
assert message == "process not found"
assert data["matched_count"] == 0
assert evidence["matches"] == []

View File

@ -1,12 +1,12 @@
# 智能化部署 Agent 当前进度总结 # 智能化部署 Agent 当前进度总结
更新时间:2026-04-08 更新时间:2026-04-09
## 1. 当前总体状态 ## 1. 当前总体状态
当前阶段已完成从"需求方案"到"技术架构"再到"接口定义"和"demo 后端骨架"的文档化收敛,整体处于: 当前阶段已完成从"需求方案"到"技术架构"再到"接口定义"和"demo 后端骨架"的文档化收敛,整体处于:
**方案已成型、文档体系已建立、技术路线已基本明确、demo 后端代码骨架已开始实现** **方案已成型、文档体系已建立、技术路线已基本明确、demo 后端主链路已可运行**
当前产出重点已经从纯文档设计切换为: 当前产出重点已经从纯文档设计切换为:
@ -38,6 +38,8 @@
7. `智能化部署agent-技术架构设计说明书.backup-20260408-141109.md` 7. `智能化部署agent-技术架构设计说明书.backup-20260408-141109.md`
为技术架构说明书备份文件。 为技术架构说明书备份文件。
8. `edge-agent/README.md``edge-agent/app/*`
用于沉淀本地 edge-agent 初始化代码骨架与运行说明。
--- ---
@ -136,6 +138,12 @@ demo 接口定义文档已覆盖:
15. 已补上首轮失败分支细化,包括 software-a 最小能力实现执行失败、审批驳回、edge 验证失败三条主失败路径。 15. 已补上首轮失败分支细化,包括 software-a 最小能力实现执行失败、审批驳回、edge 验证失败三条主失败路径。
16. 已完成 `duration_ms` 第一轮落地,`tool_call` 和 edge 验证轨迹可基于 `started_at` / `finished_at` 自动计算并返回时长。 16. 已完成 `duration_ms` 第一轮落地,`tool_call` 和 edge 验证轨迹可基于 `started_at` / `finished_at` 自动计算并返回时长。
17. 已完成结果摘要第一轮结构化改造,任务详情和任务报告可返回 `result_summary_detail`,包含最终状态、失败原因、software-a 摘要、审批摘要和验证摘要。 17. 已完成结果摘要第一轮结构化改造,任务详情和任务报告可返回 `result_summary_detail`,包含最终状态、失败原因、software-a 摘要、审批摘要和验证摘要。
18. 已补充任务报告级聚合指标 `task_metrics`,可返回总耗时、确认等待耗时、审批耗时、执行耗时、工具耗时汇总、验证耗时汇总及相关计数。
19. 已补充失败路径与幂等性测试,覆盖创建任务幂等、重复审批决策冲突、错误 edge 回传冲突、重复取消冲突等场景。
20. 已创建本地 `edge-agent` 初始化骨架,包含配置加载、后端客户端、工具注册、`http_health_check` 执行器、轮询调度器与启动入口。
21. 已补充 `edge-agent` 启动脚本与便携打包脚本,覆盖 Windows `zip` 与 Linux `tar.gz` 两类交付方向。
22. 已补充 `edge-agent` 基础测试,覆盖 `http_health_check` 执行器和轮询调度器主路径。
23. 已补充 `edge-agent` 基础执行器实现,新增 `check_port``check_process``grep_log` 三类能力并接入工具注册表。
### 3.8 当前代码可运行范围 ### 3.8 当前代码可运行范围
@ -153,15 +161,22 @@ demo 接口定义文档已覆盖:
7. edge 侧已支持: 7. edge 侧已支持:
心跳、拉取任务、回传结果、上报异常事件。 心跳、拉取任务、回传结果、上报异常事件。
8. 执行指标当前已支持: 8. 执行指标当前已支持:
`tool_trace.duration_ms``verification_trace.duration_ms` `tool_trace.duration_ms``verification_trace.duration_ms``task_metrics`
9. 结果摘要当前已支持: 9. 结果摘要当前已支持:
`result_summary_detail.final_status``final_reason``software_a``approval``verification` `result_summary_detail.final_status``final_reason``software_a``approval``verification`
10. 本地 `edge-agent` 当前已具备最小启动骨架:
心跳、拉取任务、执行 `http_health_check`、回传结果、上报异常。
11. 本地 `edge-agent` 当前已具备:
启动脚本、打包脚本、基础执行器测试和轮询调度测试。
12. 本地 `edge-agent` 当前已具备已注册工具:
`http_health_check``check_port``check_process``grep_log`
当前测试基线: 当前测试基线:
1. 共 14 条测试通过。 1. 共 20 条测试通过。
2. 使用 `sqlite:///:memory:` 做回归验证。 2. 使用 `sqlite:///:memory:` 做回归验证。
3. 当前主链路已不是“只有接口壳”,而是具备最小闭环行为。 3. 当前主链路已不是“只有接口壳”,而是具备最小闭环行为。
4. `edge-agent` 侧基础测试共 10 条通过。
--- ---
@ -265,12 +280,12 @@ demo 接口定义文档已覆盖:
当前还未收口,或仅实现了最小版本的工作包括: 当前还未收口,或仅实现了最小版本的工作包括:
1. 本地 `edge-agent` 初始化代码与打包脚本。 1. 本地 `edge-agent` 初始化代码与打包脚本已完成第一轮,但尚未接入私有 Python 运行时和真正的便携发布流程
2. 文件型 SQLite / PostgreSQL 实库运行验证。 2. 文件型 SQLite / PostgreSQL 实库运行验证。
3. 身份 demo / 审批 demo 与任务主链路的权限、审批决策联动细化。 3. 身份 demo / 审批 demo 与任务主链路的权限、审批决策联动细化。
4. 任务级聚合指标仍未完成,如总耗时、审批耗时、等待耗时 4. 任务级聚合指标已完成第一轮,但更细的任务级指标拆分仍可继续增强,如等待时长细分、失败步骤占比、阶段级统计
5. 更真实的验证插件实现。 5. 更真实的验证插件实现,尤其是服务控制、日志时间范围过滤、进程指标扩展
6. 部署脚本和运行脚本完善。 6. 部署脚本和运行脚本进一步完善,包括私有运行时打包
7. OpenAPI 扩展到第二批接口。 7. OpenAPI 扩展到第二批接口。
8. 更多测试用例与联调脚本。 8. 更多测试用例与联调脚本。
@ -291,17 +306,17 @@ demo 接口定义文档已覆盖:
当前不是继续补基础文档,而是继续补强现有可运行链路。优先级建议收敛为: 当前不是继续补基础文档,而是继续补强现有可运行链路。优先级建议收敛为:
1. 增补失败路径与幂等性测试: 1. 增补失败路径与幂等性测试:
重点补重复请求、重复回传、异常回滚等场景。 已完成一轮,后续可继续补回滚和更细冲突场景。
2. 继续丰富审计细节与任务级聚合指标: 2. 继续丰富审计细节与任务级指标拆分:
让任务级总耗时、审批耗时、等待耗时可直观看到 让任务级总耗时、审批耗时、等待耗时、阶段时长边界更直观
3. 再补更多执行指标: 3. 再补更多执行指标:
任务级聚合耗时、审批耗时、等待耗时 失败步骤占比、阶段级耗时拆分、任务级成功率统计
4. 然后再继续: 4. 然后再继续:
本地 `edge-agent` 骨架、第二批 OpenAPI、更多联调能力。 本地 `edge-agent` 执行器增强、第二批 OpenAPI、更多联调能力。
当前状态: 当前状态:
**SQLite / 去 Redis / 最小 DDL / 首批 OpenAPI / FastAPI 骨架 / 三条主接口 / demo adapter / edge 接口,均已完成第一轮落地。** **SQLite / 去 Redis / 最小 DDL / 首批 OpenAPI / FastAPI 骨架 / 主接口 / demo adapter / edge 接口 / 第一轮任务级聚合指标 / 第一轮失败与幂等性测试 / edge-agent 初始化骨架 / edge-agent 启动与打包脚本 / edge-agent 基础测试,均已完成第一轮落地。**
--- ---
@ -335,10 +350,10 @@ demo 接口定义文档已覆盖:
下一步推荐顺序: 下一步推荐顺序:
1. 再补失败路径和幂等性测试 1. 再补更细的任务级指标拆分
2. 再补任务级执行指标 2. 再补审计细节和聚合摘要
3. 再补审计细节和聚合摘要 3. 继续补本地 Agent 执行器与真正的便携运行时打包
4. 再补本地 Agent 初始化代码或第二批 OpenAPI。 4. 再补第二批 OpenAPI。
### 7.2 如果上下文快满,有什么影响 ### 7.2 如果上下文快满,有什么影响