9c9a51263c
汇总未提交工作区:Servers 列表 site_url 链接、终端页与选机器 UI、SSH 连通性与 Telegram 通知、agent/offline/unset_path 后台调度与 gate_ai_review 门控;更新 AGENTS 与 agent-exploration 规则(仅本机 commit);同步 web/app 构建产物与相关文档。
126 lines
3.8 KiB
Python
126 lines
3.8 KiB
Python
"""Long-running script jobs — Redis registration and nohup wrapper with completion callback."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import secrets
|
||
import shlex
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Optional, Tuple
|
||
|
||
from server.config import settings
|
||
from server.infrastructure.redis.client import get_redis
|
||
|
||
logger = logging.getLogger("nexus.script_jobs")
|
||
|
||
REDIS_JOB_PREFIX = "script_job:"
|
||
REDIS_JOB_TTL = 604800 # 7 days
|
||
|
||
|
||
def master_callback_url() -> str:
|
||
"""Public Nexus URL for Agent curl callbacks."""
|
||
base = (settings.API_BASE_URL or "").strip().rstrip("/")
|
||
if not base:
|
||
raise ValueError(
|
||
"NEXUS_API_BASE_URL 未配置,无法注册长任务回调;请在 .env 或安装向导中设置主站对外 URL"
|
||
)
|
||
lower = base.lower()
|
||
if lower.startswith("http://"):
|
||
host = lower[7:].split("/")[0].split(":")[0]
|
||
if host not in ("localhost", "127.0.0.1", "::1"):
|
||
raise ValueError(
|
||
"生产环境 NEXUS_API_BASE_URL 必须使用 HTTPS;"
|
||
"本地开发可使用 http://localhost"
|
||
)
|
||
return f"{base}/api/agent/script-callback"
|
||
|
||
|
||
def build_long_task_command(
|
||
user_command: str,
|
||
job_id: str,
|
||
server_id: int,
|
||
secret: str,
|
||
callback_url: str,
|
||
api_key: str,
|
||
) -> str:
|
||
"""Wrap user script in nohup; on exit POST completion to Nexus."""
|
||
safe_script = user_command.replace("'", "'\\''")
|
||
# secret/job_id are url-safe tokens from secrets.token_urlsafe
|
||
curl_payload = (
|
||
f'{{"job_id":"{job_id}","server_id":{server_id},'
|
||
f'"secret":"{secret}","exit_code":'"'"'$ec'"'"',"message":"done"}}'
|
||
)
|
||
quoted_key = shlex.quote(api_key)
|
||
inner = (
|
||
f"{safe_script}\n"
|
||
f"ec=$?\n"
|
||
f"curl -fsS -X POST '{callback_url}' "
|
||
f"-H 'Content-Type: application/json' "
|
||
f"-H 'X-API-Key: {quoted_key}' "
|
||
f"-d '{curl_payload}' "
|
||
f"|| true\n"
|
||
f"exit $ec"
|
||
)
|
||
return (
|
||
"mkdir -p /var/log/nexus-job && "
|
||
'LOG=/var/log/nexus-job/job-$(date +%Y%m%d-%H%M%S).log && '
|
||
f"nohup bash -lc '{inner}' >> \"$LOG\" 2>&1 & "
|
||
'echo $! > "${LOG}.pid" && '
|
||
f'echo "started job_id={job_id} pid=$! log=$LOG"'
|
||
)
|
||
|
||
|
||
def sanitize_long_task_command_for_audit(wrapped: str, secret: str) -> str:
|
||
"""Redact job secret before persisting execution.command."""
|
||
return wrapped.replace(secret, "***")
|
||
|
||
|
||
async def register_script_job(execution_id: int, server_id: int) -> Tuple[str, str]:
|
||
"""Create a pending job; returns (job_id, secret)."""
|
||
job_id = secrets.token_urlsafe(16)
|
||
secret = secrets.token_urlsafe(32)
|
||
payload = {
|
||
"execution_id": execution_id,
|
||
"server_id": server_id,
|
||
"secret": secret,
|
||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
redis = get_redis()
|
||
await redis.set(
|
||
f"{REDIS_JOB_PREFIX}{job_id}",
|
||
json.dumps(payload),
|
||
ex=REDIS_JOB_TTL,
|
||
)
|
||
return job_id, secret
|
||
|
||
|
||
async def revoke_script_job(job_id: str) -> None:
|
||
"""Remove pending callback registration (e.g. user cancelled the job)."""
|
||
if not job_id:
|
||
return
|
||
redis = get_redis()
|
||
await redis.delete(f"{REDIS_JOB_PREFIX}{job_id}")
|
||
|
||
|
||
async def consume_script_job(
|
||
job_id: str, server_id: int, secret: str
|
||
) -> Optional[dict[str, Any]]:
|
||
"""Validate callback credentials and remove job (one-time use)."""
|
||
redis = get_redis()
|
||
key = f"{REDIS_JOB_PREFIX}{job_id}"
|
||
raw = await redis.get(key)
|
||
if not raw:
|
||
return None
|
||
try:
|
||
data = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
await redis.delete(key)
|
||
return None
|
||
if data.get("server_id") != server_id:
|
||
return None
|
||
if not secrets.compare_digest(data.get("secret", ""), secret):
|
||
return None
|
||
await redis.delete(key)
|
||
return data
|