chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
This commit is contained in:
Executable
+19
@@ -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"]
|
||||
Executable
+108
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
Executable
+53
@@ -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}")
|
||||
@@ -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)
|
||||
Executable
+22
@@ -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"}
|
||||
Executable
+83
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
Executable
+45
@@ -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}
|
||||
Executable
+54
@@ -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}
|
||||
Executable
+19
@@ -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)
|
||||
Executable
+361
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user