quality-inspection-platform/app/main.py
qihongkun a41f61bcf6 重命名项目为 quality-inspection-platform
将仓库目录从 ai_auto_test 迁至 quality-inspection-platform,统一质量检测平台命名;MCP 环境变量新增 QIP_* 并兼容 AI_TEST_*。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 15:49:59 +08:00

3510 lines
130 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
app = FastAPI(title="质量检测平台", version="0.1.0")
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/ensurebody: {\"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."
),
}
@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),
)
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-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()