- 添加 Dockerfile、docker-compose.yml 和 Jenkins 流水线配置 - 新增 MCP 原生集成模块 (mcp_native.py) - 移除旧的 mcp_bridge.py,更新依赖和文档 - 添加部署文档 (Docker 和服务器部署指南) Co-authored-by: Cursor <cursoragent@cursor.com>
3522 lines
130 KiB
Python
3522 lines
130 KiB
Python
import asyncio
|
||
import contextlib
|
||
import json
|
||
import os
|
||
import hashlib
|
||
import hmac
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
import secrets
|
||
import subprocess
|
||
import tempfile
|
||
import time
|
||
import uuid
|
||
import threading
|
||
from collections.abc import Callable
|
||
from typing import Any
|
||
|
||
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import FileResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from sqlalchemy import func, text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from .database import Base, SessionLocal, engine, get_db
|
||
from .models import (
|
||
ApiDefinition,
|
||
FolderEntry,
|
||
McpToolConfig,
|
||
MockDataset,
|
||
SshProfile,
|
||
SshScript,
|
||
SshShortcut,
|
||
User,
|
||
UserApiKey,
|
||
Workflow,
|
||
WorkflowBatch,
|
||
WorkflowRun,
|
||
)
|
||
from .schemas import (
|
||
ApiCreate,
|
||
ApiOut,
|
||
ApiUpdate,
|
||
CurrentUserOut,
|
||
LoginRequest,
|
||
LoginResponse,
|
||
McpToolCreate,
|
||
McpToolOut,
|
||
FolderMoveRequest,
|
||
McpInvokeBatchRequest,
|
||
McpInvokeRequest,
|
||
McpInvokeResponse,
|
||
MockDataCreate,
|
||
MockDataOut,
|
||
LokiLogLinkOut,
|
||
ReplayRunRequest,
|
||
RunNodeRequest,
|
||
RunNodeResponse,
|
||
RunWorkflowBatchRequest,
|
||
RunWorkflowBatchResponse,
|
||
RunWorkflowResponse,
|
||
WorkflowBatchCreate,
|
||
WorkflowBatchUpdate,
|
||
WorkflowBatchOut,
|
||
WorkflowBatchRunItem,
|
||
SshExecuteRequest,
|
||
SshExecuteResponse,
|
||
SshProfileCreate,
|
||
SshProfileOut,
|
||
SshProfileUpdate,
|
||
SshScriptCreate,
|
||
SshScriptOut,
|
||
SshScriptUpdate,
|
||
SshTreeProfileNode,
|
||
SshTreeScriptNode,
|
||
SshShortcutCreate,
|
||
SshShortcutOut,
|
||
SshShortcutUpdate,
|
||
RunWorkflowRequest,
|
||
UserApiKeyCreate,
|
||
UserApiKeyCreateResponse,
|
||
UserApiKeyOut,
|
||
UserCreate,
|
||
UserOut,
|
||
UserUpdate,
|
||
WorkflowCreate,
|
||
WorkflowOut,
|
||
WorkflowUpdate,
|
||
)
|
||
from .services.engine import execute_single_node, execute_workflow
|
||
from .services.loki import build_run_loki_link
|
||
from .services.ssh_runner import SSH_SCRIPTS_DIR, resolve_script_body, run_script_collect, stream_script_run
|
||
|
||
Base.metadata.create_all(bind=engine)
|
||
|
||
|
||
def _ensure_sqlite_columns():
|
||
with engine.begin() as conn:
|
||
user_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(users)"))}
|
||
if user_cols and "display_name" not in user_cols:
|
||
conn.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR(64) NOT NULL DEFAULT ''"))
|
||
if user_cols and "role" not in user_cols:
|
||
conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR(32) NOT NULL DEFAULT 'user'"))
|
||
if user_cols and "is_active" not in user_cols:
|
||
conn.execute(text("ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT 1"))
|
||
|
||
api_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(api_definitions)"))}
|
||
if "folder_path" not in api_cols:
|
||
conn.execute(text("ALTER TABLE api_definitions ADD COLUMN folder_path VARCHAR(255) NOT NULL DEFAULT ''"))
|
||
if "creator_id" not in api_cols:
|
||
conn.execute(text("ALTER TABLE api_definitions ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1"))
|
||
if "creator_name" not in api_cols:
|
||
conn.execute(text("ALTER TABLE api_definitions ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'"))
|
||
|
||
workflow_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(workflows)"))}
|
||
if "folder_path" not in workflow_cols:
|
||
conn.execute(text("ALTER TABLE workflows ADD COLUMN folder_path VARCHAR(255) NOT NULL DEFAULT ''"))
|
||
if "default_headers_json" not in workflow_cols:
|
||
conn.execute(text("ALTER TABLE workflows ADD COLUMN default_headers_json TEXT NOT NULL DEFAULT '{}'"))
|
||
if "creator_id" not in workflow_cols:
|
||
conn.execute(text("ALTER TABLE workflows ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1"))
|
||
if "creator_name" not in workflow_cols:
|
||
conn.execute(text("ALTER TABLE workflows ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'"))
|
||
|
||
mock_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(mock_datasets)"))}
|
||
if "creator_id" not in mock_cols:
|
||
conn.execute(text("ALTER TABLE mock_datasets ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1"))
|
||
if "creator_name" not in mock_cols:
|
||
conn.execute(text("ALTER TABLE mock_datasets ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'"))
|
||
|
||
mcp_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(mcp_tool_configs)"))}
|
||
if "creator_id" not in mcp_cols:
|
||
conn.execute(text("ALTER TABLE mcp_tool_configs ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1"))
|
||
if "creator_name" not in mcp_cols:
|
||
conn.execute(text("ALTER TABLE mcp_tool_configs ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'"))
|
||
|
||
folder_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(folder_entries)"))}
|
||
if "creator_id" not in folder_cols:
|
||
conn.execute(text("ALTER TABLE folder_entries ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1"))
|
||
if "creator_name" not in folder_cols:
|
||
conn.execute(text("ALTER TABLE folder_entries ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'"))
|
||
|
||
ssh_shortcut_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(ssh_shortcuts)"))}
|
||
if ssh_shortcut_cols:
|
||
if "description" not in ssh_shortcut_cols:
|
||
conn.execute(text("ALTER TABLE ssh_shortcuts ADD COLUMN description VARCHAR(255) NOT NULL DEFAULT ''"))
|
||
if "creator_id" not in ssh_shortcut_cols:
|
||
conn.execute(text("ALTER TABLE ssh_shortcuts ADD COLUMN creator_id INTEGER NOT NULL DEFAULT 1"))
|
||
if "creator_name" not in ssh_shortcut_cols:
|
||
conn.execute(text("ALTER TABLE ssh_shortcuts ADD COLUMN creator_name VARCHAR(64) NOT NULL DEFAULT 'admin'"))
|
||
|
||
run_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(workflow_runs)"))}
|
||
if run_cols and "batch_id" not in run_cols:
|
||
conn.execute(text("ALTER TABLE workflow_runs ADD COLUMN batch_id INTEGER"))
|
||
|
||
|
||
def _hash_password(raw: str) -> str:
|
||
return hashlib.sha256((raw or "").encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _make_user_out(user: User) -> CurrentUserOut:
|
||
return CurrentUserOut(
|
||
id=user.id,
|
||
username=user.username,
|
||
display_name=user.display_name or user.username,
|
||
role="superadmin" if user.role == "superadmin" else "user",
|
||
)
|
||
|
||
|
||
def _issue_auth_token(user: User) -> str:
|
||
secret = os.getenv("AUTH_TOKEN_SECRET", "quality-inspection-dev-secret")
|
||
nonce = secrets.token_hex(8)
|
||
raw = f"{user.id}:{user.username}:{user.role}:{nonce}"
|
||
signature = hmac.new(secret.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256).hexdigest()
|
||
return f"{raw}:{signature}"
|
||
|
||
|
||
def _resolve_user_by_token(token: str, db: Session) -> User | None:
|
||
secret = os.getenv("AUTH_TOKEN_SECRET", "quality-inspection-dev-secret")
|
||
parts = (token or "").split(":")
|
||
if len(parts) != 5:
|
||
return None
|
||
user_id, username, role, nonce, signature = parts
|
||
raw = f"{user_id}:{username}:{role}:{nonce}"
|
||
expected = hmac.new(secret.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256).hexdigest()
|
||
if not hmac.compare_digest(expected, signature):
|
||
return None
|
||
if not user_id.isdigit():
|
||
return None
|
||
user = db.query(User).filter(User.id == int(user_id), User.is_active == True).first()
|
||
if not user:
|
||
return None
|
||
if user.username != username or user.role != role:
|
||
return None
|
||
return user
|
||
|
||
|
||
def _mask_api_key(prefix: str, hint: str) -> str:
|
||
return f"{prefix}••••••••{hint}"
|
||
|
||
|
||
def _generate_user_api_key() -> tuple[str, str, str, str]:
|
||
body = secrets.token_urlsafe(24).replace("-", "").replace("_", "")
|
||
body = "".join(ch for ch in body if ch.isalnum())[:32] or secrets.token_hex(16)
|
||
full_key = f"sto-{body}"
|
||
key_hash = _hash_password(full_key)
|
||
key_prefix = full_key[:8]
|
||
key_hint = full_key[-4:]
|
||
return full_key, key_hash, key_prefix, key_hint
|
||
|
||
|
||
def _parse_api_key_expires_at(value: str | None) -> datetime | None:
|
||
raw = (value or "").strip()
|
||
if not raw:
|
||
return None
|
||
normalized = raw.replace("Z", "+00:00")
|
||
try:
|
||
parsed = datetime.fromisoformat(normalized)
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail="expires_at must be ISO datetime") from exc
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||
return parsed.astimezone(timezone.utc)
|
||
|
||
|
||
def _api_key_is_valid(row: UserApiKey) -> bool:
|
||
if not row.is_active:
|
||
return False
|
||
if row.expires_at is None:
|
||
return True
|
||
expires_at = row.expires_at
|
||
if expires_at.tzinfo is None:
|
||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||
return expires_at > datetime.now(timezone.utc)
|
||
|
||
|
||
def _resolve_user_by_api_key(api_key: str, db: Session) -> User | None:
|
||
token = (api_key or "").strip()
|
||
if not token.startswith("sto-"):
|
||
return None
|
||
key_hash = _hash_password(token)
|
||
row = db.query(UserApiKey).filter(UserApiKey.key_hash == key_hash).first()
|
||
if not row or not _api_key_is_valid(row):
|
||
return None
|
||
user = db.query(User).filter(User.id == row.user_id, User.is_active == True).first()
|
||
return user
|
||
|
||
|
||
def _resolve_user_by_credential(token: str, db: Session) -> User | None:
|
||
token = (token or "").strip()
|
||
if not token:
|
||
return None
|
||
if token.startswith("sto-"):
|
||
return _resolve_user_by_api_key(token, db)
|
||
return _resolve_user_by_token(token, db)
|
||
|
||
|
||
def _model_to_user_api_key_out(row: UserApiKey) -> UserApiKeyOut:
|
||
expires_at = None
|
||
if row.expires_at is not None:
|
||
value = row.expires_at
|
||
if value.tzinfo is None:
|
||
value = value.replace(tzinfo=timezone.utc)
|
||
expires_at = value.isoformat()
|
||
created_at = row.created_at.isoformat() if row.created_at else ""
|
||
return UserApiKeyOut(
|
||
id=row.id,
|
||
key_prefix=row.key_prefix,
|
||
key_hint=row.key_hint,
|
||
masked_key=_mask_api_key(row.key_prefix, row.key_hint),
|
||
expires_at=expires_at,
|
||
is_active=bool(row.is_active),
|
||
created_at=created_at,
|
||
)
|
||
|
||
|
||
MCP_MUTATION_TOOLS = frozenset(
|
||
{
|
||
"folder_ensure",
|
||
"api_upsert",
|
||
"workflow_upsert",
|
||
"workflow_patch_json",
|
||
"workflow_move_folder",
|
||
"api_move_folder",
|
||
"mock_upsert",
|
||
"mcp_tool_upsert",
|
||
"ssh_script_upsert",
|
||
"workflow_batch_create",
|
||
"workflow_batch_update",
|
||
}
|
||
)
|
||
|
||
# 执行类工具:会发真实 HTTP / 跑远端脚本,必须携带有效 API Key(禁止回落 superadmin)
|
||
MCP_EXECUTION_TOOLS = frozenset(
|
||
{
|
||
"workflow_run",
|
||
"workflow_run_node",
|
||
"workflow_batch_run",
|
||
"ssh_script_run",
|
||
"workflow_run_replay",
|
||
}
|
||
)
|
||
|
||
|
||
def _mcp_global_require_api_key() -> bool:
|
||
return os.getenv("MCP_REQUIRE_API_KEY", "").strip().lower() in {"1", "true", "yes", "on"}
|
||
|
||
|
||
def _mcp_tool_requires_credential(tool: str) -> bool:
|
||
if _mcp_global_require_api_key():
|
||
return True
|
||
return tool in MCP_MUTATION_TOOLS or tool in MCP_EXECUTION_TOOLS
|
||
|
||
|
||
def _extract_mcp_token(request: Request, api_key: str | None = None) -> str:
|
||
authorization = (request.headers.get("Authorization") or "").strip()
|
||
if authorization.startswith("Bearer "):
|
||
return authorization[7:].strip()
|
||
header_key = (request.headers.get("X-API-Key") or request.headers.get("x-api-key") or "").strip()
|
||
if header_key:
|
||
return header_key
|
||
return (api_key or "").strip()
|
||
|
||
|
||
def _creator_fields(user: User) -> dict[str, Any]:
|
||
return {
|
||
"creator_id": user.id,
|
||
"creator_name": user.display_name or user.username,
|
||
}
|
||
|
||
|
||
def _resolve_mcp_actor(
|
||
request: Request,
|
||
db: Session,
|
||
*,
|
||
api_key: str | None = None,
|
||
require_credential: bool = False,
|
||
) -> User:
|
||
token = _extract_mcp_token(request, api_key)
|
||
if token:
|
||
user = _resolve_user_by_credential(token, db)
|
||
if not user:
|
||
raise HTTPException(status_code=401, detail="invalid api key or token")
|
||
return user
|
||
if require_credential:
|
||
detail = (
|
||
"MCP requires API Key: Authorization: Bearer <sto-...> or X-API-Key header"
|
||
if _mcp_global_require_api_key()
|
||
else "MCP mutation/execution requires API Key: Authorization: Bearer <sto-...> or X-API-Key header"
|
||
)
|
||
raise HTTPException(status_code=401, detail=detail)
|
||
return _get_system_user(db)
|
||
|
||
|
||
def _is_superadmin(user: User) -> bool:
|
||
return user.role == "superadmin"
|
||
|
||
|
||
def _get_system_user(db: Session) -> User:
|
||
user = db.query(User).filter(User.role == "superadmin", User.is_active == True).order_by(User.id.asc()).first()
|
||
if not user:
|
||
raise HTTPException(status_code=500, detail="superadmin not initialized")
|
||
return user
|
||
|
||
|
||
def _ensure_default_superadmin():
|
||
username = os.getenv("DEFAULT_SUPERADMIN_USERNAME", "admin")
|
||
password = os.getenv("DEFAULT_SUPERADMIN_PASSWORD", "admin123456")
|
||
display_name = os.getenv("DEFAULT_SUPERADMIN_DISPLAY_NAME", "系统超管")
|
||
with Session(engine) as db:
|
||
row = db.query(User).filter(User.username == username).first()
|
||
if row:
|
||
if row.role != "superadmin":
|
||
row.role = "superadmin"
|
||
if not row.display_name:
|
||
row.display_name = display_name
|
||
db.commit()
|
||
return
|
||
db.add(
|
||
User(
|
||
username=username,
|
||
password_hash=_hash_password(password),
|
||
display_name=display_name,
|
||
role="superadmin",
|
||
is_active=True,
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
|
||
_ensure_sqlite_columns()
|
||
_ensure_default_superadmin()
|
||
|
||
from .mcp_native import mcp_lifespan, mount_mcp_streamable_http
|
||
|
||
app = FastAPI(title="质量检测平台", version="0.1.0", lifespan=mcp_lifespan)
|
||
mount_mcp_streamable_http(app)
|
||
FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend-admin" / "dist"
|
||
FRONTEND_ASSETS_DIR = FRONTEND_DIST_DIR / "assets"
|
||
FRONTEND_INDEX_FILE = FRONTEND_DIST_DIR / "index.html"
|
||
if FRONTEND_ASSETS_DIR.exists():
|
||
app.mount("/assets", StaticFiles(directory=str(FRONTEND_ASSETS_DIR)), name="frontend-assets")
|
||
|
||
RUNNING_WORKFLOW_SNAPSHOTS: dict[str, dict[str, Any]] = {}
|
||
RUNNING_WORKFLOW_LOCK = threading.RLock()
|
||
|
||
|
||
def _split_csv_env(value: str | None) -> list[str]:
|
||
return [item.strip() for item in (value or "").split(",") if item.strip()]
|
||
|
||
|
||
frontend_origins = _split_csv_env(
|
||
os.getenv("FRONTEND_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")
|
||
)
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=frontend_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
def model_to_api_out(row: ApiDefinition) -> ApiOut:
|
||
cfg = json.loads(row.config_json)
|
||
return ApiOut(
|
||
id=row.id,
|
||
name=row.name,
|
||
folder_path=row.folder_path or "",
|
||
method=row.method,
|
||
url=row.url,
|
||
creator_id=row.creator_id or 0,
|
||
creator_name=row.creator_name or "",
|
||
**cfg,
|
||
)
|
||
|
||
|
||
def model_to_workflow_out(row: Workflow) -> WorkflowOut:
|
||
definition = json.loads(row.definition_json)
|
||
last_run = json.loads(row.last_run_json) if row.last_run_json else None
|
||
default_headers = json.loads(row.default_headers_json or "{}")
|
||
return WorkflowOut(
|
||
id=row.id,
|
||
name=row.name,
|
||
folder_path=row.folder_path or "",
|
||
creator_id=row.creator_id or 0,
|
||
creator_name=row.creator_name or "",
|
||
definition=definition,
|
||
default_headers=default_headers if isinstance(default_headers, dict) else {},
|
||
last_run=last_run,
|
||
)
|
||
|
||
|
||
def model_to_mock_out(row: MockDataset) -> MockDataOut:
|
||
return MockDataOut(
|
||
id=row.id,
|
||
name=row.name,
|
||
data=json.loads(row.data_json),
|
||
creator_id=row.creator_id or 0,
|
||
creator_name=row.creator_name or "",
|
||
)
|
||
|
||
|
||
def model_to_mcp_out(row: McpToolConfig) -> McpToolOut:
|
||
return McpToolOut(
|
||
id=row.id,
|
||
name=row.name,
|
||
enabled=row.enabled,
|
||
config=json.loads(row.config_json),
|
||
creator_id=row.creator_id or 0,
|
||
creator_name=row.creator_name or "",
|
||
)
|
||
|
||
|
||
def _normalize_ssh_presets(value: Any) -> list[dict[str, str]]:
|
||
if not isinstance(value, list):
|
||
return []
|
||
items: list[dict[str, str]] = []
|
||
for item in value:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
name = str(item.get("name", "")).strip()
|
||
command = str(item.get("command", "")).strip()
|
||
if not name or not command:
|
||
continue
|
||
items.append(
|
||
{
|
||
"name": name,
|
||
"command": command,
|
||
"description": str(item.get("description", "")).strip(),
|
||
}
|
||
)
|
||
return items
|
||
|
||
|
||
def model_to_ssh_profile_out(row: SshProfile) -> SshProfileOut:
|
||
cfg = json.loads(row.config_json or "{}")
|
||
if not isinstance(cfg, dict):
|
||
cfg = {}
|
||
auth_type = "password" if cfg.get("auth_type") == "password" else "key"
|
||
return SshProfileOut(
|
||
id=row.id,
|
||
name=row.name,
|
||
host=row.host,
|
||
port=int(row.port or 22),
|
||
username=row.username,
|
||
auth_type=auth_type,
|
||
key_path=str(cfg.get("key_path", "")).strip(),
|
||
presets=_normalize_ssh_presets(cfg.get("presets")),
|
||
creator_id=row.creator_id or 0,
|
||
creator_name=row.creator_name or "",
|
||
)
|
||
|
||
|
||
def model_to_ssh_shortcut_out(row: SshShortcut) -> SshShortcutOut:
|
||
return SshShortcutOut(
|
||
id=row.id,
|
||
name=row.name,
|
||
command=row.command,
|
||
description=row.description or "",
|
||
creator_id=row.creator_id or 0,
|
||
creator_name=row.creator_name or "",
|
||
)
|
||
|
||
|
||
def model_to_ssh_script_out(row: SshScript) -> SshScriptOut:
|
||
return SshScriptOut(
|
||
id=row.id,
|
||
profile_id=row.profile_id,
|
||
name=row.name,
|
||
description=row.description or "",
|
||
source_type=row.source_type or "inline",
|
||
content=row.content or "",
|
||
storage_path=row.storage_path or "",
|
||
creator_id=row.creator_id or 0,
|
||
creator_name=row.creator_name or "",
|
||
)
|
||
|
||
|
||
def _ensure_ssh_scripts_dir() -> Path:
|
||
SSH_SCRIPTS_DIR.mkdir(parents=True, exist_ok=True)
|
||
return SSH_SCRIPTS_DIR
|
||
|
||
|
||
def _sanitize_script_filename(name: str) -> str:
|
||
base = Path(name or "script.sh").name
|
||
safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in base).strip("._")
|
||
if not safe:
|
||
safe = "script.sh"
|
||
if not safe.endswith((".sh", ".bash")):
|
||
safe = f"{safe}.sh"
|
||
return safe
|
||
|
||
|
||
def _get_ssh_script_row(db: Session, script_id: int) -> SshScript:
|
||
row = db.query(SshScript).filter(SshScript.id == script_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="ssh script not found")
|
||
return row
|
||
|
||
|
||
def _build_ssh_tree(db: Session, user: User) -> list[SshTreeProfileNode]:
|
||
profiles = _scope_query_by_user(db.query(SshProfile), SshProfile, user).order_by(SshProfile.id.desc()).all()
|
||
if not profiles:
|
||
return []
|
||
profile_ids = [row.id for row in profiles]
|
||
script_rows = (
|
||
_scope_query_by_user(db.query(SshScript), SshScript, user)
|
||
.filter(SshScript.profile_id.in_(profile_ids))
|
||
.order_by(SshScript.id.asc())
|
||
.all()
|
||
)
|
||
scripts_by_profile: dict[int, list[SshScript]] = {}
|
||
for script_row in script_rows:
|
||
scripts_by_profile.setdefault(script_row.profile_id, []).append(script_row)
|
||
|
||
tree: list[SshTreeProfileNode] = []
|
||
for profile_row in profiles:
|
||
cfg = json.loads(profile_row.config_json or "{}")
|
||
script_nodes = [
|
||
SshTreeScriptNode(
|
||
id=script_row.id,
|
||
name=script_row.name,
|
||
description=script_row.description or "",
|
||
source_type=script_row.source_type or "inline",
|
||
storage_path=script_row.storage_path or "",
|
||
)
|
||
for script_row in scripts_by_profile.get(profile_row.id, [])
|
||
]
|
||
tree.append(
|
||
SshTreeProfileNode(
|
||
id=profile_row.id,
|
||
name=profile_row.name,
|
||
host=profile_row.host,
|
||
port=int(profile_row.port or 22),
|
||
username=profile_row.username,
|
||
auth_type=cfg.get("auth_type", "key"),
|
||
key_path=str(cfg.get("key_path", "")),
|
||
scripts=script_nodes,
|
||
)
|
||
)
|
||
return tree
|
||
|
||
|
||
def _delete_ssh_script_files(row: SshScript) -> None:
|
||
if row.source_type == "file" and row.storage_path:
|
||
file_path = (SSH_SCRIPTS_DIR / row.storage_path).resolve()
|
||
scripts_root = SSH_SCRIPTS_DIR.resolve()
|
||
if scripts_root in file_path.parents and file_path.is_file():
|
||
with contextlib.suppress(OSError):
|
||
file_path.unlink()
|
||
|
||
|
||
MCP_TOOL_SPECS = [
|
||
{
|
||
"name": "api_upsert",
|
||
"description": "Create or update one API definition.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["name", "method", "url"],
|
||
"properties": {
|
||
"api_id": {"type": "integer"},
|
||
"name": {"type": "string"},
|
||
"folder_path": {"type": "string"},
|
||
"method": {"type": "string"},
|
||
"url": {"type": "string"},
|
||
"headers": {"type": "object"},
|
||
"body": {"type": "object"},
|
||
"query": {"type": "object"},
|
||
"path_params": {"type": "object"},
|
||
"timeout_seconds": {"type": "number"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_upsert",
|
||
"description": (
|
||
"Create or update workflow JSON (requires sto- API Key; sets creator from key owner). "
|
||
"http nodes carry inline request fields "
|
||
"(method,url,headers,body,query,path_params,timeout_seconds), optional mock "
|
||
"{status_code,headers,json,text,is_mock}, mode in {http,mock,auto}, expect "
|
||
"{status_in,json_contains,text_includes}. "
|
||
"condition nodes branch true/false; loop nodes use body/done edges with while_* continue rules. "
|
||
"Each node persists last_run after exec. "
|
||
"Non-empty folder_path must be pre-registered for target workflows (folder_ensure) "
|
||
"before create or before changing folder_path on update."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["name", "definition"],
|
||
"properties": {
|
||
"workflow_id": {"type": "integer"},
|
||
"name": {"type": "string"},
|
||
"folder_path": {
|
||
"type": "string",
|
||
"description": "Empty string = root bucket. Any other path must exist in folder_entries for workflows.",
|
||
},
|
||
"definition": {"type": "object"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_get",
|
||
"description": "Fetch full workflow including each node's last_run state.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["workflow_id"],
|
||
"properties": {"workflow_id": {"type": "integer"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_node_status",
|
||
"description": "Read the latest last_run status of a node inside a workflow.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["workflow_id", "node_id"],
|
||
"properties": {
|
||
"workflow_id": {"type": "integer"},
|
||
"node_id": {"type": "string"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "folder_ensure",
|
||
"description": "Create folder path for apis/workflows.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["target", "path"],
|
||
"properties": {
|
||
"target": {"type": "string", "enum": ["apis", "workflows"]},
|
||
"path": {"type": "string"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "folder_list",
|
||
"description": "List folder paths for apis/workflows.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {"target": {"type": "string", "enum": ["apis", "workflows"]}},
|
||
},
|
||
},
|
||
{
|
||
"name": "api_move_folder",
|
||
"description": "Move an API definition into folder path.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["api_id", "folder_path"],
|
||
"properties": {"api_id": {"type": "integer"}, "folder_path": {"type": "string"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_move_folder",
|
||
"description": (
|
||
"Move workflow into folder path. Destination must be pre-registered (folder_ensure workflows), "
|
||
"except empty root path."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["workflow_id", "folder_path"],
|
||
"properties": {"workflow_id": {"type": "integer"}, "folder_path": {"type": "string"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_patch_json",
|
||
"description": "Patch workflow definition JSON by deep merge.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["workflow_id", "patch"],
|
||
"properties": {"workflow_id": {"type": "integer"}, "patch": {"type": "object"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_run",
|
||
"description": "Run workflow and return node results (requires sto- API Key).",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["workflow_id"],
|
||
"properties": {
|
||
"workflow_id": {"type": "integer"},
|
||
"base_url": {"type": "string"},
|
||
"fail_fast": {"type": "boolean"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_batch_create",
|
||
"description": (
|
||
"Create a workflow batch job in draft status (requires sto- API Key). "
|
||
"Configure workflow_ids before workflow_batch_run."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {"type": "string"},
|
||
"base_url": {"type": "string"},
|
||
"fail_fast": {"type": "boolean"},
|
||
"workflow_ids": {"type": "array", "items": {"type": "integer"}},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_batch_update",
|
||
"description": (
|
||
"Update a draft workflow batch: name, base_url, fail_fast, workflow_ids (requires sto- API Key)."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["batch_id"],
|
||
"properties": {
|
||
"batch_id": {"type": "integer"},
|
||
"name": {"type": "string"},
|
||
"base_url": {"type": "string"},
|
||
"fail_fast": {"type": "boolean"},
|
||
"workflow_ids": {"type": "array", "items": {"type": "integer"}},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_batch_run",
|
||
"description": "Execute a draft workflow batch and return per-workflow run results (requires sto- API Key).",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["batch_id"],
|
||
"properties": {"batch_id": {"type": "integer"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_batch_get",
|
||
"description": "Get workflow batch detail including linked run records.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["batch_id"],
|
||
"properties": {"batch_id": {"type": "integer"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_batch_list",
|
||
"description": "List workflow batches for the current user.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"limit": {"type": "integer"},
|
||
"after_id": {"type": "integer"},
|
||
"status": {"type": "string", "description": "Filter: draft|running|success|failed|partial"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_run_node",
|
||
"description": "Run single node in a workflow (requires sto- API Key).",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["workflow_id", "node_id"],
|
||
"properties": {
|
||
"workflow_id": {"type": "integer"},
|
||
"node_id": {"type": "string"},
|
||
"base_url": {"type": "string"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_analyze_last_run",
|
||
"description": "Analyze last run summary of a workflow.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["workflow_id"],
|
||
"properties": {"workflow_id": {"type": "integer"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_run_list",
|
||
"description": "List workflow execution history (filter by workflow_id and/or batch_id).",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"workflow_id": {"type": "integer"},
|
||
"batch_id": {"type": "integer"},
|
||
"limit": {"type": "integer"},
|
||
"after_id": {"type": "integer"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_run_get",
|
||
"description": "Get one workflow run record including full payload (results, variables).",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["run_id"],
|
||
"properties": {"run_id": {"type": "integer"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_run_replay",
|
||
"description": (
|
||
"Replay a historical run using its stored workflow snapshot (requires sto- API Key)."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["run_id"],
|
||
"properties": {
|
||
"run_id": {"type": "integer"},
|
||
"base_url": {"type": "string"},
|
||
"fail_fast": {"type": "boolean"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_run_loki_link",
|
||
"description": "Build Grafana/Loki explore URL for one HTTP node in a run.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["run_id", "node_id"],
|
||
"properties": {
|
||
"run_id": {"type": "integer"},
|
||
"node_id": {"type": "string"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "workflow_validate",
|
||
"description": "Validate workflow definition JSON (node ids, edge references) before upsert.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["definition"],
|
||
"properties": {"definition": {"type": "object"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "mock_upsert",
|
||
"description": "Create or update mock dataset.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["name", "data"],
|
||
"properties": {"name": {"type": "string"}, "data": {"type": "object"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "mcp_tool_upsert",
|
||
"description": "Create or update MCP tool config.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["name"],
|
||
"properties": {
|
||
"name": {"type": "string"},
|
||
"enabled": {"type": "boolean"},
|
||
"config": {"type": "object"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "catalog_snapshot",
|
||
"description": (
|
||
"Return APIs/workflows/mocks/workflow_batches/mcp-tool configs and SSH host/script tree."
|
||
),
|
||
"input_schema": {"type": "object", "properties": {}},
|
||
},
|
||
{
|
||
"name": "ssh_tree",
|
||
"description": "List SSH hosts and nested scripts (host tree for script management).",
|
||
"input_schema": {"type": "object", "properties": {}},
|
||
},
|
||
{
|
||
"name": "ssh_script_get",
|
||
"description": "Get SSH script metadata and inline content by script_id.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["script_id"],
|
||
"properties": {"script_id": {"type": "integer"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "ssh_script_upsert",
|
||
"description": (
|
||
"Create or update inline bash script under an SSH host. "
|
||
"AI should pass full script content (shebang recommended). "
|
||
"Use profile_id + name for create; script_id for update."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["name", "content"],
|
||
"properties": {
|
||
"script_id": {"type": "integer"},
|
||
"profile_id": {"type": "integer"},
|
||
"name": {"type": "string"},
|
||
"description": {"type": "string"},
|
||
"content": {"type": "string", "description": "Full bash script body"},
|
||
"source_type": {"type": "string", "enum": ["inline"], "default": "inline"},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"name": "ssh_script_run",
|
||
"description": (
|
||
"Run SSH script on remote host and return collected stdout/stderr (requires sto- API Key). "
|
||
"password is required. Long-running scripts (e.g. tail -f) stop at timeout_seconds."
|
||
),
|
||
"input_schema": {
|
||
"type": "object",
|
||
"required": ["profile_id", "script_id", "password"],
|
||
"properties": {
|
||
"profile_id": {"type": "integer"},
|
||
"script_id": {"type": "integer"},
|
||
"password": {"type": "string"},
|
||
"timeout_seconds": {"type": "number", "default": 120},
|
||
},
|
||
},
|
||
},
|
||
]
|
||
|
||
|
||
def _get_current_user(
|
||
authorization: str | None = Header(default=None),
|
||
db: Session = Depends(get_db),
|
||
) -> User:
|
||
header = (authorization or "").strip()
|
||
if not header.startswith("Bearer "):
|
||
raise HTTPException(status_code=401, detail="missing bearer token")
|
||
token = header[7:].strip()
|
||
user = _resolve_user_by_credential(token, db)
|
||
if not user:
|
||
raise HTTPException(status_code=401, detail="invalid token")
|
||
return user
|
||
|
||
|
||
def _get_websocket_user(websocket: WebSocket) -> User:
|
||
token = (websocket.query_params.get("token") or "").strip()
|
||
if not token:
|
||
raise HTTPException(status_code=401, detail="missing bearer token")
|
||
with SessionLocal() as db:
|
||
user = _resolve_user_by_credential(token, db)
|
||
if not user:
|
||
raise HTTPException(status_code=401, detail="invalid token")
|
||
db.expunge(user)
|
||
return user
|
||
|
||
|
||
def _require_superadmin(user: User = Depends(_get_current_user)) -> User:
|
||
if not _is_superadmin(user):
|
||
raise HTTPException(status_code=403, detail="superadmin required")
|
||
return user
|
||
|
||
|
||
def _assert_owner_or_superadmin(user: User, row: Any) -> None:
|
||
if _is_superadmin(user):
|
||
return
|
||
if getattr(row, "creator_id", None) != user.id:
|
||
raise HTTPException(status_code=403, detail="no permission for this resource")
|
||
|
||
|
||
def _scope_query_by_user(query: Any, model: Any, user: User):
|
||
if _is_superadmin(user):
|
||
return query
|
||
return query.filter(model.creator_id == user.id)
|
||
|
||
|
||
def _frontend_index_response() -> FileResponse:
|
||
if not FRONTEND_INDEX_FILE.exists():
|
||
raise HTTPException(status_code=503, detail="frontend dist not found, run `cd frontend-admin && npm run build`")
|
||
return FileResponse(FRONTEND_INDEX_FILE)
|
||
|
||
|
||
def _normalize_folder_path(value: str | None) -> str:
|
||
text_value = (value or "").strip().replace("\\", "/")
|
||
if text_value in {"", "/"}:
|
||
return ""
|
||
parts = [part for part in text_value.split("/") if part]
|
||
return "/".join(parts)
|
||
|
||
|
||
def _ensure_folder_entry(db: Session, target: str, path: str, user: User | None = None):
|
||
if target not in {"apis", "workflows"}:
|
||
raise HTTPException(status_code=400, detail="target must be apis or workflows")
|
||
normalized = _normalize_folder_path(path)
|
||
if not normalized:
|
||
return
|
||
row_query = db.query(FolderEntry).filter(FolderEntry.target == target, FolderEntry.path == normalized)
|
||
if user and not _is_superadmin(user):
|
||
row_query = row_query.filter(FolderEntry.creator_id == user.id)
|
||
row = row_query.first()
|
||
if not row:
|
||
db.add(
|
||
FolderEntry(
|
||
target=target,
|
||
path=normalized,
|
||
creator_id=(user.id if user else 1),
|
||
creator_name=(user.display_name or user.username) if user else "admin",
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
|
||
def _assert_workflow_folder_registered(db: Session, folder_path: str, user: User | None = None) -> None:
|
||
"""工作流目录须先登记:非根路径必须在 folder_entries(workflows) 中存在;保存工作流时不再隐式创建目录。"""
|
||
normalized = _normalize_folder_path(folder_path)
|
||
if not normalized:
|
||
return
|
||
query = db.query(FolderEntry.id).filter(FolderEntry.target == "workflows", FolderEntry.path == normalized)
|
||
if user and not _is_superadmin(user):
|
||
query = query.filter(FolderEntry.creator_id == user.id)
|
||
exists = query.first()
|
||
if not exists:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
"工作流目录未登记:请先在左侧「工作流目录」新建该路径,或调用 "
|
||
"POST /api/folders/ensure(body: {\"target\":\"workflows\",\"path\":\"...\"}),"
|
||
"再保存或移动工作流到该目录。"
|
||
),
|
||
)
|
||
|
||
|
||
def _replace_folder_path_prefix(path: str, old_prefix: str, new_prefix: str) -> str:
|
||
normalized = _normalize_folder_path(path)
|
||
old = _normalize_folder_path(old_prefix)
|
||
new = _normalize_folder_path(new_prefix)
|
||
if normalized == old:
|
||
return new
|
||
if old and normalized.startswith(old + "/"):
|
||
suffix = normalized[len(old) :]
|
||
return _normalize_folder_path(f"{new}{suffix}")
|
||
return normalized
|
||
|
||
|
||
def _folder_path_is_under(path: str, ancestor: str) -> bool:
|
||
normalized = _normalize_folder_path(path)
|
||
parent = _normalize_folder_path(ancestor)
|
||
if not parent:
|
||
return False
|
||
return normalized == parent or normalized.startswith(parent + "/")
|
||
|
||
|
||
def _move_folder_tree(db: Session, target: str, old_path: str, new_path: str, user: User) -> dict[str, Any]:
|
||
if target not in {"apis", "workflows"}:
|
||
raise HTTPException(status_code=400, detail="target must be apis or workflows")
|
||
old = _normalize_folder_path(old_path)
|
||
new = _normalize_folder_path(new_path)
|
||
if not old:
|
||
raise HTTPException(status_code=400, detail="from_path cannot be root")
|
||
if not new:
|
||
raise HTTPException(status_code=400, detail="to_path cannot be root")
|
||
if new == old:
|
||
return {"target": target, "from_path": old, "to_path": new, "moved_entries": 0, "moved_resources": 0}
|
||
if _folder_path_is_under(new, old):
|
||
raise HTTPException(status_code=400, detail="cannot move folder into its descendant")
|
||
|
||
conflict_query = db.query(FolderEntry).filter(FolderEntry.target == target, FolderEntry.path == new)
|
||
if not _is_superadmin(user):
|
||
conflict_query = conflict_query.filter(FolderEntry.creator_id == user.id)
|
||
if conflict_query.first():
|
||
raise HTTPException(status_code=400, detail=f"folder already exists: {new}")
|
||
|
||
moved_entries = 0
|
||
moved_resources = 0
|
||
|
||
entry_query = db.query(FolderEntry).filter(FolderEntry.target == target)
|
||
if not _is_superadmin(user):
|
||
entry_query = entry_query.filter(FolderEntry.creator_id == user.id)
|
||
for row in entry_query.all():
|
||
if not _folder_path_is_under(row.path, old) and row.path != old:
|
||
continue
|
||
updated = _replace_folder_path_prefix(row.path, old, new)
|
||
if updated != row.path:
|
||
row.path = updated
|
||
moved_entries += 1
|
||
|
||
if target == "apis":
|
||
resource_query = _scope_query_by_user(db.query(ApiDefinition), ApiDefinition, user)
|
||
else:
|
||
resource_query = _scope_query_by_user(db.query(Workflow), Workflow, user)
|
||
for row in resource_query.all():
|
||
if not _folder_path_is_under(row.folder_path, old) and _normalize_folder_path(row.folder_path) != old:
|
||
continue
|
||
updated = _replace_folder_path_prefix(row.folder_path, old, new)
|
||
if updated != row.folder_path:
|
||
row.folder_path = updated
|
||
moved_resources += 1
|
||
|
||
_ensure_folder_entry(db, target, new, user)
|
||
db.commit()
|
||
return {
|
||
"target": target,
|
||
"from_path": old,
|
||
"to_path": new,
|
||
"moved_entries": moved_entries,
|
||
"moved_resources": moved_resources,
|
||
}
|
||
|
||
|
||
def _build_folder_tree(path_count_map: dict[str, int]) -> dict[str, Any]:
|
||
root: dict[str, Any] = {"name": "/", "path": "", "count": 0, "children": {}}
|
||
for path, count in path_count_map.items():
|
||
normalized = _normalize_folder_path(path)
|
||
if not normalized:
|
||
root["count"] += count
|
||
continue
|
||
parts = [item for item in normalized.split("/") if item]
|
||
node = root
|
||
node["count"] += count
|
||
current_path = ""
|
||
for part in parts:
|
||
current_path = f"{current_path}/{part}".strip("/")
|
||
if part not in node["children"]:
|
||
node["children"][part] = {
|
||
"name": part,
|
||
"path": current_path,
|
||
"count": 0,
|
||
"children": {},
|
||
}
|
||
node = node["children"][part]
|
||
node["count"] += count
|
||
|
||
def serialize(node: dict[str, Any]) -> dict[str, Any]:
|
||
children = [serialize(item) for item in node["children"].values()]
|
||
children.sort(key=lambda x: x["path"])
|
||
return {"name": node["name"], "path": node["path"], "count": node["count"], "children": children}
|
||
|
||
return serialize(root)
|
||
|
||
|
||
def _deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
||
output = dict(base)
|
||
for key, value in patch.items():
|
||
if isinstance(value, dict) and isinstance(output.get(key), dict):
|
||
output[key] = _deep_merge_dict(output[key], value)
|
||
else:
|
||
output[key] = value
|
||
return output
|
||
|
||
|
||
def _collect_runtime_maps(db: Session, user: User):
|
||
apis = _scope_query_by_user(db.query(ApiDefinition), ApiDefinition, user).all()
|
||
mocks = _scope_query_by_user(db.query(MockDataset), MockDataset, user).all()
|
||
api_map = {}
|
||
for row in apis:
|
||
cfg = json.loads(row.config_json)
|
||
api_map[row.id] = {"method": row.method, "url": row.url, **cfg}
|
||
mock_map = {row.name: json.loads(row.data_json) for row in mocks}
|
||
return api_map, mock_map
|
||
|
||
|
||
def _build_ssh_config_json(payload: SshProfileCreate | SshProfileUpdate) -> str:
|
||
return json.dumps(
|
||
{
|
||
"auth_type": payload.auth_type,
|
||
"key_path": (payload.key_path or "").strip(),
|
||
"presets": [item.model_dump() for item in payload.presets],
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
|
||
def _resolve_ssh_command(profile: SshProfileOut, payload: SshExecuteRequest) -> str:
|
||
command = (payload.command or "").strip()
|
||
if command:
|
||
return command
|
||
preset_name = (payload.preset_name or "").strip()
|
||
if preset_name:
|
||
for item in profile.presets:
|
||
if item.name == preset_name:
|
||
return item.command.strip()
|
||
raise HTTPException(status_code=400, detail="preset command not found")
|
||
return "echo connected"
|
||
|
||
|
||
def _run_ssh_command(profile: SshProfileOut, command: str, password: str, timeout_seconds: float) -> SshExecuteResponse:
|
||
timeout_value = max(3.0, min(float(timeout_seconds or 20.0), 120.0))
|
||
ssh_args = [
|
||
"ssh",
|
||
"-o",
|
||
"StrictHostKeyChecking=accept-new",
|
||
"-o",
|
||
"ConnectTimeout=10",
|
||
"-o",
|
||
"NumberOfPasswordPrompts=1",
|
||
"-p",
|
||
str(int(profile.port or 22)),
|
||
]
|
||
if profile.auth_type == "key" and profile.key_path:
|
||
ssh_args.extend(["-i", profile.key_path])
|
||
ssh_args.extend([f"{profile.username}@{profile.host}", command])
|
||
|
||
run_args = ssh_args
|
||
env = os.environ.copy()
|
||
askpass_dir = None
|
||
|
||
if profile.auth_type == "password":
|
||
if not password.strip():
|
||
raise HTTPException(status_code=400, detail="password is required for password auth profile")
|
||
askpass_dir = tempfile.TemporaryDirectory()
|
||
askpass_path = os.path.join(askpass_dir.name, "ssh_askpass.sh")
|
||
with open(askpass_path, "w", encoding="utf-8") as handle:
|
||
handle.write("#!/bin/sh\n")
|
||
handle.write("printf '%s' \"$SSH_PASSWORD\"\n")
|
||
os.chmod(askpass_path, 0o700)
|
||
env.update(
|
||
{
|
||
"SSH_ASKPASS": askpass_path,
|
||
"SSH_ASKPASS_REQUIRE": "force",
|
||
"SSH_PASSWORD": password,
|
||
"DISPLAY": env.get("DISPLAY", "codex:0"),
|
||
}
|
||
)
|
||
run_args = ["setsid", *ssh_args]
|
||
|
||
try:
|
||
completed = subprocess.run(
|
||
run_args,
|
||
check=False,
|
||
stdin=subprocess.DEVNULL,
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=timeout_value,
|
||
env=env,
|
||
)
|
||
except subprocess.TimeoutExpired as exc:
|
||
raise HTTPException(status_code=408, detail=f"ssh command timeout after {int(timeout_value)}s") from exc
|
||
except FileNotFoundError as exc:
|
||
missing_cmd = run_args[0] if run_args else "ssh"
|
||
raise HTTPException(status_code=500, detail=f"{missing_cmd} command is not available") from exc
|
||
finally:
|
||
if askpass_dir is not None:
|
||
askpass_dir.cleanup()
|
||
|
||
return SshExecuteResponse(
|
||
ok=completed.returncode == 0,
|
||
command=command,
|
||
stdout=completed.stdout or "",
|
||
stderr=completed.stderr or "",
|
||
exit_status=int(completed.returncode or 0),
|
||
)
|
||
|
||
|
||
def _detect_trigger_source(request: Request) -> str:
|
||
path = request.url.path or ""
|
||
if path.startswith("/mcp/"):
|
||
return "mcp"
|
||
referer = request.headers.get("referer", "")
|
||
if "127.0.0.1:8000" in referer or "localhost:8000" in referer:
|
||
return "ui"
|
||
return "api"
|
||
|
||
|
||
def _record_workflow_run(
|
||
db: Session,
|
||
workflow: Workflow,
|
||
*,
|
||
run_type: str,
|
||
triggered_by: str,
|
||
status: str,
|
||
duration_ms: int,
|
||
summary: dict[str, Any],
|
||
payload: dict[str, Any],
|
||
node_id: str | None = None,
|
||
batch_id: int | None = None,
|
||
) -> WorkflowRun:
|
||
row = WorkflowRun(
|
||
batch_id=batch_id,
|
||
workflow_id=workflow.id,
|
||
workflow_name=workflow.name or "",
|
||
run_type=run_type,
|
||
node_id=node_id,
|
||
triggered_by=triggered_by,
|
||
status=status,
|
||
duration_ms=duration_ms,
|
||
summary_json=json.dumps(summary, ensure_ascii=False),
|
||
payload_json=json.dumps(payload, ensure_ascii=False),
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return row
|
||
|
||
|
||
def _workflow_default_headers(workflow: Workflow) -> dict[str, Any]:
|
||
try:
|
||
value = json.loads(workflow.default_headers_json or "{}")
|
||
except Exception:
|
||
value = {}
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
|
||
def _summarize_results(results: list[dict[str, Any]]) -> dict[str, Any]:
|
||
counters = {"success": 0, "failed": 0, "skipped": 0}
|
||
failed_nodes: list[str] = []
|
||
for item in results:
|
||
status_value = str(item.get("status", "success"))
|
||
counters[status_value] = counters.get(status_value, 0) + 1
|
||
if status_value == "failed":
|
||
failed_nodes.append(str(item.get("node_id", "")))
|
||
return {
|
||
"total": len(results),
|
||
"success": counters["success"],
|
||
"failed": counters["failed"],
|
||
"skipped": counters["skipped"],
|
||
"failed_nodes": failed_nodes,
|
||
}
|
||
|
||
|
||
def _model_to_run_dict(row: WorkflowRun) -> dict[str, Any]:
|
||
return {
|
||
"id": row.id,
|
||
"batch_id": row.batch_id,
|
||
"workflow_id": row.workflow_id,
|
||
"workflow_name": row.workflow_name,
|
||
"run_type": row.run_type,
|
||
"node_id": row.node_id,
|
||
"triggered_by": row.triggered_by,
|
||
"status": row.status,
|
||
"duration_ms": row.duration_ms,
|
||
"summary": json.loads(row.summary_json or "{}"),
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
}
|
||
|
||
|
||
def _validate_workflow_definition(definition: dict[str, Any]) -> dict[str, Any]:
|
||
node_ids = set()
|
||
for node in definition.get("nodes", []):
|
||
if "id" not in node:
|
||
return {"ok": False, "reason": "node missing id"}
|
||
node_ids.add(node["id"])
|
||
for edge in definition.get("edges", []):
|
||
if edge.get("source") not in node_ids or edge.get("target") not in node_ids:
|
||
return {"ok": False, "reason": "edge links unknown node"}
|
||
return {"ok": True}
|
||
|
||
|
||
def _list_workflow_runs_for_actor(
|
||
db: Session,
|
||
actor: User,
|
||
*,
|
||
workflow_id: int | None = None,
|
||
batch_id: int | None = None,
|
||
limit: int = 30,
|
||
after_id: int | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
limit = max(1, min(int(limit or 30), 200))
|
||
query = db.query(WorkflowRun)
|
||
if workflow_id is not None:
|
||
query = query.filter(WorkflowRun.workflow_id == workflow_id)
|
||
if batch_id is not None:
|
||
query = query.filter(WorkflowRun.batch_id == int(batch_id))
|
||
if after_id is not None:
|
||
query = query.filter(WorkflowRun.id > int(after_id))
|
||
if not _is_superadmin(actor):
|
||
owned_ids = [row.id for row in _scope_query_by_user(db.query(Workflow), Workflow, actor).all()]
|
||
if not owned_ids:
|
||
return []
|
||
query = query.filter(WorkflowRun.workflow_id.in_(owned_ids))
|
||
rows = query.order_by(WorkflowRun.id.desc()).limit(limit).all()
|
||
return [_model_to_run_dict(item) for item in rows]
|
||
|
||
|
||
def _get_workflow_run_for_actor(db: Session, actor: User, run_id: int) -> dict[str, Any]:
|
||
row = db.query(WorkflowRun).filter(WorkflowRun.id == run_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="run not found")
|
||
workflow = db.query(Workflow).filter(Workflow.id == row.workflow_id).first()
|
||
if not workflow:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(actor, workflow)
|
||
base = _model_to_run_dict(row)
|
||
base["payload"] = json.loads(row.payload_json or "{}")
|
||
return base
|
||
|
||
|
||
def _workflow_run_loki_link_for_actor(db: Session, actor: User, run_id: int, node_id: str) -> dict[str, Any]:
|
||
row = db.query(WorkflowRun).filter(WorkflowRun.id == run_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="run not found")
|
||
workflow = db.query(Workflow).filter(Workflow.id == row.workflow_id).first()
|
||
if not workflow:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(actor, workflow)
|
||
|
||
payload = json.loads(row.payload_json or "{}")
|
||
node_result: dict[str, Any] | None = None
|
||
if row.run_type == "node":
|
||
candidate = payload.get("result")
|
||
if isinstance(candidate, dict) and str(candidate.get("node_id", "")) == node_id:
|
||
node_result = candidate
|
||
else:
|
||
for item in payload.get("results", []):
|
||
if isinstance(item, dict) and str(item.get("node_id", "")) == node_id:
|
||
node_result = item
|
||
break
|
||
if not node_result:
|
||
raise HTTPException(status_code=404, detail="node result not found in run payload")
|
||
return build_run_loki_link(
|
||
run_created_at=row.created_at.isoformat() if row.created_at else None,
|
||
duration_ms=int(row.duration_ms or 0),
|
||
node_result=node_result,
|
||
)
|
||
|
||
|
||
async def _replay_workflow_run_for_actor(
|
||
db: Session,
|
||
actor: User,
|
||
run_id: int,
|
||
*,
|
||
base_url: str = "",
|
||
fail_fast: bool = True,
|
||
triggered_by: str = "mcp",
|
||
) -> dict[str, Any]:
|
||
row = db.query(WorkflowRun).filter(WorkflowRun.id == run_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="run not found")
|
||
run_payload = json.loads(row.payload_json or "{}")
|
||
snapshot = run_payload.get("workflow_snapshot") or {}
|
||
definition = snapshot.get("definition")
|
||
if not isinstance(definition, dict):
|
||
raise HTTPException(status_code=400, detail="run snapshot missing workflow definition")
|
||
default_headers = snapshot.get("default_headers") or {}
|
||
if not isinstance(default_headers, dict):
|
||
default_headers = {}
|
||
workflow = db.query(Workflow).filter(Workflow.id == row.workflow_id).first()
|
||
if not workflow:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(actor, workflow)
|
||
|
||
api_map, mock_map = _collect_runtime_maps(db, actor)
|
||
active_run_id = secrets.token_hex(12)
|
||
runtime_snapshot = _init_runtime_snapshot(
|
||
workflow,
|
||
actor,
|
||
RunWorkflowRequest(workflow_id=workflow.id, base_url=base_url, fail_fast=fail_fast),
|
||
definition,
|
||
)
|
||
runtime_snapshot["active_run_id"] = active_run_id
|
||
_set_runtime_snapshot(active_run_id, runtime_snapshot)
|
||
on_node_start, on_node_finish = _build_runtime_node_callbacks(active_run_id)
|
||
started = time.perf_counter()
|
||
try:
|
||
run_result = await execute_workflow(
|
||
definition=definition,
|
||
api_map=api_map,
|
||
mock_map=mock_map,
|
||
base_url=base_url,
|
||
fail_fast=fail_fast,
|
||
default_headers=default_headers,
|
||
on_node_start=on_node_start,
|
||
on_node_finish=on_node_finish,
|
||
)
|
||
finally:
|
||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||
_clear_runtime_snapshot(active_run_id)
|
||
replay_row = _record_workflow_run(
|
||
db,
|
||
workflow,
|
||
run_type="full",
|
||
triggered_by=triggered_by,
|
||
status=run_result["status"],
|
||
duration_ms=duration_ms,
|
||
summary=_summarize_results(run_result.get("results", [])),
|
||
payload={
|
||
"replayed_from_run_id": run_id,
|
||
"workflow_snapshot": {
|
||
"id": snapshot.get("id", workflow.id),
|
||
"name": snapshot.get("name", workflow.name),
|
||
"folder_path": snapshot.get("folder_path", workflow.folder_path or ""),
|
||
"default_headers": default_headers,
|
||
"definition": definition,
|
||
},
|
||
"request": {"base_url": base_url, "fail_fast": fail_fast},
|
||
"results": run_result.get("results", []),
|
||
"variables": run_result.get("variables", {}),
|
||
},
|
||
)
|
||
return RunWorkflowResponse(
|
||
status=run_result["status"],
|
||
variables=run_result["variables"],
|
||
results=run_result["results"],
|
||
run_id=replay_row.id,
|
||
workflow_id=workflow.id,
|
||
).model_dump()
|
||
|
||
|
||
def _clone_runtime_snapshot(snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
||
return json.loads(json.dumps(snapshot or {}, ensure_ascii=False))
|
||
|
||
|
||
def _init_runtime_snapshot(workflow: Workflow, user: User, payload: RunWorkflowRequest, definition: dict[str, Any]) -> dict[str, Any]:
|
||
nodes = definition.get("nodes", [])
|
||
return {
|
||
"workflow_id": workflow.id,
|
||
"workflow_name": workflow.name or "",
|
||
"workflow_folder_path": workflow.folder_path or "",
|
||
"creator_id": getattr(workflow, "creator_id", 0),
|
||
"viewer_user_id": user.id,
|
||
"triggered_by": user.display_name or user.username,
|
||
"status": "running",
|
||
"started_at": time.time(),
|
||
"updated_at": time.time(),
|
||
"current_node_id": None,
|
||
"current_node_label": "",
|
||
"completed_nodes": 0,
|
||
"total_nodes": len(nodes),
|
||
"request": {
|
||
"base_url": payload.base_url,
|
||
"fail_fast": payload.fail_fast,
|
||
},
|
||
"definition": _clone_runtime_snapshot(definition),
|
||
"node_statuses": {
|
||
str(node.get("id", "")): {
|
||
"node_id": str(node.get("id", "")),
|
||
"label": str((node.get("data") or {}).get("label") or node.get("id") or ""),
|
||
"type": str((node.get("data") or {}).get("type") or "http"),
|
||
"status": "pending",
|
||
"started_at": None,
|
||
"finished_at": None,
|
||
"error": None,
|
||
}
|
||
for node in nodes
|
||
if node.get("id")
|
||
},
|
||
}
|
||
|
||
|
||
def _set_runtime_snapshot(snapshot_key: str, snapshot: dict[str, Any]) -> None:
|
||
with RUNNING_WORKFLOW_LOCK:
|
||
RUNNING_WORKFLOW_SNAPSHOTS[snapshot_key] = _clone_runtime_snapshot(snapshot)
|
||
|
||
|
||
def _get_runtime_snapshot(snapshot_key: str) -> dict[str, Any] | None:
|
||
with RUNNING_WORKFLOW_LOCK:
|
||
snapshot = RUNNING_WORKFLOW_SNAPSHOTS.get(snapshot_key)
|
||
return _clone_runtime_snapshot(snapshot) if snapshot else None
|
||
|
||
|
||
def _list_runtime_snapshots_for_user(user: User) -> list[dict[str, Any]]:
|
||
with RUNNING_WORKFLOW_LOCK:
|
||
items = [
|
||
_clone_runtime_snapshot(snapshot)
|
||
for snapshot in RUNNING_WORKFLOW_SNAPSHOTS.values()
|
||
if _is_superadmin(user) or int(snapshot.get("creator_id") or 0) == user.id
|
||
]
|
||
items.sort(key=lambda item: float(item.get("started_at") or 0), reverse=True)
|
||
return items
|
||
|
||
|
||
def _clear_runtime_snapshot(snapshot_key: str) -> None:
|
||
with RUNNING_WORKFLOW_LOCK:
|
||
RUNNING_WORKFLOW_SNAPSHOTS.pop(snapshot_key, None)
|
||
|
||
|
||
def _build_runtime_node_callbacks(snapshot_key: str) -> tuple[Callable[..., None], Callable[..., None]]:
|
||
def on_node_start(node: dict[str, Any], current_index: int, total_nodes: int) -> None:
|
||
node_id = str(node.get("id", ""))
|
||
node_data = node.get("data") or {}
|
||
now = time.time()
|
||
with RUNNING_WORKFLOW_LOCK:
|
||
snapshot = RUNNING_WORKFLOW_SNAPSHOTS.get(snapshot_key)
|
||
if not snapshot:
|
||
return
|
||
snapshot["updated_at"] = now
|
||
snapshot["current_node_id"] = node_id
|
||
snapshot["current_node_label"] = str(node_data.get("label") or node_id)
|
||
snapshot["completed_nodes"] = max(0, current_index - 1)
|
||
snapshot["total_nodes"] = total_nodes
|
||
node_status = snapshot["node_statuses"].setdefault(
|
||
node_id,
|
||
{
|
||
"node_id": node_id,
|
||
"label": str(node_data.get("label") or node_id),
|
||
"type": str(node_data.get("type") or "http"),
|
||
},
|
||
)
|
||
node_status["status"] = "running"
|
||
node_status["started_at"] = now
|
||
node_status["finished_at"] = None
|
||
node_status["error"] = None
|
||
|
||
def on_node_finish(node: dict[str, Any], result: dict[str, Any], completed_nodes: int, total_nodes: int) -> None:
|
||
node_id = str(node.get("id", ""))
|
||
node_data = node.get("data") or {}
|
||
now = time.time()
|
||
with RUNNING_WORKFLOW_LOCK:
|
||
snapshot = RUNNING_WORKFLOW_SNAPSHOTS.get(snapshot_key)
|
||
if not snapshot:
|
||
return
|
||
snapshot["updated_at"] = now
|
||
snapshot["completed_nodes"] = completed_nodes
|
||
snapshot["total_nodes"] = total_nodes
|
||
node_status = snapshot["node_statuses"].setdefault(
|
||
node_id,
|
||
{
|
||
"node_id": node_id,
|
||
"label": str(node_data.get("label") or node_id),
|
||
"type": str(node_data.get("type") or "http"),
|
||
},
|
||
)
|
||
node_status["status"] = str(result.get("status") or "success")
|
||
node_status["finished_at"] = now
|
||
node_status["error"] = result.get("error")
|
||
if snapshot.get("current_node_id") == node_id:
|
||
snapshot["current_node_id"] = None
|
||
snapshot["current_node_label"] = ""
|
||
|
||
return on_node_start, on_node_finish
|
||
|
||
|
||
def _analyze_run_payload(run_payload: dict[str, Any]) -> dict[str, Any]:
|
||
results = run_payload.get("results", [])
|
||
failed = [item for item in results if item.get("status") == "failed"]
|
||
skipped = [item for item in results if item.get("status") == "skipped"]
|
||
success = [item for item in results if item.get("status") == "success"]
|
||
return {
|
||
"status": run_payload.get("status", "unknown"),
|
||
"total_nodes": len(results),
|
||
"success_count": len(success),
|
||
"failed_count": len(failed),
|
||
"skipped_count": len(skipped),
|
||
"failed_nodes": [item.get("node_id") for item in failed],
|
||
"variables": run_payload.get("variables", {}),
|
||
}
|
||
|
||
|
||
def _runtime_snapshot_summary(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||
node_statuses = snapshot.get("node_statuses") or {}
|
||
counts = {"pending": 0, "running": 0, "success": 0, "failed": 0, "skipped": 0}
|
||
for item in node_statuses.values():
|
||
status = str(item.get("status") or "pending")
|
||
counts[status] = counts.get(status, 0) + 1
|
||
return {
|
||
"active_run_id": snapshot.get("active_run_id", ""),
|
||
"workflow_id": snapshot.get("workflow_id", 0),
|
||
"workflow_name": snapshot.get("workflow_name", ""),
|
||
"workflow_folder_path": snapshot.get("workflow_folder_path", ""),
|
||
"creator_id": snapshot.get("creator_id", 0),
|
||
"triggered_by": snapshot.get("triggered_by", ""),
|
||
"status": snapshot.get("status", "running"),
|
||
"started_at": snapshot.get("started_at"),
|
||
"updated_at": snapshot.get("updated_at"),
|
||
"current_node_id": snapshot.get("current_node_id", ""),
|
||
"current_node_label": snapshot.get("current_node_label", ""),
|
||
"completed_nodes": snapshot.get("completed_nodes", 0),
|
||
"total_nodes": snapshot.get("total_nodes", 0),
|
||
"progress": {
|
||
"pending": counts["pending"],
|
||
"running": counts["running"],
|
||
"success": counts["success"],
|
||
"failed": counts["failed"],
|
||
"skipped": counts["skipped"],
|
||
},
|
||
}
|
||
|
||
|
||
def _runtime_snapshot_detail(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||
base = _runtime_snapshot_summary(snapshot)
|
||
base["request"] = _clone_runtime_snapshot(snapshot.get("request") or {})
|
||
base["definition"] = _clone_runtime_snapshot(snapshot.get("definition") or {})
|
||
base["node_states"] = _clone_runtime_snapshot(snapshot.get("node_statuses") or {})
|
||
return base
|
||
|
||
|
||
@app.get("/mcp/tools")
|
||
def mcp_tools(request: Request, db: Session = Depends(get_db)):
|
||
if _mcp_global_require_api_key():
|
||
_resolve_mcp_actor(request, db, require_credential=True)
|
||
return {
|
||
"tools": MCP_TOOL_SPECS,
|
||
"require_api_key": _mcp_global_require_api_key(),
|
||
"ai_guide": "docs/mcp_tools_for_ai.md",
|
||
"ai_guide_summary": (
|
||
"Read docs/mcp_tools_for_ai.md for tool selection, auth, batch workflow, "
|
||
"and node types. Start with catalog_snapshot; batch: create→update→run."
|
||
),
|
||
}
|
||
|
||
|
||
async def _dispatch_mcp_tool(
|
||
tool: str,
|
||
args: dict[str, Any],
|
||
db: Session,
|
||
actor: User,
|
||
) -> McpInvokeResponse:
|
||
try:
|
||
if tool == "folder_ensure":
|
||
target = str(args["target"])
|
||
path = str(args.get("path", ""))
|
||
_ensure_folder_entry(db, target, path, actor)
|
||
return McpInvokeResponse(
|
||
ok=True,
|
||
tool=tool,
|
||
data={"target": target, "path": _normalize_folder_path(path)},
|
||
)
|
||
|
||
if tool == "folder_list":
|
||
target = str(args.get("target", "workflows"))
|
||
data = list_folders(target=target, db=db, user=actor)
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "api_upsert":
|
||
api_id = args.get("api_id")
|
||
request_model = ApiUpdate if api_id else ApiCreate
|
||
request_data = request_model(**args)
|
||
if api_id:
|
||
data = update_api(int(api_id), request_data, db, actor).model_dump()
|
||
else:
|
||
data = create_api(request_data, db, actor).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_upsert":
|
||
workflow_id = args.get("workflow_id")
|
||
request_model = WorkflowUpdate if workflow_id else WorkflowCreate
|
||
request_data = request_model(**args)
|
||
if workflow_id:
|
||
data = update_workflow(int(workflow_id), request_data, db, actor).model_dump()
|
||
else:
|
||
data = create_workflow(request_data, db, actor).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_patch_json":
|
||
workflow_id = int(args["workflow_id"])
|
||
patch = args.get("patch", {})
|
||
row = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(actor, row)
|
||
current = json.loads(row.definition_json)
|
||
merged = _deep_merge_dict(current, patch)
|
||
row.definition_json = json.dumps(merged, ensure_ascii=False)
|
||
db.commit()
|
||
db.refresh(row)
|
||
data = model_to_workflow_out(row).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "api_move_folder":
|
||
api_id = int(args["api_id"])
|
||
folder_path = _normalize_folder_path(str(args.get("folder_path", "")))
|
||
row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="API not found")
|
||
_assert_owner_or_superadmin(actor, row)
|
||
row.folder_path = folder_path
|
||
db.commit()
|
||
db.refresh(row)
|
||
_ensure_folder_entry(db, "apis", folder_path, actor)
|
||
return McpInvokeResponse(ok=True, tool=tool, data=model_to_api_out(row).model_dump())
|
||
|
||
if tool == "workflow_move_folder":
|
||
workflow_id = int(args["workflow_id"])
|
||
folder_path = _normalize_folder_path(str(args.get("folder_path", "")))
|
||
row = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(actor, row)
|
||
_assert_workflow_folder_registered(db, folder_path, actor)
|
||
row.folder_path = folder_path
|
||
db.commit()
|
||
db.refresh(row)
|
||
return McpInvokeResponse(ok=True, tool=tool, data=model_to_workflow_out(row).model_dump())
|
||
|
||
if tool == "workflow_get":
|
||
workflow_id = int(args["workflow_id"])
|
||
row = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(actor, row)
|
||
data = model_to_workflow_out(row).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_node_status":
|
||
workflow_id = int(args["workflow_id"])
|
||
target_node_id = str(args["node_id"])
|
||
row = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(actor, row)
|
||
definition = json.loads(row.definition_json)
|
||
target_node = next((n for n in definition.get("nodes", []) if n.get("id") == target_node_id), None)
|
||
if not target_node:
|
||
raise HTTPException(status_code=404, detail="node not found")
|
||
last_run = (target_node.get("data") or {}).get("last_run") or {}
|
||
return McpInvokeResponse(
|
||
ok=True,
|
||
tool=tool,
|
||
data={"node_id": target_node_id, "last_run": last_run},
|
||
)
|
||
|
||
if tool == "workflow_run":
|
||
request_data = RunWorkflowRequest(**args)
|
||
data = (await _do_run_workflow(request_data, db, "mcp", actor)).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_batch_create":
|
||
request_data = WorkflowBatchCreate(**args)
|
||
batch = _create_workflow_batch_row(request_data, db, actor, "mcp")
|
||
return McpInvokeResponse(ok=True, tool=tool, data=_model_to_batch_dict(batch))
|
||
|
||
if tool == "workflow_batch_update":
|
||
batch_id = int(args["batch_id"])
|
||
batch = _get_workflow_batch_or_404(batch_id, db, actor)
|
||
request_data = WorkflowBatchUpdate(**{k: v for k, v in args.items() if k != "batch_id"})
|
||
batch = _update_workflow_batch_row(batch, request_data, db)
|
||
return McpInvokeResponse(ok=True, tool=tool, data=_model_to_batch_dict(batch))
|
||
|
||
if tool == "workflow_batch_run":
|
||
batch_id = int(args["batch_id"])
|
||
batch = _get_workflow_batch_or_404(batch_id, db, actor)
|
||
data = (await _execute_workflow_batch(batch, db, "mcp", actor)).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_batch_get":
|
||
batch_id = int(args["batch_id"])
|
||
batch = _get_workflow_batch_or_404(batch_id, db, actor)
|
||
runs = (
|
||
db.query(WorkflowRun)
|
||
.filter(WorkflowRun.batch_id == batch_id)
|
||
.order_by(WorkflowRun.id.asc())
|
||
.all()
|
||
)
|
||
return McpInvokeResponse(
|
||
ok=True,
|
||
tool=tool,
|
||
data=_model_to_batch_dict(batch, [_model_to_run_dict(item) for item in runs]),
|
||
)
|
||
|
||
if tool == "workflow_batch_list":
|
||
limit = int(args.get("limit") or 30)
|
||
after_id = args.get("after_id")
|
||
status_filter = args.get("status")
|
||
query = db.query(WorkflowBatch)
|
||
if not _is_superadmin(actor):
|
||
query = query.filter(WorkflowBatch.creator_id == actor.id)
|
||
if status_filter:
|
||
query = query.filter(WorkflowBatch.status == str(status_filter).strip())
|
||
if after_id is not None:
|
||
query = query.filter(WorkflowBatch.id < int(after_id))
|
||
rows = query.order_by(WorkflowBatch.id.desc()).limit(max(1, min(limit, 200))).all()
|
||
return McpInvokeResponse(
|
||
ok=True,
|
||
tool=tool,
|
||
data={"items": [_model_to_batch_dict(row) for row in rows]},
|
||
)
|
||
|
||
if tool == "workflow_run_node":
|
||
request_data = RunNodeRequest(**args)
|
||
data = (await _do_run_workflow_node(request_data, db, "mcp", actor)).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_analyze_last_run":
|
||
workflow_id = int(args["workflow_id"])
|
||
row = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(actor, row)
|
||
last = json.loads(row.last_run_json) if row.last_run_json else {"status": "unknown", "results": []}
|
||
data = _analyze_run_payload(last)
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_run_list":
|
||
items = _list_workflow_runs_for_actor(
|
||
db,
|
||
actor,
|
||
workflow_id=args.get("workflow_id"),
|
||
batch_id=args.get("batch_id"),
|
||
limit=int(args.get("limit") or 30),
|
||
after_id=args.get("after_id"),
|
||
)
|
||
return McpInvokeResponse(ok=True, tool=tool, data={"items": items})
|
||
|
||
if tool == "workflow_run_get":
|
||
data = _get_workflow_run_for_actor(db, actor, int(args["run_id"]))
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_run_replay":
|
||
replay_args = {k: v for k, v in args.items() if k != "run_id"}
|
||
request_data = ReplayRunRequest(**replay_args)
|
||
data = await _replay_workflow_run_for_actor(
|
||
db,
|
||
actor,
|
||
int(args["run_id"]),
|
||
base_url=request_data.base_url,
|
||
fail_fast=request_data.fail_fast,
|
||
triggered_by="mcp",
|
||
)
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_run_loki_link":
|
||
data = _workflow_run_loki_link_for_actor(db, actor, int(args["run_id"]), str(args["node_id"]))
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "workflow_validate":
|
||
definition = args.get("definition") or {}
|
||
if not isinstance(definition, dict):
|
||
raise HTTPException(status_code=400, detail="definition must be an object")
|
||
data = _validate_workflow_definition(definition)
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "mock_upsert":
|
||
request_data = MockDataCreate(**args)
|
||
data = create_mock(request_data, db, actor).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "mcp_tool_upsert":
|
||
request_data = McpToolCreate(**args)
|
||
data = upsert_mcp_tool(request_data, db, actor).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "catalog_snapshot":
|
||
workflows = [item.model_dump() for item in list_workflows(db=db, user=actor)]
|
||
apis = [item.model_dump() for item in list_apis(db=db, user=actor)]
|
||
mocks = [item.model_dump() for item in list_mocks(db, actor)]
|
||
configs = [item.model_dump() for item in list_mcp_tools(db, actor)]
|
||
ssh_tree = [item.model_dump() for item in _build_ssh_tree(db, actor)]
|
||
batch_query = db.query(WorkflowBatch)
|
||
if not _is_superadmin(actor):
|
||
batch_query = batch_query.filter(WorkflowBatch.creator_id == actor.id)
|
||
batch_rows = batch_query.order_by(WorkflowBatch.id.desc()).limit(50).all()
|
||
data = {
|
||
"apis": apis,
|
||
"workflows": workflows,
|
||
"mocks": mocks,
|
||
"workflow_batches": [_model_to_batch_dict(row) for row in batch_rows],
|
||
"mcp_tools": configs,
|
||
"ssh_tree": ssh_tree,
|
||
"folders": {
|
||
"apis": list_folders(target="apis", db=db, user=actor)["folders"],
|
||
"workflows": list_folders(target="workflows", db=db, user=actor)["folders"],
|
||
},
|
||
}
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "ssh_tree":
|
||
data = [item.model_dump() for item in _build_ssh_tree(db, actor)]
|
||
return McpInvokeResponse(ok=True, tool=tool, data={"tree": data})
|
||
|
||
if tool == "ssh_script_get":
|
||
script_id = int(args["script_id"])
|
||
row = _get_ssh_script_row(db, script_id)
|
||
_assert_owner_or_superadmin(actor, row)
|
||
data = model_to_ssh_script_out(row).model_dump()
|
||
if row.source_type == "file" and not data.get("content"):
|
||
data["content"] = resolve_script_body(model_to_ssh_script_out(row))
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "ssh_script_upsert":
|
||
script_id = args.get("script_id")
|
||
content = str(args.get("content", "")).strip()
|
||
if not content:
|
||
raise HTTPException(status_code=400, detail="script content is required")
|
||
if script_id:
|
||
row = _get_ssh_script_row(db, int(script_id))
|
||
_assert_owner_or_superadmin(actor, row)
|
||
if row.source_type == "file":
|
||
raise HTTPException(status_code=400, detail="file script cannot be edited inline, re-upload instead")
|
||
row.name = str(args["name"]).strip()
|
||
row.description = str(args.get("description", "")).strip()
|
||
row.content = content
|
||
db.commit()
|
||
db.refresh(row)
|
||
data = model_to_ssh_script_out(row).model_dump()
|
||
else:
|
||
profile_id = int(args.get("profile_id") or 0)
|
||
if not profile_id:
|
||
raise HTTPException(status_code=400, detail="profile_id is required when creating script")
|
||
profile = db.query(SshProfile).filter(SshProfile.id == profile_id).first()
|
||
if not profile:
|
||
raise HTTPException(status_code=404, detail="ssh profile not found")
|
||
_assert_owner_or_superadmin(actor, profile)
|
||
row = SshScript(
|
||
profile_id=profile_id,
|
||
name=str(args["name"]).strip(),
|
||
description=str(args.get("description", "")).strip(),
|
||
source_type="inline",
|
||
content=content,
|
||
**_creator_fields(actor),
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
data = model_to_ssh_script_out(row).model_dump()
|
||
return McpInvokeResponse(ok=True, tool=tool, data=data)
|
||
|
||
if tool == "ssh_script_run":
|
||
profile_id = int(args["profile_id"])
|
||
script_id = int(args["script_id"])
|
||
password = str(args.get("password") or "")
|
||
if not password.strip():
|
||
raise HTTPException(status_code=400, detail="password is required")
|
||
profile_row = db.query(SshProfile).filter(SshProfile.id == profile_id).first()
|
||
if not profile_row:
|
||
raise HTTPException(status_code=404, detail="ssh profile not found")
|
||
script_row = db.query(SshScript).filter(SshScript.id == script_id, SshScript.profile_id == profile_id).first()
|
||
if not script_row:
|
||
raise HTTPException(status_code=404, detail="ssh script not found")
|
||
_assert_owner_or_superadmin(actor, profile_row)
|
||
_assert_owner_or_superadmin(actor, script_row)
|
||
profile = model_to_ssh_profile_out(profile_row)
|
||
script = model_to_ssh_script_out(script_row)
|
||
if profile.auth_type == "key" and profile.key_path and not os.path.exists(profile.key_path):
|
||
raise HTTPException(status_code=400, detail="configured key_path does not exist")
|
||
timeout_seconds = float(args.get("timeout_seconds") or 120.0)
|
||
data = await run_script_collect(profile, script, password, timeout_seconds=timeout_seconds)
|
||
return McpInvokeResponse(ok=bool(data.get("ok")), tool=tool, data=data)
|
||
|
||
raise HTTPException(status_code=404, detail=f"unknown mcp tool: {tool}")
|
||
except HTTPException as exc:
|
||
return McpInvokeResponse(ok=False, tool=tool, error=str(exc.detail))
|
||
except Exception as exc:
|
||
return McpInvokeResponse(ok=False, tool=tool, error=str(exc))
|
||
|
||
|
||
@app.post("/mcp/invoke", response_model=McpInvokeResponse)
|
||
async def mcp_invoke(payload: McpInvokeRequest, request: Request, db: Session = Depends(get_db)):
|
||
tool = payload.tool
|
||
args = payload.arguments or {}
|
||
actor = _resolve_mcp_actor(
|
||
request,
|
||
db,
|
||
api_key=payload.api_key,
|
||
require_credential=_mcp_tool_requires_credential(tool),
|
||
)
|
||
return await _dispatch_mcp_tool(tool, args, db, actor)
|
||
|
||
|
||
@app.post("/mcp/invoke-batch")
|
||
async def mcp_invoke_batch(payload: McpInvokeBatchRequest, request: Request, db: Session = Depends(get_db)):
|
||
outputs: list[dict[str, Any]] = []
|
||
for call in payload.calls:
|
||
result = await mcp_invoke(call, request, db)
|
||
outputs.append(result.model_dump())
|
||
if payload.stop_on_error and not result.ok:
|
||
break
|
||
return {"results": outputs}
|
||
|
||
|
||
@app.get("/health")
|
||
def health():
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/auth/login", response_model=LoginResponse)
|
||
def login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||
user = db.query(User).filter(User.username == payload.username.strip()).first()
|
||
if not user or not user.is_active or user.password_hash != _hash_password(payload.password):
|
||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||
return LoginResponse(token=_issue_auth_token(user), user=_make_user_out(user))
|
||
|
||
|
||
@app.get("/api/auth/me", response_model=CurrentUserOut)
|
||
def auth_me(user: User = Depends(_get_current_user)):
|
||
return _make_user_out(user)
|
||
|
||
|
||
@app.get("/api/me/api-keys", response_model=list[UserApiKeyOut])
|
||
def list_my_api_keys(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
rows = (
|
||
db.query(UserApiKey)
|
||
.filter(UserApiKey.user_id == user.id)
|
||
.order_by(UserApiKey.id.desc())
|
||
.all()
|
||
)
|
||
return [_model_to_user_api_key_out(row) for row in rows]
|
||
|
||
|
||
@app.post("/api/me/api-keys", response_model=UserApiKeyCreateResponse)
|
||
def create_my_api_key(
|
||
payload: UserApiKeyCreate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
expires_at = _parse_api_key_expires_at(payload.expires_at)
|
||
full_key, key_hash, key_prefix, key_hint = _generate_user_api_key()
|
||
row = UserApiKey(
|
||
user_id=user.id,
|
||
key_prefix=key_prefix,
|
||
key_hash=key_hash,
|
||
key_hint=key_hint,
|
||
expires_at=expires_at,
|
||
is_active=True,
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
base = _model_to_user_api_key_out(row)
|
||
return UserApiKeyCreateResponse(**base.model_dump(), api_key=full_key)
|
||
|
||
|
||
@app.delete("/api/me/api-keys/{key_id}")
|
||
def revoke_my_api_key(key_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = db.query(UserApiKey).filter(UserApiKey.id == key_id, UserApiKey.user_id == user.id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="api key not found")
|
||
db.delete(row)
|
||
db.commit()
|
||
return {"deleted": True, "id": key_id}
|
||
|
||
|
||
@app.get("/api/users", response_model=list[UserOut])
|
||
def list_users(
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(_require_superadmin),
|
||
):
|
||
rows = db.query(User).order_by(User.id.asc()).all()
|
||
return [
|
||
UserOut(
|
||
id=row.id,
|
||
username=row.username,
|
||
display_name=row.display_name or row.username,
|
||
role="superadmin" if row.role == "superadmin" else "user",
|
||
is_active=bool(row.is_active),
|
||
)
|
||
for row in rows
|
||
]
|
||
|
||
|
||
@app.post("/api/users", response_model=UserOut)
|
||
def create_user(
|
||
payload: UserCreate,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(_require_superadmin),
|
||
):
|
||
username = payload.username.strip()
|
||
if not username:
|
||
raise HTTPException(status_code=400, detail="username is required")
|
||
if db.query(User.id).filter(User.username == username).first():
|
||
raise HTTPException(status_code=400, detail="username already exists")
|
||
row = User(
|
||
username=username,
|
||
password_hash=_hash_password(payload.password),
|
||
display_name=(payload.display_name or username).strip(),
|
||
role=payload.role,
|
||
is_active=True,
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return UserOut(
|
||
id=row.id,
|
||
username=row.username,
|
||
display_name=row.display_name or row.username,
|
||
role="superadmin" if row.role == "superadmin" else "user",
|
||
is_active=bool(row.is_active),
|
||
)
|
||
|
||
|
||
@app.put("/api/users/{user_id}", response_model=UserOut)
|
||
def update_user(
|
||
user_id: int,
|
||
payload: UserUpdate,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(_require_superadmin),
|
||
):
|
||
row = db.query(User).filter(User.id == user_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="user not found")
|
||
|
||
next_role = "superadmin" if payload.role == "superadmin" else "user"
|
||
next_active = bool(payload.is_active)
|
||
active_superadmin_count = db.query(User.id).filter(User.role == "superadmin", User.is_active == True).count()
|
||
|
||
if row.id == current_user.id and not next_active:
|
||
raise HTTPException(status_code=400, detail="cannot deactivate current user")
|
||
if row.role == "superadmin" and row.is_active and (not next_active or next_role != "superadmin") and active_superadmin_count <= 1:
|
||
raise HTTPException(status_code=400, detail="at least one active superadmin is required")
|
||
|
||
row.display_name = (payload.display_name or row.username).strip() or row.username
|
||
row.role = next_role
|
||
row.is_active = next_active
|
||
if payload.password.strip():
|
||
row.password_hash = _hash_password(payload.password)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return UserOut(
|
||
id=row.id,
|
||
username=row.username,
|
||
display_name=row.display_name or row.username,
|
||
role="superadmin" if row.role == "superadmin" else "user",
|
||
is_active=bool(row.is_active),
|
||
)
|
||
|
||
|
||
@app.get("/api/folders")
|
||
def list_folders(target: str = "workflows", db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
if target not in {"apis", "workflows"}:
|
||
raise HTTPException(status_code=400, detail="target must be apis or workflows")
|
||
if target == "apis":
|
||
query = db.query(ApiDefinition.folder_path, func.count(ApiDefinition.id))
|
||
query = _scope_query_by_user(query, ApiDefinition, user)
|
||
grouped_rows = query.group_by(ApiDefinition.folder_path).all()
|
||
else:
|
||
query = db.query(Workflow.folder_path, func.count(Workflow.id))
|
||
query = _scope_query_by_user(query, Workflow, user)
|
||
grouped_rows = query.group_by(Workflow.folder_path).all()
|
||
declared_query = db.query(FolderEntry.path).filter(FolderEntry.target == target)
|
||
declared_query = _scope_query_by_user(declared_query, FolderEntry, user)
|
||
declared_rows = declared_query.all()
|
||
path_count_map: dict[str, int] = {}
|
||
for path_value, count_value in grouped_rows:
|
||
normalized = _normalize_folder_path(path_value)
|
||
path_count_map[normalized] = path_count_map.get(normalized, 0) + int(count_value)
|
||
for row in declared_rows:
|
||
normalized = _normalize_folder_path(row[0])
|
||
if normalized not in path_count_map:
|
||
path_count_map[normalized] = 0
|
||
tree = _build_folder_tree(path_count_map)
|
||
folders = sorted(path for path in path_count_map.keys() if path)
|
||
return {
|
||
"target": target,
|
||
"folders": folders,
|
||
"tree": tree["children"],
|
||
"total_count": tree["count"],
|
||
"root_count": int(path_count_map.get("", 0)),
|
||
}
|
||
|
||
|
||
@app.post("/api/folders/ensure")
|
||
def ensure_folder(payload: dict[str, Any], db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
target = str(payload.get("target", "workflows"))
|
||
path = str(payload.get("path", ""))
|
||
_ensure_folder_entry(db, target, path, user)
|
||
return {"ok": True, "target": target, "path": _normalize_folder_path(path)}
|
||
|
||
|
||
@app.put("/api/folders/move")
|
||
def move_folder(payload: FolderMoveRequest, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
data = _move_folder_tree(db, payload.target, payload.from_path, payload.to_path, user)
|
||
return {"ok": True, **data}
|
||
|
||
|
||
def _api_request_signature(method: str | None, url: str | None) -> tuple[str, str]:
|
||
return (str(method or "GET").upper(), str(url or "").strip())
|
||
|
||
|
||
def _derive_api_name_from_node(data: dict[str, Any], url: str) -> str:
|
||
label = str(data.get("label") or "").strip()
|
||
if label:
|
||
return label[:128]
|
||
path = url.split("?")[0].rstrip("/")
|
||
tail = path.split("/")[-1] if path else ""
|
||
if tail:
|
||
return tail[:128]
|
||
return url[:128]
|
||
|
||
|
||
def _sync_apis_from_workflows(db: Session, user: User) -> dict[str, int]:
|
||
workflows = _scope_query_by_user(db.query(Workflow), Workflow, user).all()
|
||
existing_rows = _scope_query_by_user(db.query(ApiDefinition), ApiDefinition, user).all()
|
||
existing = {_api_request_signature(row.method, row.url): row for row in existing_rows}
|
||
|
||
created = 0
|
||
skipped = 0
|
||
pending: dict[tuple[str, str], dict[str, Any]] = {}
|
||
|
||
for workflow in workflows:
|
||
folder_path = _normalize_folder_path(workflow.folder_path)
|
||
try:
|
||
definition = json.loads(workflow.definition_json)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
for node in definition.get("nodes", []):
|
||
data = node.get("data") or {}
|
||
if str(data.get("type", "")).lower() != "http":
|
||
continue
|
||
url = str(data.get("url") or "").strip()
|
||
if not url:
|
||
continue
|
||
signature = _api_request_signature(data.get("method"), url)
|
||
if signature in existing or signature in pending:
|
||
if signature in existing:
|
||
skipped += 1
|
||
continue
|
||
pending[signature] = {
|
||
"name": _derive_api_name_from_node(data, url),
|
||
"folder_path": folder_path,
|
||
"method": signature[0],
|
||
"url": signature[1],
|
||
"config": {
|
||
"headers": data.get("headers") or {},
|
||
"body": data.get("body") or {},
|
||
"query": data.get("query") or {},
|
||
"path_params": data.get("path_params") or {},
|
||
"timeout_seconds": float(data.get("timeout_seconds") or 10),
|
||
},
|
||
}
|
||
|
||
creator = _creator_fields(user)
|
||
for item in pending.values():
|
||
row = ApiDefinition(
|
||
name=item["name"],
|
||
folder_path=item["folder_path"],
|
||
method=item["method"],
|
||
url=item["url"],
|
||
config_json=json.dumps(item["config"], ensure_ascii=False),
|
||
**creator,
|
||
)
|
||
db.add(row)
|
||
if item["folder_path"]:
|
||
_ensure_folder_entry(db, "apis", item["folder_path"], user)
|
||
created += 1
|
||
|
||
if created:
|
||
db.commit()
|
||
return {
|
||
"created": created,
|
||
"skipped": skipped,
|
||
"total": len(existing) + created,
|
||
}
|
||
|
||
|
||
@app.get("/api/apis", response_model=list[ApiOut])
|
||
def list_apis(folder_path: str | None = None, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
query = _scope_query_by_user(db.query(ApiDefinition), ApiDefinition, user)
|
||
if folder_path is not None:
|
||
query = query.filter(ApiDefinition.folder_path == _normalize_folder_path(folder_path))
|
||
rows = query.order_by(ApiDefinition.id.desc()).all()
|
||
return [model_to_api_out(r) for r in rows]
|
||
|
||
|
||
@app.get("/api/apis/{api_id}", response_model=ApiOut)
|
||
def get_api(api_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="API not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
return model_to_api_out(row)
|
||
|
||
|
||
@app.post("/api/apis", response_model=ApiOut)
|
||
def create_api(payload: ApiCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
cfg = {
|
||
"headers": payload.headers,
|
||
"body": payload.body,
|
||
"query": payload.query,
|
||
"path_params": payload.path_params,
|
||
"timeout_seconds": payload.timeout_seconds,
|
||
}
|
||
row = ApiDefinition(
|
||
name=payload.name,
|
||
folder_path=_normalize_folder_path(payload.folder_path),
|
||
method=payload.method.upper(),
|
||
url=payload.url,
|
||
creator_id=user.id,
|
||
creator_name=user.display_name or user.username,
|
||
config_json=json.dumps(cfg, ensure_ascii=False),
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
_ensure_folder_entry(db, "apis", row.folder_path, user)
|
||
return model_to_api_out(row)
|
||
|
||
|
||
@app.post("/api/apis/sync-from-workflows")
|
||
def sync_apis_from_workflows(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
result = _sync_apis_from_workflows(db, user)
|
||
return {"ok": True, **result}
|
||
|
||
|
||
@app.put("/api/apis/{api_id}", response_model=ApiOut)
|
||
def update_api(api_id: int, payload: ApiUpdate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="API not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
row.name = payload.name
|
||
row.folder_path = _normalize_folder_path(payload.folder_path)
|
||
row.method = payload.method.upper()
|
||
row.url = payload.url
|
||
row.config_json = json.dumps(
|
||
{
|
||
"headers": payload.headers,
|
||
"body": payload.body,
|
||
"query": payload.query,
|
||
"path_params": payload.path_params,
|
||
"timeout_seconds": payload.timeout_seconds,
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
db.commit()
|
||
db.refresh(row)
|
||
_ensure_folder_entry(db, "apis", row.folder_path, user)
|
||
return model_to_api_out(row)
|
||
|
||
|
||
@app.delete("/api/apis/{api_id}")
|
||
def delete_api(api_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="API not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
db.delete(row)
|
||
db.commit()
|
||
return {"deleted": True}
|
||
|
||
|
||
@app.post("/api/apis/{api_id}/move-folder")
|
||
def move_api_folder(api_id: int, payload: dict[str, Any], db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = db.query(ApiDefinition).filter(ApiDefinition.id == api_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="API not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
folder_path = _normalize_folder_path(str(payload.get("folder_path", "")))
|
||
row.folder_path = folder_path
|
||
db.commit()
|
||
db.refresh(row)
|
||
_ensure_folder_entry(db, "apis", folder_path, user)
|
||
return {"ok": True, "api": model_to_api_out(row).model_dump()}
|
||
|
||
|
||
@app.get("/api/workflows", response_model=list[WorkflowOut])
|
||
def list_workflows(
|
||
folder_path: str | None = None,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
query = _scope_query_by_user(db.query(Workflow), Workflow, user)
|
||
if folder_path is not None:
|
||
query = query.filter(Workflow.folder_path == _normalize_folder_path(folder_path))
|
||
rows = query.order_by(Workflow.id.desc()).all()
|
||
return [model_to_workflow_out(r) for r in rows]
|
||
|
||
|
||
@app.post("/api/workflows", response_model=WorkflowOut)
|
||
def create_workflow(payload: WorkflowCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
folder_path = _normalize_folder_path(payload.folder_path)
|
||
_assert_workflow_folder_registered(db, folder_path, user)
|
||
row = Workflow(
|
||
name=payload.name,
|
||
folder_path=folder_path,
|
||
creator_id=user.id,
|
||
creator_name=user.display_name or user.username,
|
||
definition_json=payload.definition.model_dump_json(),
|
||
default_headers_json=json.dumps(payload.default_headers or {}, ensure_ascii=False),
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_workflow_out(row)
|
||
|
||
|
||
@app.put("/api/workflows/{workflow_id}", response_model=WorkflowOut)
|
||
def update_workflow(
|
||
workflow_id: int,
|
||
payload: WorkflowUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
row = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
new_folder = _normalize_folder_path(payload.folder_path)
|
||
old_folder = _normalize_folder_path(row.folder_path)
|
||
if new_folder != old_folder:
|
||
_assert_workflow_folder_registered(db, new_folder, user)
|
||
row.name = payload.name
|
||
row.folder_path = new_folder
|
||
row.definition_json = payload.definition.model_dump_json()
|
||
row.default_headers_json = json.dumps(payload.default_headers or {}, ensure_ascii=False)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_workflow_out(row)
|
||
|
||
|
||
@app.get("/api/workflows/{workflow_id}", response_model=WorkflowOut)
|
||
def get_workflow(workflow_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
return model_to_workflow_out(row)
|
||
|
||
|
||
@app.post("/api/workflows/{workflow_id}/move-folder")
|
||
def move_workflow_folder(
|
||
workflow_id: int,
|
||
payload: dict[str, Any],
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
row = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
folder_path = _normalize_folder_path(str(payload.get("folder_path", "")))
|
||
_assert_workflow_folder_registered(db, folder_path, user)
|
||
row.folder_path = folder_path
|
||
db.commit()
|
||
db.refresh(row)
|
||
return {"ok": True, "workflow": model_to_workflow_out(row).model_dump()}
|
||
|
||
|
||
@app.post("/api/workflows/validate")
|
||
def validate_workflow(definition: dict[str, Any], _: User = Depends(_get_current_user)):
|
||
if not isinstance(definition, dict):
|
||
raise HTTPException(status_code=400, detail="definition must be an object")
|
||
return _validate_workflow_definition(definition)
|
||
|
||
|
||
async def _do_run_workflow(
|
||
payload: RunWorkflowRequest,
|
||
db: Session,
|
||
triggered_by: str,
|
||
user: User,
|
||
batch_id: int | None = None,
|
||
) -> RunWorkflowResponse:
|
||
workflow = db.query(Workflow).filter(Workflow.id == payload.workflow_id).first()
|
||
if not workflow:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(user, workflow)
|
||
|
||
definition = json.loads(workflow.definition_json)
|
||
api_map, mock_map = _collect_runtime_maps(db, user)
|
||
active_run_id = secrets.token_hex(12)
|
||
runtime_snapshot = _init_runtime_snapshot(workflow, user, payload, definition)
|
||
runtime_snapshot["active_run_id"] = active_run_id
|
||
_set_runtime_snapshot(active_run_id, runtime_snapshot)
|
||
on_node_start, on_node_finish = _build_runtime_node_callbacks(active_run_id)
|
||
|
||
started = time.perf_counter()
|
||
try:
|
||
run_result = await execute_workflow(
|
||
definition=definition,
|
||
api_map=api_map,
|
||
mock_map=mock_map,
|
||
base_url=payload.base_url,
|
||
fail_fast=payload.fail_fast,
|
||
default_headers=_workflow_default_headers(workflow),
|
||
on_node_start=on_node_start,
|
||
on_node_finish=on_node_finish,
|
||
)
|
||
except Exception:
|
||
with RUNNING_WORKFLOW_LOCK:
|
||
snapshot = RUNNING_WORKFLOW_SNAPSHOTS.get(active_run_id)
|
||
if snapshot:
|
||
snapshot["status"] = "failed"
|
||
snapshot["updated_at"] = time.time()
|
||
raise
|
||
finally:
|
||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||
_clear_runtime_snapshot(active_run_id)
|
||
|
||
workflow.definition_json = json.dumps(definition, ensure_ascii=False)
|
||
workflow.last_run_json = json.dumps(run_result, ensure_ascii=False)
|
||
db.commit()
|
||
run_row = _record_workflow_run(
|
||
db,
|
||
workflow,
|
||
run_type="full",
|
||
triggered_by=triggered_by,
|
||
status=run_result["status"],
|
||
duration_ms=duration_ms,
|
||
summary=_summarize_results(run_result.get("results", [])),
|
||
payload={
|
||
"workflow_snapshot": {
|
||
"id": workflow.id,
|
||
"name": workflow.name,
|
||
"folder_path": workflow.folder_path or "",
|
||
"default_headers": _workflow_default_headers(workflow),
|
||
"definition": definition,
|
||
},
|
||
"request": {"base_url": payload.base_url, "fail_fast": payload.fail_fast},
|
||
"results": run_result.get("results", []),
|
||
"variables": run_result.get("variables", {}),
|
||
},
|
||
batch_id=batch_id,
|
||
)
|
||
return RunWorkflowResponse(
|
||
status=run_result["status"],
|
||
variables=run_result["variables"],
|
||
results=run_result["results"],
|
||
run_id=run_row.id,
|
||
workflow_id=workflow.id,
|
||
batch_id=batch_id,
|
||
)
|
||
|
||
|
||
@app.post("/api/workflows/run", response_model=RunWorkflowResponse)
|
||
async def run_workflow(
|
||
payload: RunWorkflowRequest,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
return await _do_run_workflow(payload, db, _detect_trigger_source(request), user)
|
||
|
||
|
||
async def _do_run_workflow_node(
|
||
payload: RunNodeRequest,
|
||
db: Session,
|
||
triggered_by: str,
|
||
user: User,
|
||
) -> RunNodeResponse:
|
||
workflow = db.query(Workflow).filter(Workflow.id == payload.workflow_id).first()
|
||
if not workflow:
|
||
raise HTTPException(status_code=404, detail="workflow not found")
|
||
_assert_owner_or_superadmin(user, workflow)
|
||
|
||
definition = json.loads(workflow.definition_json)
|
||
nodes = definition.get("nodes", [])
|
||
node = next((item for item in nodes if item.get("id") == payload.node_id), None)
|
||
if not node:
|
||
raise HTTPException(status_code=404, detail="node not found in workflow")
|
||
|
||
api_map, mock_map = _collect_runtime_maps(db, user)
|
||
variables = dict(definition.get("variables", {}))
|
||
|
||
started = time.perf_counter()
|
||
result = await execute_single_node(
|
||
node=node,
|
||
variables=variables,
|
||
api_map=api_map,
|
||
mock_map=mock_map,
|
||
base_url=payload.base_url,
|
||
default_headers=_workflow_default_headers(workflow),
|
||
)
|
||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||
node_data = node.setdefault("data", {})
|
||
if isinstance(node_data, dict):
|
||
node_data["last_run"] = {
|
||
"status": result.get("status"),
|
||
"error": result.get("error"),
|
||
"output": result.get("output", {}),
|
||
}
|
||
workflow.definition_json = json.dumps(definition, ensure_ascii=False)
|
||
db.commit()
|
||
status = "failed" if result["status"] == "failed" else "success"
|
||
_record_workflow_run(
|
||
db,
|
||
workflow,
|
||
run_type="node",
|
||
node_id=payload.node_id,
|
||
triggered_by=triggered_by,
|
||
status=status,
|
||
duration_ms=duration_ms,
|
||
summary=_summarize_results([result]),
|
||
payload={
|
||
"workflow_snapshot": {
|
||
"id": workflow.id,
|
||
"name": workflow.name,
|
||
"folder_path": workflow.folder_path or "",
|
||
"default_headers": _workflow_default_headers(workflow),
|
||
"definition": definition,
|
||
},
|
||
"request": {"base_url": payload.base_url},
|
||
"result": result,
|
||
"variables": variables,
|
||
},
|
||
)
|
||
return RunNodeResponse(status=status, variables=variables, result=result)
|
||
|
||
|
||
@app.post("/api/workflows/run-node", response_model=RunNodeResponse)
|
||
async def run_workflow_node(
|
||
payload: RunNodeRequest,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
return await _do_run_workflow_node(payload, db, _detect_trigger_source(request), user)
|
||
|
||
|
||
def _model_to_batch_dict(row: WorkflowBatch, runs: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||
try:
|
||
workflow_ids = json.loads(row.workflow_ids_json or "[]")
|
||
except Exception:
|
||
workflow_ids = []
|
||
if not isinstance(workflow_ids, list):
|
||
workflow_ids = []
|
||
return {
|
||
"id": row.id,
|
||
"name": row.name or "",
|
||
"status": row.status,
|
||
"triggered_by": row.triggered_by,
|
||
"creator_id": row.creator_id,
|
||
"creator_name": row.creator_name,
|
||
"base_url": row.base_url or "",
|
||
"fail_fast": bool(row.fail_fast),
|
||
"workflow_ids": [int(item) for item in workflow_ids if str(item).isdigit()],
|
||
"summary": json.loads(row.summary_json or "{}"),
|
||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||
"finished_at": row.finished_at.isoformat() if row.finished_at else None,
|
||
"runs": runs or [],
|
||
}
|
||
|
||
|
||
def _normalize_workflow_ids(raw_ids: list[Any] | None) -> list[int]:
|
||
workflow_ids: list[int] = []
|
||
for raw_id in raw_ids or []:
|
||
try:
|
||
workflow_ids.append(int(raw_id))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return list(dict.fromkeys(workflow_ids))
|
||
|
||
|
||
def _get_workflow_batch_or_404(batch_id: int, db: Session, user: User) -> WorkflowBatch:
|
||
row = db.query(WorkflowBatch).filter(WorkflowBatch.id == batch_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="batch not found")
|
||
if not _is_superadmin(user) and row.creator_id != user.id:
|
||
raise HTTPException(status_code=403, detail="no permission for this resource")
|
||
return row
|
||
|
||
|
||
def _create_workflow_batch_row(
|
||
payload: WorkflowBatchCreate,
|
||
db: Session,
|
||
user: User,
|
||
triggered_by: str,
|
||
) -> WorkflowBatch:
|
||
workflow_ids = _normalize_workflow_ids(payload.workflow_ids)
|
||
batch = WorkflowBatch(
|
||
name=(payload.name or "").strip() or f"批跑 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')}",
|
||
status="draft",
|
||
triggered_by=triggered_by,
|
||
creator_id=user.id,
|
||
creator_name=user.display_name or user.username,
|
||
base_url=payload.base_url or "",
|
||
fail_fast=bool(payload.fail_fast),
|
||
workflow_ids_json=json.dumps(workflow_ids, ensure_ascii=False),
|
||
summary_json="{}",
|
||
)
|
||
db.add(batch)
|
||
db.commit()
|
||
db.refresh(batch)
|
||
return batch
|
||
|
||
|
||
def _update_workflow_batch_row(
|
||
batch: WorkflowBatch,
|
||
payload: WorkflowBatchUpdate,
|
||
db: Session,
|
||
) -> WorkflowBatch:
|
||
if batch.status != "draft":
|
||
raise HTTPException(status_code=400, detail="仅 draft 状态的批次可编辑")
|
||
|
||
if payload.name is not None:
|
||
batch.name = str(payload.name).strip() or batch.name
|
||
if payload.base_url is not None:
|
||
batch.base_url = str(payload.base_url)
|
||
if payload.fail_fast is not None:
|
||
batch.fail_fast = bool(payload.fail_fast)
|
||
if payload.workflow_ids is not None:
|
||
batch.workflow_ids_json = json.dumps(_normalize_workflow_ids(payload.workflow_ids), ensure_ascii=False)
|
||
|
||
db.commit()
|
||
db.refresh(batch)
|
||
return batch
|
||
|
||
|
||
def _finalize_batch_status(items: list[WorkflowBatchRunItem]) -> str:
|
||
if not items:
|
||
return "failed"
|
||
statuses = {str(item.status) for item in items}
|
||
if statuses <= {"success"}:
|
||
return "success"
|
||
if statuses <= {"failed"}:
|
||
return "failed"
|
||
if "success" in statuses and "failed" in statuses:
|
||
return "partial"
|
||
return "partial"
|
||
|
||
|
||
async def _execute_workflow_batch(
|
||
batch: WorkflowBatch,
|
||
db: Session,
|
||
triggered_by: str,
|
||
user: User,
|
||
) -> RunWorkflowBatchResponse:
|
||
if batch.status != "draft":
|
||
raise HTTPException(status_code=400, detail="仅 draft 状态的批次可执行")
|
||
|
||
workflow_ids = _normalize_workflow_ids(json.loads(batch.workflow_ids_json or "[]"))
|
||
if not workflow_ids:
|
||
raise HTTPException(status_code=400, detail="请先为批次选择至少一个工作流")
|
||
|
||
batch.status = "running"
|
||
batch.finished_at = None
|
||
db.commit()
|
||
|
||
items: list[WorkflowBatchRunItem] = []
|
||
success_count = 0
|
||
failed_count = 0
|
||
|
||
for workflow_id in workflow_ids:
|
||
item = WorkflowBatchRunItem(workflow_id=workflow_id, status="running")
|
||
workflow = db.query(Workflow).filter(Workflow.id == workflow_id).first()
|
||
if not workflow:
|
||
item.status = "failed"
|
||
item.error = "workflow not found"
|
||
failed_count += 1
|
||
items.append(item)
|
||
if batch.fail_fast:
|
||
break
|
||
continue
|
||
try:
|
||
_assert_owner_or_superadmin(user, workflow)
|
||
except HTTPException as exc:
|
||
item.status = "failed"
|
||
item.error = str(exc.detail)
|
||
item.workflow_name = workflow.name or ""
|
||
failed_count += 1
|
||
items.append(item)
|
||
if batch.fail_fast:
|
||
break
|
||
continue
|
||
|
||
item.workflow_name = workflow.name or ""
|
||
try:
|
||
result = await _do_run_workflow(
|
||
RunWorkflowRequest(
|
||
workflow_id=workflow_id,
|
||
base_url=batch.base_url or "",
|
||
fail_fast=bool(batch.fail_fast),
|
||
),
|
||
db,
|
||
triggered_by,
|
||
user,
|
||
batch_id=batch.id,
|
||
)
|
||
item.run_id = result.run_id
|
||
item.status = result.status
|
||
if result.status == "success":
|
||
success_count += 1
|
||
else:
|
||
failed_count += 1
|
||
if batch.fail_fast:
|
||
items.append(item)
|
||
break
|
||
except HTTPException as exc:
|
||
item.status = "failed"
|
||
item.error = str(exc.detail)
|
||
failed_count += 1
|
||
if batch.fail_fast:
|
||
items.append(item)
|
||
break
|
||
except Exception as exc:
|
||
item.status = "failed"
|
||
item.error = str(exc)
|
||
failed_count += 1
|
||
if batch.fail_fast:
|
||
items.append(item)
|
||
break
|
||
items.append(item)
|
||
|
||
batch_status = _finalize_batch_status(items)
|
||
batch.status = batch_status
|
||
batch.summary_json = json.dumps(
|
||
{
|
||
"total": len(workflow_ids),
|
||
"executed": len(items),
|
||
"success": success_count,
|
||
"failed": failed_count,
|
||
"items": [item.model_dump() for item in items],
|
||
},
|
||
ensure_ascii=False,
|
||
)
|
||
batch.finished_at = datetime.now(timezone.utc)
|
||
db.commit()
|
||
|
||
return RunWorkflowBatchResponse(batch_id=batch.id, status=batch_status, items=items)
|
||
|
||
|
||
@app.post("/api/workflow-batches", response_model=WorkflowBatchOut)
|
||
def create_workflow_batch(
|
||
payload: WorkflowBatchCreate,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
batch = _create_workflow_batch_row(payload, db, user, _detect_trigger_source(request))
|
||
return _model_to_batch_dict(batch)
|
||
|
||
|
||
@app.put("/api/workflow-batches/{batch_id}", response_model=WorkflowBatchOut)
|
||
def update_workflow_batch(
|
||
batch_id: int,
|
||
payload: WorkflowBatchUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
batch = _get_workflow_batch_or_404(batch_id, db, user)
|
||
batch = _update_workflow_batch_row(batch, payload, db)
|
||
return _model_to_batch_dict(batch)
|
||
|
||
|
||
@app.post("/api/workflow-batches/{batch_id}/run", response_model=RunWorkflowBatchResponse)
|
||
async def run_workflow_batch_by_id(
|
||
batch_id: int,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
batch = _get_workflow_batch_or_404(batch_id, db, user)
|
||
return await _execute_workflow_batch(batch, db, _detect_trigger_source(request), user)
|
||
|
||
|
||
@app.post("/api/workflow-batches/run", response_model=RunWorkflowBatchResponse)
|
||
async def run_workflow_batch_quick(
|
||
payload: RunWorkflowBatchRequest,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
"""快捷:创建批次并立即执行。"""
|
||
triggered_by = _detect_trigger_source(request)
|
||
batch = _create_workflow_batch_row(
|
||
WorkflowBatchCreate(
|
||
name=payload.name,
|
||
base_url=payload.base_url,
|
||
fail_fast=payload.fail_fast,
|
||
workflow_ids=payload.workflow_ids,
|
||
),
|
||
db,
|
||
user,
|
||
triggered_by,
|
||
)
|
||
return await _execute_workflow_batch(batch, db, triggered_by, user)
|
||
|
||
|
||
@app.get("/api/workflow-batches")
|
||
def list_workflow_batches(
|
||
limit: int = 30,
|
||
after_id: int | None = None,
|
||
status: str | None = None,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
limit = max(1, min(int(limit or 30), 200))
|
||
query = db.query(WorkflowBatch)
|
||
if not _is_superadmin(user):
|
||
query = query.filter(WorkflowBatch.creator_id == user.id)
|
||
if status:
|
||
query = query.filter(WorkflowBatch.status == str(status).strip())
|
||
if after_id is not None:
|
||
query = query.filter(WorkflowBatch.id < int(after_id))
|
||
rows = query.order_by(WorkflowBatch.id.desc()).limit(limit).all()
|
||
return {"items": [_model_to_batch_dict(row) for row in rows]}
|
||
|
||
|
||
@app.get("/api/workflow-batches/{batch_id}", response_model=WorkflowBatchOut)
|
||
def get_workflow_batch(
|
||
batch_id: int,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
row = _get_workflow_batch_or_404(batch_id, db, user)
|
||
runs = (
|
||
db.query(WorkflowRun)
|
||
.filter(WorkflowRun.batch_id == batch_id)
|
||
.order_by(WorkflowRun.id.asc())
|
||
.all()
|
||
)
|
||
return _model_to_batch_dict(row, [_model_to_run_dict(item) for item in runs])
|
||
|
||
|
||
@app.get("/api/workflow-runs")
|
||
def list_workflow_runs(
|
||
workflow_id: int | None = None,
|
||
batch_id: int | None = None,
|
||
limit: int = 30,
|
||
after_id: int | None = None,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
items = _list_workflow_runs_for_actor(
|
||
db,
|
||
user,
|
||
workflow_id=workflow_id,
|
||
batch_id=batch_id,
|
||
limit=limit,
|
||
after_id=after_id,
|
||
)
|
||
return {"items": items}
|
||
|
||
|
||
@app.get("/api/workflow-runs/active")
|
||
def list_active_workflow_runs(user: User = Depends(_get_current_user)):
|
||
items = [_runtime_snapshot_summary(item) for item in _list_runtime_snapshots_for_user(user)]
|
||
return {"items": items}
|
||
|
||
|
||
@app.get("/api/workflow-runs/active/{active_run_id}")
|
||
def get_active_workflow_run(active_run_id: str, user: User = Depends(_get_current_user)):
|
||
snapshot = _get_runtime_snapshot(active_run_id)
|
||
if not snapshot:
|
||
raise HTTPException(status_code=404, detail="active workflow run not found")
|
||
if not _is_superadmin(user) and int(snapshot.get("creator_id") or 0) != user.id:
|
||
raise HTTPException(status_code=403, detail="no permission for this resource")
|
||
return _runtime_snapshot_detail(snapshot)
|
||
|
||
|
||
@app.get("/api/workflow-runs/{run_id}")
|
||
def get_workflow_run(run_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
return _get_workflow_run_for_actor(db, user, run_id)
|
||
|
||
|
||
@app.post("/api/workflow-runs/{run_id}/replay", response_model=RunWorkflowResponse)
|
||
async def replay_workflow_run(
|
||
run_id: int,
|
||
payload: ReplayRunRequest,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
data = await _replay_workflow_run_for_actor(
|
||
db,
|
||
user,
|
||
run_id,
|
||
base_url=payload.base_url or "",
|
||
fail_fast=payload.fail_fast,
|
||
triggered_by=_detect_trigger_source(request),
|
||
)
|
||
return RunWorkflowResponse(**data)
|
||
|
||
|
||
@app.get("/api/workflow-runs/{run_id}/loki-link", response_model=LokiLogLinkOut)
|
||
def get_workflow_run_loki_link(
|
||
run_id: int,
|
||
node_id: str,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
link = _workflow_run_loki_link_for_actor(db, user, run_id, node_id)
|
||
return LokiLogLinkOut(**link)
|
||
|
||
|
||
@app.get("/api/mocks", response_model=list[MockDataOut])
|
||
def list_mocks(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
rows = _scope_query_by_user(db.query(MockDataset), MockDataset, user).order_by(MockDataset.id.desc()).all()
|
||
return [model_to_mock_out(r) for r in rows]
|
||
|
||
|
||
@app.post("/api/mocks", response_model=MockDataOut)
|
||
def create_mock(payload: MockDataCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = _scope_query_by_user(db.query(MockDataset), MockDataset, user).filter(MockDataset.name == payload.name).first()
|
||
if row:
|
||
_assert_owner_or_superadmin(user, row)
|
||
row.data_json = json.dumps(payload.data, ensure_ascii=False)
|
||
else:
|
||
row = MockDataset(
|
||
name=payload.name,
|
||
data_json=json.dumps(payload.data, ensure_ascii=False),
|
||
creator_id=user.id,
|
||
creator_name=user.display_name or user.username,
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_mock_out(row)
|
||
|
||
|
||
@app.get("/api/mcp-tools", response_model=list[McpToolOut])
|
||
def list_mcp_tools(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
rows = _scope_query_by_user(db.query(McpToolConfig), McpToolConfig, user).order_by(McpToolConfig.id.desc()).all()
|
||
return [model_to_mcp_out(r) for r in rows]
|
||
|
||
|
||
@app.post("/api/mcp-tools", response_model=McpToolOut)
|
||
def upsert_mcp_tool(payload: McpToolCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = _scope_query_by_user(db.query(McpToolConfig), McpToolConfig, user).filter(McpToolConfig.name == payload.name).first()
|
||
if row:
|
||
_assert_owner_or_superadmin(user, row)
|
||
row.enabled = payload.enabled
|
||
row.config_json = json.dumps(payload.config, ensure_ascii=False)
|
||
else:
|
||
row = McpToolConfig(
|
||
name=payload.name,
|
||
enabled=payload.enabled,
|
||
creator_id=user.id,
|
||
creator_name=user.display_name or user.username,
|
||
config_json=json.dumps(payload.config, ensure_ascii=False),
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_mcp_out(row)
|
||
|
||
|
||
@app.get("/api/ssh-profiles", response_model=list[SshProfileOut])
|
||
def list_ssh_profiles(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
rows = _scope_query_by_user(db.query(SshProfile), SshProfile, user).order_by(SshProfile.id.desc()).all()
|
||
return [model_to_ssh_profile_out(row) for row in rows]
|
||
|
||
|
||
@app.post("/api/ssh-profiles", response_model=SshProfileOut)
|
||
def create_ssh_profile(
|
||
payload: SshProfileCreate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
row = SshProfile(
|
||
name=payload.name.strip(),
|
||
host=payload.host.strip(),
|
||
port=int(payload.port or 22),
|
||
username=payload.username.strip(),
|
||
creator_id=user.id,
|
||
creator_name=user.display_name or user.username,
|
||
config_json=_build_ssh_config_json(payload),
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_ssh_profile_out(row)
|
||
|
||
|
||
@app.put("/api/ssh-profiles/{profile_id}", response_model=SshProfileOut)
|
||
def update_ssh_profile(
|
||
profile_id: int,
|
||
payload: SshProfileUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
row = db.query(SshProfile).filter(SshProfile.id == profile_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="ssh profile not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
row.name = payload.name.strip()
|
||
row.host = payload.host.strip()
|
||
row.port = int(payload.port or 22)
|
||
row.username = payload.username.strip()
|
||
row.config_json = _build_ssh_config_json(payload)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_ssh_profile_out(row)
|
||
|
||
|
||
@app.delete("/api/ssh-profiles/{profile_id}")
|
||
def delete_ssh_profile(profile_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = db.query(SshProfile).filter(SshProfile.id == profile_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="ssh profile not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
script_rows = db.query(SshScript).filter(SshScript.profile_id == profile_id).all()
|
||
for script_row in script_rows:
|
||
_delete_ssh_script_files(script_row)
|
||
db.delete(script_row)
|
||
db.delete(row)
|
||
db.commit()
|
||
return {"deleted": True}
|
||
|
||
|
||
@app.get("/api/ssh-tree", response_model=list[SshTreeProfileNode])
|
||
def list_ssh_tree(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
return _build_ssh_tree(db, user)
|
||
|
||
|
||
@app.get("/api/ssh-scripts/{script_id}", response_model=SshScriptOut)
|
||
def get_ssh_script(script_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = _get_ssh_script_row(db, script_id)
|
||
_assert_owner_or_superadmin(user, row)
|
||
return model_to_ssh_script_out(row)
|
||
|
||
|
||
@app.post("/api/ssh-scripts", response_model=SshScriptOut)
|
||
def create_ssh_script(
|
||
payload: SshScriptCreate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
profile = db.query(SshProfile).filter(SshProfile.id == payload.profile_id).first()
|
||
if not profile:
|
||
raise HTTPException(status_code=404, detail="ssh profile not found")
|
||
_assert_owner_or_superadmin(user, profile)
|
||
content = payload.content.strip()
|
||
if payload.source_type == "inline" and not content:
|
||
raise HTTPException(status_code=400, detail="inline script content is required")
|
||
row = SshScript(
|
||
profile_id=payload.profile_id,
|
||
name=payload.name.strip(),
|
||
description=payload.description.strip(),
|
||
source_type=payload.source_type,
|
||
content=content,
|
||
creator_id=user.id,
|
||
creator_name=user.display_name or user.username,
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_ssh_script_out(row)
|
||
|
||
|
||
@app.put("/api/ssh-scripts/{script_id}", response_model=SshScriptOut)
|
||
def update_ssh_script(
|
||
script_id: int,
|
||
payload: SshScriptUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
row = _get_ssh_script_row(db, script_id)
|
||
_assert_owner_or_superadmin(user, row)
|
||
if row.source_type == "file":
|
||
raise HTTPException(status_code=400, detail="file script cannot be edited inline, re-upload instead")
|
||
content = payload.content.strip()
|
||
if not content:
|
||
raise HTTPException(status_code=400, detail="script content is required")
|
||
row.name = payload.name.strip()
|
||
row.description = payload.description.strip()
|
||
row.content = content
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_ssh_script_out(row)
|
||
|
||
|
||
@app.delete("/api/ssh-scripts/{script_id}")
|
||
def delete_ssh_script(script_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = _get_ssh_script_row(db, script_id)
|
||
_assert_owner_or_superadmin(user, row)
|
||
_delete_ssh_script_files(row)
|
||
db.delete(row)
|
||
db.commit()
|
||
return {"deleted": True}
|
||
|
||
|
||
@app.post("/api/ssh-scripts/upload", response_model=SshScriptOut)
|
||
async def upload_ssh_script(
|
||
profile_id: int = Form(...),
|
||
name: str = Form(""),
|
||
description: str = Form(""),
|
||
file: UploadFile = File(...),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
profile = db.query(SshProfile).filter(SshProfile.id == profile_id).first()
|
||
if not profile:
|
||
raise HTTPException(status_code=404, detail="ssh profile not found")
|
||
_assert_owner_or_superadmin(user, profile)
|
||
|
||
raw = await file.read()
|
||
if not raw:
|
||
raise HTTPException(status_code=400, detail="empty script file")
|
||
try:
|
||
body = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
raise HTTPException(status_code=400, detail="script file must be utf-8 text") from exc
|
||
|
||
script_name = (name or file.filename or "script.sh").strip()
|
||
safe_filename = _sanitize_script_filename(file.filename or script_name)
|
||
storage_name = f"{uuid.uuid4().hex}_{safe_filename}"
|
||
scripts_dir = _ensure_ssh_scripts_dir()
|
||
target_path = scripts_dir / storage_name
|
||
target_path.write_text(body, encoding="utf-8")
|
||
|
||
row = SshScript(
|
||
profile_id=profile_id,
|
||
name=script_name,
|
||
description=description.strip(),
|
||
source_type="file",
|
||
content="",
|
||
storage_path=storage_name,
|
||
creator_id=user.id,
|
||
creator_name=user.display_name or user.username,
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_ssh_script_out(row)
|
||
|
||
|
||
@app.post("/api/ssh-profiles/{profile_id}/execute", response_model=SshExecuteResponse)
|
||
def execute_ssh_command(
|
||
profile_id: int,
|
||
payload: SshExecuteRequest,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
row = db.query(SshProfile).filter(SshProfile.id == profile_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="ssh profile not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
profile = model_to_ssh_profile_out(row)
|
||
command = _resolve_ssh_command(profile, payload)
|
||
if profile.auth_type == "key" and profile.key_path and not os.path.exists(profile.key_path):
|
||
raise HTTPException(status_code=400, detail="configured key_path does not exist")
|
||
return _run_ssh_command(profile, command, payload.password or "", payload.timeout_seconds)
|
||
|
||
|
||
@app.get("/api/ssh-shortcuts", response_model=list[SshShortcutOut])
|
||
def list_ssh_shortcuts(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
rows = _scope_query_by_user(db.query(SshShortcut), SshShortcut, user).order_by(SshShortcut.id.desc()).all()
|
||
return [model_to_ssh_shortcut_out(row) for row in rows]
|
||
|
||
|
||
@app.post("/api/ssh-shortcuts", response_model=SshShortcutOut)
|
||
def create_ssh_shortcut(
|
||
payload: SshShortcutCreate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
row = SshShortcut(
|
||
name=payload.name.strip(),
|
||
command=payload.command.strip(),
|
||
description=payload.description.strip(),
|
||
creator_id=user.id,
|
||
creator_name=user.display_name or user.username,
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_ssh_shortcut_out(row)
|
||
|
||
|
||
@app.put("/api/ssh-shortcuts/{shortcut_id}", response_model=SshShortcutOut)
|
||
def update_ssh_shortcut(
|
||
shortcut_id: int,
|
||
payload: SshShortcutUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(_get_current_user),
|
||
):
|
||
row = db.query(SshShortcut).filter(SshShortcut.id == shortcut_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="ssh shortcut not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
row.name = payload.name.strip()
|
||
row.command = payload.command.strip()
|
||
row.description = payload.description.strip()
|
||
db.commit()
|
||
db.refresh(row)
|
||
return model_to_ssh_shortcut_out(row)
|
||
|
||
|
||
@app.delete("/api/ssh-shortcuts/{shortcut_id}")
|
||
def delete_ssh_shortcut(shortcut_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
|
||
row = db.query(SshShortcut).filter(SshShortcut.id == shortcut_id).first()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="ssh shortcut not found")
|
||
_assert_owner_or_superadmin(user, row)
|
||
db.delete(row)
|
||
db.commit()
|
||
return {"deleted": True}
|
||
|
||
|
||
@app.websocket("/ws/ssh-script-run")
|
||
async def ssh_script_run_socket(websocket: WebSocket):
|
||
try:
|
||
user = _get_websocket_user(websocket)
|
||
except HTTPException:
|
||
await websocket.close(code=4401)
|
||
return
|
||
|
||
await websocket.accept()
|
||
try:
|
||
message = await websocket.receive_json()
|
||
except WebSocketDisconnect:
|
||
return
|
||
|
||
msg_type = str(message.get("type", "")).strip()
|
||
if msg_type != "start":
|
||
await websocket.send_json({"type": "error", "detail": "first message must be start"})
|
||
await websocket.close(code=1003)
|
||
return
|
||
|
||
profile_id = int(message.get("profile_id") or 0)
|
||
script_id = int(message.get("script_id") or 0)
|
||
password = str(message.get("password") or "")
|
||
if not profile_id or not script_id:
|
||
await websocket.send_json({"type": "error", "detail": "profile_id and script_id are required"})
|
||
await websocket.close(code=1003)
|
||
return
|
||
if not password.strip():
|
||
await websocket.send_json({"type": "error", "detail": "执行前必须输入密码"})
|
||
await websocket.close(code=1003)
|
||
return
|
||
|
||
with SessionLocal() as db:
|
||
profile_row = db.query(SshProfile).filter(SshProfile.id == profile_id).first()
|
||
if not profile_row:
|
||
await websocket.send_json({"type": "error", "detail": "ssh profile not found"})
|
||
await websocket.close(code=1008)
|
||
return
|
||
script_row = db.query(SshScript).filter(SshScript.id == script_id, SshScript.profile_id == profile_id).first()
|
||
if not script_row:
|
||
await websocket.send_json({"type": "error", "detail": "ssh script not found"})
|
||
await websocket.close(code=1008)
|
||
return
|
||
try:
|
||
_assert_owner_or_superadmin(user, profile_row)
|
||
_assert_owner_or_superadmin(user, script_row)
|
||
except HTTPException:
|
||
await websocket.send_json({"type": "error", "detail": "forbidden"})
|
||
await websocket.close(code=1008)
|
||
return
|
||
profile = model_to_ssh_profile_out(profile_row)
|
||
script = model_to_ssh_script_out(script_row)
|
||
|
||
try:
|
||
resolve_script_body(script)
|
||
except ValueError as exc:
|
||
await websocket.send_json({"type": "error", "detail": str(exc)})
|
||
await websocket.close(code=1011)
|
||
return
|
||
|
||
await websocket.send_json(
|
||
{
|
||
"type": "started",
|
||
"profile": {"id": profile.id, "name": profile.name},
|
||
"script": {"id": script.id, "name": script.name},
|
||
}
|
||
)
|
||
|
||
cancel_event = asyncio.Event()
|
||
stream_error: Exception | None = None
|
||
|
||
async def pump_stream() -> None:
|
||
nonlocal stream_error
|
||
try:
|
||
async for event in stream_script_run(profile, script, password, cancel_event):
|
||
await websocket.send_json(event)
|
||
except Exception as exc:
|
||
stream_error = exc
|
||
|
||
pump_task = asyncio.create_task(pump_stream())
|
||
try:
|
||
while not pump_task.done():
|
||
try:
|
||
message = await asyncio.wait_for(websocket.receive_json(), timeout=0.2)
|
||
except asyncio.TimeoutError:
|
||
continue
|
||
except WebSocketDisconnect:
|
||
cancel_event.set()
|
||
break
|
||
msg_type = str(message.get("type", "")).strip()
|
||
if msg_type == "stop":
|
||
cancel_event.set()
|
||
break
|
||
if not pump_task.done():
|
||
cancel_event.set()
|
||
await pump_task
|
||
elif stream_error is not None:
|
||
raise stream_error
|
||
except WebSocketDisconnect:
|
||
cancel_event.set()
|
||
if not pump_task.done():
|
||
await pump_task
|
||
except Exception as exc:
|
||
cancel_event.set()
|
||
if not pump_task.done():
|
||
pump_task.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError):
|
||
await pump_task
|
||
with contextlib.suppress(Exception):
|
||
await websocket.send_json({"type": "error", "detail": str(exc)})
|
||
finally:
|
||
cancel_event.set()
|
||
if not pump_task.done():
|
||
pump_task.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError):
|
||
await pump_task
|
||
with contextlib.suppress(Exception):
|
||
await websocket.close()
|
||
|
||
|
||
@app.get("/", include_in_schema=False)
|
||
@app.get("/{full_path:path}", include_in_schema=False)
|
||
def frontend_app(full_path: str = ""):
|
||
if full_path in {"api", "docs", "health", "mcp", "openapi.json", "redoc", "ui"} or full_path.startswith(
|
||
("api/", "docs/", "mcp/", "redoc/", "ui/")
|
||
):
|
||
raise HTTPException(status_code=404, detail="Not Found")
|
||
if full_path:
|
||
candidate = (FRONTEND_DIST_DIR / full_path).resolve()
|
||
frontend_root = FRONTEND_DIST_DIR.resolve()
|
||
if candidate != frontend_root and frontend_root not in candidate.parents:
|
||
raise HTTPException(status_code=404, detail="Not Found")
|
||
if candidate.is_file():
|
||
return FileResponse(candidate)
|
||
return _frontend_index_response()
|