chore(repo): initialize team collaboration repository
CI / Python 3.12 (push) Waiting to run
CI / Python 3.9 (push) Waiting to run

This commit is contained in:
2026-07-27 20:40:12 +08:00
commit c91a64fddb
109 changed files with 21121 additions and 0 deletions
@@ -0,0 +1 @@
"""FastAPI 路由。"""
@@ -0,0 +1,276 @@
"""拉模式 Host Agent 的注册、认证与心跳 API。"""
from __future__ import annotations
import asyncio
import hmac
import secrets
import time
import uuid
from typing import Dict, List, Optional
from fastapi import APIRouter, Header, HTTPException, Response, status
from sqlalchemy import select
from ..config import get_settings
from ..db import session_scope
from ..enums import HostStatus
from ..models import TestHost, iso_z, utcnow
from ..schemas import (
AgentHeartbeatBody,
AgentLeaseBody,
AgentRegisterBody,
AgentStepAttemptBody,
AgentStepCompleteBody,
AgentStepEventsBody,
)
from ..services.agent_status import refresh_agent_host
from ..services.step_leases import (
StepLeaseConflict,
acknowledge_step,
complete_step,
record_step_events,
renew_step,
try_lease_next_step,
)
from .deps import agent_token_digest, require_agent
router = APIRouter(prefix="/api/v1/agent", tags=["host-agent"])
def _idempotency_key(value: str) -> str:
if len(value) > 60:
raise HTTPException(status_code=422, detail="Idempotency-Key 过长")
try:
uuid.UUID(value)
except (ValueError, AttributeError) as exc:
raise HTTPException(
status_code=422, detail="Idempotency-Key 必须是 UUID"
) from exc
return value
def _lease_conflict(exc: StepLeaseConflict) -> HTTPException:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
def _check_enrollment_token(provided: Optional[str]) -> None:
settings = get_settings()
required = settings.agent_enrollment_token
if settings.env == "production" and not required:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="生产环境尚未配置 Agent 注册口令",
)
if required and not hmac.compare_digest(provided or "", required):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent 注册口令无效",
)
async def _resolve_host(body: AgentRegisterBody) -> Dict[str, object]:
async with session_scope() as session:
host: Optional[TestHost] = None
if body.host_id:
host = await session.get(TestHost, body.host_id)
if host is None:
raise HTTPException(status_code=404, detail="Test Host 不存在")
elif body.host_name:
host = await session.scalar(
select(TestHost).where(TestHost.name == body.host_name)
)
else:
host = await session.scalar(select(TestHost).order_by(TestHost.created_at))
if host is None:
host = TestHost(name=body.host_name or "agent-host-" + uuid.uuid4().hex[:8])
session.add(host)
await session.flush()
raw_token = secrets.token_urlsafe(32)
host.agent_id = host.agent_id or "agent_" + uuid.uuid4().hex[:20]
host.agent_token = agent_token_digest(raw_token)
host.agent_version = body.agent_version
host.os_family = body.os_family
if body.os_version is not None:
host.os_version = body.os_version
if body.kernel is not None:
host.kernel = body.kernel
if body.cpu is not None:
host.cpu = body.cpu
if body.motherboard is not None:
host.motherboard = body.motherboard
if body.bios_version is not None:
host.bios_version = body.bios_version
if body.ip_address is not None:
host.ip_address = body.ip_address
if body.toolchain:
host.toolchain = body.toolchain
if body.labels:
host.labels = {**(host.labels or {}), **body.labels}
host.status = HostStatus.ONLINE.value
host.last_heartbeat_at = utcnow()
payload = {
"agent_id": host.agent_id,
"host_id": host.id,
"token": raw_token,
"heartbeat_interval_s": get_settings().timing.heartbeat_interval_s,
"server_time": iso_z(host.last_heartbeat_at),
}
return payload
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register_agent(
body: AgentRegisterBody,
enrollment_token: Optional[str] = Header(
default=None, alias="X-FlashOps-Enrollment-Token"
),
) -> Dict[str, object]:
_check_enrollment_token(enrollment_token)
return await _resolve_host(body)
@router.post("/{agent_id}/heartbeat")
async def heartbeat(
agent_id: str,
body: AgentHeartbeatBody,
authorization: Optional[str] = Header(default=None),
) -> Dict[str, object]:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
host.status = (
HostStatus.ONLINE.value if body.healthy else HostStatus.DEGRADED.value
)
host.last_heartbeat_at = utcnow()
if body.agent_version:
host.agent_version = body.agent_version
if body.ip_address:
host.ip_address = body.ip_address
if body.toolchain:
host.toolchain = body.toolchain
if body.device_probe:
host.labels = {
**(host.labels or {}),
"agent_probe": body.device_probe,
}
return {
"ok": True,
"server_time": iso_z(host.last_heartbeat_at),
"heartbeat_interval_s": get_settings().timing.heartbeat_interval_s,
"commands": [],
}
@router.post("/{agent_id}/lease")
async def lease_step(
agent_id: str,
body: AgentLeaseBody,
authorization: Optional[str] = Header(default=None),
) -> object:
deadline = time.monotonic() + min(
body.wait_timeout_s, get_settings().timing.lease_poll_timeout_s
)
while True:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
lease = await try_lease_next_step(session, host)
if lease is not None:
return lease
remaining = deadline - time.monotonic()
if remaining <= 0:
return Response(status_code=status.HTTP_204_NO_CONTENT)
await asyncio.sleep(min(get_settings().engine_tick_s, remaining))
@router.post("/{agent_id}/steps/{step_id}/ack")
async def ack_step(
agent_id: str,
step_id: str,
body: AgentStepAttemptBody,
authorization: Optional[str] = Header(default=None),
idempotency_key: str = Header(alias="Idempotency-Key"),
) -> Dict[str, object]:
key = _idempotency_key(idempotency_key)
try:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
return await acknowledge_step(session, host, step_id, body.attempt, key)
except StepLeaseConflict as exc:
raise _lease_conflict(exc) from exc
@router.post("/{agent_id}/steps/{step_id}/renew")
async def renew_step_lease(
agent_id: str,
step_id: str,
body: AgentStepAttemptBody,
authorization: Optional[str] = Header(default=None),
idempotency_key: str = Header(alias="Idempotency-Key"),
) -> Dict[str, object]:
key = _idempotency_key(idempotency_key)
try:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
return await renew_step(session, host, step_id, body.attempt, key)
except StepLeaseConflict as exc:
raise _lease_conflict(exc) from exc
@router.post("/{agent_id}/steps/{step_id}/events")
async def step_events(
agent_id: str,
step_id: str,
body: AgentStepEventsBody,
authorization: Optional[str] = Header(default=None),
idempotency_key: str = Header(alias="Idempotency-Key"),
) -> Dict[str, object]:
key = _idempotency_key(idempotency_key)
try:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
return await record_step_events(
session, host, step_id, body.attempt, body.events, key
)
except StepLeaseConflict as exc:
raise _lease_conflict(exc) from exc
@router.post("/{agent_id}/steps/{step_id}/complete")
async def finish_step(
agent_id: str,
step_id: str,
body: AgentStepCompleteBody,
authorization: Optional[str] = Header(default=None),
idempotency_key: str = Header(alias="Idempotency-Key"),
) -> Dict[str, object]:
key = _idempotency_key(idempotency_key)
try:
async with session_scope() as session:
host = await require_agent(session, agent_id, authorization)
return await complete_step(session, host, step_id, body, key)
except StepLeaseConflict as exc:
raise _lease_conflict(exc) from exc
@router.get("/hosts")
async def list_agent_hosts() -> List[Dict[str, object]]:
async with session_scope() as session:
hosts = (
await session.scalars(select(TestHost).order_by(TestHost.name))
).all()
for host in hosts:
refresh_agent_host(host)
return [
{
"host_id": host.id,
"host_name": host.name,
"agent_id": host.agent_id,
"agent_version": host.agent_version,
"status": host.status,
"last_heartbeat_at": iso_z(host.last_heartbeat_at),
"ip_address": host.ip_address,
"toolchain": host.toolchain,
}
for host in hosts
]
@@ -0,0 +1,47 @@
"""HTTP dependencies shared by Agent endpoints."""
from __future__ import annotations
import hashlib
import hmac
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import TestHost
def agent_token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def bearer_token(authorization: Optional[str]) -> str:
scheme, separator, value = (authorization or "").partition(" ")
if not separator or scheme.lower() != "bearer" or not value.strip():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent bearer token 缺失",
headers={"WWW-Authenticate": "Bearer"},
)
return value.strip()
async def require_agent(
session: AsyncSession,
agent_id: str,
authorization: Optional[str],
) -> TestHost:
host = await session.scalar(select(TestHost).where(TestHost.agent_id == agent_id))
token = bearer_token(authorization)
if (
host is None
or not host.agent_token
or not hmac.compare_digest(host.agent_token, agent_token_digest(token))
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Agent 身份无效",
headers={"WWW-Authenticate": "Bearer"},
)
return host
@@ -0,0 +1,258 @@
"""控制台所需的最小纵向 API。"""
from __future__ import annotations
from typing import Dict, List
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import func, select
from ..db import session_scope
from ..enums import EventKind, EventSource, RunState, Severity
from ..events import append_event
from ..models import EvidenceBundle, Run, RunEvent, ResourceLock, utcnow
from ..schemas import CreateRunBody, HumanActionBody, PreflightBody
from ..services.runs import (
cancel_simulation,
create_run,
event_to_dict,
prepare_agent_run,
preflight_for_body,
run_to_dict,
start_simulation,
)
router = APIRouter(prefix="/api/v1")
@router.get("/health")
async def health() -> Dict[str, str]:
return {"status": "ok", "service": "flashops-control-plane"}
@router.post("/preflight")
async def preflight(body: PreflightBody) -> Dict[str, object]:
try:
async with session_scope() as session:
return await preflight_for_body(session, body)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/runs", status_code=status.HTTP_201_CREATED)
async def create_run_route(body: CreateRunBody) -> Dict[str, object]:
try:
async with session_scope() as session:
run = await create_run(session, body)
if body.execution_mode == "agent_dry_run":
await prepare_agent_run(session, run)
payload = run_to_dict(run)
if body.execution_mode == "simulated":
start_simulation(run.id)
return payload
except LookupError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/runs/demo", status_code=status.HTTP_201_CREATED)
async def create_demo_run() -> Dict[str, object]:
return await create_run_route(CreateRunBody())
@router.get("/runs")
async def list_runs(limit: int = 30) -> List[Dict[str, object]]:
async with session_scope() as session:
runs = (
await session.scalars(
select(Run).order_by(Run.created_at.desc()).limit(min(max(limit, 1), 100))
)
).all()
return [run_to_dict(run) for run in runs]
@router.get("/runs/{run_id}")
async def get_run(run_id: str) -> Dict[str, object]:
async with session_scope() as session:
run = await session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run 不存在")
payload = run_to_dict(run)
bundle = await session.scalar(
select(EvidenceBundle)
.where(EvidenceBundle.run_id == run_id)
.order_by(EvidenceBundle.created_at.desc())
)
payload["evidence"] = (
{
"id": bundle.id,
"uri": bundle.uri,
"completeness": bundle.completeness,
"size_bytes": bundle.size_bytes,
}
if bundle
else None
)
return payload
@router.get("/runs/{run_id}/events")
async def get_events(run_id: str, after_seq: int = 0) -> List[Dict[str, object]]:
async with session_scope() as session:
exists = await session.get(Run, run_id)
if exists is None:
raise HTTPException(status_code=404, detail="Run 不存在")
events = (
await session.scalars(
select(RunEvent)
.where(RunEvent.run_id == run_id, RunEvent.seq > after_seq)
.order_by(RunEvent.seq)
)
).all()
return [event_to_dict(event) for event in events]
@router.post("/runs/{run_id}/pause")
async def pause_run(run_id: str, body: HumanActionBody) -> Dict[str, object]:
async with session_scope() as session:
run = await session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run 不存在")
if run.state != RunState.RUNNING.value:
raise HTTPException(status_code=409, detail="只有 RUNNING 任务可以暂停")
await append_event(
session,
run,
EventKind.HUMAN_TOUCH.value,
"{0}: {1}".format(body.actor, body.reason),
source=EventSource.HUMAN.value,
projection={
"human_touches": run.human_touches + 1,
"unattended_completion": False,
},
)
await append_event(
session,
run,
EventKind.RUN_PAUSED.value,
"任务已在安全检查点暂停",
source=EventSource.HUMAN.value,
target_state=RunState.PAUSED.value,
)
return run_to_dict(run)
@router.post("/runs/{run_id}/resume")
async def resume_run(run_id: str, body: HumanActionBody) -> Dict[str, object]:
async with session_scope() as session:
run = await session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run 不存在")
if run.state != RunState.PAUSED.value:
raise HTTPException(status_code=409, detail="只有 PAUSED 任务可以继续")
await append_event(
session,
run,
EventKind.HUMAN_TOUCH.value,
"{0}: {1}".format(body.actor, body.reason),
source=EventSource.HUMAN.value,
projection={
"human_touches": run.human_touches + 1,
"unattended_completion": False,
},
)
await append_event(
session,
run,
EventKind.RUN_RESUMED.value,
"人工确认后从检查点继续",
source=EventSource.HUMAN.value,
target_state=RunState.RUNNING.value,
)
return run_to_dict(run)
@router.post("/runs/{run_id}/emergency-stop")
async def emergency_stop(run_id: str, body: HumanActionBody) -> Dict[str, object]:
async with session_scope() as session:
run = await session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run 不存在")
if run.state in {
RunState.COMPLETED.value,
RunState.ABORTED.value,
RunState.FROZEN.value,
RunState.REJECTED.value,
}:
raise HTTPException(status_code=409, detail="终态任务不能再次停止")
await append_event(
session,
run,
EventKind.HUMAN_TOUCH.value,
"{0}: {1}".format(body.actor, body.reason),
source=EventSource.HUMAN.value,
severity=Severity.CRITICAL.value,
projection={
"human_touches": run.human_touches + 1,
"unattended_completion": False,
},
)
await append_event(
session,
run,
EventKind.RUN_ABORTED.value,
"紧急停止已执行,现场与检查点已保留",
source=EventSource.HUMAN.value,
severity=Severity.CRITICAL.value,
target_state=RunState.ABORTED.value,
projection={"ended_at": utcnow()},
)
await session.execute(
ResourceLock.__table__.delete().where(ResourceLock.run_id == run.id)
)
payload = run_to_dict(run)
cancel_simulation(run_id)
return payload
@router.get("/dashboard/summary")
async def dashboard_summary() -> Dict[str, object]:
async with session_scope() as session:
total = await session.scalar(select(func.count()).select_from(Run)) or 0
running = (
await session.scalar(
select(func.count()).select_from(Run).where(
Run.state.in_(
[
RunState.QUEUED.value,
RunState.PREFLIGHT.value,
RunState.RUNNING.value,
RunState.RECOVERING.value,
]
)
)
)
or 0
)
completed = (
await session.scalar(
select(func.count()).select_from(Run).where(
Run.state == RunState.COMPLETED.value
)
)
or 0
)
unattended = (
await session.scalar(
select(func.count()).select_from(Run).where(
Run.state == RunState.COMPLETED.value,
Run.unattended_completion.is_(True),
)
)
or 0
)
ucr = round(unattended / completed * 100, 1) if completed else 0.0
return {
"total_runs": total,
"active_runs": running,
"completed_runs": completed,
"unattended_completion_rate": ucr,
}