chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:51 +08:00
commit d0e4308def
614 changed files with 74458 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
"""Aggregates API routers."""
from . import artifacts, execute, execute_sync, health, sessions, uploads, vuegraphs, workflows, websocket, batch, tools
ALL_ROUTERS = [
health.router,
vuegraphs.router,
workflows.router,
uploads.router,
artifacts.router,
sessions.router,
batch.router,
execute.router,
execute_sync.router,
tools.router,
websocket.router,
]
__all__ = ["ALL_ROUTERS"]
+108
View File
@@ -0,0 +1,108 @@
import asyncio
from pathlib import Path
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from server.state import get_websocket_manager
from utils.attachments import encode_file_to_data_uri
router = APIRouter()
MAX_FILE_SIZE = 20 * 1024 * 1024 # 20 MB
def _split_csv(value: Optional[str]) -> Optional[List[str]]:
if not value:
return None
parts = [part.strip() for part in value.split(",")]
filtered = [part for part in parts if part]
return filtered or None
def _get_session_and_queue(session_id: str):
manager = get_websocket_manager()
session = manager.session_store.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
queue = session.artifact_queue
if queue is None:
raise HTTPException(status_code=404, detail="Artifact stream not available")
return manager, queue
@router.get("/api/sessions/{session_id}/artifact-events")
async def poll_artifact_events(
session_id: str,
wait_seconds: float = Query(25.0, ge=0.0, le=60.0),
after: Optional[int] = Query(None, ge=0),
include_mime: Optional[str] = Query(None),
include_ext: Optional[str] = Query(None),
max_size: Optional[int] = Query(None, gt=0),
limit: int = Query(25, ge=1, le=100),
):
manager, queue = _get_session_and_queue(session_id)
include_mime_list = _split_csv(include_mime)
include_ext_list = _split_csv(include_ext)
events, next_cursor, timed_out = await asyncio.to_thread(
queue.wait_for_events,
after=after,
include_mime=include_mime_list,
include_ext=include_ext_list,
max_size=max_size,
limit=limit,
timeout=wait_seconds,
)
payload = {
"events": [event.to_dict() for event in events],
"next_cursor": next_cursor,
"timed_out": timed_out,
"has_more": queue.last_sequence > (next_cursor or 0),
}
return payload
@router.get("/api/sessions/{session_id}/artifacts/{artifact_id}")
async def get_artifact(
session_id: str,
artifact_id: str,
mode: str = Query("meta", pattern="^(meta|stream)$"),
download: bool = Query(False),
):
manager, _ = _get_session_and_queue(session_id)
store = manager.attachment_service.get_attachment_store(session_id)
record = store.get(artifact_id)
if not record:
raise HTTPException(status_code=404, detail="Artifact not found")
ref = record.ref
if mode == "stream":
local_path = ref.local_path
if not local_path:
raise HTTPException(status_code=404, detail="Artifact content unavailable")
path = Path(local_path)
if not path.exists():
raise HTTPException(status_code=404, detail="Artifact file missing")
media_type = ref.mime_type or "application/octet-stream"
disposition = "attachment" if download else "inline"
headers = {"Content-Disposition": f'{disposition}; filename="{ref.name}"'}
return StreamingResponse(path.open("rb"), media_type=media_type, headers=headers)
data_uri = ref.data_uri
if not data_uri and ref.local_path and (ref.size or 0) <= MAX_FILE_SIZE:
local_path = Path(ref.local_path)
if local_path.exists():
data_uri = encode_file_to_data_uri(local_path, ref.mime_type or "application/octet-stream")
return {
"artifact_id": artifact_id,
"name": ref.name,
"mime_type": ref.mime_type,
"size": ref.size,
"sha256": ref.sha256,
"data_uri": data_uri,
"local_path": ref.local_path,
"extra": record.extra,
}
+61
View File
@@ -0,0 +1,61 @@
import asyncio
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from entity.enums import LogLevel
from server.services.batch_parser import parse_batch_file
from server.services.batch_run_service import BatchRunService
from server.state import ensure_known_session
from utils.exceptions import ValidationError
router = APIRouter()
@router.post("/api/workflows/batch")
async def execute_batch(
file: UploadFile = File(...),
session_id: str = Form(...),
yaml_file: str = Form(...),
max_parallel: int = Form(5),
log_level: str | None = Form(None),
):
try:
manager = ensure_known_session(session_id, require_connection=True)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if max_parallel < 1:
raise HTTPException(status_code=400, detail="max_parallel must be >= 1")
try:
content = await file.read()
tasks, file_base = parse_batch_file(content, file.filename or "batch.csv")
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
resolved_level = None
if log_level:
try:
resolved_level = LogLevel(log_level)
except ValueError:
raise HTTPException(status_code=400, detail="log_level must be either DEBUG or INFO")
service = BatchRunService()
asyncio.create_task(
service.run_batch(
session_id,
yaml_file,
tasks,
manager,
max_parallel=max_parallel,
file_base=file_base,
log_level=resolved_level,
)
)
return {
"status": "accepted",
"session_id": session_id,
"batch_id": session_id,
"task_count": len(tasks),
}
+53
View File
@@ -0,0 +1,53 @@
import asyncio
from fastapi import APIRouter, HTTPException
from entity.enums import LogLevel
from server.models import WorkflowRequest
from server.state import ensure_known_session
from utils.exceptions import ValidationError, WorkflowExecutionError
from utils.structured_logger import get_server_logger, LogType
router = APIRouter()
@router.post("/api/workflow/execute")
async def execute_workflow(request: WorkflowRequest):
try:
manager = ensure_known_session(request.session_id, require_connection=True)
# log_level = LogLevel(request.log_level) if request.log_level else None
log_level = None
except ValueError:
raise HTTPException(status_code=400, detail="log_level must be either DEBUG or INFO")
try:
asyncio.create_task(
manager.workflow_run_service.start_workflow(
request.session_id,
request.yaml_file,
request.task_prompt,
manager,
attachments=request.attachments,
log_level=log_level,
)
)
logger = get_server_logger()
logger.info(
"Workflow execution started",
log_type=LogType.WORKFLOW,
session_id=request.session_id,
yaml_file=request.yaml_file,
task_prompt_length=len(request.task_prompt or ""),
)
return {
"status": "started",
"session_id": request.session_id,
"message": "Workflow execution started",
}
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, "Failed to start workflow execution")
raise WorkflowExecutionError(f"Failed to start workflow: {exc}")
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import json
import queue
import threading
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, Sequence, Union
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from starlette.concurrency import run_in_threadpool
from check.check import load_config
from entity.enums import LogLevel
from entity.graph_config import GraphConfig
from entity.messages import Message
from runtime.bootstrap.schema import ensure_schema_registry_populated
from runtime.sdk import OUTPUT_ROOT, run_workflow
from server.models import WorkflowRunRequest
from server.settings import YAML_DIR
from utils.attachments import AttachmentStore
from utils.exceptions import ValidationError, WorkflowExecutionError
from utils.logger import WorkflowLogger
from utils.structured_logger import get_server_logger, LogType
from utils.task_input import TaskInputBuilder
from workflow.graph import GraphExecutor
from workflow.graph_context import GraphContext
router = APIRouter()
_SSE_CONTENT_TYPE = "text/event-stream"
def _normalize_session_name(yaml_path: Path, session_name: Optional[str]) -> str:
if session_name and session_name.strip():
return session_name.strip()
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
return f"sdk_{yaml_path.stem}_{timestamp}"
def _resolve_yaml_path(yaml_file: Union[str, Path]) -> Path:
candidate = Path(yaml_file).expanduser()
if candidate.is_absolute():
return candidate
if candidate.exists():
return candidate
repo_root = Path(__file__).resolve().parents[2]
yaml_root = YAML_DIR if YAML_DIR.is_absolute() else (repo_root / YAML_DIR)
return (yaml_root / candidate).expanduser()
def _build_task_input(
graph_context: GraphContext,
prompt: str,
attachments: Sequence[Union[str, Path]],
) -> Union[str, list[Message]]:
if not attachments:
return prompt
attachments_dir = graph_context.directory / "code_workspace" / "attachments"
attachments_dir.mkdir(parents=True, exist_ok=True)
store = AttachmentStore(attachments_dir)
builder = TaskInputBuilder(store)
normalized_paths = [str(Path(path).expanduser()) for path in attachments]
return builder.build_from_file_paths(prompt, normalized_paths)
def _run_workflow_with_logger(
*,
yaml_file: Union[str, Path],
task_prompt: str,
attachments: Optional[Sequence[Union[str, Path]]],
session_name: Optional[str],
variables: Optional[dict],
log_level: Optional[LogLevel],
log_callback,
) -> tuple[Optional[Message], dict[str, Any]]:
ensure_schema_registry_populated()
yaml_path = _resolve_yaml_path(yaml_file)
if not yaml_path.exists():
raise FileNotFoundError(f"YAML file not found: {yaml_path}")
attachments = attachments or []
if (not task_prompt or not task_prompt.strip()) and not attachments:
raise ValidationError(
"Task prompt cannot be empty",
details={"task_prompt_provided": bool(task_prompt)},
)
design = load_config(yaml_path, vars_override=variables)
normalized_session = _normalize_session_name(yaml_path, session_name)
graph_config = GraphConfig.from_definition(
design.graph,
name=normalized_session,
output_root=OUTPUT_ROOT,
source_path=str(yaml_path),
vars=design.vars,
)
if log_level:
graph_config.log_level = log_level
graph_config.definition.log_level = log_level
graph_context = GraphContext(config=graph_config)
task_input = _build_task_input(graph_context, task_prompt, attachments)
class _StreamingWorkflowLogger(WorkflowLogger):
def add_log(self, *args, **kwargs):
entry = super().add_log(*args, **kwargs)
if entry:
payload = entry.to_dict()
payload.pop("details", None)
log_callback("log", payload)
return entry
class _StreamingExecutor(GraphExecutor):
def _create_logger(self) -> WorkflowLogger:
level = log_level or self.graph.log_level
return _StreamingWorkflowLogger(
self.graph.name,
level,
use_structured_logging=True,
log_to_console=False,
)
executor = _StreamingExecutor(graph_context, session_id=normalized_session)
executor._execute(task_input)
final_message = executor.get_final_output_message()
logger = executor.log_manager.get_logger() if executor.log_manager else None
log_id = logger.workflow_id if logger else None
token_usage = executor.token_tracker.get_token_usage() if executor.token_tracker else None
meta = {
"session_name": normalized_session,
"yaml_file": str(yaml_path),
"log_id": log_id,
"token_usage": token_usage,
"output_dir": graph_context.directory,
}
return final_message, meta
def _sse_event(event_type: str, data: Any) -> str:
payload = json.dumps(data, ensure_ascii=False, default=str)
return f"event: {event_type}\ndata: {payload}\n\n"
@router.post("/api/workflow/run")
async def run_workflow_sync(request: WorkflowRunRequest, http_request: Request):
try:
resolved_log_level: Optional[LogLevel] = None
if request.log_level:
resolved_log_level = LogLevel(request.log_level)
except ValueError:
raise HTTPException(
status_code=400,
detail="log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL",
)
accepts_stream = _SSE_CONTENT_TYPE in (http_request.headers.get("accept") or "")
if not accepts_stream:
try:
result = await run_in_threadpool(
run_workflow,
request.yaml_file,
task_prompt=request.task_prompt,
attachments=request.attachments,
session_name=request.session_name,
variables=request.variables,
log_level=resolved_log_level,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, "Failed to run workflow via sync API")
raise WorkflowExecutionError(f"Failed to run workflow: {exc}")
final_message = result.final_message.text_content() if result.final_message else ""
meta = result.meta_info
logger = get_server_logger()
logger.info(
"Workflow execution completed via sync API",
log_type=LogType.WORKFLOW,
session_id=meta.session_name,
yaml_path=meta.yaml_file,
)
return {
"status": "completed",
"final_message": final_message,
"token_usage": meta.token_usage,
"output_dir": str(meta.output_dir.resolve()),
}
event_queue: queue.Queue[tuple[str, Any]] = queue.Queue()
done_event = threading.Event()
def enqueue(event_type: str, data: Any) -> None:
event_queue.put((event_type, data))
def worker() -> None:
try:
enqueue(
"started",
{"yaml_file": request.yaml_file, "task_prompt": request.task_prompt},
)
final_message, meta = _run_workflow_with_logger(
yaml_file=request.yaml_file,
task_prompt=request.task_prompt,
attachments=request.attachments,
session_name=request.session_name,
variables=request.variables,
log_level=resolved_log_level,
log_callback=enqueue,
)
enqueue(
"completed",
{
"status": "completed",
"final_message": final_message.text_content() if final_message else "",
"token_usage": meta["token_usage"],
"output_dir": str(meta["output_dir"].resolve()),
},
)
except (FileNotFoundError, ValidationError) as exc:
enqueue("error", {"message": str(exc)})
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, "Failed to run workflow via streaming API")
enqueue("error", {"message": f"Failed to run workflow: {exc}"})
finally:
done_event.set()
threading.Thread(target=worker, daemon=True).start()
async def stream():
while True:
try:
event_type, data = event_queue.get(timeout=0.1)
yield _sse_event(event_type, data)
except queue.Empty:
if done_event.is_set():
break
return StreamingResponse(stream(), media_type=_SSE_CONTENT_TYPE)
+22
View File
@@ -0,0 +1,22 @@
from fastapi import APIRouter
from utils.structured_logger import get_server_logger, LogType
router = APIRouter()
@router.get("/health")
async def health_check():
logger = get_server_logger()
logger.info("Health check requested", log_type=LogType.REQUEST)
return {"status": "healthy"}
@router.get("/health/live")
async def liveness_check():
return {"status": "alive"}
@router.get("/health/ready")
async def readiness_check():
return {"status": "ready"}
+83
View File
@@ -0,0 +1,83 @@
import re
import shutil
import tempfile
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from server.settings import WARE_HOUSE_DIR
from utils.exceptions import ResourceNotFoundError, ValidationError
from utils.structured_logger import get_server_logger, LogType
router = APIRouter()
@router.get("/api/sessions/{session_id}/download")
async def download_session(session_id: str):
try:
if not re.match(r"^[a-zA-Z0-9_-]+$", session_id):
logger = get_server_logger()
logger.log_security_event(
"INVALID_SESSION_ID_FORMAT",
f"Invalid session_id format: {session_id}",
details={"received_session_id": session_id},
)
raise ValidationError(
"Invalid session_id: only letters, digits, underscores, and hyphens are allowed",
field="session_id",
)
dir_name = f"session_{session_id}"
session_path = WARE_HOUSE_DIR / dir_name
if not session_path.exists() or not session_path.is_dir():
raise ResourceNotFoundError(
"Session directory not found",
resource_type="session",
resource_id=session_id,
)
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_file:
zip_path = Path(tmp_file.name)
archive_base = zip_path.with_suffix("")
try:
shutil.make_archive(str(archive_base), "zip", root_dir=WARE_HOUSE_DIR, base_dir=dir_name)
except Exception as exc:
if zip_path.exists():
zip_path.unlink()
logger = get_server_logger()
logger.log_exception(exc, f"Failed to create zip archive for session: {session_id}")
raise HTTPException(status_code=500, detail="Failed to create zip archive")
logger = get_server_logger()
logger.info(
"Session download prepared",
log_type=LogType.WORKFLOW,
session_id=session_id,
archive_path=str(zip_path),
)
def cleanup_zip():
if zip_path.exists():
zip_path.unlink()
return FileResponse(
path=zip_path,
filename=f"{dir_name}.zip",
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={dir_name}.zip"},
background=BackgroundTask(cleanup_zip),
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Session directory not found")
except HTTPException:
raise
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, f"Unexpected error during session download: {session_id}")
raise HTTPException(status_code=500, detail="Failed to download session")
+74
View File
@@ -0,0 +1,74 @@
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, constr
from utils.function_catalog import get_function_catalog
from utils.function_manager import FUNCTION_CALLING_DIR
router = APIRouter()
class LocalToolCreateRequest(BaseModel):
filename: constr(strip_whitespace=True, min_length=1, max_length=255)
content: str
overwrite: bool = False
@router.get("/api/tools/local")
def list_local_tools():
catalog = get_function_catalog()
metadata = catalog.list_metadata()
tools = []
for name, meta in metadata.items():
tools.append(
{
"name": name,
"description": meta.description,
"parameters": meta.parameters_schema,
"module": meta.module_name,
"file_path": meta.file_path,
}
)
tools.sort(key=lambda item: item["name"])
return {
"success": True,
"count": len(tools),
"tools": tools,
"load_error": str(catalog.load_error) if catalog.load_error else None,
}
@router.post("/api/tools/local")
def create_local_tool(payload: LocalToolCreateRequest):
raw_name = payload.filename.strip()
if not raw_name:
raise HTTPException(status_code=400, detail="filename is required")
if not re.match(r"^[A-Za-z0-9_-]+(\.py)?$", raw_name):
raise HTTPException(status_code=400, detail="filename must be alphanumeric with optional .py extension")
filename = raw_name if raw_name.endswith(".py") else f"{raw_name}.py"
tools_dir = Path(FUNCTION_CALLING_DIR).resolve()
tools_dir.mkdir(parents=True, exist_ok=True)
target_path = (tools_dir / filename).resolve()
try:
target_path.relative_to(tools_dir)
except ValueError:
raise HTTPException(status_code=400, detail="filename resolves outside function tools directory")
if target_path.exists() and not payload.overwrite:
raise HTTPException(status_code=409, detail="tool file already exists")
target_path.write_text(payload.content, encoding="utf-8")
catalog = get_function_catalog()
catalog.refresh()
return {
"success": True,
"filename": filename,
"path": str(target_path),
"load_error": str(catalog.load_error) if catalog.load_error else None,
}
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, File, HTTPException, UploadFile
from server.state import ensure_known_session
from utils.exceptions import ValidationError
from utils.structured_logger import get_server_logger, LogType
router = APIRouter()
@router.post("/api/uploads/{session_id}")
async def upload_attachment(session_id: str, file: UploadFile = File(...)):
try:
manager = ensure_known_session(session_id, require_connection=False)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
try:
record = await manager.attachment_service.save_upload_file(session_id, file)
except ValidationError:
raise HTTPException(status_code=400, detail="Session not connected")
except Exception as exc:
logger = get_server_logger()
logger.error(
"Failed to save attachment",
log_type=LogType.REQUEST,
session_id=session_id,
error=str(exc),
)
raise HTTPException(status_code=500, detail="Failed to store attachment")
ref = record.ref
return {
"attachment_id": ref.attachment_id,
"name": ref.name,
"mime_type": ref.mime_type,
"size": ref.size,
}
@router.get("/api/uploads/{session_id}")
async def list_attachments(session_id: str):
try:
manager = ensure_known_session(session_id, require_connection=False)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc))
manifest = manager.attachment_service.list_attachment_manifests(session_id)
return {"attachments": manifest}
+54
View File
@@ -0,0 +1,54 @@
from fastapi import APIRouter, HTTPException
from server.models import VueGraphContentPayload
from server.services.vuegraphs_storage import fetch_vuegraph_content, save_vuegraph_content
from utils.structured_logger import get_server_logger, LogType
router = APIRouter()
@router.post("/api/vuegraphs/upload/content")
async def upload_vuegraph_content(payload: VueGraphContentPayload):
logger = get_server_logger()
try:
save_vuegraph_content(payload.filename, payload.content)
except Exception as exc:
logger.error(
"Failed to persist Vue graph content",
log_type=LogType.ERROR,
filename=payload.filename,
error=str(exc),
)
raise HTTPException(status_code=500, detail="Unable to save graph content")
logger.info(
"Vue graph content saved",
log_type=LogType.REQUEST,
filename=payload.filename,
)
return {"filename": payload.filename, "status": "saved"}
@router.get("/api/vuegraphs/{filename}")
async def get_vuegraph_content(filename: str):
logger = get_server_logger()
try:
content = fetch_vuegraph_content(filename)
except Exception as exc:
logger.error(
"Failed to load Vue graph content",
log_type=LogType.ERROR,
filename=filename,
error=str(exc),
)
raise HTTPException(status_code=500, detail="Unable to read graph content")
if content is None:
raise HTTPException(status_code=404, detail="Graph content not found")
logger.info(
"Vue graph content fetched",
log_type=LogType.REQUEST,
filename=filename,
)
return {"filename": filename, "content": content}
+19
View File
@@ -0,0 +1,19 @@
"""WebSocket endpoint routing."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from server.state import get_websocket_manager
router = APIRouter()
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket, session_id: str = ""):
manager = get_websocket_manager()
sid = await manager.connect(websocket, session_id=session_id or None)
try:
while True:
message = await websocket.receive_text()
await manager.handle_message(sid, message)
except WebSocketDisconnect:
manager.disconnect(sid)
+361
View File
@@ -0,0 +1,361 @@
from fastapi import APIRouter, HTTPException
from typing import Any
from server.models import (
WorkflowCopyRequest,
WorkflowRenameRequest,
WorkflowUpdateContentRequest,
WorkflowUploadContentRequest,
)
from server.services.workflow_storage import (
copy_workflow,
persist_workflow,
rename_workflow,
validate_workflow_content,
validate_workflow_filename,
)
from server.settings import YAML_DIR
from utils.exceptions import (
ResourceConflictError,
ResourceNotFoundError,
SecurityError,
ValidationError,
WorkflowExecutionError,
)
from utils.structured_logger import get_server_logger, LogType
router = APIRouter()
def _persist_workflow_from_content(
filename: str,
content: str,
*,
allow_overwrite: bool,
action: str,
success_message: str,
):
try:
safe_filename, yaml_content = validate_workflow_content(filename.strip(), content)
save_path = YAML_DIR / safe_filename
if save_path.exists() and not allow_overwrite:
raise HTTPException(status_code=409, detail="Workflow already exists; use the update API to overwrite")
if not save_path.exists() and allow_overwrite:
raise HTTPException(status_code=404, detail="Workflow file not found")
persist_workflow(safe_filename, content, yaml_content, action=action, directory=YAML_DIR)
return {
"status": "success",
"filename": safe_filename,
"message": success_message.format(filename=safe_filename),
}
except ValidationError:
raise
except HTTPException:
raise
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, f"Unexpected error during workflow {action}")
raise WorkflowExecutionError(f"Failed to {action} workflow: {exc}")
@router.get("/api/workflows")
async def list_workflows():
if not YAML_DIR.exists():
return {"workflows": []}
return {"workflows": [file.name for file in YAML_DIR.glob("*.yaml")]}
@router.get("/api/workflows/{filename}/args")
async def get_workflow_args(filename: str):
print(str)
try:
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
print(safe_filename)
file_path = YAML_DIR / safe_filename
if not file_path.exists() or not file_path.is_file():
raise ResourceNotFoundError(
"Workflow file not found",
resource_type="workflow",
resource_id=safe_filename,
)
# Load and validate YAML content
raw_content = file_path.read_text(encoding="utf-8")
_, yaml_content = validate_workflow_content(safe_filename, raw_content)
args: list[dict[str, Any]] = []
if isinstance(yaml_content, dict):
graph = yaml_content.get("graph") or {}
if isinstance(graph, dict):
raw_args = graph.get("args") or []
if isinstance(raw_args, list):
if len(raw_args) == 0:
raise ResourceNotFoundError(
"Workflow file does not have args",
resource_type="workflow",
resource_id=safe_filename,
)
for item in raw_args:
# Each item is expected to be like: { arg_name: [ {key: value}, ... ] }
if not isinstance(item, dict) or len(item) != 1:
continue
(arg_name, spec_list), = item.items()
if not isinstance(arg_name, str):
continue
arg_info: dict[str, Any] = {"name": arg_name}
if isinstance(spec_list, list):
for spec in spec_list:
if isinstance(spec, dict):
for key, value in spec.items():
# Later entries override earlier ones if duplicated
arg_info[str(key)] = value
args.append(arg_info)
logger = get_server_logger()
logger.info(
"Workflow args retrieved",
log_type=LogType.WORKFLOW,
filename=safe_filename,
args_count=len(args),
)
return {"args": args}
except ValidationError as exc:
# 参数或文件名等校验错误
raise HTTPException(
status_code=400,
detail={"message": str(exc)},
)
except SecurityError as exc:
# 安全相关错误(例如路径遍历)
raise HTTPException(
status_code=400,
detail={"message": str(exc)},
)
except ResourceNotFoundError as exc:
# 文件不存在
raise HTTPException(
status_code=404,
detail={"message": str(exc)},
)
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, f"Unexpected error retrieving workflow args: {filename}")
# 兜底错误
raise HTTPException(
status_code=500,
detail={"message": f"Failed to retrieve workflow args: {exc}"},
)
@router.get("/api/workflows/{filename}/desc")
async def get_workflow_desc(filename: str):
try:
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
file_path = YAML_DIR / safe_filename
if not file_path.exists() or not file_path.is_file():
raise ResourceNotFoundError(
"Workflow file not found",
resource_type="workflow",
resource_id=safe_filename,
)
# Load and validate YAML content
raw_content = file_path.read_text(encoding="utf-8")
_, yaml_content = validate_workflow_content(safe_filename, raw_content)
desc = ""
if isinstance(yaml_content, dict):
graph = yaml_content.get("graph") or {}
if isinstance(graph, dict):
desc = graph.get("description") or ""
if len(desc) == 0:
raise ResourceNotFoundError(
"Workflow file does not have args",
resource_type="workflow",
resource_id=safe_filename,
)
logger = get_server_logger()
logger.info(
"Workflow description retrieved",
log_type=LogType.WORKFLOW,
filename=safe_filename,
)
return {"description": desc}
except ValidationError as exc:
# 参数或文件名等校验错误
raise HTTPException(
status_code=400,
detail={"message": str(exc)},
)
except SecurityError as exc:
# 安全相关错误(例如路径遍历)
raise HTTPException(
status_code=400,
detail={"message": str(exc)},
)
except ResourceNotFoundError as exc:
# 文件不存在
raise HTTPException(
status_code=404,
detail={"message": str(exc)},
)
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, f"Unexpected error retrieving workflow args: {filename}")
# 兜底错误
raise HTTPException(
status_code=500,
detail={"message": f"Failed to retrieve workflow args: {exc}"},
)
@router.post("/api/workflows/upload/content")
async def upload_workflow_content(request: WorkflowUploadContentRequest):
return _persist_workflow_from_content(
request.filename,
request.content,
allow_overwrite=False,
action="upload",
success_message="Workflow {filename} created successfully from content",
)
@router.put("/api/workflows/{filename}/update")
async def update_workflow_content(filename: str, request: WorkflowUpdateContentRequest):
return _persist_workflow_from_content(
filename,
request.content,
allow_overwrite=True,
action="update",
success_message="Workflow {filename} updated successfully",
)
@router.delete("/api/workflows/{filename}/delete")
async def delete_workflow(filename: str):
try:
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
file_path = YAML_DIR / safe_filename
if not file_path.exists() or not file_path.is_file():
raise ResourceNotFoundError(
"Workflow file not found",
resource_type="workflow",
resource_id=safe_filename,
)
try:
file_path.unlink()
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, f"Failed to delete workflow file: {safe_filename}")
raise WorkflowExecutionError("Failed to delete workflow file", details={"filename": safe_filename})
logger = get_server_logger()
logger.info(
"Workflow file deleted",
log_type=LogType.WORKFLOW,
filename=safe_filename,
)
return {
"status": "deleted",
"filename": safe_filename,
"message": f"Workflow '{safe_filename}' deleted successfully",
}
except ValidationError:
raise
except SecurityError:
raise
except ResourceNotFoundError:
raise
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, f"Unexpected error deleting workflow: {filename}")
raise WorkflowExecutionError(f"Failed to delete workflow: {exc}")
@router.post("/api/workflows/{filename}/rename")
async def rename_workflow_file(filename: str, request: WorkflowRenameRequest):
try:
rename_workflow(filename, request.new_filename, directory=YAML_DIR)
return {
"status": "success",
"source": validate_workflow_filename(filename, require_yaml_extension=True),
"target": validate_workflow_filename(request.new_filename, require_yaml_extension=True),
"message": f"Workflow renamed to '{request.new_filename}' successfully",
}
except ValidationError:
raise
except SecurityError:
raise
except ResourceConflictError:
raise
except ResourceNotFoundError:
raise
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, f"Unexpected error renaming workflow: {filename}")
raise WorkflowExecutionError(f"Failed to rename workflow: {exc}")
@router.post("/api/workflows/{filename}/copy")
async def copy_workflow_file(filename: str, request: WorkflowCopyRequest):
try:
copy_workflow(filename, request.new_filename, directory=YAML_DIR)
return {
"status": "success",
"source": validate_workflow_filename(filename, require_yaml_extension=True),
"target": validate_workflow_filename(request.new_filename, require_yaml_extension=True),
"message": f"Workflow copied to '{request.new_filename}' successfully",
}
except ValidationError:
raise
except SecurityError:
raise
except ResourceConflictError:
raise
except ResourceNotFoundError:
raise
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, f"Unexpected error copying workflow: {filename}")
raise WorkflowExecutionError(f"Failed to copy workflow: {exc}")
@router.get("/api/workflows/{filename}/get")
async def get_workflow_raw_content(filename: str):
try:
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
file_path = YAML_DIR / safe_filename
if not file_path.exists() or not file_path.is_file():
raise ResourceNotFoundError(
"Workflow file not found",
resource_type="workflow",
resource_id=safe_filename,
)
with open(file_path, "r", encoding="utf-8") as handle:
raw_content = handle.read()
logger = get_server_logger()
logger.info("Workflow file content retrieved", log_type=LogType.WORKFLOW, filename=safe_filename)
return {"content": raw_content}
except ValidationError:
raise
except SecurityError:
raise
except ResourceNotFoundError:
raise
except Exception as exc:
logger = get_server_logger()
logger.log_exception(exc, f"Unexpected error retrieving workflow: {filename}")
raise WorkflowExecutionError(f"Failed to retrieve workflow: {exc}")