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