Files
autocoder/server/schemas.py
Auto 85f6940a54 feat: add concurrent agents with dependency system and delightful UI
Major feature implementation for parallel agent execution with dependency-aware
scheduling and an engaging multi-agent UI experience.

Backend Changes:
- Add parallel_orchestrator.py for concurrent feature processing
- Add api/dependency_resolver.py with cycle detection (Kahn's algorithm + DFS)
- Add atomic feature_claim_next() with retry limit and exponential backoff
- Fix circular dependency check arguments in 4 locations
- Add AgentTracker class for parsing agent output and emitting updates
- Add browser isolation with --isolated flag for Playwright MCP
- Extend WebSocket protocol with agent_update messages and log attribution
- Add WSAgentUpdateMessage schema with agent states and mascot names
- Fix WSProgressMessage to include in_progress field

New UI Components:
- AgentMissionControl: Dashboard showing active agents with collapsible activity
- AgentCard: Individual agent status with avatar and thought bubble
- AgentAvatar: SVG mascots (Spark, Fizz, Octo, Hoot, Buzz) with animations
- ActivityFeed: Recent activity stream with stable keys (no flickering)
- CelebrationOverlay: Confetti animation with click/Escape dismiss
- DependencyGraph: Interactive node graph visualization with dagre layout
- DependencyBadge: Visual indicator for feature dependencies
- ViewToggle: Switch between Kanban and Graph views
- KeyboardShortcutsHelp: Help overlay accessible via ? key

UI/UX Improvements:
- Celebration queue system to handle rapid success messages
- Accessibility attributes on AgentAvatar (role, aria-label, aria-live)
- Collapsible Recent Activity section with persisted preference
- Agent count display in header
- Keyboard shortcut G to toggle Kanban/Graph view
- Real-time thought bubbles and state animations

Bug Fixes:
- Fix circular dependency validation (swapped source/target arguments)
- Add MAX_CLAIM_RETRIES=10 to prevent stack overflow under contention
- Fix THOUGHT_PATTERNS to match actual [Tool: name] format
- Fix ActivityFeed key prop to prevent re-renders on new items
- Add featureId/agentIndex to log messages for proper attribution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 12:59:42 +02:00

448 lines
13 KiB
Python

"""
Pydantic Schemas
================
Request/Response models for the API endpoints.
"""
import base64
import sys
from datetime import datetime
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, field_validator
# Import model constants from registry (single source of truth)
_root = Path(__file__).parent.parent
if str(_root) not in sys.path:
sys.path.insert(0, str(_root))
from registry import DEFAULT_MODEL, VALID_MODELS
# ============================================================================
# Project Schemas
# ============================================================================
class ProjectCreate(BaseModel):
"""Request schema for creating a new project."""
name: str = Field(..., min_length=1, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
path: str = Field(..., min_length=1, description="Absolute path to project directory")
spec_method: Literal["claude", "manual"] = "claude"
class ProjectStats(BaseModel):
"""Project statistics."""
passing: int = 0
in_progress: int = 0
total: int = 0
percentage: float = 0.0
class ProjectSummary(BaseModel):
"""Summary of a project for list view."""
name: str
path: str
has_spec: bool
stats: ProjectStats
class ProjectDetail(BaseModel):
"""Detailed project information."""
name: str
path: str
has_spec: bool
stats: ProjectStats
prompts_dir: str
class ProjectPrompts(BaseModel):
"""Project prompt files content."""
app_spec: str = ""
initializer_prompt: str = ""
coding_prompt: str = ""
class ProjectPromptsUpdate(BaseModel):
"""Request schema for updating project prompts."""
app_spec: str | None = None
initializer_prompt: str | None = None
coding_prompt: str | None = None
# ============================================================================
# Feature Schemas
# ============================================================================
class FeatureBase(BaseModel):
"""Base feature attributes."""
category: str
name: str
description: str
steps: list[str]
dependencies: list[int] = Field(default_factory=list) # Optional dependencies
class FeatureCreate(FeatureBase):
"""Request schema for creating a new feature."""
priority: int | None = None
class FeatureUpdate(BaseModel):
"""Request schema for updating a feature (partial updates allowed)."""
category: str | None = None
name: str | None = None
description: str | None = None
steps: list[str] | None = None
priority: int | None = None
dependencies: list[int] | None = None # Optional - can update dependencies
class FeatureResponse(FeatureBase):
"""Response schema for a feature."""
id: int
priority: int
passes: bool
in_progress: bool
blocked: bool = False # Computed: has unmet dependencies
blocking_dependencies: list[int] = Field(default_factory=list) # Computed
class Config:
from_attributes = True
class FeatureListResponse(BaseModel):
"""Response containing list of features organized by status."""
pending: list[FeatureResponse]
in_progress: list[FeatureResponse]
done: list[FeatureResponse]
class FeatureBulkCreate(BaseModel):
"""Request schema for bulk creating features."""
features: list[FeatureCreate]
starting_priority: int | None = None # If None, appends after max priority
class FeatureBulkCreateResponse(BaseModel):
"""Response for bulk feature creation."""
created: int
features: list[FeatureResponse]
# ============================================================================
# Dependency Graph Schemas
# ============================================================================
class DependencyGraphNode(BaseModel):
"""Minimal node for graph visualization (no description exposed for security)."""
id: int
name: str
category: str
status: Literal["pending", "in_progress", "done", "blocked"]
priority: int
dependencies: list[int]
class DependencyGraphEdge(BaseModel):
"""Edge in the dependency graph."""
source: int
target: int
class DependencyGraphResponse(BaseModel):
"""Response for dependency graph visualization."""
nodes: list[DependencyGraphNode]
edges: list[DependencyGraphEdge]
class DependencyUpdate(BaseModel):
"""Request schema for updating a feature's dependencies."""
dependency_ids: list[int] = Field(..., max_length=20) # Security: limit
# ============================================================================
# Agent Schemas
# ============================================================================
class AgentStartRequest(BaseModel):
"""Request schema for starting the agent."""
yolo_mode: bool | None = None # None means use global settings
model: str | None = None # None means use global settings
parallel_mode: bool | None = None # Enable parallel execution
max_concurrency: int | None = None # Max concurrent agents (1-5)
@field_validator('model')
@classmethod
def validate_model(cls, v: str | None) -> str | None:
"""Validate model is in the allowed list."""
if v is not None and v not in VALID_MODELS:
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
return v
@field_validator('max_concurrency')
@classmethod
def validate_concurrency(cls, v: int | None) -> int | None:
"""Validate max_concurrency is between 1 and 5."""
if v is not None and (v < 1 or v > 5):
raise ValueError("max_concurrency must be between 1 and 5")
return v
class AgentStatus(BaseModel):
"""Current agent status."""
status: Literal["stopped", "running", "paused", "crashed"]
pid: int | None = None
started_at: datetime | None = None
yolo_mode: bool = False
model: str | None = None # Model being used by running agent
parallel_mode: bool = False
max_concurrency: int | None = None
class AgentActionResponse(BaseModel):
"""Response for agent control actions."""
success: bool
status: str
message: str = ""
# ============================================================================
# Setup Schemas
# ============================================================================
class SetupStatus(BaseModel):
"""System setup status."""
claude_cli: bool
credentials: bool
node: bool
npm: bool
# ============================================================================
# WebSocket Message Schemas
# ============================================================================
class WSProgressMessage(BaseModel):
"""WebSocket message for progress updates."""
type: Literal["progress"] = "progress"
passing: int
in_progress: int
total: int
percentage: float
class WSFeatureUpdateMessage(BaseModel):
"""WebSocket message for feature status updates."""
type: Literal["feature_update"] = "feature_update"
feature_id: int
passes: bool
class WSLogMessage(BaseModel):
"""WebSocket message for agent log output."""
type: Literal["log"] = "log"
line: str
timestamp: datetime
featureId: int | None = None
agentIndex: int | None = None
class WSAgentStatusMessage(BaseModel):
"""WebSocket message for agent status changes."""
type: Literal["agent_status"] = "agent_status"
status: str
# Agent state for multi-agent tracking
AgentState = Literal["idle", "thinking", "working", "testing", "success", "error", "struggling"]
# Agent mascot names assigned by index
AGENT_MASCOTS = ["Spark", "Fizz", "Octo", "Hoot", "Buzz"]
class WSAgentUpdateMessage(BaseModel):
"""WebSocket message for multi-agent status updates."""
type: Literal["agent_update"] = "agent_update"
agentIndex: int
agentName: str # One of AGENT_MASCOTS
featureId: int
featureName: str
state: AgentState
thought: str | None = None
timestamp: datetime
# ============================================================================
# Spec Chat Schemas
# ============================================================================
# Maximum image file size: 5 MB
MAX_IMAGE_SIZE = 5 * 1024 * 1024
class ImageAttachment(BaseModel):
"""Image attachment from client for spec creation chat."""
filename: str = Field(..., min_length=1, max_length=255)
mimeType: Literal['image/jpeg', 'image/png']
base64Data: str
@field_validator('base64Data')
@classmethod
def validate_base64_and_size(cls, v: str) -> str:
"""Validate that base64 data is valid and within size limit."""
try:
decoded = base64.b64decode(v)
if len(decoded) > MAX_IMAGE_SIZE:
raise ValueError(
f'Image size ({len(decoded) / (1024 * 1024):.1f} MB) exceeds '
f'maximum of {MAX_IMAGE_SIZE // (1024 * 1024)} MB'
)
return v
except Exception as e:
if 'Image size' in str(e):
raise
raise ValueError(f'Invalid base64 data: {e}')
# ============================================================================
# Filesystem Schemas
# ============================================================================
class DriveInfo(BaseModel):
"""Information about a drive (Windows only)."""
letter: str
label: str
available: bool = True
class DirectoryEntry(BaseModel):
"""An entry in a directory listing."""
name: str
path: str # POSIX format
is_directory: bool
is_hidden: bool = False
size: int | None = None # Bytes, for files
has_children: bool = False # True if directory has subdirectories
class DirectoryListResponse(BaseModel):
"""Response for directory listing."""
current_path: str # POSIX format
parent_path: str | None
entries: list[DirectoryEntry]
drives: list[DriveInfo] | None = None # Windows only
class PathValidationResponse(BaseModel):
"""Response for path validation."""
valid: bool
exists: bool
is_directory: bool
can_read: bool
can_write: bool
message: str = ""
class CreateDirectoryRequest(BaseModel):
"""Request to create a new directory."""
parent_path: str
name: str = Field(..., min_length=1, max_length=255)
# ============================================================================
# Settings Schemas
# ============================================================================
# Note: VALID_MODELS and DEFAULT_MODEL are imported from registry at the top of this file
class ModelInfo(BaseModel):
"""Information about an available model."""
id: str
name: str
class SettingsResponse(BaseModel):
"""Response schema for global settings."""
yolo_mode: bool = False
model: str = DEFAULT_MODEL
glm_mode: bool = False # True if GLM API is configured via .env
class ModelsResponse(BaseModel):
"""Response schema for available models list."""
models: list[ModelInfo]
default: str
class SettingsUpdate(BaseModel):
"""Request schema for updating global settings."""
yolo_mode: bool | None = None
model: str | None = None
@field_validator('model')
@classmethod
def validate_model(cls, v: str | None) -> str | None:
if v is not None and v not in VALID_MODELS:
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
return v
# ============================================================================
# Dev Server Schemas
# ============================================================================
class DevServerStartRequest(BaseModel):
"""Request schema for starting the dev server."""
command: str | None = None # If None, uses effective command from config
class DevServerStatus(BaseModel):
"""Current dev server status."""
status: Literal["stopped", "running", "crashed"]
pid: int | None = None
url: str | None = None
command: str | None = None
started_at: datetime | None = None
class DevServerActionResponse(BaseModel):
"""Response for dev server control actions."""
success: bool
status: Literal["stopped", "running", "crashed"]
message: str = ""
class DevServerConfigResponse(BaseModel):
"""Response for dev server configuration."""
detected_type: str | None = None
detected_command: str | None = None
custom_command: str | None = None
effective_command: str | None = None
class DevServerConfigUpdate(BaseModel):
"""Request schema for updating dev server configuration."""
custom_command: str | None = None # None clears the custom command
# ============================================================================
# Dev Server WebSocket Message Schemas
# ============================================================================
class WSDevLogMessage(BaseModel):
"""WebSocket message for dev server log output."""
type: Literal["dev_log"] = "dev_log"
line: str
timestamp: datetime
class WSDevServerStatusMessage(BaseModel):
"""WebSocket message for dev server status changes."""
type: Literal["dev_server_status"] = "dev_server_status"
status: Literal["stopped", "running", "crashed"]
url: str | None = None