chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Waiting to run

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
+1
View File
@@ -0,0 +1 @@
"""Server package for DevAll workflow system"""
Executable
+8
View File
@@ -0,0 +1,8 @@
from fastapi import FastAPI
from server.bootstrap import init_app
from utils.env_loader import load_dotenv_file
load_dotenv_file()
app = FastAPI(title="DevAll Workflow Server", version="1.0.0")
init_app(app)
+32
View File
@@ -0,0 +1,32 @@
"""Application bootstrap helpers for the FastAPI server."""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from server import state
from server.config_schema_router import router as config_schema_router
from server.routes import ALL_ROUTERS
from utils.error_handler import add_exception_handlers
from utils.middleware import add_middleware
def init_app(app: FastAPI) -> None:
"""Apply shared middleware, routers, and global state to ``app``."""
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
add_exception_handlers(app)
add_middleware(app)
state.init_state()
for router in ALL_ROUTERS:
app.include_router(router)
app.include_router(config_schema_router)
+71
View File
@@ -0,0 +1,71 @@
"""FastAPI router for dynamic configuration schema endpoints."""
from typing import Any, Dict, List, Mapping
import yaml
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from entity.config_loader import load_design_from_mapping
from entity.configs import ConfigError
from utils.schema_exporter import build_schema_response, SchemaResolutionError
router = APIRouter(prefix="/api/config", tags=["config-schema"])
class SchemaRequest(BaseModel):
breadcrumbs: List[Mapping[str, Any]] | None = Field(
default=None,
description="Breadcrumb path starting from DesignConfig, e.g. [{\"node\":\"DesignConfig\",\"field\":\"graph\"}]",
)
class SchemaValidateRequest(SchemaRequest):
document: str = Field(..., description="Full YAML/JSON content")
def _resolve_schema(breadcrumbs: List[Mapping[str, Any]] | None) -> Dict[str, Any] | None:
if not breadcrumbs:
return None
try:
return build_schema_response(breadcrumbs)
except SchemaResolutionError:
return None
@router.post("/schema")
def get_schema(request: SchemaRequest) -> Dict[str, Any]:
try:
return build_schema_response(request.breadcrumbs)
except SchemaResolutionError as exc:
raise HTTPException(status_code=422, detail={"message": str(exc)}) from exc
@router.post("/schema/validate")
def validate_document(request: SchemaValidateRequest) -> Dict[str, Any]:
try:
parsed = yaml.safe_load(request.document)
except yaml.YAMLError as exc:
raise HTTPException(status_code=400, detail={"message": "invalid_yaml", "error": str(exc)}) from exc
if not isinstance(parsed, Mapping):
raise HTTPException(status_code=422, detail={"message": "document_root_not_mapping"})
try:
load_design_from_mapping(parsed)
except ConfigError as exc:
return {
"valid": False,
"error": str(exc),
"path": exc.path,
"schema": _resolve_schema(request.breadcrumbs),
}
return {
"valid": True,
"schema": _resolve_schema(request.breadcrumbs),
}
__all__ = ["router"]
+44
View File
@@ -0,0 +1,44 @@
"""Pydantic models shared across server routes."""
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, constr
class WorkflowRequest(BaseModel):
yaml_file: str
task_prompt: str
session_id: Optional[str] = None
attachments: Optional[List[str]] = None
log_level: Literal["INFO", "DEBUG"] = "INFO"
class WorkflowRunRequest(BaseModel):
yaml_file: str
task_prompt: str
attachments: Optional[List[str]] = None
session_name: Optional[str] = None
variables: Optional[Dict[str, Any]] = None
log_level: Optional[Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]] = None
class WorkflowUploadContentRequest(BaseModel):
filename: str
content: str
class WorkflowUpdateContentRequest(BaseModel):
content: str
class WorkflowRenameRequest(BaseModel):
new_filename: str
class WorkflowCopyRequest(BaseModel):
new_filename: str
class VueGraphContentPayload(BaseModel):
filename: constr(strip_whitespace=True, min_length=1, max_length=255)
content: str
+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}")
View File
+66
View File
@@ -0,0 +1,66 @@
"""Utilities to distribute artifact events to internal consumers."""
import logging
from typing import Sequence
from server.services.artifact_events import ArtifactEvent
from server.services.session_store import WorkflowSessionStore
from workflow.hooks.workspace_artifact import WorkspaceArtifact
class ArtifactDispatcher:
"""Persists artifact events and optionally mirrors them to WebSocket clients."""
def __init__(
self,
session_id: str,
session_store: WorkflowSessionStore,
websocket_manager=None,
) -> None:
self.session_id = session_id
self.session_store = session_store
self.websocket_manager = websocket_manager
self.logger = logging.getLogger(__name__)
def emit_workspace_artifacts(self, artifacts: Sequence[WorkspaceArtifact]) -> None:
if not artifacts:
return
events = [self._workspace_to_event(artifact) for artifact in artifacts]
self.emit(events)
def emit(self, events: Sequence[ArtifactEvent]) -> None:
if not events:
return
queue = self.session_store.get_artifact_queue(self.session_id)
if not queue:
self.logger.debug("Artifact queue missing for session %s", self.session_id)
return
queue.append_many(events)
if self.websocket_manager:
payload = {
"type": "artifact_created",
"data": {
"session_id": self.session_id,
"events": [event.to_dict() for event in events],
},
}
try:
self.websocket_manager.send_message_sync(self.session_id, payload)
except Exception as exc:
self.logger.warning("Failed to broadcast artifact events: %s", exc)
def _workspace_to_event(self, artifact: WorkspaceArtifact) -> ArtifactEvent:
return ArtifactEvent(
node_id=artifact.node_id,
attachment_id=artifact.attachment_id,
file_name=artifact.file_name,
relative_path=artifact.relative_path,
workspace_path=artifact.absolute_path,
mime_type=artifact.mime_type,
size=artifact.size,
sha256=artifact.sha256,
data_uri=artifact.data_uri,
created_at=artifact.created_at,
change_type=artifact.change_type,
extra=artifact.extra,
)
+174
View File
@@ -0,0 +1,174 @@
"""Artifact event queue utilities used to expose workflow-produced files."""
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Deque, Dict, Iterable, List, Optional, Sequence
@dataclass
class ArtifactEvent:
"""Represents a single file artifact surfaced to the frontend."""
node_id: str
attachment_id: str
file_name: str
relative_path: str
workspace_path: str
mime_type: Optional[str]
size: Optional[int]
sha256: Optional[str]
data_uri: Optional[str]
created_at: float = field(default_factory=lambda: time.time())
event_id: str = field(default_factory=lambda: uuid.uuid4().hex)
sequence: int = 0
change_type: str = "created"
extra: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"event_id": self.event_id,
"sequence": self.sequence,
"node_id": self.node_id,
"attachment_id": self.attachment_id,
"file_name": self.file_name,
"relative_path": self.relative_path,
"workspace_path": self.workspace_path,
"mime_type": self.mime_type,
"size": self.size,
"sha256": self.sha256,
"data_uri": self.data_uri,
"created_at": self.created_at,
"change_type": self.change_type,
"extra": self.extra,
}
def matches_filter(
self,
*,
include_mime: Optional[Sequence[str]] = None,
include_ext: Optional[Sequence[str]] = None,
max_size: Optional[int] = None,
) -> bool:
if max_size is not None and self.size is not None and self.size > max_size:
return False
if include_mime:
mime = (self.mime_type or "").lower()
if mime and any(mime.startswith(prefix.lower()) for prefix in include_mime):
pass
elif mime in (m.lower() for m in include_mime):
pass
else:
return False
if include_ext:
suffix = Path(self.file_name).suffix.lower()
if suffix.startswith("."):
suffix = suffix[1:]
include_ext_normalized = {ext.lower().lstrip(".") for ext in include_ext}
if suffix not in include_ext_normalized:
return False
return True
class ArtifactEventQueue:
"""Thread-safe bounded queue that supports blocking waits."""
def __init__(self, *, max_events: int = 2000) -> None:
self._events: Deque[ArtifactEvent] = deque()
self._condition = threading.Condition()
self._max_events = max_events
self._last_sequence = 0
self._min_sequence = 1
def append_many(self, events: Iterable[ArtifactEvent]) -> None:
materialized = [event for event in events if event is not None]
if not materialized:
return
with self._condition:
for event in materialized:
self._last_sequence += 1
event.sequence = self._last_sequence
self._events.append(event)
while len(self._events) > self._max_events:
self._events.popleft()
self._min_sequence = max(self._min_sequence, self._last_sequence - len(self._events) + 1)
self._condition.notify_all()
def snapshot(
self,
*,
after: Optional[int] = None,
include_mime: Optional[Sequence[str]] = None,
include_ext: Optional[Sequence[str]] = None,
max_size: Optional[int] = None,
limit: int = 50,
) -> tuple[List[ArtifactEvent], int]:
limit = max(1, min(limit, 200))
start_seq = after if after is not None else 0
start_seq = max(start_seq, self._min_sequence - 1)
events: List[ArtifactEvent] = []
next_cursor = start_seq
for event in self._events:
if event.sequence <= start_seq:
continue
next_cursor = event.sequence
if event.matches_filter(
include_mime=include_mime,
include_ext=include_ext,
max_size=max_size,
):
events.append(event)
if len(events) >= limit:
break
if next_cursor < start_seq:
next_cursor = start_seq
return events, next_cursor
def wait_for_events(
self,
*,
after: Optional[int],
include_mime: Optional[Sequence[str]],
include_ext: Optional[Sequence[str]],
max_size: Optional[int],
limit: int,
timeout: float,
) -> tuple[List[ArtifactEvent], int, bool]:
"""Block until matching events appear or timeout expires.
Returns (events, next_cursor, timeout_reached)
"""
deadline = time.time() + max(0.0, timeout)
with self._condition:
events, next_cursor = self.snapshot(
after=after,
include_mime=include_mime,
include_ext=include_ext,
max_size=max_size,
limit=limit,
)
while not events and time.time() < deadline:
remaining = deadline - time.time()
if remaining <= 0:
break
self._condition.wait(timeout=remaining)
events, next_cursor = self.snapshot(
after=after,
include_mime=include_mime,
include_ext=include_ext,
max_size=max_size,
limit=limit,
)
timed_out = not events
return events, next_cursor or (after or 0), timed_out
@property
def last_sequence(self) -> int:
return self._last_sequence
+131
View File
@@ -0,0 +1,131 @@
"""Attachment helpers shared by HTTP routes and executors."""
import logging
import mimetypes
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import UploadFile
from entity.messages import MessageBlock, MessageBlockType
from utils.attachments import AttachmentStore, AttachmentRecord
class AttachmentService:
"""Handles attachment lifecycle per session."""
def __init__(self, *, root: Path | str = Path("WareHouse")) -> None:
self.logger = logging.getLogger(__name__)
self.attachments_root = Path(root)
self.attachments_root.mkdir(parents=True, exist_ok=True)
env_flag = os.environ.get("MAC_AUTO_CLEAN_ATTACHMENTS", "0").strip().lower()
self.clean_on_cleanup = env_flag in {"1", "true", "yes"}
def prepare_session_workspace(self, session_id: str) -> Path:
return self._session_attachments_path(session_id, create=True)
def cleanup_session(self, session_id: str) -> None:
attachment_dir = self._session_attachments_path(session_id, create=False)
if not attachment_dir:
return
if self.clean_on_cleanup:
shutil.rmtree(attachment_dir, ignore_errors=True)
self.logger.info("Cleaned attachment directory for session %s", session_id)
else:
self.logger.info(
"Attachment cleanup disabled; preserved files for session %s", session_id
)
def get_attachment_store(self, session_id: str) -> AttachmentStore:
path = self.prepare_session_workspace(session_id)
return AttachmentStore(path)
@staticmethod
def _safe_upload_filename(raw: Optional[str]) -> str:
"""Reduce a client-supplied upload filename to a safe basename.
The multipart ``filename`` is attacker-controlled. Joining it onto the
temporary upload directory verbatim allows path traversal (e.g.
``../../../etc/cron.d/x``): the write target escapes the temp dir and the
cleanup step then unlinks the same traversed path, yielding arbitrary
file write and delete. Normalise both POSIX and Windows separators and
keep only the final path component so the write stays confined.
"""
candidate = os.path.basename((raw or "").replace("\\", "/")).strip()
if not candidate or candidate in {".", ".."}:
return "upload.bin"
return candidate
async def save_upload_file(self, session_id: str, upload: UploadFile) -> AttachmentRecord:
filename = self._safe_upload_filename(upload.filename)
temp_dir = Path(tempfile.mkdtemp(prefix="mac_upload_"))
temp_path = temp_dir / filename
try:
with temp_path.open("wb") as buffer:
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
buffer.write(chunk)
store = self.get_attachment_store(session_id)
mime_type = upload.content_type or mimetypes.guess_type(filename)[0]
record = store.register_file(
temp_path,
kind=MessageBlockType.from_mime_type(mime_type),
display_name=filename,
mime_type=mime_type,
extra={
"source": "user_upload",
"origin": "web_upload",
"session_id": session_id,
},
)
return record
finally:
if temp_path.exists():
try:
temp_path.unlink()
except OSError:
pass
try:
temp_dir.rmdir()
except OSError:
pass
def build_attachment_blocks(
self,
session_id: str,
attachment_ids: List[str],
*,
target_store: Optional[AttachmentStore] = None,
) -> List[MessageBlock]:
if not attachment_ids:
return []
source_store = self.get_attachment_store(session_id)
source_root = source_store.root.resolve()
target_root = target_store.root.resolve() if target_store else None
blocks: List[MessageBlock] = []
for attachment_id in attachment_ids:
record = source_store.get(attachment_id)
if not record:
continue
if target_store:
copy_required = target_root != source_root
record = target_store.ingest_record(record, copy_file=copy_required)
blocks.append(record.as_message_block())
return blocks
def list_attachment_manifests(self, session_id: str) -> Dict[str, Any]:
store = self.get_attachment_store(session_id)
return store.export_manifest()
def _session_attachments_path(self, session_id: str, *, create: bool = True) -> Optional[Path]:
session_dir_name = session_id if session_id.startswith("session_") else f"session_{session_id}"
path = self.attachments_root / session_dir_name / "code_workspace" / "attachments"
if create:
path.mkdir(parents=True, exist_ok=True)
return path
return path if path.exists() else None
+192
View File
@@ -0,0 +1,192 @@
"""Parse batch task files (CSV/Excel) into runnable tasks."""
import json
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import pandas as pd
from utils.exceptions import ValidationError
@dataclass(frozen=True)
class BatchTask:
row_index: int
task_id: Optional[str]
task_prompt: str
attachment_paths: List[str]
vars_override: Dict[str, Any]
def parse_batch_file(content: bytes, filename: str) -> Tuple[List[BatchTask], str]:
"""Parse a CSV/Excel batch file and return tasks plus file base name."""
suffix = Path(filename or "").suffix.lower()
if suffix not in {".csv", ".xlsx", ".xls"}:
raise ValidationError("Unsupported file type; must be .csv or .xlsx/.xls", field="file")
if suffix == ".csv":
df = _read_csv(content)
else:
df = _read_excel(content)
file_base = Path(filename).stem or "batch"
tasks = _parse_dataframe(df)
if not tasks:
raise ValidationError("Batch file contains no tasks", field="file")
return tasks, file_base
def _read_csv(content: bytes) -> pd.DataFrame:
try:
import chardet
except Exception:
chardet = None
encoding = "utf-8"
if chardet:
detected = chardet.detect(content)
encoding = detected.get("encoding") or encoding
try:
return pd.read_csv(BytesIO(content), encoding=encoding)
except Exception as exc:
raise ValidationError(f"Failed to read CSV: {exc}", field="file")
def _read_excel(content: bytes) -> pd.DataFrame:
try:
return pd.read_excel(BytesIO(content))
except Exception as exc:
raise ValidationError(f"Failed to read Excel file: {exc}", field="file")
def _parse_dataframe(df: pd.DataFrame) -> List[BatchTask]:
column_map = {str(col).strip().lower(): col for col in df.columns}
id_col = column_map.get("id")
task_col = column_map.get("task")
attachments_col = column_map.get("attachments")
vars_col = column_map.get("vars")
tasks: List[BatchTask] = []
seen_ids: set[str] = set()
for row_index, row in enumerate(df.to_dict(orient="records"), start=1):
task_prompt = _get_cell_text(row, task_col)
attachment_paths = _parse_json_list(row, attachments_col, row_index)
vars_override = _parse_json_dict(row, vars_col, row_index)
if not task_prompt and not attachment_paths:
raise ValidationError(
"Task and attachments cannot both be empty",
details={"row_index": row_index},
)
task_id = _get_cell_text(row, id_col)
if task_id:
if task_id in seen_ids:
raise ValidationError(
"Duplicate ID in batch file",
details={"row_index": row_index, "task_id": task_id},
)
seen_ids.add(task_id)
tasks.append(
BatchTask(
row_index=row_index,
task_id=task_id or None,
task_prompt=task_prompt,
attachment_paths=attachment_paths,
vars_override=vars_override,
)
)
return tasks
def _get_cell_text(row: Dict[str, Any], column: Optional[str]) -> str:
if not column:
return ""
value = row.get(column)
if value is None:
return ""
if isinstance(value, float) and pd.isna(value):
return ""
if pd.isna(value):
return ""
return str(value).strip()
def _parse_json_list(
row: Dict[str, Any],
column: Optional[str],
row_index: int,
) -> List[str]:
if not column:
return []
raw_value = row.get(column)
if raw_value is None or (isinstance(raw_value, float) and pd.isna(raw_value)):
return []
if isinstance(raw_value, list):
return _ensure_string_list(raw_value, row_index, "Attachments")
if isinstance(raw_value, str):
if not raw_value.strip():
return []
try:
parsed = json.loads(raw_value)
except json.JSONDecodeError as exc:
raise ValidationError(
f"Invalid JSON in Attachments: {exc}",
details={"row_index": row_index},
)
return _ensure_string_list(parsed, row_index, "Attachments")
raise ValidationError(
"Attachments must be a JSON list",
details={"row_index": row_index},
)
def _parse_json_dict(
row: Dict[str, Any],
column: Optional[str],
row_index: int,
) -> Dict[str, Any]:
if not column:
return {}
raw_value = row.get(column)
if raw_value is None or (isinstance(raw_value, float) and pd.isna(raw_value)):
return {}
if isinstance(raw_value, dict):
return raw_value
if isinstance(raw_value, str):
if not raw_value.strip():
return {}
try:
parsed = json.loads(raw_value)
except json.JSONDecodeError as exc:
raise ValidationError(
f"Invalid JSON in Vars: {exc}",
details={"row_index": row_index},
)
if not isinstance(parsed, dict):
raise ValidationError(
"Vars must be a JSON object",
details={"row_index": row_index},
)
return parsed
raise ValidationError(
"Vars must be a JSON object",
details={"row_index": row_index},
)
def _ensure_string_list(value: Any, row_index: int, field: str) -> List[str]:
if not isinstance(value, list):
raise ValidationError(
f"{field} must be a JSON list",
details={"row_index": row_index},
)
result: List[str] = []
for item in value:
if item is None or (isinstance(item, float) and pd.isna(item)):
continue
result.append(str(item))
return result
+255
View File
@@ -0,0 +1,255 @@
"""Batch workflow execution helpers."""
import asyncio
import csv
import json
import logging
import re
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from check.check import load_config
from entity.enums import LogLevel
from entity.graph_config import GraphConfig
from utils.exceptions import ValidationError
from utils.task_input import TaskInputBuilder
from workflow.graph import GraphExecutor
from workflow.graph_context import GraphContext
from server.services.batch_parser import BatchTask
from server.services.workflow_storage import validate_workflow_filename
from server.settings import WARE_HOUSE_DIR, YAML_DIR
class BatchRunService:
"""Runs batch workflows and reports progress over WebSocket."""
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
async def run_batch(
self,
session_id: str,
yaml_file: str,
tasks: List[BatchTask],
websocket_manager,
*,
max_parallel: int = 5,
file_base: str = "batch",
log_level: Optional[LogLevel] = None,
) -> None:
batch_id = session_id
total = len(tasks)
await websocket_manager.send_message(
session_id,
{"type": "batch_started", "data": {"batch_id": batch_id, "total": total}},
)
semaphore = asyncio.Semaphore(max_parallel)
success_count = 0
failure_count = 0
result_rows: List[Dict[str, Any]] = []
result_lock = asyncio.Lock()
async def run_task(task: BatchTask) -> None:
nonlocal success_count, failure_count
task_id = task.task_id or str(uuid.uuid4())
task_dir = self._sanitize_label(f"{file_base}-{task_id}")
await websocket_manager.send_message(
session_id,
{
"type": "batch_task_started",
"data": {
"row_index": task.row_index,
"task_id": task_id,
"task_dir": task_dir,
},
},
)
try:
result = await asyncio.to_thread(
self._run_single_task,
session_id,
yaml_file,
task,
task_dir,
log_level,
)
success_count += 1
async with result_lock:
result_rows.append(
{
"row_index": task.row_index,
"task_id": task_id,
"task_dir": task_dir,
"status": "success",
"duration_ms": result["duration_ms"],
"token_usage": result["token_usage"],
"graph_output": result["graph_output"],
"results": result["results"],
"error": "",
}
)
await websocket_manager.send_message(
session_id,
{
"type": "batch_task_completed",
"data": {
"row_index": task.row_index,
"task_id": task_id,
"task_dir": task_dir,
"results": result["results"],
"token_usage": result["token_usage"],
"duration_ms": result["duration_ms"],
},
},
)
except Exception as exc:
failure_count += 1
async with result_lock:
result_rows.append(
{
"row_index": task.row_index,
"task_id": task_id,
"task_dir": task_dir,
"status": "failed",
"duration_ms": None,
"token_usage": None,
"graph_output": "",
"results": None,
"error": str(exc),
}
)
await websocket_manager.send_message(
session_id,
{
"type": "batch_task_failed",
"data": {
"row_index": task.row_index,
"task_id": task_id,
"task_dir": task_dir,
"error": str(exc),
},
},
)
async def run_with_limit(task: BatchTask) -> None:
async with semaphore:
await run_task(task)
await asyncio.gather(*(run_with_limit(task) for task in tasks))
self._write_batch_outputs(session_id, result_rows)
await websocket_manager.send_message(
session_id,
{
"type": "batch_completed",
"data": {
"batch_id": batch_id,
"total": total,
"succeeded": success_count,
"failed": failure_count,
},
},
)
def _write_batch_outputs(self, session_id: str, result_rows: List[Dict[str, Any]]) -> None:
output_root = WARE_HOUSE_DIR / f"session_{session_id}"
output_root.mkdir(parents=True, exist_ok=True)
csv_path = output_root / "batch_results.csv"
json_path = output_root / "batch_manifest.json"
fieldnames = [
"row_index",
"task_id",
"task_dir",
"status",
"duration_ms",
"token_usage",
"results",
"error",
]
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for row in result_rows:
row_copy = dict(row)
row_copy["token_usage"] = json.dumps(row_copy.get("token_usage"))
row_copy["results"] = row_copy.get("graph_output", "")
writer.writerow(row_copy)
with json_path.open("w", encoding="utf-8") as handle:
json.dump(result_rows, handle, ensure_ascii=True, indent=2)
def _run_single_task(
self,
session_id: str,
yaml_file: str,
task: BatchTask,
task_dir: str,
log_level: Optional[LogLevel],
) -> Dict[str, Any]:
yaml_path = self._resolve_yaml_path(yaml_file)
design = load_config(yaml_path, vars_override=task.vars_override or None)
if any(node.type == "human" for node in design.graph.nodes):
raise ValidationError(
"Batch execution does not support human nodes",
details={"yaml_file": yaml_file},
)
output_root = WARE_HOUSE_DIR / f"session_{session_id}"
graph_config = GraphConfig.from_definition(
design.graph,
name=task_dir,
output_root=output_root,
source_path=str(yaml_path),
vars=design.vars,
)
graph_config.metadata["fixed_output_dir"] = True
if log_level:
graph_config.log_level = log_level
graph_config.definition.log_level = log_level
graph_context = GraphContext(config=graph_config)
start_time = time.perf_counter()
executor = GraphExecutor(graph_context, session_id=session_id)
task_input = self._build_task_input(executor.attachment_store, task)
executor._execute(task_input)
duration_ms = int((time.perf_counter() - start_time) * 1000)
return {
"results": executor.outputs,
"token_usage": executor.token_tracker.get_token_usage(),
"duration_ms": duration_ms,
"graph_output": executor.get_final_output(),
}
@staticmethod
def _build_task_input(attachment_store, task: BatchTask):
if task.attachment_paths:
builder = TaskInputBuilder(attachment_store)
return builder.build_from_file_paths(task.task_prompt, task.attachment_paths)
return task.task_prompt
@staticmethod
def _sanitize_label(value: str) -> str:
cleaned = re.sub(r"[^a-zA-Z0-9._-]+", "_", value)
return cleaned.strip("_") or "task"
@staticmethod
def _resolve_yaml_path(yaml_filename: str) -> Path:
safe_name = validate_workflow_filename(yaml_filename, require_yaml_extension=True)
yaml_path = YAML_DIR / safe_name
if not yaml_path.exists():
raise ValidationError("YAML file not found", details={"yaml_file": safe_name})
return yaml_path
+91
View File
@@ -0,0 +1,91 @@
import logging
from typing import Any, Dict
from utils.exceptions import ValidationError
from server.services.session_execution import SessionExecutionController
from server.services.session_store import WorkflowSessionStore
class MessageHandler:
"""Routes WebSocket messages to the appropriate handlers."""
def __init__(
self,
session_store: WorkflowSessionStore,
session_controller: SessionExecutionController,
workflow_run_service=None,
) -> None:
self.session_store = session_store
self.session_controller = session_controller
self.workflow_run_service = workflow_run_service
self.logger = logging.getLogger(__name__)
async def handle_message(self, session_id: str, data: Dict[str, Any], websocket_manager):
message_type = data.get("type")
if message_type == "human_input":
await self._handle_human_input(session_id, data, websocket_manager)
elif message_type == "ping":
await self._handle_ping(session_id, websocket_manager)
elif message_type == "get_status":
await self._handle_get_status(session_id, websocket_manager)
elif message_type == "cancel":
await self._handle_cancel(session_id, websocket_manager)
else:
await websocket_manager.send_message(
session_id,
{"type": "error", "data": {"message": f"Unknown message type: {message_type}"}},
)
async def _handle_cancel(self, session_id: str, websocket_manager):
if self.workflow_run_service:
self.workflow_run_service.request_cancel(session_id, reason="User requested cancellation")
await websocket_manager.send_message(
session_id,
{"type": "input_received", "data": {"message": "Cancellation requested"}},
)
async def _handle_human_input(self, session_id: str, data: Dict[str, Any], websocket_manager):
try:
payload = data.get("data", {}) or {}
user_input = payload.get("input", "")
attachments = payload.get("attachments") or []
if not user_input and not attachments:
await websocket_manager.send_message(
session_id,
{"type": "error", "data": {"message": "Empty input"}},
)
return
self.session_controller.provide_human_input(
session_id,
{"text": user_input, "attachments": attachments},
)
await websocket_manager.send_message(
session_id,
{"type": "input_received", "data": {"message": "Input received"}},
)
except ValidationError as exc:
await websocket_manager.send_message(
session_id,
{"type": "error", "data": {"message": str(exc)}},
)
except Exception as exc:
self.logger.error("Error handling human input for session %s: %s", session_id, exc)
await websocket_manager.send_message(
session_id,
{"type": "error", "data": {"message": str(exc)}},
)
async def _handle_ping(self, session_id: str, websocket_manager):
await websocket_manager.handle_heartbeat(session_id)
async def _handle_get_status(self, session_id: str, websocket_manager):
session_info = self.session_store.get_session_info(session_id)
await websocket_manager.send_message(
session_id,
{"type": "status", "data": session_info or {"message": "Session not found"}},
)
+122
View File
@@ -0,0 +1,122 @@
"""PromptChannel implementation backed by WebSocket sessions."""
import asyncio
from typing import Any, Dict, List, Optional
from entity.messages import MessageBlock
from server.services.attachment_service import AttachmentService
from server.services.session_execution import SessionExecutionController
from utils.attachments import AttachmentStore
from utils.exceptions import TimeoutError
from utils.human_prompt import PromptChannel, PromptResult
from utils.structured_logger import get_server_logger
class WebPromptChannel(PromptChannel):
"""Prompt channel that mediates through the WebSocket session controller."""
def __init__(
self,
*,
session_id: str,
session_controller: SessionExecutionController,
websocket_manager: Any,
attachment_service: AttachmentService,
attachment_store: AttachmentStore,
) -> None:
self.session_id = session_id
self.session_controller = session_controller
self.websocket_manager = websocket_manager
self.attachment_service = attachment_service
self.attachment_store = attachment_store
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._loop = None
def request(
self,
*,
node_id: str,
task: str,
inputs: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> PromptResult:
preview = inputs or ""
payload = {
"input": preview,
"task_description": task,
**(metadata or {}),
}
self.session_controller.set_waiting_for_input(
self.session_id,
node_id,
payload,
)
self._notify_human_prompt(node_id, preview, task)
try:
human_response = self.session_controller.wait_for_human_input(self.session_id)
except TimeoutError:
raise
except Exception as exc: # pragma: no cover - propagated upstream
logger = get_server_logger()
logger.log_exception(exc, "Error waiting for human input", node_id=node_id, session_id=self.session_id)
raise
response_text, attachment_ids = self._extract_response(human_response)
blocks = self._build_blocks(response_text, attachment_ids)
metadata_out = {
"attachment_count": len(attachment_ids),
"input_size": len(preview),
}
return PromptResult(text=response_text, blocks=blocks, metadata=metadata_out)
def _extract_response(self, payload: Any) -> tuple[str, List[str]]:
if isinstance(payload, dict):
response_text = payload.get("text") or ""
attachments = payload.get("attachments") or []
return response_text, attachments
if payload is None:
return "", []
return str(payload), []
def _build_blocks(self, text: str, attachment_ids: List[str]) -> List[MessageBlock]:
blocks: List[MessageBlock] = []
if text:
blocks.append(MessageBlock.text_block(text))
if attachment_ids:
blocks.extend(
self.attachment_service.build_attachment_blocks(
self.session_id,
attachment_ids,
target_store=self.attachment_store,
)
)
if not blocks:
blocks.append(MessageBlock.text_block(""))
return blocks
def _notify_human_prompt(self, node_id: str, preview: str, task: str) -> None:
message = {
"type": "human_input_required",
"data": {
"node_id": node_id,
"input": preview,
"task_description": task,
},
}
if self._loop and self._loop.is_running():
future = asyncio.run_coroutine_threadsafe(
self.websocket_manager.send_message(self.session_id, message),
self._loop,
)
try:
future.result()
except Exception:
# fallback to sync send to surface errors/logging
self.websocket_manager.send_message_sync(self.session_id, message)
else:
self.websocket_manager.send_message_sync(self.session_id, message)
+148
View File
@@ -0,0 +1,148 @@
"""Human input coordination for workflow sessions."""
import concurrent.futures
import logging
import time
from concurrent.futures import Future
from typing import Any, Dict, Optional
from utils.exceptions import ValidationError, TimeoutError as CustomTimeoutError, WorkflowCancelledError
from utils.structured_logger import LogType, get_server_logger
from .session_store import SessionStatus, WorkflowSessionStore
class SessionExecutionController:
"""Handles blocking wait/provide cycles for human input."""
def __init__(self, store: WorkflowSessionStore) -> None:
self.store = store
self.logger = logging.getLogger(__name__)
def set_waiting_for_input(self, session_id: str, node_id: str, input_data: Dict[str, Any]) -> None:
session = self.store.get_session(session_id)
if not session:
raise ValidationError("Session not found", details={"session_id": session_id})
session.waiting_for_input = True
session.current_node_id = node_id
session.pending_input_data = input_data
session.status = SessionStatus.WAITING_FOR_INPUT
session.human_input_future = Future()
session.human_input_value = None
self.logger.info("Session %s waiting for input at node %s", session_id, node_id)
def wait_for_human_input(self, session_id: str, timeout: float = 1800.0) -> Any:
session = self.store.get_session(session_id)
if not session:
logger = get_server_logger()
logger.warning(
"Session %s not found when waiting for human input", session_id, log_type=LogType.WORKFLOW
)
raise ValidationError("Session not found", details={"session_id": session_id})
future: Optional[Future] = session.human_input_future
if not session.waiting_for_input or future is None:
logger = get_server_logger()
logger.warning(
"Session %s is not waiting for input", session_id, log_type=LogType.WORKFLOW
)
raise ValidationError(
"Session is not waiting for input",
details={"session_id": session_id, "waiting_for_input": session.waiting_for_input},
)
start_time = time.time()
poll_interval = 1.0
try:
while True:
if session.cancel_event.is_set():
raise WorkflowCancelledError("Workflow execution cancelled", workflow_id=session_id)
elapsed = time.time() - start_time
remaining = timeout - elapsed
if remaining <= 0:
raise concurrent.futures.TimeoutError()
wait_time = min(poll_interval, remaining)
try:
result = future.result(timeout=wait_time)
logger = get_server_logger()
input_length = 0
if isinstance(result, dict):
input_length = len(result.get("text") or "")
elif result is not None:
input_length = len(str(result))
logger.info(
"Human input received",
log_type=LogType.WORKFLOW,
session_id=session_id,
input_length=input_length,
)
return result
except concurrent.futures.TimeoutError:
continue
except concurrent.futures.TimeoutError:
self.logger.warning("Session %s human input timeout", session_id)
logger = get_server_logger()
logger.warning(
"Human input timeout",
log_type=LogType.WORKFLOW,
session_id=session_id,
timeout_duration=timeout,
)
raise CustomTimeoutError("Input timeout", operation="wait_for_human_input", timeout_duration=timeout)
finally:
session.waiting_for_input = False
session.current_node_id = None
session.pending_input_data = None
session.human_input_future = None
def provide_human_input(self, session_id: str, user_input: Any) -> None:
session = self.store.get_session(session_id)
if not session:
logger = get_server_logger()
logger.warning("Session %s not found when providing human input", session_id)
raise ValidationError(
"Session not found", details={"session_id": session_id, "input_provided": user_input is not None}
)
future: Optional[Future] = session.human_input_future
if not session.waiting_for_input or future is None:
logger = get_server_logger()
logger.warning("Session %s is not waiting for input when providing data", session_id)
raise ValidationError(
"Session is not waiting for input",
details={"session_id": session_id, "waiting_for_input": session.waiting_for_input},
)
future.set_result(user_input)
session.waiting_for_input = False
length = 0
if isinstance(user_input, dict):
length = len(user_input.get("text") or "")
elif user_input is not None:
length = len(str(user_input))
logger = get_server_logger()
logger.info(
"Human input provided",
log_type=LogType.WORKFLOW,
session_id=session_id,
input_length=length,
)
def cleanup_session(self, session_id: str) -> None:
session = self.store.get_session(session_id)
if not session:
return
future: Optional[Future] = session.human_input_future
if future and not future.done():
future.cancel()
promise = session.input_promise
if promise and not promise.done():
promise.cancel()
session.waiting_for_input = False
session.current_node_id = None
session.pending_input_data = None
session.human_input_future = None
session.human_input_value = None
self.logger.info("Session %s cleaned from execution controller", session_id)
+158
View File
@@ -0,0 +1,158 @@
"""Session persistence primitives for workflow runs."""
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
from threading import Event
from typing import Any, Dict, Optional
from server.services.artifact_events import ArtifactEventQueue
class SessionStatus(Enum):
"""Lifecycle states for a workflow session."""
IDLE = "idle"
RUNNING = "running"
WAITING_FOR_INPUT = "waiting_for_input"
COMPLETED = "completed"
ERROR = "error"
CANCELLED = "cancelled"
@dataclass
class WorkflowSession:
"""Mutable record describing a workflow session."""
session_id: str
yaml_file: str
task_prompt: str
task_attachments: list[str] = field(default_factory=list)
status: SessionStatus = SessionStatus.IDLE
created_at: float = field(default_factory=lambda: time.time())
updated_at: float = field(default_factory=lambda: time.time())
# Execution metadata
executor: Optional[Any] = None
graph: Optional[Any] = None
current_node_id: Optional[str] = None
# Human input tracking
waiting_for_input: bool = False
input_promise: Optional[Any] = None
pending_input_data: Optional[Dict[str, Any]] = None
human_input_future: Optional[Any] = None
human_input_value: Optional[str] = None
# Results + errors
results: Dict[str, Any] = field(default_factory=dict)
error_message: Optional[str] = None
# Artifact streaming
artifact_queue: ArtifactEventQueue = field(default_factory=ArtifactEventQueue)
# Cancellation tracking
cancel_event: Event = field(default_factory=Event)
cancel_reason: Optional[str] = None
# Message buffer for reconnection replay
message_buffer: list = field(default_factory=list)
MAX_BUFFER_SIZE: int = 1000
def append_message(self, message: Dict[str, Any]) -> None:
if len(self.message_buffer) >= self.MAX_BUFFER_SIZE:
self.message_buffer.pop(0)
self.message_buffer.append(message)
class WorkflowSessionStore:
"""In-memory registry that tracks workflow session metadata."""
def __init__(self) -> None:
self._sessions: Dict[str, WorkflowSession] = {}
self.logger = logging.getLogger(__name__)
def create_session(
self,
*,
yaml_file: str,
task_prompt: str,
session_id: str,
attachments: Optional[list[str]] = None,
) -> WorkflowSession:
session = WorkflowSession(
session_id=session_id,
yaml_file=yaml_file,
task_prompt=task_prompt,
task_attachments=list(attachments or []),
)
self._sessions[session_id] = session
self.logger.info("Created session %s for workflow %s", session_id, yaml_file)
return session
def get_session(self, session_id: str) -> Optional[WorkflowSession]:
return self._sessions.get(session_id)
def has_session(self, session_id: str) -> bool:
return session_id in self._sessions
def update_session_status(self, session_id: str, status: SessionStatus, **kwargs: Any) -> None:
session = self._sessions.get(session_id)
if not session:
return
session.status = status
session.updated_at = time.time()
for key, value in kwargs.items():
if hasattr(session, key):
setattr(session, key, value)
self.logger.info("Updated session %s status to %s", session_id, status.value)
def set_session_error(self, session_id: str, error_message: str) -> None:
self.update_session_status(session_id, SessionStatus.ERROR, error_message=error_message)
def complete_session(self, session_id: str, results: Dict[str, Any]) -> None:
self.update_session_status(session_id, SessionStatus.COMPLETED, results=results)
def pop_session(self, session_id: str) -> Optional[WorkflowSession]:
return self._sessions.pop(session_id, None)
def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
session = self._sessions.get(session_id)
if not session:
return None
return {
"session_id": session.session_id,
"yaml_file": session.yaml_file,
"status": session.status.value,
"created_at": session.created_at,
"updated_at": session.updated_at,
"current_node_id": session.current_node_id,
"waiting_for_input": session.waiting_for_input,
"error_message": session.error_message,
}
def list_sessions(self) -> Dict[str, Dict[str, Any]]:
return {session_id: self.get_session_info(session_id) for session_id in self._sessions.keys()}
def get_artifact_queue(self, session_id: str) -> Optional[ArtifactEventQueue]:
session = self._sessions.get(session_id)
return session.artifact_queue if session else None
def get_session_snapshot(self, session_id: str) -> Optional[Dict[str, Any]]:
session = self._sessions.get(session_id)
if not session:
return None
return {
"session_id": session.session_id,
"yaml_file": session.yaml_file,
"task_prompt": session.task_prompt,
"status": session.status.value,
"current_node_id": session.current_node_id,
"created_at": session.created_at,
"updated_at": session.updated_at,
"waiting_for_input": session.waiting_for_input,
"error_message": session.error_message,
"message_count": len(session.message_buffer),
}
+62
View File
@@ -0,0 +1,62 @@
"""
SQLite-backed storage helpers for Vue graph editor payloads.
"""
import os
import sqlite3
from pathlib import Path
from typing import Optional
_INITIALIZED_PATHS: set[Path] = set()
def _get_db_path() -> Path:
"""Resolve the SQLite database path, allowing overrides via env."""
return Path(os.getenv("VUEGRAPHS_DB_PATH", "data/vuegraphs.db"))
def _ensure_db_initialized() -> Path:
"""Create the SQLite database and table if they do not already exist."""
db_path = _get_db_path()
if db_path not in _INITIALIZED_PATHS or not db_path.exists():
db_path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(db_path) as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS vuegraphs (
filename TEXT PRIMARY KEY,
content TEXT NOT NULL
)
"""
)
connection.commit()
_INITIALIZED_PATHS.add(db_path)
return db_path
def save_vuegraph_content(filename: str, content: str) -> None:
"""Insert or update the stored content for the provided filename."""
db_path = _ensure_db_initialized()
with sqlite3.connect(db_path) as connection:
connection.execute(
"""
INSERT INTO vuegraphs (filename, content)
VALUES (?, ?)
ON CONFLICT(filename) DO UPDATE SET content=excluded.content
""",
(filename, content),
)
connection.commit()
def fetch_vuegraph_content(filename: str) -> Optional[str]:
"""Return the stored content for filename, or None when absent."""
db_path = _ensure_db_initialized()
with sqlite3.connect(db_path) as connection:
cursor = connection.execute(
"SELECT content FROM vuegraphs WHERE filename = ?",
(filename,),
)
row = cursor.fetchone()
return row[0] if row else None
+72
View File
@@ -0,0 +1,72 @@
"""GraphExecutor variant that reports results over WebSocket."""
import asyncio
from typing import List
from utils.logger import WorkflowLogger
from workflow.graph import GraphExecutor
from workflow.graph_context import GraphContext
from server.services.attachment_service import AttachmentService
from server.services.artifact_dispatcher import ArtifactDispatcher
from server.services.prompt_channel import WebPromptChannel
from server.services.session_store import WorkflowSessionStore
from server.services.session_execution import SessionExecutionController
from workflow.hooks.workspace_artifact import WorkspaceArtifact, WorkspaceArtifactHook
class WebSocketGraphExecutor(GraphExecutor):
"""GraphExecutor subclass that emits events via WebSocket."""
def __init__(
self,
graph: GraphContext,
session_id: str,
session_controller: SessionExecutionController,
attachment_service: AttachmentService,
websocket_manager,
session_store: WorkflowSessionStore,
cancel_event=None,
):
self.session_id = session_id
self.session_controller = session_controller
self.attachment_service = attachment_service
self.websocket_manager = websocket_manager
self.session_store = session_store
self.results = {}
self.artifact_dispatcher = ArtifactDispatcher(session_id, session_store, websocket_manager)
def hook_factory(runtime_context):
prompt_channel = WebPromptChannel(
session_id=session_id,
session_controller=session_controller,
websocket_manager=websocket_manager,
attachment_service=attachment_service,
attachment_store=runtime_context.attachment_store,
)
return WorkspaceArtifactHook(
attachment_store=runtime_context.attachment_store,
emit_callback=self._handle_workspace_artifacts,
prompt_channel=prompt_channel,
)
super().__init__(
graph,
session_id=session_id,
workspace_hook_factory=hook_factory,
cancel_event=cancel_event,
)
def _create_logger(self) -> WorkflowLogger:
from server.services.websocket_logger import WebSocketLogger
return WebSocketLogger(self.websocket_manager, self.session_id, self.graph.name, self.graph.log_level)
async def execute_graph_async(self, task_prompt):
await asyncio.get_event_loop().run_in_executor(None, self._execute, task_prompt)
def get_results(self):
return self.outputs
def _handle_workspace_artifacts(self, artifacts: List[WorkspaceArtifact]) -> None:
self.artifact_dispatcher.emit_workspace_artifacts(artifacts)
+30
View File
@@ -0,0 +1,30 @@
import asyncio
from typing import Any, Dict
from entity.enums import LogLevel, EventType
from utils.logger import WorkflowLogger, LogEntry
from utils.structured_logger import get_workflow_logger
class WebSocketLogger(WorkflowLogger):
"""Workflow logger that also pushes entries via WebSocket."""
def __init__(self, websocket_manager, session_id: str, workflow_id: str = None, log_level: LogLevel = LogLevel.DEBUG):
super().__init__(workflow_id, log_level, log_to_console=False)
self.websocket_manager = websocket_manager
self.session_id = session_id
def add_log(self, level: LogLevel, message: str = None, node_id: str = None,
event_type: EventType = None, details: Dict[str, Any] = None,
duration: float = None) -> LogEntry | None:
log_entry = super().add_log(level, message, node_id, event_type, details, duration)
if not log_entry:
return None
# Send the message using the sync method which handles event loop properly
self.websocket_manager.send_message_sync(self.session_id, {
"type": "log",
"data": log_entry.to_dict()
})
return log_entry
+250
View File
@@ -0,0 +1,250 @@
"""WebSocket connection manager used by FastAPI app."""
import asyncio
import json
import logging
import time
import traceback
import uuid
from typing import Any, Dict, Optional
from fastapi import WebSocket
from server.services.message_handler import MessageHandler
from server.services.attachment_service import AttachmentService
from server.services.session_execution import SessionExecutionController
from server.services.session_store import WorkflowSessionStore, SessionStatus
from server.services.workflow_run_service import WorkflowRunService
def _json_default(value):
to_dict = getattr(value, "to_dict", None)
if callable(to_dict):
try:
return to_dict()
except Exception:
pass
if hasattr(value, "__dict__"):
try:
return vars(value)
except Exception:
pass
return str(value)
def _encode_ws_message(message: Any) -> str:
if isinstance(message, str):
return message
return json.dumps(message, default=_json_default)
class WebSocketManager:
SESSION_TTL_SECONDS = 24 * 60 * 60 # 24 hours
def __init__(
self,
*,
session_store: WorkflowSessionStore | None = None,
session_controller: SessionExecutionController | None = None,
attachment_service: AttachmentService | None = None,
workflow_run_service: WorkflowRunService | None = None,
):
self.active_connections: Dict[str, WebSocket] = {}
self.connection_timestamps: Dict[str, float] = {}
self._owner_loop: Optional[asyncio.AbstractEventLoop] = None
self._gc_task: Optional[asyncio.Task] = None
self.session_store = session_store or WorkflowSessionStore()
self.session_controller = session_controller or SessionExecutionController(self.session_store)
self.attachment_service = attachment_service or AttachmentService()
self.workflow_run_service = workflow_run_service or WorkflowRunService(
self.session_store,
self.session_controller,
self.attachment_service,
)
self.message_handler = MessageHandler(
self.session_store,
self.session_controller,
self.workflow_run_service,
)
async def connect(self, websocket: WebSocket, session_id: Optional[str] = None) -> str:
await websocket.accept()
# Capture the event loop that owns the WebSocket connections so that
# worker threads can safely schedule sends via run_coroutine_threadsafe.
if self._owner_loop is None:
self._owner_loop = asyncio.get_running_loop()
# --- Reconnect to existing session ---
if session_id and self.session_store.has_session(session_id):
# If an old WebSocket is still tied to this session, close it first
if session_id in self.active_connections:
old_ws = self.active_connections[session_id]
try:
await old_ws.close(code=1000, reason="Replaced by new connection")
except Exception:
pass
self.active_connections[session_id] = websocket
self.connection_timestamps[session_id] = time.time()
logging.info("WebSocket reconnected to existing session: %s", session_id)
# Always start the GC loop (idempotent)
self._start_gc()
# Send connection confirmation
await self._send_raw(
session_id,
{"type": "connection", "data": {"session_id": session_id, "status": "connected"}},
)
# Replay all buffered messages (snapshot to avoid including messages
# that arrive during replay)
session = self.session_store.get_session(session_id)
if session:
messages_to_replay = list(session.message_buffer)
for msg in messages_to_replay:
await self._send_raw(session_id, msg)
# Send session state snapshot
snapshot = self.session_store.get_session_snapshot(session_id)
if snapshot:
await self._send_raw(session_id, {"type": "session_resumed", "data": snapshot})
return session_id
# --- New connection ---
if not session_id:
session_id = str(uuid.uuid4())
self.active_connections[session_id] = websocket
self.connection_timestamps[session_id] = time.time()
logging.info("WebSocket connected: %s", session_id)
# Always start the GC loop (idempotent)
self._start_gc()
await self.send_message(
session_id,
{
"type": "connection",
"data": {"session_id": session_id, "status": "connected"},
},
)
return session_id
def disconnect(self, session_id: str) -> None:
if session_id in self.active_connections:
del self.active_connections[session_id]
if session_id in self.connection_timestamps:
del self.connection_timestamps[session_id]
logging.info("WebSocket disconnected (session preserved): %s", session_id)
async def send_message(self, session_id: str, message: Dict[str, Any]) -> None:
# Buffer business messages for reconnection replay (exclude transport messages)
if message.get("type") not in ("connection", "pong"):
session = self.session_store.get_session(session_id)
if session:
session.append_message(message)
if session_id in self.active_connections:
websocket = self.active_connections[session_id]
try:
await websocket.send_text(_encode_ws_message(message))
except Exception as exc:
traceback.print_exc()
logging.error("Failed to send message to %s: %s", session_id, exc)
async def _send_raw(self, session_id: str, message: Dict[str, Any]) -> None:
"""Send a message without buffering. Used for replay and connection management."""
if session_id in self.active_connections:
websocket = self.active_connections[session_id]
try:
await websocket.send_text(_encode_ws_message(message))
except Exception as exc:
traceback.print_exc()
logging.error("Failed to send raw message to %s: %s", session_id, exc)
def send_message_sync(self, session_id: str, message: Dict[str, Any]) -> None:
"""Send a WebSocket message from any thread (including worker threads).
WebSocket objects are bound to the event loop that created them (the main
uvicorn loop). Previous code called ``asyncio.run()`` from worker threads
which spins up a *new* event loop, causing ``RuntimeError: … attached to a
different loop`` or silent delivery failures.
The fix: always schedule the coroutine on the loop that owns the sockets
via ``asyncio.run_coroutine_threadsafe`` and wait for the result with a
short timeout so the caller knows if delivery failed.
"""
loop = self._owner_loop
if loop is None or loop.is_closed():
logging.warning(
"Cannot send sync message to %s: owner event loop unavailable",
session_id,
)
return
future = asyncio.run_coroutine_threadsafe(
self.send_message(session_id, message), loop
)
try:
future.result(timeout=10)
except TimeoutError:
logging.warning(
"Timed out sending sync WS message to %s", session_id
)
except Exception as exc:
logging.error(
"Error sending sync WS message to %s: %s", session_id, exc
)
async def broadcast(self, message: Dict[str, Any]) -> None:
for session_id in list(self.active_connections.keys()):
await self.send_message(session_id, message)
async def handle_heartbeat(self, session_id: str) -> None:
if session_id in self.active_connections:
await self.send_message(
session_id,
{"type": "pong", "data": {"timestamp": time.time()}},
)
else:
logging.warning("Heartbeat request from disconnected session: %s", session_id)
async def handle_message(self, session_id: str, message: str) -> None:
try:
data = json.loads(message)
await self.message_handler.handle_message(session_id, data, self)
except json.JSONDecodeError:
await self.send_message(
session_id,
{"type": "error", "data": {"message": "Invalid JSON format"}},
)
except Exception as exc:
logging.error("Error handling message from %s: %s", session_id, exc)
await self.send_message(
session_id,
{"type": "error", "data": {"message": str(exc)}},
)
def _start_gc(self) -> None:
"""Start the background GC task if not already running."""
if self._gc_task is not None and not self._gc_task.done():
return
loop = asyncio.get_running_loop()
self._gc_task = loop.create_task(self._gc_loop())
async def _gc_loop(self) -> None:
"""Periodically clean up terminal sessions older than TTL."""
TERMINAL = {SessionStatus.COMPLETED, SessionStatus.ERROR, SessionStatus.CANCELLED}
while True:
await asyncio.sleep(3600) # run every hour
now = time.time()
to_remove = []
for sid, session in self.session_store._sessions.items():
if session.status in TERMINAL:
if now - session.updated_at > self.SESSION_TTL_SECONDS:
to_remove.append(sid)
for sid in to_remove:
self.session_store.pop_session(sid)
self.attachment_service.cleanup_session(sid)
logging.info("GC: removed expired session %s", sid)
+293
View File
@@ -0,0 +1,293 @@
"""Service responsible for executing workflows for WebSocket sessions."""
import logging
from pathlib import Path
from typing import List, Optional, Union
from check.check import load_config
from entity.graph_config import GraphConfig
from entity.messages import Message
from entity.enums import LogLevel
from utils.exceptions import ValidationError, WorkflowCancelledError
from utils.structured_logger import get_server_logger, LogType
from utils.task_input import TaskInputBuilder
from workflow.graph_context import GraphContext
from server.services.attachment_service import AttachmentService
from server.services.session_execution import SessionExecutionController
from server.services.session_store import SessionStatus, WorkflowSessionStore
from server.services.websocket_executor import WebSocketGraphExecutor
from server.services.workflow_storage import validate_workflow_filename
from server.settings import WARE_HOUSE_DIR, YAML_DIR
class WorkflowRunService:
def __init__(
self,
session_store: WorkflowSessionStore,
session_controller: SessionExecutionController,
attachment_service: AttachmentService,
) -> None:
self.session_store = session_store
self.session_controller = session_controller
self.attachment_service = attachment_service
self.logger = logging.getLogger(__name__)
def request_cancel(self, session_id: str, *, reason: Optional[str] = None) -> bool:
session = self.session_store.get_session(session_id)
if not session:
return False
cancel_message = reason or "Cancellation requested"
session.cancel_reason = cancel_message
if not session.cancel_event.is_set():
session.cancel_event.set()
self.logger.info("Cancellation requested for session %s", session_id)
if session.executor:
try:
session.executor.request_cancel(cancel_message)
except Exception as exc:
self.logger.warning("Failed to propagate cancellation to executor for %s: %s", session_id, exc)
self.session_store.update_session_status(session_id, SessionStatus.CANCELLED, error_message=cancel_message)
return True
async def start_workflow(
self,
session_id: str,
yaml_file: str,
task_prompt: str,
websocket_manager,
*,
attachments: Optional[List[str]] = None,
log_level: Optional[LogLevel] = None,
) -> None:
normalized_yaml_name = (yaml_file or "").strip()
try:
yaml_path = self._resolve_yaml_path(normalized_yaml_name)
normalized_yaml_name = yaml_path.name
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)},
)
self.attachment_service.prepare_session_workspace(session_id)
self.session_store.create_session(
yaml_file=normalized_yaml_name,
task_prompt=task_prompt,
session_id=session_id,
attachments=attachments,
)
self.session_store.update_session_status(session_id, SessionStatus.RUNNING)
await websocket_manager.send_message(
session_id,
{
"type": "workflow_started",
"data": {"yaml_file": normalized_yaml_name, "task_prompt": task_prompt},
},
)
await self._execute_workflow_async(
session_id,
yaml_path,
task_prompt,
websocket_manager,
attachments,
log_level,
)
except ValidationError as exc:
self.logger.error(str(exc))
logger = get_server_logger()
logger.error(
"Workflow validation error",
log_type=LogType.WORKFLOW,
session_id=session_id,
yaml_file=normalized_yaml_name,
validation_details=getattr(exc, "details", None),
)
self.session_store.set_session_error(session_id, str(exc))
await websocket_manager.send_message(
session_id,
{"type": "error", "data": {"message": str(exc)}},
)
except Exception as exc:
self.logger.error(f"Error starting workflow for session {session_id}: {exc}")
logger = get_server_logger()
logger.log_exception(
exc,
"Error starting workflow",
session_id=session_id,
yaml_file=normalized_yaml_name,
)
self.session_store.set_session_error(session_id, str(exc))
await websocket_manager.send_message(
session_id,
{
"type": "error",
"data": {"message": f"Failed to start workflow: {exc}"},
},
)
async def _execute_workflow_async(
self,
session_id: str,
yaml_path: Path,
task_prompt: str,
websocket_manager,
attachments: List[str],
log_level: LogLevel,
) -> None:
session = self.session_store.get_session(session_id)
cancel_event = session.cancel_event if session else None
try:
design = load_config(yaml_path)
graph_config = GraphConfig.from_definition(
design.graph,
name=f"session_{session_id}",
output_root=WARE_HOUSE_DIR,
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)
executor = WebSocketGraphExecutor(
graph_context,
session_id,
self.session_controller,
self.attachment_service,
websocket_manager,
self.session_store,
cancel_event=cancel_event,
)
if session:
session.graph = graph_context
session.executor = executor
if session.cancel_event.is_set():
executor.request_cancel(session.cancel_reason or "Cancellation requested")
task_input = self._build_initial_task_input(
session_id,
graph_context,
task_prompt,
attachments,
executor.attachment_store,
)
await executor.execute_graph_async(task_input)
# If cancellation was requested during execution but not raised inside threads,
# treat the run as cancelled to avoid conflicting status.
if cancel_event and cancel_event.is_set():
reason = session.cancel_reason if session else "Cancellation requested"
raise WorkflowCancelledError(reason, workflow_id=graph_context.name)
results = executor.get_results()
self.session_store.complete_session(session_id, results)
await websocket_manager.send_message(
session_id,
{
"type": "workflow_completed",
"data": {
"results": results,
"summary": graph_context.final_message(),
"token_usage": executor.token_tracker.get_token_usage(),
},
},
)
logger = get_server_logger()
logger.info(
"Workflow execution completed successfully",
log_type=LogType.WORKFLOW,
session_id=session_id,
yaml_path=str(yaml_path),
result_count=len(results) if isinstance(results, dict) else 0,
)
except WorkflowCancelledError as exc:
reason = str(exc)
self.session_store.update_session_status(session_id, SessionStatus.CANCELLED, error_message=reason)
await websocket_manager.send_message(
session_id,
{
"type": "workflow_cancelled",
"data": {"message": reason},
},
)
logger = get_server_logger()
logger.info(
"Workflow execution cancelled",
log_type=LogType.WORKFLOW,
session_id=session_id,
yaml_path=str(yaml_path),
cancellation_reason=reason,
)
except ValidationError as exc:
self.session_store.set_session_error(session_id, str(exc))
await websocket_manager.send_message(
session_id,
{"type": "error", "data": {"message": str(exc)}},
)
logger = get_server_logger()
logger.error(
"Workflow validation error",
log_type=LogType.WORKFLOW,
session_id=session_id,
yaml_path=str(yaml_path),
validation_details=getattr(exc, "details", None),
)
except Exception as exc:
self.session_store.set_session_error(session_id, str(exc))
await websocket_manager.send_message(
session_id,
{"type": "error", "data": {"message": f"Workflow execution error: {exc}"}},
)
logger = get_server_logger()
logger.log_exception(
exc,
f"Error executing workflow for session {session_id}",
session_id=session_id,
yaml_path=str(yaml_path),
)
finally:
session_ref = self.session_store.get_session(session_id)
if session_ref:
session_ref.executor = None
session_ref.graph = None
self.session_controller.cleanup_session(session_id)
def _build_initial_task_input(
self,
session_id: str,
graph_context: GraphContext,
prompt: str,
attachment_ids: List[str],
store,
) -> Union[List[Message], str]:
if not attachment_ids:
return prompt
blocks = self.attachment_service.build_attachment_blocks(
session_id,
attachment_ids,
target_store=store,
)
return TaskInputBuilder(store).build_from_blocks(prompt, blocks)
def _resolve_yaml_path(self, yaml_filename: str) -> Path:
"""Validate and resolve YAML paths inside the configured directory."""
safe_name = validate_workflow_filename(yaml_filename, require_yaml_extension=True)
yaml_path = YAML_DIR / safe_name
if not yaml_path.exists():
raise ValidationError("YAML file not found", details={"yaml_file": safe_name})
return yaml_path
+207
View File
@@ -0,0 +1,207 @@
"""Utilities for validating and persisting workflow YAML files."""
import re
from pathlib import Path
from typing import Any, Tuple
import yaml
from check.check import check_config
from utils.exceptions import (
ResourceConflictError,
ResourceNotFoundError,
SecurityError,
ValidationError,
WorkflowExecutionError,
)
from utils.structured_logger import get_server_logger, LogType
def _update_workflow_id(content: str, workflow_id: str) -> str:
pattern = re.compile(r"^(id:\\s*).*$", re.MULTILINE)
match = pattern.search(content)
if match:
return pattern.sub(rf"\\1{workflow_id}", content, count=1)
lines = content.splitlines()
insert_index = 0
if lines and lines[0].strip() == "---":
insert_index = 1
lines.insert(insert_index, f"id: {workflow_id}")
updated = "\n".join(lines)
if content.endswith("\n"):
updated += "\n"
return updated
def validate_workflow_filename(filename: str, *, require_yaml_extension: bool = True) -> str:
"""Sanitize workflow filenames and guard against traversal attempts."""
value = (filename or "").strip()
if not value:
raise ValidationError("Filename cannot be empty", field="filename")
if ".." in value or value.startswith(("/", "\\")):
logger = get_server_logger()
logger.log_security_event(
"PATH_TRAVERSAL_ATTEMPT",
f"Suspicious filename detected: {value}",
details={"received_filename": value},
)
raise SecurityError("Invalid filename format", details={"filename": value})
if not re.match(r"^[a-zA-Z0-9._-]+$", value):
raise ValidationError(
"Invalid filename: only letters, digits, dots, underscores, and hyphens are allowed",
field="filename",
)
if require_yaml_extension and not value.endswith((".yaml", ".yml")):
raise ValidationError("Filename must end with .yaml or .yml", field="filename")
return Path(value).name
def validate_workflow_content(filename: str, content: str) -> Tuple[str, Any]:
safe_filename = validate_workflow_filename(filename, require_yaml_extension=True)
try:
yaml_content = yaml.safe_load(content)
if yaml_content is None:
raise ValidationError("YAML content is empty", field="content")
errors = check_config(yaml_content)
if errors:
raise ValidationError(f"YAML validation errors:\n{errors}", field="content")
except yaml.YAMLError as exc:
logger = get_server_logger()
logger.warning("Invalid YAML content in upload", details={"error": str(exc)})
raise ValidationError(f"Invalid YAML syntax: {exc}", field="content")
return safe_filename, yaml_content
def persist_workflow(
safe_filename: str,
content: str,
yaml_content: Any,
*,
action: str,
directory: Path,
) -> None:
save_path = directory / safe_filename
logger = get_server_logger()
try:
save_path.write_text(content, encoding="utf-8")
except Exception as exc:
logger.log_exception(exc, f"Failed to save workflow file {safe_filename}")
raise WorkflowExecutionError(
"Failed to save workflow file", details={"filename": safe_filename}
)
logger.info(
"Workflow file persisted",
log_type=LogType.WORKFLOW,
filename=safe_filename,
action=action,
)
def rename_workflow(source_filename: str, target_filename: str, *, directory: Path) -> None:
source_safe = validate_workflow_filename(source_filename, require_yaml_extension=True)
target_safe = validate_workflow_filename(target_filename, require_yaml_extension=True)
if source_safe == target_safe:
raise ValidationError("Source and target filenames must be different", field="new_filename")
source_path = directory / source_safe
target_path = directory / target_safe
if not source_path.exists() or not source_path.is_file():
raise ResourceNotFoundError(
"Workflow file not found",
resource_type="workflow",
resource_id=source_safe,
)
if target_path.exists():
raise ResourceConflictError(
"Target workflow already exists",
resource_type="workflow",
resource_id=target_safe,
)
logger = get_server_logger()
try:
source_path.rename(target_path)
except Exception as exc:
logger.log_exception(exc, f"Failed to rename workflow file {source_safe} to {target_safe}")
raise WorkflowExecutionError(
"Failed to rename workflow file",
details={"source": source_safe, "target": target_safe},
)
try:
new_workflow_id = Path(target_safe).stem
content = target_path.read_text(encoding="utf-8")
updated = _update_workflow_id(content, new_workflow_id)
if updated != content:
target_path.write_text(updated, encoding="utf-8")
except Exception as exc:
logger.log_exception(exc, f"Failed to update workflow id after rename to {target_safe}")
raise WorkflowExecutionError(
"Failed to update workflow id after rename",
details={"target": target_safe},
)
logger.info(
"Workflow file renamed",
log_type=LogType.WORKFLOW,
source=source_safe,
target=target_safe,
action="rename",
)
def copy_workflow(source_filename: str, target_filename: str, *, directory: Path) -> None:
source_safe = validate_workflow_filename(source_filename, require_yaml_extension=True)
target_safe = validate_workflow_filename(target_filename, require_yaml_extension=True)
if source_safe == target_safe:
raise ValidationError("Source and target filenames must be different", field="new_filename")
source_path = directory / source_safe
target_path = directory / target_safe
if not source_path.exists() or not source_path.is_file():
raise ResourceNotFoundError(
"Workflow file not found",
resource_type="workflow",
resource_id=source_safe,
)
if target_path.exists():
raise ResourceConflictError(
"Target workflow already exists",
resource_type="workflow",
resource_id=target_safe,
)
logger = get_server_logger()
try:
target_path.write_text(source_path.read_text(encoding="utf-8"), encoding="utf-8")
except Exception as exc:
logger.log_exception(exc, f"Failed to copy workflow file {source_safe} to {target_safe}")
raise WorkflowExecutionError(
"Failed to copy workflow file",
details={"source": source_safe, "target": target_safe},
)
logger.info(
"Workflow file copied",
log_type=LogType.WORKFLOW,
source=source_safe,
target=target_safe,
action="copy",
)
+6
View File
@@ -0,0 +1,6 @@
"""Shared server configuration constants."""
from pathlib import Path
YAML_DIR = Path("yaml_instance")
WARE_HOUSE_DIR = Path("WareHouse")
+37
View File
@@ -0,0 +1,37 @@
"""Global state shared across server modules."""
from typing import Optional
from server.services.websocket_manager import WebSocketManager
from utils.exceptions import ValidationError
websocket_manager: Optional[WebSocketManager] = None
def init_state() -> None:
"""Ensure global singletons are ready for incoming requests."""
get_websocket_manager()
def get_websocket_manager() -> WebSocketManager:
global websocket_manager
if websocket_manager is None:
websocket_manager = WebSocketManager()
return websocket_manager
def ensure_known_session(session_id: str, *, require_connection: bool = False) -> WebSocketManager:
"""Return the WebSocket manager if the session is connected or known."""
manager = get_websocket_manager()
if not session_id:
raise ValidationError("Session not connected", details={"session_id": session_id})
if session_id in manager.active_connections:
return manager
if not require_connection and manager.session_store.has_session(session_id):
return manager
raise ValidationError("Session not connected", details={"session_id": session_id})