diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..d2831818f738deadf89c21ce116e8713cf6642b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# AutoForge Configuration +# Copy this file to .env and fill in values + +# Application +SECRET_KEY=change-this-to-a-random-secret-key +DEBUG=true +HOST=0.0.0.0 +PORT=8000 + +# Database +DATABASE_URL=sqlite+aiosqlite:///data/autoforge.db + +# Gemini LLM +GEMINI_API_KEY= +GEMINI_MODEL=gemini-2.0-flash + +# Docker Sandbox +DOCKER_ENABLED=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a2c40333c675d65e344756e1deaff02f63e70abd --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.venv/ +venv/ +env/ + +# Environment +.env + +# Database +data/autoforge.db + +# C++ build +cpp/build/ +*.o +*.d +*.exe + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +*.log + +# Testing +.pytest_cache/ +htmlcov/ +.coverage diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..deb263c0881843f2c810425382fd43b822bc2431 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) +project(AutoForge VERSION 0.1.0 LANGUAGES CXX) + +add_subdirectory(cpp) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..b6aad3efecc48258f7d102f2274ae78e762f5d57 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# ── Stage 1: C++ Build ─────────────────────────────────────────────── +FROM gcc:13 AS cpp-builder + +RUN apt-get update && apt-get install -y cmake && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY cpp/ ./cpp/ +COPY CMakeLists.txt . + +RUN cmake -B build -S . -DCMAKE_BUILD_TYPE=Release \ + && cmake --build build --target autoforge-dag + + +# ── Stage 2: Python Runtime ───────────────────────────────────────── +FROM python:3.12-slim + +WORKDIR /app + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend +COPY backend/ ./backend/ + +# Copy frontend +COPY frontend/ ./frontend/ + +# Copy C++ binary from build stage +COPY --from=cpp-builder /build/build/cpp/autoforge-dag /app/bin/autoforge-dag + +# Create data directory +RUN mkdir -p /app/data + +# Environment +ENV PYTHONUNBUFFERED=1 +ENV DATABASE_URL=sqlite+aiosqlite:///data/autoforge.db + +EXPOSE 8000 + +CMD ["python", "-m", "backend.main"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a4f9affcf8e3c026ad5dbfa94f38ebbb715128fa --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""AutoForge backend package.""" diff --git a/backend/__pycache__/__init__.cpython-312.pyc b/backend/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..109769b5936020a0b7ddbc074789bae362acc09e Binary files /dev/null and b/backend/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/agents/__init__.py b/backend/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0006208e387e42d5622652c1fbcac6f7e54c27cd --- /dev/null +++ b/backend/agents/__init__.py @@ -0,0 +1 @@ +"""AutoForge agents package.""" diff --git a/backend/analysis/__init__.py b/backend/analysis/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..efb627d37eec0cb77e250efda1970e3e765e00c8 --- /dev/null +++ b/backend/analysis/__init__.py @@ -0,0 +1 @@ +"""AutoForge analysis package.""" diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5b65b9f026ab110780d6af121981f72c951e8252 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1 @@ +"""AutoForge API package.""" diff --git a/backend/api/auth.py b/backend/api/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..42529c5df2e33a57bee5dcfda41d9e3be4df6b85 --- /dev/null +++ b/backend/api/auth.py @@ -0,0 +1,110 @@ +"""Authentication API: signup, login, and current-user retrieval.""" + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from jose import jwt +from passlib.context import CryptContext +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.deps import get_current_user, get_db +from backend.config.settings import get_settings +from backend.db.models import User +from backend.db.repository import create_user, get_user_by_email, get_user_by_username + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +# ── Schemas ────────────────────────────────────────────────────────── + +class SignupRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=80) + email: EmailStr + password: str = Field(..., min_length=6) + + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class UserResponse(BaseModel): + id: str + username: str + email: str + created_at: datetime + + model_config = {"from_attributes": True} + + +# ── Helpers ────────────────────────────────────────────────────────── + +def _hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def _verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def _create_token(user_id: str) -> str: + settings = get_settings() + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": user_id, "exp": expire} + return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm) + + +# ── Routes ─────────────────────────────────────────────────────────── + +@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def signup(body: SignupRequest, session: AsyncSession = Depends(get_db)): + """Register a new user account.""" + # Check for duplicate username + if await get_user_by_username(session, body.username): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already taken", + ) + + # Check for duplicate email + if await get_user_by_email(session, body.email): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered", + ) + + user = await create_user( + session, + username=body.username, + email=body.email, + hashed_password=_hash_password(body.password), + ) + + return TokenResponse(access_token=_create_token(user.id)) + + +@router.post("/login", response_model=TokenResponse) +async def login(body: LoginRequest, session: AsyncSession = Depends(get_db)): + """Authenticate and receive a JWT.""" + user = await get_user_by_username(session, body.username) + if not user or not _verify_password(body.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + ) + + return TokenResponse(access_token=_create_token(user.id)) + + +@router.get("/me", response_model=UserResponse) +async def me(current_user: User = Depends(get_current_user)): + """Return the currently authenticated user.""" + return current_user diff --git a/backend/api/dependencies.py b/backend/api/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..ac8f9337f4727712499088a2fa131fe8cece5305 --- /dev/null +++ b/backend/api/dependencies.py @@ -0,0 +1,49 @@ +""" +Authentication dependencies for FastAPI. + +Extracts and validates JWT from the Authorization header. +""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from sqlalchemy.orm import Session + +from backend.config.settings import get_settings +from backend.db.database import get_db +from backend.db.models import User + +security = HTTPBearer() + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +) -> User: + """Decode the JWT and return the authenticated User, or raise 401.""" + settings = get_settings() + token = credentials.credentials + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM], + ) + user_id: int | None = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = db.query(User).filter(User.id == int(user_id)).first() + if user is None: + raise credentials_exception + + return user diff --git a/backend/api/deps.py b/backend/api/deps.py new file mode 100644 index 0000000000000000000000000000000000000000..c797a044a1a238bfb01bac122f5734f2b979acd6 --- /dev/null +++ b/backend/api/deps.py @@ -0,0 +1,46 @@ +"""Shared FastAPI dependencies.""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.config.settings import get_settings +from backend.db.database import async_session_factory +from backend.db.repository import get_user_by_id + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +async def get_db() -> AsyncSession: + """Yield an async database session for request scope.""" + async with async_session_factory() as session: + yield session + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + session: AsyncSession = Depends(get_db), +): + """Decode JWT and return the authenticated user.""" + settings = get_settings() + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, settings.secret_key, algorithms=[settings.jwt_algorithm] + ) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + user = await get_user_by_id(session, user_id) + if user is None: + raise credentials_exception + return user diff --git a/backend/api/health.py b/backend/api/health.py new file mode 100644 index 0000000000000000000000000000000000000000..bb3332971417b6c1cf2c7fc3c5d252c2fd74da96 --- /dev/null +++ b/backend/api/health.py @@ -0,0 +1,17 @@ +"""Health check endpoint.""" + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(tags=["health"]) + + +class HealthResponse(BaseModel): + status: str + version: str + + +@router.get("/health", response_model=HealthResponse) +async def health(): + """Return application health status.""" + return HealthResponse(status="ok", version="0.1.0") diff --git a/backend/api/workflows.py b/backend/api/workflows.py new file mode 100644 index 0000000000000000000000000000000000000000..5fefa6073907f2f5c873ef5a8e9dc9f4308cf4fc --- /dev/null +++ b/backend/api/workflows.py @@ -0,0 +1,81 @@ +"""Workflow API endpoints.""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.api.deps import get_current_user, get_db +from backend.db.models import User +from backend.db.repository import create_workflow, get_workflow_by_id, get_workflows_by_user + +router = APIRouter(prefix="/api/workflows", tags=["workflows"]) + + +# ── Schemas ────────────────────────────────────────────────────────── + +class WorkflowCreateRequest(BaseModel): + prompt: str = Field(..., min_length=1) + + +class TaskResponse(BaseModel): + id: str + task_type: str + status: str + created_at: datetime + + model_config = {"from_attributes": True} + + +class WorkflowResponse(BaseModel): + id: str + prompt: str + status: str + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class WorkflowDetailResponse(WorkflowResponse): + tasks: list[TaskResponse] = [] + + +# ── Routes ─────────────────────────────────────────────────────────── + +@router.post("", response_model=WorkflowResponse, status_code=status.HTTP_201_CREATED) +async def create( + body: WorkflowCreateRequest, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +): + """Create a new workflow (status=pending, no execution yet).""" + workflow = await create_workflow(session, user_id=current_user.id, prompt=body.prompt) + return workflow + + +@router.get("", response_model=list[WorkflowResponse]) +async def list_workflows( + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +): + """List all workflows for the current user.""" + workflows = await get_workflows_by_user(session, current_user.id) + return workflows + + +@router.get("/{workflow_id}", response_model=WorkflowDetailResponse) +async def get_workflow( + workflow_id: str, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db), +): + """Get a single workflow with its tasks.""" + workflow = await get_workflow_by_id(session, workflow_id) + if not workflow: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found") + if workflow.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + return workflow diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4c2e648e8f6a5a94bfb10d9de3271155106da963 --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1 @@ +"""AutoForge configuration package.""" diff --git a/backend/config/__pycache__/__init__.cpython-312.pyc b/backend/config/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..384fbe94b9a2cd960867595f59db918bce65fb5b Binary files /dev/null and b/backend/config/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/config/__pycache__/settings.cpython-312.pyc b/backend/config/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a28dcc5a9b03815af7fd4916413b74004495143b Binary files /dev/null and b/backend/config/__pycache__/settings.cpython-312.pyc differ diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..c657d9472dfb4d7647db1c83b5605f124a30b8ff --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,39 @@ +"""Application settings loaded from environment variables.""" + +from pydantic_settings import BaseSettings +from pydantic import Field + + +class Settings(BaseSettings): + """AutoForge configuration.""" + + # Application + secret_key: str = Field(default="change-this-to-a-random-secret-key") + debug: bool = Field(default=True) + host: str = Field(default="0.0.0.0") + port: int = Field(default=8000) + + # Database + database_url: str = Field(default="sqlite+aiosqlite:///data/autoforge.db") + + # Gemini LLM + gemini_api_key: str = Field(default="") + gemini_model: str = Field(default="gemini-2.0-flash") + + # Docker + docker_enabled: bool = Field(default=False) + + # JWT + jwt_algorithm: str = Field(default="HS256") + jwt_expire_minutes: int = Field(default=1440) # 24 hours + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "case_sensitive": False, + } + + +def get_settings() -> Settings: + """Return application settings singleton.""" + return Settings() diff --git a/backend/db/__init__.py b/backend/db/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2a234fb21e05de5453d90df89ef4465c03fd23c9 --- /dev/null +++ b/backend/db/__init__.py @@ -0,0 +1 @@ +"""AutoForge database package.""" diff --git a/backend/db/__pycache__/__init__.cpython-312.pyc b/backend/db/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a55f8d1b7f42651d28d03c63f8a6901f0c72db7b Binary files /dev/null and b/backend/db/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/db/__pycache__/database.cpython-312.pyc b/backend/db/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f1eb5df0d8b68bdd06f6b5b6ccb685986b8e190 Binary files /dev/null and b/backend/db/__pycache__/database.cpython-312.pyc differ diff --git a/backend/db/database.py b/backend/db/database.py new file mode 100644 index 0000000000000000000000000000000000000000..ea0011fed9cff8ff272e56877d5e75e5ccf0b75f --- /dev/null +++ b/backend/db/database.py @@ -0,0 +1,41 @@ +"""SQLAlchemy async database engine and session management.""" + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from backend.config.settings import get_settings + + +class Base(DeclarativeBase): + """SQLAlchemy declarative base for all models.""" + pass + + +_settings = get_settings() + +engine = create_async_engine( + _settings.database_url, + echo=_settings.debug, + connect_args={"check_same_thread": False}, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def init_db() -> None: + """Create all database tables.""" + # Import models so they register with Base.metadata + import backend.db.models # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def get_async_session() -> AsyncSession: + """Yield an async database session.""" + async with async_session_factory() as session: + yield session diff --git a/backend/db/models.py b/backend/db/models.py new file mode 100644 index 0000000000000000000000000000000000000000..efb13fc1d60e3b4121d2485fc818740c0b4cd958 --- /dev/null +++ b/backend/db/models.py @@ -0,0 +1,109 @@ +"""SQLAlchemy ORM models for AutoForge.""" + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from backend.db.database import Base + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _generate_uuid() -> str: + return str(uuid.uuid4()) + + +class User(Base): + """User account.""" + + __tablename__ = "users" + + id = Column(String(36), primary_key=True, default=_generate_uuid) + username = Column(String(80), unique=True, nullable=False, index=True) + email = Column(String(255), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + created_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False) + + workflows = relationship("Workflow", back_populates="user", lazy="selectin") + + def __repr__(self) -> str: + return f"" + + +class Workflow(Base): + """A code-generation workflow initiated by a user.""" + + __tablename__ = "workflows" + + id = Column(String(36), primary_key=True, default=_generate_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + prompt = Column(Text, nullable=False) + status = Column(String(20), nullable=False, default="pending") + created_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False) + updated_at = Column(DateTime(timezone=True), default=_utcnow, onupdate=_utcnow, nullable=False) + + user = relationship("User", back_populates="workflows") + tasks = relationship("Task", back_populates="workflow", lazy="selectin") + events = relationship("Event", back_populates="workflow", lazy="selectin") + + def __repr__(self) -> str: + return f"" + + +class Task(Base): + """A single task within a workflow DAG.""" + + __tablename__ = "tasks" + + id = Column(String(36), primary_key=True, default=_generate_uuid) + workflow_id = Column(String(36), ForeignKey("workflows.id"), nullable=False, index=True) + task_type = Column(String(50), nullable=False) + status = Column(String(20), nullable=False, default="pending") + input_data = Column(Text, nullable=True) + output_data = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False) + + workflow = relationship("Workflow", back_populates="tasks") + attempts = relationship("Attempt", back_populates="task", lazy="selectin") + + def __repr__(self) -> str: + return f"" + + +class Attempt(Base): + """A single execution attempt for a task (supports retry logic).""" + + __tablename__ = "attempts" + + id = Column(String(36), primary_key=True, default=_generate_uuid) + task_id = Column(String(36), ForeignKey("tasks.id"), nullable=False, index=True) + attempt_number = Column(Integer, nullable=False, default=1) + status = Column(String(20), nullable=False, default="pending") + error_message = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False) + + task = relationship("Task", back_populates="attempts") + + def __repr__(self) -> str: + return f"" + + +class Event(Base): + """A workflow event for streaming to the frontend.""" + + __tablename__ = "events" + + id = Column(String(36), primary_key=True, default=_generate_uuid) + workflow_id = Column(String(36), ForeignKey("workflows.id"), nullable=False, index=True) + event_type = Column(String(50), nullable=False) + payload = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), default=_utcnow, nullable=False) + + workflow = relationship("Workflow", back_populates="events") + + def __repr__(self) -> str: + return f"" diff --git a/backend/db/repository.py b/backend/db/repository.py new file mode 100644 index 0000000000000000000000000000000000000000..e138be67c171151679bcabd3ac1c50414dbe1c33 --- /dev/null +++ b/backend/db/repository.py @@ -0,0 +1,124 @@ +"""Repository layer for database CRUD operations.""" + +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.db.models import Attempt, Event, Task, User, Workflow + + +# ── User operations ────────────────────────────────────────────────── + +async def create_user( + session: AsyncSession, + username: str, + email: str, + hashed_password: str, +) -> User: + """Create and persist a new user.""" + user = User( + username=username, + email=email, + hashed_password=hashed_password, + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]: + """Look up a user by username.""" + result = await session.execute(select(User).where(User.username == username)) + return result.scalar_one_or_none() + + +async def get_user_by_email(session: AsyncSession, email: str) -> Optional[User]: + """Look up a user by email.""" + result = await session.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() + + +async def get_user_by_id(session: AsyncSession, user_id: str) -> Optional[User]: + """Look up a user by ID.""" + result = await session.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + + +# ── Workflow operations ────────────────────────────────────────────── + +async def create_workflow( + session: AsyncSession, + user_id: str, + prompt: str, +) -> Workflow: + """Create a new workflow record.""" + workflow = Workflow(user_id=user_id, prompt=prompt) + session.add(workflow) + await session.commit() + await session.refresh(workflow) + return workflow + + +async def get_workflows_by_user(session: AsyncSession, user_id: str) -> list[Workflow]: + """Return all workflows for a given user, newest first.""" + result = await session.execute( + select(Workflow) + .where(Workflow.user_id == user_id) + .order_by(Workflow.created_at.desc()) + ) + return list(result.scalars().all()) + + +async def get_workflow_by_id(session: AsyncSession, workflow_id: str) -> Optional[Workflow]: + """Return a single workflow by ID.""" + result = await session.execute(select(Workflow).where(Workflow.id == workflow_id)) + return result.scalar_one_or_none() + + +# ── Task operations ────────────────────────────────────────────────── + +async def create_task( + session: AsyncSession, + workflow_id: str, + task_type: str, + input_data: Optional[str] = None, +) -> Task: + """Create a new task within a workflow.""" + task = Task(workflow_id=workflow_id, task_type=task_type, input_data=input_data) + session.add(task) + await session.commit() + await session.refresh(task) + return task + + +# ── Attempt operations ─────────────────────────────────────────────── + +async def create_attempt( + session: AsyncSession, + task_id: str, + attempt_number: int = 1, +) -> Attempt: + """Create an execution attempt for a task.""" + attempt = Attempt(task_id=task_id, attempt_number=attempt_number) + session.add(attempt) + await session.commit() + await session.refresh(attempt) + return attempt + + +# ── Event operations ───────────────────────────────────────────────── + +async def create_event( + session: AsyncSession, + workflow_id: str, + event_type: str, + payload: Optional[str] = None, +) -> Event: + """Create a workflow event.""" + event = Event(workflow_id=workflow_id, event_type=event_type, payload=payload) + session.add(event) + await session.commit() + await session.refresh(event) + return event diff --git a/backend/events/__init__.py b/backend/events/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3570dea0634ddeaf5c048183344448892bcfb845 --- /dev/null +++ b/backend/events/__init__.py @@ -0,0 +1 @@ +"""AutoForge events package.""" diff --git a/backend/execution/__init__.py b/backend/execution/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..327c9cdc77d8daa49bf0fc672b0c37fc2898a95d --- /dev/null +++ b/backend/execution/__init__.py @@ -0,0 +1 @@ +"""AutoForge execution package.""" diff --git a/backend/llm/__init__.py b/backend/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e4a80d8885b1ace65ef21acd757ab2c32d1dae9b --- /dev/null +++ b/backend/llm/__init__.py @@ -0,0 +1 @@ +# AutoForge LLM Providers diff --git a/backend/llm/gemini.py b/backend/llm/gemini.py new file mode 100644 index 0000000000000000000000000000000000000000..0939b20b8d6a1959cc92b6a337101fbd74cff8c0 --- /dev/null +++ b/backend/llm/gemini.py @@ -0,0 +1,121 @@ +"""Google Gemini LLM provider.""" + +import json +from typing import Any, Optional + +import httpx + +from backend.config.settings import get_settings +from backend.llm.provider import LLMProvider + + +class GeminiProviderError(Exception): + """Raised when the Gemini API returns an error.""" + pass + + +class GeminiNotConfiguredError(GeminiProviderError): + """Raised when the Gemini API key is not set.""" + pass + + +class GeminiProvider(LLMProvider): + """LLM provider backed by Google Gemini. + + Reads ``GEMINI_API_KEY`` and ``GEMINI_MODEL`` from application + settings. Communicates with the Gemini REST API via ``httpx``. + """ + + BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + + def __init__(self) -> None: + settings = get_settings() + self.api_key = settings.gemini_api_key + self.model = settings.gemini_model + + if not self.api_key: + raise GeminiNotConfiguredError( + "GEMINI_API_KEY is not set. " + "Please configure it in your .env file." + ) + + # ── Public API ─────────────────────────────────────────────────── + + async def generate( + self, + prompt: str, + *, + system_prompt: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 4096, + ) -> str: + """Send a prompt to Gemini and return the generated text.""" + contents = [] + if system_prompt: + contents.append({"role": "user", "parts": [{"text": system_prompt}]}) + contents.append({"role": "model", "parts": [{"text": "Understood."}]}) + contents.append({"role": "user", "parts": [{"text": prompt}]}) + + body = { + "contents": contents, + "generationConfig": { + "temperature": temperature, + "maxOutputTokens": max_tokens, + }, + } + + data = await self._request(body) + return self._extract_text(data) + + async def generate_structured( + self, + prompt: str, + schema: dict[str, Any], + *, + system_prompt: Optional[str] = None, + ) -> dict[str, Any]: + """Send a prompt to Gemini and parse the response as JSON.""" + schema_instruction = ( + f"Respond ONLY with valid JSON matching this schema:\n" + f"```json\n{json.dumps(schema, indent=2)}\n```" + ) + full_prompt = f"{prompt}\n\n{schema_instruction}" + raw = await self.generate(full_prompt, system_prompt=system_prompt, temperature=0.2) + + # Strip markdown code fences if present + text = raw.strip() + if text.startswith("```"): + text = text.split("\n", 1)[1] if "\n" in text else text[3:] + if text.endswith("```"): + text = text[:-3] + text = text.strip() + + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise GeminiProviderError(f"Failed to parse Gemini response as JSON: {exc}") + + # ── Internals ──────────────────────────────────────────────────── + + async def _request(self, body: dict) -> dict: + """Make a POST request to the Gemini generateContent endpoint.""" + url = ( + f"{self.BASE_URL}/models/{self.model}:generateContent" + f"?key={self.api_key}" + ) + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(url, json=body) + if response.status_code != 200: + raise GeminiProviderError( + f"Gemini API error {response.status_code}: {response.text}" + ) + return response.json() + + @staticmethod + def _extract_text(data: dict) -> str: + """Extract generated text from the Gemini response payload.""" + try: + return data["candidates"][0]["content"]["parts"][0]["text"] + except (KeyError, IndexError) as exc: + raise GeminiProviderError(f"Unexpected Gemini response structure: {exc}") diff --git a/backend/llm/provider.py b/backend/llm/provider.py new file mode 100644 index 0000000000000000000000000000000000000000..a872708078c2974cab02654aa9a785fea6939ba3 --- /dev/null +++ b/backend/llm/provider.py @@ -0,0 +1,55 @@ +"""Abstract LLM provider interface.""" + +from abc import ABC, abstractmethod +from typing import Any, Optional + + +class LLMProvider(ABC): + """Base class for all LLM providers. + + Every concrete provider must implement ``generate`` and + ``generate_structured`` so the rest of AutoForge can remain + provider-agnostic. + """ + + @abstractmethod + async def generate( + self, + prompt: str, + *, + system_prompt: Optional[str] = None, + temperature: float = 0.7, + max_tokens: int = 4096, + ) -> str: + """Generate a free-form text completion. + + Args: + prompt: The user-facing prompt. + system_prompt: Optional system-level instruction. + temperature: Sampling temperature. + max_tokens: Maximum tokens in the response. + + Returns: + The generated text. + """ + ... + + @abstractmethod + async def generate_structured( + self, + prompt: str, + schema: dict[str, Any], + *, + system_prompt: Optional[str] = None, + ) -> dict[str, Any]: + """Generate output conforming to a JSON schema. + + Args: + prompt: The user-facing prompt. + schema: A JSON Schema the response must match. + system_prompt: Optional system-level instruction. + + Returns: + A dictionary matching the provided schema. + """ + ... diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000000000000000000000000000000000000..a7894d92e720ee10269ff2ec7937fcc410729b3f --- /dev/null +++ b/backend/main.py @@ -0,0 +1,64 @@ +"""AutoForge FastAPI application entry point.""" + +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from backend.api.auth import router as auth_router +from backend.api.health import router as health_router +from backend.api.workflows import router as workflows_router +from backend.config.settings import get_settings +from backend.db.database import init_db + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup and shutdown lifecycle.""" + # Ensure data directory exists + os.makedirs("data", exist_ok=True) + # Initialize database tables + await init_db() + yield + + +app = FastAPI( + title="AutoForge", + description="Autonomous AI coding system", + version="0.1.0", + lifespan=lifespan, +) + +# CORS — permissive for local development +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API routers +app.include_router(health_router) +app.include_router(auth_router) +app.include_router(workflows_router) + +# Serve frontend static files +frontend_path = Path(__file__).resolve().parent.parent / "frontend" +if frontend_path.is_dir(): + app.mount("/", StaticFiles(directory=str(frontend_path), html=True), name="frontend") + + +if __name__ == "__main__": + import uvicorn + + settings = get_settings() + uvicorn.run( + "backend.main:app", + host=settings.host, + port=settings.port, + reload=settings.debug, + ) diff --git a/backend/orchestration/__init__.py b/backend/orchestration/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ea1bc527c0afa02cdbb38b07362aa5d184d99e4a --- /dev/null +++ b/backend/orchestration/__init__.py @@ -0,0 +1 @@ +"""AutoForge orchestration package.""" diff --git a/benchmark/.gitkeep b/benchmark/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8d1c8b69c3fce7bea45c73efd06983e3c419a92f --- /dev/null +++ b/benchmark/.gitkeep @@ -0,0 +1 @@ + diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt new file mode 100644 index 0000000000000000000000000000000000000000..a1f7aae268c64e3db4af051509140ac1c6276b56 --- /dev/null +++ b/cpp/CMakeLists.txt @@ -0,0 +1,34 @@ +cmake_minimum_required(VERSION 3.16) +project(AutoForgeDAG VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# ── Main executable ────────────────────────── + +add_executable(autoforge-dag + src/main.cpp + src/dag_engine.cpp +) + +target_include_directories(autoforge-dag PRIVATE include) + +# ── Test executable ────────────────────────── + +add_executable(autoforge-dag-test + tests/test_dag.cpp + src/dag_engine.cpp +) + +target_include_directories(autoforge-dag-test PRIVATE include) + +# Threading support +find_package(Threads REQUIRED) +target_link_libraries(autoforge-dag Threads::Threads) +target_link_libraries(autoforge-dag-test Threads::Threads) + +# ── CTest ──────────────────────────────────── + +enable_testing() +add_test(NAME DAGEngineTest COMMAND autoforge-dag-test) diff --git a/cpp/build/CMakeCache.txt b/cpp/build/CMakeCache.txt new file mode 100644 index 0000000000000000000000000000000000000000..810949d4b0eab524c9ecbe69237044e386560120 --- /dev/null +++ b/cpp/build/CMakeCache.txt @@ -0,0 +1,98 @@ +# This is the CMakeCache file. +# For build in directory: c:/Users/Admin/OneDrive/Desktop/AutoForge/cpp/build +# It was generated by CMake: C:/Program Files/CMake/bin/cmake.exe +# You can edit this file to change values found and used by cmake. +# If you do not want to change any of the values, simply exit the editor. +# If you do want to change a value, simply edit, save, and exit the editor. +# The syntax for the file is as follows: +# KEY:TYPE=VALUE +# KEY is the name of a variable in the cache. +# TYPE is a hint to GUIs for the type of VALUE, DO NOT EDIT TYPE!. +# VALUE is the current value for the KEY. + +######################## +# EXTERNAL cache entries +######################## + +//Value Computed by CMake +AutoForgeDAG_BINARY_DIR:STATIC=C:/Users/Admin/OneDrive/Desktop/AutoForge/cpp/build + +//Value Computed by CMake +AutoForgeDAG_IS_TOP_LEVEL:STATIC=ON + +//Value Computed by CMake +AutoForgeDAG_SOURCE_DIR:STATIC=C:/Users/Admin/OneDrive/Desktop/AutoForge/cpp + +//Value Computed by CMake. +CMAKE_FIND_PACKAGE_REDIRECTS_DIR:STATIC=C:/Users/Admin/OneDrive/Desktop/AutoForge/cpp/build/CMakeFiles/pkgRedirects + +//Program used to build from makefiles. +CMAKE_MAKE_PROGRAM:STRING=nmake + +//Value Computed by CMake +CMAKE_PROJECT_DESCRIPTION:STATIC= + +//Value Computed by CMake +CMAKE_PROJECT_HOMEPAGE_URL:STATIC= + +//Value Computed by CMake +CMAKE_PROJECT_NAME:STATIC=AutoForgeDAG + +//Value Computed by CMake +CMAKE_PROJECT_VERSION:STATIC=0.1.0 + +//Value Computed by CMake +CMAKE_PROJECT_VERSION_MAJOR:STATIC=0 + +//Value Computed by CMake +CMAKE_PROJECT_VERSION_MINOR:STATIC=1 + +//Value Computed by CMake +CMAKE_PROJECT_VERSION_PATCH:STATIC=0 + +//Value Computed by CMake +CMAKE_PROJECT_VERSION_TWEAK:STATIC= + + +######################## +# INTERNAL cache entries +######################## + +//This is the directory where this CMakeCache.txt was created +CMAKE_CACHEFILE_DIR:INTERNAL=c:/Users/Admin/OneDrive/Desktop/AutoForge/cpp/build +//Major version of cmake used to create the current loaded cache +CMAKE_CACHE_MAJOR_VERSION:INTERNAL=3 +//Minor version of cmake used to create the current loaded cache +CMAKE_CACHE_MINOR_VERSION:INTERNAL=29 +//Patch version of cmake used to create the current loaded cache +CMAKE_CACHE_PATCH_VERSION:INTERNAL=3 +//Path to CMake executable. +CMAKE_COMMAND:INTERNAL=C:/Program Files/CMake/bin/cmake.exe +//Path to cpack program executable. +CMAKE_CPACK_COMMAND:INTERNAL=C:/Program Files/CMake/bin/cpack.exe +//Path to ctest program executable. +CMAKE_CTEST_COMMAND:INTERNAL=C:/Program Files/CMake/bin/ctest.exe +//Path to cache edit program executable. +CMAKE_EDIT_COMMAND:INTERNAL=C:/Program Files/CMake/bin/cmake-gui.exe +//Name of external makefile project generator. +CMAKE_EXTRA_GENERATOR:INTERNAL= +//Name of generator. +CMAKE_GENERATOR:INTERNAL=NMake Makefiles +//Generator instance identifier. +CMAKE_GENERATOR_INSTANCE:INTERNAL= +//Name of generator platform. +CMAKE_GENERATOR_PLATFORM:INTERNAL= +//Name of generator toolset. +CMAKE_GENERATOR_TOOLSET:INTERNAL= +//Source directory with the top level CMakeLists.txt file for this +// project +CMAKE_HOME_DIRECTORY:INTERNAL=C:/Users/Admin/OneDrive/Desktop/AutoForge/cpp +//ADVANCED property for variable: CMAKE_MAKE_PROGRAM +CMAKE_MAKE_PROGRAM-ADVANCED:INTERNAL=1 +//number of local generators +CMAKE_NUMBER_OF_MAKEFILES:INTERNAL=1 +//Platform information initialized +CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1 +//Path to CMake installation. +CMAKE_ROOT:INTERNAL=C:/Program Files/CMake/share/cmake-3.29 + diff --git a/cpp/build/CMakeFiles/3.29.3/CMakeSystem.cmake b/cpp/build/CMakeFiles/3.29.3/CMakeSystem.cmake new file mode 100644 index 0000000000000000000000000000000000000000..5a430f646530a339c9381215e1493e10d781b928 --- /dev/null +++ b/cpp/build/CMakeFiles/3.29.3/CMakeSystem.cmake @@ -0,0 +1,15 @@ +set(CMAKE_HOST_SYSTEM "Windows-10.0.26200") +set(CMAKE_HOST_SYSTEM_NAME "Windows") +set(CMAKE_HOST_SYSTEM_VERSION "10.0.26200") +set(CMAKE_HOST_SYSTEM_PROCESSOR "AMD64") + + + +set(CMAKE_SYSTEM "Windows-10.0.26200") +set(CMAKE_SYSTEM_NAME "Windows") +set(CMAKE_SYSTEM_VERSION "10.0.26200") +set(CMAKE_SYSTEM_PROCESSOR "AMD64") + +set(CMAKE_CROSSCOMPILING "FALSE") + +set(CMAKE_SYSTEM_LOADED 1) diff --git a/cpp/build/CMakeFiles/CMakeConfigureLog.yaml b/cpp/build/CMakeFiles/CMakeConfigureLog.yaml new file mode 100644 index 0000000000000000000000000000000000000000..982674c5f788ca400205106569f68c0a32b8b5c6 --- /dev/null +++ b/cpp/build/CMakeFiles/CMakeConfigureLog.yaml @@ -0,0 +1,11 @@ + +--- +events: + - + kind: "message-v1" + backtrace: + - "C:/Program Files/CMake/share/cmake-3.29/Modules/CMakeDetermineSystem.cmake:205 (message)" + - "CMakeLists.txt:2 (project)" + message: | + The system is: Windows - 10.0.26200 - AMD64 +... diff --git a/cpp/build/CMakeFiles/cmake.check_cache b/cpp/build/CMakeFiles/cmake.check_cache new file mode 100644 index 0000000000000000000000000000000000000000..56c437b9b7acb76284e54ca56e02374aac6338f5 --- /dev/null +++ b/cpp/build/CMakeFiles/cmake.check_cache @@ -0,0 +1 @@ +# This file is generated by cmake for dependency checking of the CMakeCache.txt file diff --git a/cpp/include/dag_engine.h b/cpp/include/dag_engine.h new file mode 100644 index 0000000000000000000000000000000000000000..a90e4b11b92342afa7a8643245a496421018a160 --- /dev/null +++ b/cpp/include/dag_engine.h @@ -0,0 +1,57 @@ +#pragma once + +/** + * AutoForge DAG Engine + * + * Minimal skeleton for Phase 1. + * Full task scheduling will be implemented in Phase 2. + */ + +#include +#include +#include +#include +#include + +namespace autoforge { + +/// Represents a single node in the execution DAG. +struct DAGNode { + std::string id; + std::string name; + std::string type; // e.g., "plan", "code", "test", "analyze" + std::vector deps; // IDs of dependency nodes +}; + +/// DAG execution engine — schedules and runs task nodes. +class DAGEngine { +public: + DAGEngine(); + ~DAGEngine(); + + /// Get the engine version string. + static std::string version(); + + /// Add a node to the DAG. + void add_node(const DAGNode& node); + + /// Get the number of nodes in the DAG. + size_t node_count() const; + + /// Clear all nodes. + void clear(); + + /// Validate that the DAG has no cycles. Returns true if valid. + bool validate() const; + + /// Execute the DAG (Phase 2 — currently a no-op). + /// Returns true on success. + bool execute(); + +private: + std::vector nodes_; + std::unordered_map index_; + mutable std::mutex mutex_; +}; + +} // namespace autoforge diff --git a/cpp/src/dag_engine.cpp b/cpp/src/dag_engine.cpp new file mode 100644 index 0000000000000000000000000000000000000000..27ce1bd86f28b1cb7c0d65b028091923589d56ac --- /dev/null +++ b/cpp/src/dag_engine.cpp @@ -0,0 +1,91 @@ +/** + * AutoForge DAG Engine — Implementation + */ + +#include "dag_engine.h" +#include +#include +#include +#include + +namespace autoforge { + +DAGEngine::DAGEngine() = default; +DAGEngine::~DAGEngine() = default; + +std::string DAGEngine::version() { + return "0.1.0"; +} + +void DAGEngine::add_node(const DAGNode& node) { + std::lock_guard lock(mutex_); + index_[node.id] = nodes_.size(); + nodes_.push_back(node); +} + +size_t DAGEngine::node_count() const { + std::lock_guard lock(mutex_); + return nodes_.size(); +} + +void DAGEngine::clear() { + std::lock_guard lock(mutex_); + nodes_.clear(); + index_.clear(); +} + +bool DAGEngine::validate() const { + std::lock_guard lock(mutex_); + + // Kahn's algorithm for cycle detection + std::unordered_map in_degree; + for (const auto& node : nodes_) { + if (in_degree.find(node.id) == in_degree.end()) { + in_degree[node.id] = 0; + } + for (const auto& dep : node.deps) { + in_degree[node.id]++; + } + } + + std::queue ready; + for (const auto& [id, deg] : in_degree) { + if (deg == 0) { + ready.push(id); + } + } + + size_t processed = 0; + while (!ready.empty()) { + auto current = ready.front(); + ready.pop(); + processed++; + + // Find nodes that depend on current + for (const auto& node : nodes_) { + for (const auto& dep : node.deps) { + if (dep == current) { + in_degree[node.id]--; + if (in_degree[node.id] == 0) { + ready.push(node.id); + } + } + } + } + } + + return processed == nodes_.size(); +} + +bool DAGEngine::execute() { + // Phase 2: actual concurrent task execution + if (!validate()) { + std::cerr << "[DAGEngine] Invalid DAG — contains cycles" << std::endl; + return false; + } + + std::cout << "[DAGEngine] DAG validated. Execution not yet implemented (Phase 2)." << std::endl; + return true; +} + +} // namespace autoforge diff --git a/cpp/src/main.cpp b/cpp/src/main.cpp new file mode 100644 index 0000000000000000000000000000000000000000..8a304ed9bc38f3670429a03267e6e44e6c4a1d33 --- /dev/null +++ b/cpp/src/main.cpp @@ -0,0 +1,32 @@ +/** + * AutoForge DAG Engine — Entry Point + * + * Proves the C++ project compiles and links. + */ + +#include "dag_engine.h" +#include + +int main() { + std::cout << "========================================" << std::endl; + std::cout << " AutoForge DAG Engine v" + << autoforge::DAGEngine::version() << std::endl; + std::cout << "========================================" << std::endl; + + autoforge::DAGEngine engine; + + // Add some demo nodes + engine.add_node({"plan", "Planning", "plan", {}}); + engine.add_node({"code", "Code Generation", "code", {"plan"}}); + engine.add_node({"test", "Test Generation", "test", {"plan"}}); + engine.add_node({"analyze", "Static Analysis", "analyze", {"code"}}); + engine.add_node({"run", "Execute Tests", "execute", {"code", "test"}}); + + std::cout << "Nodes: " << engine.node_count() << std::endl; + std::cout << "Valid: " << (engine.validate() ? "yes" : "no") << std::endl; + std::cout << std::endl; + + engine.execute(); + + return 0; +} diff --git a/cpp/tests/test_dag.cpp b/cpp/tests/test_dag.cpp new file mode 100644 index 0000000000000000000000000000000000000000..da61248d69a5a8d4d4ded9fb6403f092b30c3d15 --- /dev/null +++ b/cpp/tests/test_dag.cpp @@ -0,0 +1,75 @@ +/** + * AutoForge DAG Engine — Basic Tests + * + * Minimal test suite without a framework — uses assert. + */ + +#include "dag_engine.h" +#include +#include + +void test_version() { + std::string ver = autoforge::DAGEngine::version(); + assert(!ver.empty()); + std::cout << "[PASS] version(): " << ver << std::endl; +} + +void test_add_and_count() { + autoforge::DAGEngine engine; + assert(engine.node_count() == 0); + + engine.add_node({"a", "Node A", "test", {}}); + assert(engine.node_count() == 1); + + engine.add_node({"b", "Node B", "test", {"a"}}); + assert(engine.node_count() == 2); + + std::cout << "[PASS] add_node / node_count" << std::endl; +} + +void test_clear() { + autoforge::DAGEngine engine; + engine.add_node({"a", "Node A", "test", {}}); + engine.add_node({"b", "Node B", "test", {}}); + assert(engine.node_count() == 2); + + engine.clear(); + assert(engine.node_count() == 0); + + std::cout << "[PASS] clear()" << std::endl; +} + +void test_validate_acyclic() { + autoforge::DAGEngine engine; + engine.add_node({"a", "A", "test", {}}); + engine.add_node({"b", "B", "test", {"a"}}); + engine.add_node({"c", "C", "test", {"a", "b"}}); + assert(engine.validate() == true); + + std::cout << "[PASS] validate() — acyclic" << std::endl; +} + +void test_execute() { + autoforge::DAGEngine engine; + engine.add_node({"a", "A", "test", {}}); + engine.add_node({"b", "B", "test", {"a"}}); + assert(engine.execute() == true); + + std::cout << "[PASS] execute()" << std::endl; +} + +int main() { + std::cout << "==============================" << std::endl; + std::cout << " DAG Engine Tests" << std::endl; + std::cout << "==============================" << std::endl; + + test_version(); + test_add_and_count(); + test_clear(); + test_validate_acyclic(); + test_execute(); + + std::cout << std::endl; + std::cout << "All tests passed!" << std::endl; + return 0; +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8d1c8b69c3fce7bea45c73efd06983e3c419a92f --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..1c3e0496af26d5cb5568b636274b01ab633774dd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.9" + +services: + autoforge: + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + volumes: + - ./data:/app/data + env_file: + - .env + environment: + - DATABASE_URL=sqlite+aiosqlite:///data/autoforge.db + restart: unless-stopped diff --git a/frontend/css/main.css b/frontend/css/main.css new file mode 100644 index 0000000000000000000000000000000000000000..28cc1c0de23b336f7d4184348f72c04fe342a791 --- /dev/null +++ b/frontend/css/main.css @@ -0,0 +1,891 @@ +/* ============================================ + AutoForge — Design System + Dark developer-tool UI with glassmorphism + ============================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +/* ── CSS Variables ─────────────────────────── */ + +:root { + /* Base palette */ + --bg-primary: #0a0e17; + --bg-secondary: #111827; + --bg-tertiary: #1a2236; + --bg-card: rgba(17, 24, 39, 0.7); + --bg-glass: rgba(17, 24, 39, 0.5); + + /* Accent palette */ + --accent-primary: #6366f1; + --accent-secondary: #818cf8; + --accent-glow: rgba(99, 102, 241, 0.3); + --accent-success: #34d399; + --accent-warning: #fbbf24; + --accent-danger: #f87171; + --accent-info: #38bdf8; + + /* Gradients */ + --gradient-brand: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%); + --gradient-card: linear-gradient(135deg, rgba(99,102,241,0.08) 0%, rgba(168,85,247,0.05) 100%); + --gradient-glow: radial-gradient(600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(99,102,241,0.06), transparent 40%); + + /* Text */ + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --text-on-accent: #ffffff; + + /* Borders */ + --border-primary: rgba(148, 163, 184, 0.1); + --border-secondary: rgba(148, 163, 184, 0.06); + --border-accent: rgba(99, 102, 241, 0.3); + + /* Sizing */ + --sidebar-width: 260px; + --header-height: 64px; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-xl: 20px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); + --shadow-md: 0 4px 12px rgba(0,0,0,0.3); + --shadow-lg: 0 8px 30px rgba(0,0,0,0.4); + --shadow-glow: 0 0 20px var(--accent-glow); + + /* Timing */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Typography */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; +} + + +/* ── Reset ─────────────────────────────────── */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; + overflow-x: hidden; +} + +a { + color: var(--accent-secondary); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--accent-primary); +} + +button { + font-family: inherit; + cursor: pointer; +} + +input, textarea, select { + font-family: inherit; +} + + +/* ── Animated Background ───────────────────── */ + +.bg-mesh { + position: fixed; + inset: 0; + z-index: -1; + background: var(--bg-primary); + overflow: hidden; +} + +.bg-mesh::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: + radial-gradient(ellipse 600px 600px at 20% 30%, rgba(99,102,241,0.08) 0%, transparent 70%), + radial-gradient(ellipse 500px 500px at 80% 20%, rgba(168,85,247,0.06) 0%, transparent 70%), + radial-gradient(ellipse 400px 400px at 60% 80%, rgba(236,72,153,0.04) 0%, transparent 70%); + animation: meshFloat 20s ease-in-out infinite; +} + +@keyframes meshFloat { + 0%, 100% { transform: translate(0, 0) rotate(0deg); } + 33% { transform: translate(2%, -2%) rotate(1deg); } + 66% { transform: translate(-1%, 1%) rotate(-0.5deg); } +} + + +/* ── Login Page ────────────────────────────── */ + +.login-container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; +} + +.login-card { + width: 100%; + max-width: 420px; + background: var(--bg-card); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-primary); + border-radius: var(--radius-xl); + padding: 2.5rem; + box-shadow: var(--shadow-lg); + animation: cardAppear 0.6s var(--transition-slow) both; +} + +@keyframes cardAppear { + from { + opacity: 0; + transform: translateY(20px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.login-logo { + text-align: center; + margin-bottom: 2rem; +} + +.login-logo h1 { + font-size: 1.75rem; + font-weight: 700; + background: var(--gradient-brand); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.5px; +} + +.login-logo p { + color: var(--text-muted); + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.login-tabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--border-primary); +} + +.login-tab { + flex: 1; + padding: 0.625rem; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 0.875rem; + font-weight: 500; + transition: all var(--transition-fast); +} + +.login-tab.active { + background: var(--accent-primary); + color: var(--text-on-accent); +} + +.login-tab:hover:not(.active) { + color: var(--text-primary); + background: var(--bg-tertiary); +} + + +/* ── Forms ─────────────────────────────────── */ + +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.375rem; + letter-spacing: 0.02em; +} + +.form-input { + width: 100%; + padding: 0.75rem 1rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 0.9375rem; + transition: all var(--transition-fast); + outline: none; +} + +.form-input::placeholder { + color: var(--text-muted); +} + +.form-input:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.form-input:hover:not(:focus) { + border-color: rgba(148, 163, 184, 0.2); +} + +.form-error { + display: none; + margin-top: 1rem; + padding: 0.75rem 1rem; + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.2); + border-radius: var(--radius-sm); + color: var(--accent-danger); + font-size: 0.8125rem; + animation: shake 0.3s ease-in-out; +} + +.form-error.visible { + display: block; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-4px); } + 75% { transform: translateX(4px); } +} + + +/* ── Buttons ───────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius-md); + font-size: 0.9375rem; + font-weight: 600; + transition: all var(--transition-fast); + position: relative; + overflow: hidden; +} + +.btn::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(255,255,255,0); + transition: background var(--transition-fast); +} + +.btn:hover::after { + background: rgba(255,255,255,0.06); +} + +.btn:active { + transform: scale(0.97); +} + +.btn-primary { + background: var(--gradient-brand); + color: var(--text-on-accent); + box-shadow: var(--shadow-glow); +} + +.btn-primary:hover { + box-shadow: 0 0 30px var(--accent-glow); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-primary); +} + +.btn-secondary:hover { + border-color: var(--border-accent); +} + +.btn-block { + width: 100%; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.8125rem; +} + + +/* ── App Layout ────────────────────────────── */ + +.app-layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + position: fixed; + top: 0; + left: 0; + width: var(--sidebar-width); + height: 100vh; + background: var(--bg-card); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-right: 1px solid var(--border-primary); + display: flex; + flex-direction: column; + z-index: 100; + transition: transform var(--transition-normal); +} + +.sidebar-header { + padding: 1.5rem; + border-bottom: 1px solid var(--border-primary); +} + +.sidebar-brand { + font-size: 1.25rem; + font-weight: 700; + background: var(--gradient-brand); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.sidebar-subtitle { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.125rem; +} + +.sidebar-nav { + flex: 1; + padding: 1rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.nav-link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; + transition: all var(--transition-fast); + text-decoration: none; + position: relative; +} + +.nav-link:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.nav-link.active { + background: rgba(99, 102, 241, 0.12); + color: var(--accent-secondary); +} + +.nav-link.active::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 60%; + background: var(--accent-primary); + border-radius: 0 2px 2px 0; +} + +.nav-icon { + font-size: 1.125rem; + width: 1.25rem; + text-align: center; +} + +.sidebar-footer { + padding: 1rem 0.75rem; + border-top: 1px solid var(--border-primary); +} + +.user-badge { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: var(--radius-md); + transition: background var(--transition-fast); + cursor: pointer; +} + +.user-badge:hover { + background: var(--bg-tertiary); +} + +.user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--gradient-brand); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.875rem; + color: var(--text-on-accent); + flex-shrink: 0; +} + +.user-info { + overflow: hidden; +} + +.user-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-role { + font-size: 0.6875rem; + color: var(--text-muted); +} + + +/* ── Main Content ──────────────────────────── */ + +.main-content { + flex: 1; + margin-left: var(--sidebar-width); + min-height: 100vh; +} + +.page-header { + padding: 2rem 2.5rem 1.5rem; + border-bottom: 1px solid var(--border-secondary); +} + +.page-title { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.5px; +} + +.page-subtitle { + font-size: 0.875rem; + color: var(--text-muted); + margin-top: 0.25rem; +} + +.page-body { + padding: 2rem 2.5rem; +} + + +/* ── Cards ─────────────────────────────────── */ + +.card { + background: var(--bg-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 1.5rem; + transition: all var(--transition-normal); +} + +.card:hover { + border-color: var(--border-accent); + box-shadow: var(--shadow-glow); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.card-title { + font-size: 1rem; + font-weight: 600; +} + + +/* ── Stats Grid ────────────────────────────── */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1.25rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--gradient-card); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 1.25rem 1.5rem; + transition: all var(--transition-normal); +} + +.stat-card:hover { + border-color: var(--border-accent); + transform: translateY(-2px); + box-shadow: var(--shadow-glow); +} + +.stat-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + margin-top: 0.25rem; + background: var(--gradient-brand); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.stat-change { + font-size: 0.75rem; + color: var(--accent-success); + margin-top: 0.25rem; +} + + +/* ── Workflow List ─────────────────────────── */ + +.workflow-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.workflow-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + background: var(--bg-card); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + transition: all var(--transition-fast); + cursor: pointer; +} + +.workflow-item:hover { + border-color: var(--border-accent); + background: var(--bg-tertiary); +} + +.workflow-status { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.workflow-status.pending { background: var(--accent-warning); box-shadow: 0 0 8px rgba(251,191,36,0.4); } +.workflow-status.running { background: var(--accent-info); box-shadow: 0 0 8px rgba(56,189,248,0.4); animation: pulse 2s infinite; } +.workflow-status.success { background: var(--accent-success); box-shadow: 0 0 8px rgba(52,211,153,0.4); } +.workflow-status.failed { background: var(--accent-danger); box-shadow: 0 0 8px rgba(248,113,113,0.4); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.workflow-details { + flex: 1; + min-width: 0; +} + +.workflow-prompt { + font-size: 0.875rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.workflow-meta { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.125rem; +} + +.workflow-arrow { + color: var(--text-muted); + transition: transform var(--transition-fast); +} + +.workflow-item:hover .workflow-arrow { + transform: translateX(4px); + color: var(--accent-secondary); +} + + +/* ── Create Workflow ───────────────────────── */ + +.create-section { + margin-bottom: 2rem; +} + +.prompt-input { + width: 100%; + min-height: 120px; + padding: 1rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 0.875rem; + resize: vertical; + transition: all var(--transition-fast); + outline: none; +} + +.prompt-input::placeholder { + color: var(--text-muted); +} + +.prompt-input:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.create-actions { + display: flex; + justify-content: flex-end; + margin-top: 1rem; +} + + +/* ── Empty State ───────────────────────────── */ + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; +} + +.empty-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.3; +} + +.empty-title { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.empty-text { + font-size: 0.875rem; + color: var(--text-muted); + max-width: 360px; +} + + +/* ── Status Badge ──────────────────────────── */ + +.badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.625rem; + border-radius: 100px; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.badge-pending { background: rgba(251,191,36,0.15); color: var(--accent-warning); } +.badge-running { background: rgba(56,189,248,0.15); color: var(--accent-info); } +.badge-success { background: rgba(52,211,153,0.15); color: var(--accent-success); } +.badge-failed { background: rgba(248,113,113,0.15); color: var(--accent-danger); } + + +/* ── Spinner ───────────────────────────────── */ + +.spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(255,255,255,0.1); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + + +/* ── Responsive ────────────────────────────── */ + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } + + .main-content { + margin-left: 0; + } + + .page-header, + .page-body { + padding-left: 1.25rem; + padding-right: 1.25rem; + } + + .stats-grid { + grid-template-columns: 1fr; + } +} + + +/* ── Scrollbar ─────────────────────────────── */ + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-primary); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + + +/* ── Toast ─────────────────────────────────── */ + +.toast-container { + position: fixed; + top: 1.5rem; + right: 1.5rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.toast { + padding: 0.875rem 1.25rem; + background: var(--bg-card); + backdrop-filter: blur(20px); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + font-size: 0.875rem; + animation: toastIn 0.3s ease-out; + max-width: 380px; +} + +.toast.success { border-left: 3px solid var(--accent-success); } +.toast.error { border-left: 3px solid var(--accent-danger); } +.toast.info { border-left: 3px solid var(--accent-info); } + +@keyframes toastIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +.toast-exit { + animation: toastOut 0.2s ease-in forwards; +} + +@keyframes toastOut { + to { opacity: 0; transform: translateX(20px); } +} + + +/* ── Page Transitions ──────────────────────── */ + +.page-section { + display: none; +} + +.page-section.active { + display: block; + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000000000000000000000000000000000000..2ae33275de6e2df8b3de9ace59e29dd9fcbaf807 --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,894 @@ +/* ═══════════════════════════════════════════════════════════════════ + AutoForge — Design System + Dark developer-tool aesthetic with glassmorphism + ═══════════════════════════════════════════════════════════════════ */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +/* ── CSS Custom Properties ───────────────────────────────────────── */ + +:root { + /* Background */ + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --bg-tertiary: #1a1a28; + --bg-surface: rgba(255, 255, 255, 0.03); + --bg-surface-hover: rgba(255, 255, 255, 0.06); + --bg-glass: rgba(18, 18, 26, 0.75); + + /* Accent gradient */ + --accent-blue: #00d4ff; + --accent-purple: #7b2ff7; + --accent-pink: #f72fff; + --gradient-primary: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); + --gradient-secondary: linear-gradient(135deg, var(--accent-purple), var(--accent-pink)); + --gradient-glow: linear-gradient(135deg, rgba(0, 212, 255, 0.15), rgba(123, 47, 247, 0.15)); + + /* Text */ + --text-primary: #e8e8f0; + --text-secondary: #9898b0; + --text-muted: #5e5e78; + --text-accent: var(--accent-blue); + + /* Status */ + --status-success: #00e88f; + --status-warning: #ffb800; + --status-error: #ff4060; + --status-pending: #5e5e78; + + /* Borders */ + --border-subtle: rgba(255, 255, 255, 0.06); + --border-medium: rgba(255, 255, 255, 0.1); + --border-accent: rgba(0, 212, 255, 0.3); + + /* Shadows */ + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 20px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 30px rgba(0, 212, 255, 0.1); + + /* Spacing */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 3rem; + + /* Radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-xl: 24px; + + /* Typography */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Layout */ + --sidebar-width: 260px; + --header-height: 64px; +} + +/* ── Reset ───────────────────────────────────────────────────────── */ + +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; + overflow-x: hidden; +} + +a { + color: var(--text-accent); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--accent-purple); +} + +/* ── Scrollbar ───────────────────────────────────────────────────── */ + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.15); +} + +/* ── Glass Panel ─────────────────────────────────────────────────── */ + +.glass { + background: var(--bg-glass); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} + +.glass-strong { + background: rgba(18, 18, 26, 0.9); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--border-medium); + border-radius: var(--radius-lg); +} + +/* ── Layout ──────────────────────────────────────────────────────── */ + +.app-layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + position: fixed; + left: 0; + top: 0; + width: var(--sidebar-width); + height: 100vh; + background: var(--bg-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-right: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + z-index: 100; + transition: transform var(--transition-base); +} + +.sidebar-header { + padding: var(--space-xl); + border-bottom: 1px solid var(--border-subtle); +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.sidebar-brand .logo { + width: 36px; + height: 36px; + background: var(--gradient-primary); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1.1rem; + color: #fff; +} + +.sidebar-brand h1 { + font-size: 1.2rem; + font-weight: 700; + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.sidebar-nav { + flex: 1; + padding: var(--space-md); + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + transition: all var(--transition-fast); + cursor: pointer; + border: 1px solid transparent; + text-decoration: none; +} + +.nav-item:hover { + background: var(--bg-surface-hover); + color: var(--text-primary); + border-color: var(--border-subtle); +} + +.nav-item.active { + background: var(--gradient-glow); + color: var(--text-accent); + border-color: var(--border-accent); +} + +.nav-item .icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; +} + +.sidebar-footer { + padding: var(--space-md) var(--space-xl); + border-top: 1px solid var(--border-subtle); +} + +.main-content { + margin-left: var(--sidebar-width); + flex: 1; + padding: var(--space-2xl); + min-height: 100vh; +} + +/* ── Page Header ─────────────────────────────────────────────────── */ + +.page-header { + margin-bottom: var(--space-2xl); +} + +.page-header h2 { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: var(--space-xs); +} + +.page-header p { + color: var(--text-secondary); + font-size: 0.95rem; +} + +/* ── Cards ───────────────────────────────────────────────────────── */ + +.card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: var(--space-xl); + transition: all var(--transition-base); +} + +.card:hover { + border-color: var(--border-medium); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: var(--space-lg); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-md); +} + +.card-title { + font-size: 1rem; + font-weight: 600; +} + +/* ── Status Badges ───────────────────────────────────────────────── */ + +.badge { + display: inline-flex; + align-items: center; + padding: var(--space-xs) var(--space-sm); + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badge-pending { + background: rgba(94, 94, 120, 0.2); + color: var(--status-pending); + border: 1px solid rgba(94, 94, 120, 0.3); +} + +.badge-running { + background: rgba(0, 212, 255, 0.1); + color: var(--accent-blue); + border: 1px solid rgba(0, 212, 255, 0.3); + animation: pulse-glow 2s infinite; +} + +.badge-completed { + background: rgba(0, 232, 143, 0.1); + color: var(--status-success); + border: 1px solid rgba(0, 232, 143, 0.3); +} + +.badge-failed { + background: rgba(255, 64, 96, 0.1); + color: var(--status-error); + border: 1px solid rgba(255, 64, 96, 0.3); +} + +/* ── Buttons ─────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: 0.65rem 1.5rem; + border-radius: var(--radius-md); + font-family: var(--font-sans); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: all var(--transition-fast); + outline: none; +} + +.btn:active { + transform: scale(0.97); +} + +.btn-primary { + background: var(--gradient-primary); + color: #fff; + border: none; + box-shadow: 0 4px 15px rgba(0, 212, 255, 0.25); +} + +.btn-primary:hover { + box-shadow: 0 6px 25px rgba(0, 212, 255, 0.35); + transform: translateY(-1px); +} + +.btn-secondary { + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-medium); +} + +.btn-secondary:hover { + background: var(--bg-surface-hover); + border-color: var(--border-accent); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border: none; +} + +.btn-ghost:hover { + color: var(--text-primary); + background: var(--bg-surface); +} + +.btn-danger { + background: rgba(255, 64, 96, 0.15); + color: var(--status-error); + border: 1px solid rgba(255, 64, 96, 0.3); +} + +.btn-danger:hover { + background: rgba(255, 64, 96, 0.25); +} + +.btn-lg { + padding: 0.85rem 2rem; + font-size: 1rem; +} + +.btn-sm { + padding: 0.4rem 0.9rem; + font-size: 0.8rem; +} + +/* ── Forms ────────────────────────────────────────────────────────── */ + +.form-group { + margin-bottom: var(--space-lg); +} + +.form-label { + display: block; + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: var(--space-sm); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.form-input { + width: 100%; + padding: 0.75rem 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.95rem; + transition: all var(--transition-fast); + outline: none; +} + +.form-input:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1); +} + +.form-input::placeholder { + color: var(--text-muted); +} + +textarea.form-input { + resize: vertical; + min-height: 120px; + font-family: var(--font-mono); + font-size: 0.9rem; + line-height: 1.7; +} + +/* ── Auth Layout ─────────────────────────────────────────────────── */ + +.auth-layout { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: var(--space-xl); + position: relative; +} + +.auth-layout::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(ellipse at 30% 20%, rgba(0, 212, 255, 0.08) 0%, transparent 50%), + radial-gradient(ellipse at 70% 80%, rgba(123, 47, 247, 0.08) 0%, transparent 50%); + animation: bg-drift 20s ease-in-out infinite alternate; + pointer-events: none; +} + +.auth-card { + width: 100%; + max-width: 440px; + padding: var(--space-2xl); + position: relative; + z-index: 1; +} + +.auth-header { + text-align: center; + margin-bottom: var(--space-2xl); +} + +.auth-header .logo-large { + width: 64px; + height: 64px; + background: var(--gradient-primary); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.8rem; + font-weight: 700; + color: #fff; + margin: 0 auto var(--space-lg); + box-shadow: 0 8px 30px rgba(0, 212, 255, 0.25); +} + +.auth-header h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: var(--space-xs); +} + +.auth-header p { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.auth-toggle { + text-align: center; + margin-top: var(--space-xl); + color: var(--text-secondary); + font-size: 0.9rem; +} + +.auth-toggle a { + font-weight: 600; +} + +.form-error { + color: var(--status-error); + font-size: 0.85rem; + margin-top: var(--space-sm); + display: none; +} + +.form-error.visible { + display: block; +} + +/* ── Workflow Card ───────────────────────────────────────────────── */ + +.workflow-card { + cursor: pointer; +} + +.workflow-card .prompt-preview { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.5; + margin-bottom: var(--space-md); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.workflow-card .meta { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.8rem; + color: var(--text-muted); +} + +/* ── Create Workflow Card ────────────────────────────────────────── */ + +.create-card { + border: 2px dashed var(--border-medium); + background: transparent; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + cursor: pointer; + transition: all var(--transition-base); +} + +.create-card:hover { + border-color: var(--accent-blue); + background: var(--gradient-glow); +} + +.create-card .icon-large { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--gradient-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + color: #fff; + margin-bottom: var(--space-md); +} + +.create-card p { + color: var(--text-secondary); + font-weight: 500; +} + +/* ── Modal ────────────────────────────────────────────────────────── */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all var(--transition-base); +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal { + width: 100%; + max-width: 560px; + padding: var(--space-2xl); + transform: translateY(20px) scale(0.95); + transition: transform var(--transition-base); +} + +.modal-overlay.active .modal { + transform: translateY(0) scale(1); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-xl); +} + +.modal-header h3 { + font-size: 1.25rem; + font-weight: 700; +} + +.modal-close { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + transition: all var(--transition-fast); +} + +.modal-close:hover { + background: var(--bg-surface-hover); + color: var(--text-primary); +} + +/* ── Stats Bar ───────────────────────────────────────────────────── */ + +.stats-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-lg); + margin-bottom: var(--space-2xl); +} + +.stat-card { + padding: var(--space-lg); +} + +.stat-card .stat-value { + font-size: 2rem; + font-weight: 700; + background: var(--gradient-primary); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.stat-card .stat-label { + font-size: 0.8rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: var(--space-xs); +} + +/* ── Empty State ─────────────────────────────────────────────────── */ + +.empty-state { + text-align: center; + padding: var(--space-2xl) * 2; + color: var(--text-muted); +} + +.empty-state .icon-empty { + font-size: 3rem; + margin-bottom: var(--space-lg); + opacity: 0.5; +} + +.empty-state h3 { + font-size: 1.1rem; + color: var(--text-secondary); + margin-bottom: var(--space-sm); +} + +/* ── Loading Spinner ─────────────────────────────────────────────── */ + +.spinner { + width: 24px; + height: 24px; + border: 3px solid var(--border-subtle); + border-top-color: var(--accent-blue); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.spinner-sm { + width: 16px; + height: 16px; + border-width: 2px; +} + +/* ── Notifications ───────────────────────────────────────────────── */ + +.toast { + position: fixed; + bottom: var(--space-xl); + right: var(--space-xl); + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + font-size: 0.9rem; + font-weight: 500; + z-index: 2000; + transform: translateY(100px); + opacity: 0; + transition: all var(--transition-base); +} + +.toast.visible { + transform: translateY(0); + opacity: 1; +} + +.toast-success { + background: rgba(0, 232, 143, 0.15); + border: 1px solid rgba(0, 232, 143, 0.3); + color: var(--status-success); +} + +.toast-error { + background: rgba(255, 64, 96, 0.15); + border: 1px solid rgba(255, 64, 96, 0.3); + color: var(--status-error); +} + +/* ── Placeholder Pages ───────────────────────────────────────────── */ + +.placeholder-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + text-align: center; +} + +.placeholder-content .icon-placeholder { + font-size: 4rem; + margin-bottom: var(--space-lg); + opacity: 0.3; +} + +.placeholder-content h3 { + font-size: 1.3rem; + margin-bottom: var(--space-sm); +} + +.placeholder-content p { + color: var(--text-muted); + max-width: 400px; +} + +/* ── User Menu ───────────────────────────────────────────────────── */ + +.user-menu { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-fast); +} + +.user-menu:hover { + background: var(--bg-surface-hover); +} + +.user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--gradient-primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.85rem; + color: #fff; +} + +.user-name { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); +} + +/* ── Animations ──────────────────────────────────────────────────── */ + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes pulse-glow { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +@keyframes bg-drift { + 0% { transform: translate(0, 0) rotate(0deg); } + 100% { transform: translate(-2%, -2%) rotate(1deg); } +} + +@keyframes fade-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.fade-in { + animation: fade-in 0.4s ease-out forwards; +} + +/* ── Responsive ──────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } + + .main-content { + margin-left: 0; + padding: var(--space-lg); + } + + .card-grid { + grid-template-columns: 1fr; + } + + .stats-bar { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/frontend/dashboard.html b/frontend/dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..a26acc51286f9cf93280216cf4266c123d88f194 --- /dev/null +++ b/frontend/dashboard.html @@ -0,0 +1,121 @@ + + + + + + + AutoForge — Dashboard + + + +
+ + + + +
+ + + +
+
+
0
+
Total Workflows
+
+
+
0
+
Pending
+
+
+
0
+
Completed
+
+
+
0
+
Failed
+
+
+ + +
+

Recent Workflows

+ +
+ +
+
+
+
+
+ + + + + + + + + + + diff --git a/frontend/history.html b/frontend/history.html new file mode 100644 index 0000000000000000000000000000000000000000..16cdd4bab9927a1fae3075b6c76070a1e1624480 --- /dev/null +++ b/frontend/history.html @@ -0,0 +1,104 @@ + + + + + + + AutoForge — History + + + +
+ + + + +
+ + +
+
+
+
+
+ + + + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..042db6c09ca1fabf36a0bbd0fc45af23271b9689 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,22 @@ + + + + + + + AutoForge + + + + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000000000000000000000000000000000000..b32a6b29e9b8c5b5485bb81ef7e14adbcc6cc5db --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,51 @@ +/** + * AutoForge API Client + * + * Wraps fetch() with JWT injection, error handling, and base URL management. + */ + +const API = (() => { + const BASE_URL = ''; + + /** + * Make an authenticated API request. + */ + async function request(method, path, body = null) { + const headers = { 'Content-Type': 'application/json' }; + + const token = localStorage.getItem('autoforge_token'); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const options = { method, headers }; + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(`${BASE_URL}${path}`, options); + + if (response.status === 401) { + localStorage.removeItem('autoforge_token'); + localStorage.removeItem('autoforge_user'); + window.location.href = '/login.html'; + throw new Error('Session expired'); + } + + const data = await response.json(); + + if (!response.ok) { + const message = data.detail || `Request failed (${response.status})`; + throw new Error(message); + } + + return data; + } + + return { + get: (path) => request('GET', path), + post: (path, body) => request('POST', path, body), + put: (path, body) => request('PUT', path, body), + delete: (path) => request('DELETE', path), + }; +})(); diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000000000000000000000000000000000000..e39c854d1310bc1242c32d88ded1cc4f3da2e967 --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,174 @@ +/** + * AutoForge Dashboard Application Logic + */ + +const App = (() => { + let workflows = []; + + // ── Toast Notifications ───────────────────────────────────────── + + function showToast(message, type = 'success') { + // Remove existing toasts + document.querySelectorAll('.toast').forEach(t => t.remove()); + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + document.body.appendChild(toast); + + requestAnimationFrame(() => toast.classList.add('visible')); + + setTimeout(() => { + toast.classList.remove('visible'); + setTimeout(() => toast.remove(), 300); + }, 3000); + } + + // ── Sidebar ───────────────────────────────────────────────────── + + function initSidebar() { + const user = Auth.getUser(); + const userNameEl = document.getElementById('user-display-name'); + const userAvatarEl = document.getElementById('user-avatar'); + const logoutBtn = document.getElementById('logout-btn'); + + if (user && userNameEl) { + userNameEl.textContent = user.username; + } + if (user && userAvatarEl) { + userAvatarEl.textContent = user.username.charAt(0).toUpperCase(); + } + if (logoutBtn) { + logoutBtn.addEventListener('click', (e) => { + e.preventDefault(); + Auth.logout(); + }); + } + + // Highlight current nav item + const currentPage = window.location.pathname.split('/').pop() || 'dashboard.html'; + document.querySelectorAll('.nav-item').forEach(item => { + const href = item.getAttribute('href'); + if (href && currentPage === href) { + item.classList.add('active'); + } + }); + } + + // ── Workflows ─────────────────────────────────────────────────── + + async function loadWorkflows() { + try { + workflows = await API.get('/api/workflows'); + renderWorkflows(); + updateStats(); + } catch (err) { + showToast(err.message, 'error'); + } + } + + function renderWorkflows() { + const container = document.getElementById('workflows-list'); + if (!container) return; + + if (workflows.length === 0) { + container.innerHTML = ` +
+
⚡
+

No workflows yet

+

Create your first workflow to get started with autonomous code generation.

+
+ `; + return; + } + + container.innerHTML = workflows.map((wf, i) => ` +
+
+ ${wf.status} + ${_formatDate(wf.created_at)} +
+
${_escapeHtml(wf.prompt)}
+
+ ID: ${wf.id.substring(0, 8)}… +
+
+ `).join(''); + } + + function updateStats() { + const totalEl = document.getElementById('stat-total'); + const pendingEl = document.getElementById('stat-pending'); + const completedEl = document.getElementById('stat-completed'); + const failedEl = document.getElementById('stat-failed'); + + if (totalEl) totalEl.textContent = workflows.length; + if (pendingEl) pendingEl.textContent = workflows.filter(w => w.status === 'pending').length; + if (completedEl) completedEl.textContent = workflows.filter(w => w.status === 'completed').length; + if (failedEl) failedEl.textContent = workflows.filter(w => w.status === 'failed').length; + } + + // ── Create Workflow ───────────────────────────────────────────── + + function openCreateModal() { + const overlay = document.getElementById('create-modal'); + if (overlay) overlay.classList.add('active'); + } + + function closeCreateModal() { + const overlay = document.getElementById('create-modal'); + if (overlay) overlay.classList.remove('active'); + const input = document.getElementById('workflow-prompt'); + if (input) input.value = ''; + } + + async function createWorkflow() { + const input = document.getElementById('workflow-prompt'); + const prompt = input ? input.value.trim() : ''; + if (!prompt) { + showToast('Please enter a prompt', 'error'); + return; + } + + try { + const wf = await API.post('/api/workflows', { prompt }); + showToast('Workflow created successfully!'); + closeCreateModal(); + await loadWorkflows(); + } catch (err) { + showToast(err.message, 'error'); + } + } + + function viewWorkflow(id) { + window.location.href = `/workflow.html?id=${id}`; + } + + // ── Helpers ───────────────────────────────────────────────────── + + function _formatDate(iso) { + const d = new Date(iso); + return d.toLocaleDateString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + } + + function _escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + // ── Public API ────────────────────────────────────────────────── + + return { + showToast, + initSidebar, + loadWorkflows, + openCreateModal, + closeCreateModal, + createWorkflow, + viewWorkflow, + }; +})(); diff --git a/frontend/js/auth.js b/frontend/js/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..6328a2e9bd1d648398c6acfc5db2ece9b7c15f91 --- /dev/null +++ b/frontend/js/auth.js @@ -0,0 +1,58 @@ +/** + * AutoForge Authentication Helpers + */ + +const Auth = (() => { + const TOKEN_KEY = 'autoforge_token'; + const USER_KEY = 'autoforge_user'; + + function getToken() { + return localStorage.getItem(TOKEN_KEY); + } + + function isAuthenticated() { + return !!getToken(); + } + + function saveSession(token, user = null) { + localStorage.setItem(TOKEN_KEY, token); + if (user) { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + } + } + + function getUser() { + const data = localStorage.getItem(USER_KEY); + return data ? JSON.parse(data) : null; + } + + function logout() { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + window.location.href = '/login.html'; + } + + async function signup(username, email, password) { + const data = await API.post('/api/auth/signup', { username, email, password }); + saveSession(data.access_token); + + // Fetch full user profile + const user = await API.get('/api/auth/me'); + localStorage.setItem(USER_KEY, JSON.stringify(user)); + + return user; + } + + async function login(username, password) { + const data = await API.post('/api/auth/login', { username, password }); + saveSession(data.access_token); + + // Fetch full user profile + const user = await API.get('/api/auth/me'); + localStorage.setItem(USER_KEY, JSON.stringify(user)); + + return user; + } + + return { getToken, isAuthenticated, saveSession, getUser, logout, signup, login }; +})(); diff --git a/frontend/js/router.js b/frontend/js/router.js new file mode 100644 index 0000000000000000000000000000000000000000..6f229a740d355f5f9ee2a7f82a1cec95ce1cbbfb --- /dev/null +++ b/frontend/js/router.js @@ -0,0 +1,26 @@ +/** + * AutoForge Router — Simple navigation guard + */ + +const Router = (() => { + const PUBLIC_PAGES = ['/login.html', '/login']; + + function init() { + const path = window.location.pathname; + const isPublic = PUBLIC_PAGES.some(p => path.endsWith(p)); + + if (!Auth.isAuthenticated() && !isPublic) { + window.location.href = '/login.html'; + return false; + } + + if (Auth.isAuthenticated() && isPublic) { + window.location.href = '/dashboard.html'; + return false; + } + + return true; + } + + return { init }; +})(); diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000000000000000000000000000000000000..fc8fb5822ff508f0ebd21713462b0381e1e2e987 --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,125 @@ + + + + + + + AutoForge — Sign In + + + +
+
+
+
⚡
+

Welcome back

+

Sign in to your AutoForge account

+
+ +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Don't have an account? + Sign Up +
+
+
+ + + + + + + diff --git a/frontend/settings.html b/frontend/settings.html new file mode 100644 index 0000000000000000000000000000000000000000..9a5447f090445641255d9c98b8d390a792889aa1 --- /dev/null +++ b/frontend/settings.html @@ -0,0 +1,97 @@ + + + + + + + AutoForge — Settings + + + +
+ + + + +
+ + +
+
+ LLM Configuration +
+
+ + +
+
+ + +
+

+ LLM settings are configured via the .env file on the server. + UI-based configuration will be available in a future phase. +

+
+ +
+
+ Account +
+
+ Loading… +
+ +
+
+
+ + + + + + + + diff --git a/frontend/workflow.html b/frontend/workflow.html new file mode 100644 index 0000000000000000000000000000000000000000..791ac37a037ecbd5e25048fe5d398eb639f2873b --- /dev/null +++ b/frontend/workflow.html @@ -0,0 +1,118 @@ + + + + + + + AutoForge — Workflow + + + +
+ + + + +
+ + +
+
+
+
+
+ + + + + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..5480d9276738361e6150dec3e46ad4d0c9ac3fc7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +# Web framework +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +python-multipart==0.0.9 + +# Data validation & settings +pydantic==2.9.1 +pydantic-settings==2.5.2 + +# Database +sqlalchemy==2.0.35 +aiosqlite==0.20.0 + +# Authentication +passlib[bcrypt]==1.7.4 +python-jose[cryptography]==3.3.0 +bcrypt==4.2.0 + +# HTTP client (for LLM providers) +httpx==0.27.2 + +# Testing +pytest==8.3.3 +pytest-asyncio==0.24.0 +httpx diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..8d1c8b69c3fce7bea45c73efd06983e3c419a92f --- /dev/null +++ b/scripts/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..461dd99b5a9a0aee316ce824ee875c55a7f7c4cc --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# AutoForge Tests diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..18ce655303abafb03b33fe8d7b41f3fe699c7ed4 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc3ef14a675581f6a750f93600ce406346f47b00 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..22f69493a90cef4d5bbfada101972830eeb35454 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +""" +Shared test fixtures for the AutoForge test suite. +""" + +import os +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# Force test database before importing anything else +os.environ["DATABASE_URL"] = "sqlite:///data/test_autoforge.db" +os.environ["SECRET_KEY"] = "test-secret-key-not-for-production" + +from backend.db.database import Base, get_db +from backend.main import app + + +# ── Test DB Engine ─────────────────────────── + +TEST_DATABASE_URL = "sqlite:///data/test_autoforge.db" +test_engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) +TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine) + + +def override_get_db(): + db = TestSessionLocal() + try: + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + + +# ── Fixtures ───────────────────────────────── + +@pytest.fixture(autouse=True) +def setup_database(): + """Create tables before each test, drop after.""" + os.makedirs("data", exist_ok=True) + Base.metadata.create_all(bind=test_engine) + yield + Base.metadata.drop_all(bind=test_engine) + + +@pytest.fixture +def client(): + """FastAPI test client.""" + return TestClient(app) + + +@pytest.fixture +def auth_headers(client): + """Sign up a test user and return Authorization headers.""" + client.post("/api/auth/signup", json={ + "username": "testuser", + "email": "test@example.com", + "password": "testpass123", + }) + resp = client.post("/api/auth/login", json={ + "username": "testuser", + "password": "testpass123", + }) + token = resp.json()["access_token"] + return {"Authorization": f"Bearer {token}"} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8271dd16e3af492d10e4500caa568b515e9a7671 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +# AutoForge Integration Tests diff --git a/tests/integration/test_cpp_build.py b/tests/integration/test_cpp_build.py new file mode 100644 index 0000000000000000000000000000000000000000..160a0e1c74c78e62e9c547b792201973bed87f7d --- /dev/null +++ b/tests/integration/test_cpp_build.py @@ -0,0 +1,85 @@ +"""Integration test: C++ DAG engine compilation.""" + +import os +import subprocess +import sys + +import pytest + + +@pytest.mark.skipif( + not any( + os.path.exists(p) + for p in [ + r"C:\Program Files\CMake\bin\cmake.exe", + r"C:\Program Files (x86)\CMake\bin\cmake.exe", + "/usr/bin/cmake", + "/usr/local/bin/cmake", + ] + ) + and subprocess.run(["cmake", "--version"], capture_output=True).returncode != 0, + reason="CMake not found on system", +) +class TestCppBuild: + """Test that the C++ DAG engine compiles successfully.""" + + @staticmethod + def _project_root(): + return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + def test_cmake_configure(self): + """CMake should configure without errors.""" + root = self._project_root() + build_dir = os.path.join(root, "cpp", "build") + os.makedirs(build_dir, exist_ok=True) + + result = subprocess.run( + ["cmake", "-S", os.path.join(root, "cpp"), "-B", build_dir], + capture_output=True, + text=True, + cwd=root, + ) + assert result.returncode == 0, f"CMake configure failed:\n{result.stderr}" + + def test_cmake_build(self): + """CMake should build the autoforge-dag target.""" + root = self._project_root() + build_dir = os.path.join(root, "cpp", "build") + + # Ensure configured + subprocess.run( + ["cmake", "-S", os.path.join(root, "cpp"), "-B", build_dir], + capture_output=True, + cwd=root, + ) + + result = subprocess.run( + ["cmake", "--build", build_dir], + capture_output=True, + text=True, + cwd=root, + ) + assert result.returncode == 0, f"CMake build failed:\n{result.stderr}" + + def test_binary_exists(self): + """The built binary should exist.""" + root = self._project_root() + build_dir = os.path.join(root, "cpp", "build") + + # Build if needed + subprocess.run(["cmake", "-S", os.path.join(root, "cpp"), "-B", build_dir], capture_output=True, cwd=root) + subprocess.run(["cmake", "--build", build_dir], capture_output=True, cwd=root) + + # Check for binary (platform-dependent extension) + if sys.platform == "win32": + candidates = [ + os.path.join(build_dir, "autoforge-dag.exe"), + os.path.join(build_dir, "Debug", "autoforge-dag.exe"), + os.path.join(build_dir, "Release", "autoforge-dag.exe"), + ] + else: + candidates = [os.path.join(build_dir, "autoforge-dag")] + + assert any(os.path.exists(c) for c in candidates), ( + f"Binary not found. Checked: {candidates}" + ) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d292a6dea81d7edc1332d33ff74d8faa78b62da4 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +# AutoForge Unit Tests diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..8fd2ea1d2459012dc9416d35580f2d224fc6a766 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,99 @@ +""" +Tests for authentication: signup, login, /me. +""" + + +class TestSignup: + def test_signup_success(self, client): + resp = client.post("/api/auth/signup", json={ + "username": "newuser", + "email": "new@example.com", + "password": "securepass", + }) + assert resp.status_code == 201 + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + def test_signup_duplicate_username(self, client): + payload = { + "username": "dupuser", + "email": "dup1@example.com", + "password": "pass123", + } + client.post("/api/auth/signup", json=payload) + + # Try again with same username + resp = client.post("/api/auth/signup", json={ + "username": "dupuser", + "email": "dup2@example.com", + "password": "pass123", + }) + assert resp.status_code == 409 + + def test_signup_duplicate_email(self, client): + client.post("/api/auth/signup", json={ + "username": "user1", + "email": "same@example.com", + "password": "pass123", + }) + resp = client.post("/api/auth/signup", json={ + "username": "user2", + "email": "same@example.com", + "password": "pass123", + }) + assert resp.status_code == 409 + + +class TestLogin: + def test_login_success(self, client): + client.post("/api/auth/signup", json={ + "username": "loginuser", + "email": "login@example.com", + "password": "mypassword", + }) + resp = client.post("/api/auth/login", json={ + "username": "loginuser", + "password": "mypassword", + }) + assert resp.status_code == 200 + assert "access_token" in resp.json() + + def test_login_wrong_password(self, client): + client.post("/api/auth/signup", json={ + "username": "wrongpw", + "email": "wrongpw@example.com", + "password": "correctpass", + }) + resp = client.post("/api/auth/login", json={ + "username": "wrongpw", + "password": "wrongpassword", + }) + assert resp.status_code == 401 + + def test_login_nonexistent_user(self, client): + resp = client.post("/api/auth/login", json={ + "username": "ghost", + "password": "nope", + }) + assert resp.status_code == 401 + + +class TestMe: + def test_me_returns_user(self, client, auth_headers): + resp = client.get("/api/auth/me", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["username"] == "testuser" + assert data["email"] == "test@example.com" + assert "id" in data + + def test_me_requires_auth(self, client): + resp = client.get("/api/auth/me") + assert resp.status_code in (401, 403) + + def test_me_rejects_bad_token(self, client): + resp = client.get("/api/auth/me", headers={ + "Authorization": "Bearer invalid.token.here" + }) + assert resp.status_code == 401 diff --git a/tests/unit/test_db.py b/tests/unit/test_db.py new file mode 100644 index 0000000000000000000000000000000000000000..16241f24e8f708e33ceea76f74ae0e126b6cc477 --- /dev/null +++ b/tests/unit/test_db.py @@ -0,0 +1,94 @@ +""" +Tests for SQLite database initialization and models. +""" + +import os +from sqlalchemy import create_engine, inspect +from sqlalchemy.orm import sessionmaker + +from backend.db.database import Base +from backend.db.models import User, Workflow, Task, Attempt, Event + + +def test_tables_are_created(): + """Verify all expected tables exist after init.""" + engine = create_engine( + "sqlite:///data/test_autoforge.db", + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(bind=engine) + + inspector = inspect(engine) + tables = inspector.get_table_names() + + assert "users" in tables + assert "workflows" in tables + assert "tasks" in tables + assert "attempts" in tables + assert "events" in tables + + Base.metadata.drop_all(bind=engine) + + +def test_user_model_creation(): + """Create a user and verify fields.""" + engine = create_engine( + "sqlite:///data/test_autoforge.db", + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(bind=engine) + Session = sessionmaker(bind=engine) + session = Session() + + user = User(username="dbuser", email="db@test.com", password_hash="fakehash") + session.add(user) + session.commit() + session.refresh(user) + + assert user.id is not None + assert user.username == "dbuser" + assert user.created_at is not None + + session.close() + Base.metadata.drop_all(bind=engine) + + +def test_workflow_task_relationship(): + """Verify workflow → task → attempt relationships.""" + engine = create_engine( + "sqlite:///data/test_autoforge.db", + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(bind=engine) + Session = sessionmaker(bind=engine) + session = Session() + + user = User(username="reluser", email="rel@test.com", password_hash="hash") + session.add(user) + session.commit() + + wf = Workflow(user_id=user.id, prompt="test prompt") + session.add(wf) + session.commit() + + task = Task(workflow_id=wf.id, name="code_gen", type="code") + session.add(task) + session.commit() + + attempt = Attempt(task_id=task.id, attempt_number=1, status="pending") + session.add(attempt) + session.commit() + + event = Event(workflow_id=wf.id, event_type="test", payload={"key": "value"}) + session.add(event) + session.commit() + + # Verify relationships + session.refresh(wf) + assert len(wf.tasks) == 1 + assert len(wf.events) == 1 + assert wf.tasks[0].name == "code_gen" + assert wf.tasks[0].attempts[0].attempt_number == 1 + + session.close() + Base.metadata.drop_all(bind=engine) diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py new file mode 100644 index 0000000000000000000000000000000000000000..a1ec8ba731c4ac34df283b7688f46043a38b40fb --- /dev/null +++ b/tests/unit/test_health.py @@ -0,0 +1,16 @@ +""" +Tests for the /health endpoint. +""" + + +def test_health_returns_200(client): + resp = client.get("/health") + assert resp.status_code == 200 + + +def test_health_returns_ok_status(client): + resp = client.get("/health") + data = resp.json() + assert data["status"] == "ok" + assert data["service"] == "autoforge" + assert "version" in data diff --git a/tests/unit/test_workflows.py b/tests/unit/test_workflows.py new file mode 100644 index 0000000000000000000000000000000000000000..03103c5d77cc46132d4a94aabe61e747dbef16fc --- /dev/null +++ b/tests/unit/test_workflows.py @@ -0,0 +1,75 @@ +""" +Tests for workflow creation and retrieval. +""" + + +class TestCreateWorkflow: + def test_create_workflow(self, client, auth_headers): + resp = client.post("/api/workflows", json={ + "prompt": "Create a Python calculator", + }, headers=auth_headers) + assert resp.status_code == 201 + data = resp.json() + assert data["prompt"] == "Create a Python calculator" + assert data["status"] == "pending" + assert "id" in data + + def test_create_workflow_requires_auth(self, client): + resp = client.post("/api/workflows", json={ + "prompt": "No auth test", + }) + assert resp.status_code in (401, 403) + + def test_create_workflow_requires_prompt(self, client, auth_headers): + resp = client.post("/api/workflows", json={}, headers=auth_headers) + assert resp.status_code == 422 + + +class TestListWorkflows: + def test_list_empty(self, client, auth_headers): + resp = client.get("/api/workflows", headers=auth_headers) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 0 + assert data["workflows"] == [] + + def test_list_after_create(self, client, auth_headers): + client.post("/api/workflows", json={ + "prompt": "Workflow 1", + }, headers=auth_headers) + client.post("/api/workflows", json={ + "prompt": "Workflow 2", + }, headers=auth_headers) + + resp = client.get("/api/workflows", headers=auth_headers) + data = resp.json() + assert data["total"] == 2 + assert len(data["workflows"]) == 2 + + def test_list_requires_auth(self, client): + resp = client.get("/api/workflows") + assert resp.status_code in (401, 403) + + +class TestGetWorkflow: + def test_get_workflow_by_id(self, client, auth_headers): + create_resp = client.post("/api/workflows", json={ + "prompt": "Get me by ID", + }, headers=auth_headers) + workflow_id = create_resp.json()["id"] + + resp = client.get(f"/api/workflows/{workflow_id}", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["id"] == workflow_id + assert resp.json()["prompt"] == "Get me by ID" + + def test_get_nonexistent_workflow(self, client, auth_headers): + resp = client.get( + "/api/workflows/00000000-0000-0000-0000-000000000000", + headers=auth_headers, + ) + assert resp.status_code == 404 + + def test_get_workflow_requires_auth(self, client): + resp = client.get("/api/workflows/some-id") + assert resp.status_code in (401, 403)